Compare commits

..
23 Commits
Author SHA1 Message Date
adminandClaude Opus 4.8 5a4d9d77e5 v0.1.36 - What's new dialog: historical changelog accordion
Add a "Read previous changes" accordion to the What's new dialog and
backfill the changelog history from git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:36:17 +12:00
adminandClaude Opus 4.8 dc50e0538e v0.1.35 - Ingredient categories: created categories now available across all rows
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:28:23 +12:00
admin c9f233dc0e v0.1.34 2026-06-21 12:16:41 +12:00
admin 87878e70fc v0.1.32 - Mix Calculator search, ingredient categories, throughput tidy-up
Mix Calculator: searchable Mix Name picker (mirrors Throughput search)

Ingredients Editor: add manual Category column; used to order Mix Calculator output

Mix Calculator: surface formula-only mixes (no product yet) via -mix_id sentinel

Throughput: remove unused For order / For stock destination controls from composer

Editor change history: show timestamps in local time (stored UTC) instead of raw UTC
2026-06-21 11:57:14 +12:00
adminandClaude Opus 4.8 696f1e7b09 v0.1.32 - Mix Calculator search, ingredient categories, throughput tidy-up
- Mix Calculator: searchable Mix Name picker (mirrors Throughput search)
- Ingredients Editor: add manual Category column; used to order Mix Calculator output
- Mix Calculator: surface formula-only mixes (no product yet) via -mix_id sentinel
- Throughput: remove unused For order / For stock destination controls from composer
- Editor change history: show timestamps in local time (stored UTC) instead of raw UTC

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:09:43 +12:00
admin 10722a65a6 v0.1.31 - Mix Editor multi edit 2026-06-18 15:15:46 +12:00
adminandClaude Opus 4.8 1062c038e8 v0.1.30 - Throughput overview today-only mix cards; mix formula save 500 fix
Throughput Overview: Horse Mix and Grain Mix are now the first two cards and
show TODAY's output only. Removed the 7d/4w/6w/12w range selector; the cards
are fixed to Horse mix today, Grain mix today, Today, This week, 4-week average.

Mix Editor formula save: fix HTTP 500 on PUT /editor/mixes/{id}/formula. The
audit-diff path read the resolved formula by attribute, but the resolver returns
dicts -> AttributeError. Read by key and expire stale ORM state so the response
reflects the just-saved rows. Adds regression tests for both save branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:04:49 +12:00
adminandClaude Opus 4.8 e7a7b11589 v0.1.29 - Mix Editor % edits no longer rebalance other ingredients
Editing one ingredient's % now converts only that row to kg against the
Total mix anchor; other ingredients are left untouched (no proportional
redistribution). Removing a row likewise leaves the rest as-is. The hard
"percentages must total 100%" save guard is relaxed (kg is canonical and
the backend does not require 100%); the % chip remains as live feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:49:15 +12:00
adminandClaude Opus 4.8 1dd48bc771 v0.1.28 - Version bump and editor/throughput updates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:51:29 +12:00
admin 3f8279af10 v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
2026-06-17 21:55:04 +12:00
admin 7db95e2027 v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
2026-06-16 14:43:17 +12:00
admin 8f9a7b8193 Release v0.1.23 2026-06-15 11:59:04 +12:00
admin 250d6ab6a9 Release v0.1.22 2026-06-15 10:13:02 +12:00
adminandClaude Opus 4.8 8b81f804f7 v0.1.21 - Mix calculator composer restyle
Mirror the throughput composer's green design language on the mix calculator
inputs: brand-tinted composer surface with a calculator-icon header, green
48px fields, Mix Name disabled until a client is chosen, a darker readable
on-brand hint, and optional notes via the "+ Add a note" reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:26:07 +12:00
adminandClaude Opus 4.8 b1c0d3f3da v0.1.20 - Even composer fields on laptops & add-success modal
- Throughput: even three-across layout for the "Add a packing run" fields
  on laptop widths so placeholders stay readable in the narrow content band
- Throughput: centred "Added" confirmation modal with in-card confetti on
  save (3s), composer clears for the next entry
- Fix stray character in changelog.ts type comment

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:25:52 +12:00
adminandClaude Opus 4.8 2de82776cb 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>
2026-06-13 10:01:10 +12:00
admin 4ff372d307 v0.1.14 - b2b portal 2026-06-11 23:56:02 +12:00
admin 349e4a4b5b v0.1.12 2026-06-09 21:28:53 +12:00
admin daa6e60a69 v0.1.11b fixes 2026-06-03 15:09:21 +12:00
admin cf968e802b v0.1.11 - Editor 2026-06-03 00:17:12 +12:00
admin f5a588d631 Dockerfile updates 2026-06-02 15:41:53 +12:00
admin 84792c0947 tweaks 2026-05-31 20:19:44 +12:00
admin 2f2466ecac Updates 2026-05-10 09:46:07 +12:00
222 changed files with 37713 additions and 4660 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=Lean 101 Clients API
APP_NAME=Hunter App
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=alex@lean-101.com
CLIENT_PASSWORD=JBBwVCDqmPA7
+19 -8
View File
@@ -1,17 +1,28 @@
APP_NAME=Lean 101 Clients API
APP_NAME=Hunter App
APP_ENV=alpha
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com
CLIENT_PASSWORD=changeme
CLIENT_PASSWORD=replace-with-strong-password
CLIENT_TENANT_ID=hunter-premium-produce
ADMIN_NAME=Lean 101
ADMIN_EMAIL=admin@lean101.local
ADMIN_PASSWORD=lean101-admin
AUTH_SECRET=replace-with-a-long-random-secret
ORIGIN=https://clients.lean-101.com.au
PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-strong-password
AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret
ORIGIN=https://clients.example.com
PUBLIC_API_BASE_URL=https://clients.example.com
INTERNAL_API_BASE_URL=http://backend:8000
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
CORS_ALLOW_ORIGINS=https://clients.example.com
CORS_ALLOW_ORIGIN_REGEX=
TRUSTED_HOSTS=clients.example.com
CLIENTS_APP_PORT=8081
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=lax
SESSION_COOKIE_DOMAIN=
SESSION_TTL_SECONDS=43200
REQUEST_BODY_MAX_BYTES=1048576
LOGIN_RATE_LIMIT_ATTEMPTS=8
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
DOCS_ENABLED=false
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
DATABASE_URL=sqlite:////data/data_entry_app.db
+33
View File
@@ -0,0 +1,33 @@
APP_NAME=Hunter App
APP_ENV=production
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com
CLIENT_PASSWORD=replace-with-a-strong-client-password
CLIENT_TENANT_ID=hunter-premium-produce
ADMIN_NAME=Lean 101
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-a-strong-admin-password
AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret
POSTGRES_USER=lean101_app
POSTGRES_PASSWORD=replace-with-a-long-random-database-password
POSTGRES_DB=lean101
ORIGIN=https://clients.example.com
PUBLIC_API_BASE_URL=https://clients.example.com
INTERNAL_API_BASE_URL=http://backend:8000
CORS_ALLOW_ORIGINS=https://clients.example.com
CORS_ALLOW_ORIGIN_REGEX=
TRUSTED_HOSTS=clients.example.com,localhost,127.0.0.1
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=lax
SESSION_COOKIE_DOMAIN=
SESSION_TTL_SECONDS=43200
REQUEST_BODY_MAX_BYTES=1048576
LOGIN_RATE_LIMIT_ATTEMPTS=8
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
DOCS_ENABLED=false
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
+25 -7
View File
@@ -1,26 +1,44 @@
APP_NAME=Lean 101 Clients API
APP_NAME=Hunter App
APP_ENV=production
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com
CLIENT_PASSWORD=replace-with-strong-password
CLIENT_TENANT_ID=hunter-premium-produce
ADMIN_NAME=Lean 101
ADMIN_EMAIL=admin@lean101.local
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-strong-password
AUTH_SECRET=replace-with-a-long-random-secret
AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret
# Postgres credentials. The compose file builds DATABASE_URL from these
# so you do not need to set DATABASE_URL explicitly. Override DATABASE_URL
# only if you want to point at a managed Postgres outside the compose stack.
POSTGRES_USER=lean101
POSTGRES_USER=lean101_app
POSTGRES_PASSWORD=replace-with-a-long-random-password
POSTGRES_DB=lean101
# DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME
ORIGIN=https://clients.lean-101.com.au
PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
ORIGIN=https://clients.example.com
PUBLIC_API_BASE_URL=https://clients.example.com
INTERNAL_API_BASE_URL=http://backend:8000
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
CORS_ALLOW_ORIGINS=https://clients.example.com
CORS_ALLOW_ORIGIN_REGEX=
TRUSTED_HOSTS=clients.example.com,127.0.0.1,localhost
CLIENTS_APP_PORT=8081
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=lax
SESSION_COOKIE_DOMAIN=
SESSION_TTL_SECONDS=43200
REQUEST_BODY_MAX_BYTES=1048576
LOGIN_RATE_LIMIT_ATTEMPTS=8
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
DOCS_ENABLED=false
# Read-only Power BI / external data API at /api/v1. Set a long random key to
# enable it; leave blank to disable the API entirely. Power BI sends this as an
# "X-API-Key" header (or "?api_key=" query parameter).
POWERBI_API_KEY=V7BI59yhRBF7VMiPNfgmqPxrsPuNuPFJ
# Tenant the Power BI API reads from. Defaults to CLIENT_TENANT_ID.
POWERBI_TENANT_ID=
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
+8 -1
View File
@@ -3,13 +3,20 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
*.log
dist/
build/
node_modules/
.svelte-kit/
backend/.venv/
backend/.pytest_cache/
backend/.tmp/
backend/pytest-cache-files-*/
backend/tests/__pycache__/
frontend/node_modules/
frontend/.vite/
frontend/coverage/
*.pyc
*.pyo
*.pyd
@@ -17,4 +24,4 @@ frontend/node_modules/
*.db
.env.production
.env.alpha
.env
+114
View File
@@ -1,3 +1,117 @@
## Repository operations
### RUles for Svelte
If a block has its own UI + state + behaviour, make it a component.
If logic is reused or long, move it to a .ts utility file.
If CSS is over 300500 lines, split components.
### Dependencies
Current app dependency entry points:
| Area | File | Notes |
| --- | --- | --- |
| Frontend runtime + tooling | `frontend/package.json` | SvelteKit app, Vite build, Vitest tests |
| Frontend lockfile | `frontend/package-lock.json` | Generated by npm, commit this with dependency changes |
| Backend runtime + tooling | `backend/pyproject.toml` | FastAPI app, SQLAlchemy, pytest, packaging metadata |
Current declared dependencies:
#### Frontend
- Runtime: `lucide-svelte`
- Dev/build: `@sveltejs/adapter-auto`, `@sveltejs/adapter-node`, `@sveltejs/kit`, `svelte`, `typescript`, `vite`, `vitest`
#### Backend
- Runtime/tooling: `fastapi`, `openpyxl`, `rich`, `uvicorn[standard]`, `sqlalchemy`, `pydantic`, `psycopg[binary]`, `reportlab`
- Test dependency: `pytest`
### Dependency update workflow
Use a small, controlled update flow rather than bulk-upgrading everything immediately before production.
#### Frontend
Check what is outdated:
```bash
cd frontend
npm outdated
```
Install targeted upgrades:
```bash
npm install <package>@latest
```
For a broader refresh within `package.json` ranges:
```bash
npm update
```
Then verify:
```bash
npm run test
npm run build
```
#### Backend
Check current declared versions in:
```bash
backend/pyproject.toml
```
Upgrade by editing version ranges in `backend/pyproject.toml`, then reinstall:
```bash
cd backend
pip install -e .
pytest
```
If a backend dependency is high-risk near production, prefer upgrading one package at a time and re-running API tests after each change.
### Repository hygiene
The repo should keep source code and deployment assets, but not generated local artifacts.
Expected long-lived top-level folders:
- `backend/`
- `frontend/`
- `deploy/`
Expected long-lived top-level docs/config files:
- `README.md`
- `CLAUDE.MD`
- `docker-compose*.yml`
- `.env*.example`
Files that should stay out of version control or be moved out of the project root over time:
- SQLite databases such as `data_entry_app.db`
- local cache folders such as `.pytest_cache/` and `pytest-cache-files-*`
- virtual environments such as `.venv/`
- one-off working assets such as loose spreadsheets, image exports, or temporary notes unless they are intentional project deliverables
### Tests and pytest files
There are not many real pytest source files in this repo right now.
Current actual backend tests:
- `backend/tests/test_access.py`
- `backend/tests/test_costing_engine.py`
Most of the extra `pytest`-named items are generated cache/temp directories from local test runs, not hand-written test suites.
## Spreadsheet analysis summary
The workbook is effectively a costing and pricing model with three core calculation layers:
+373
View File
@@ -0,0 +1,373 @@
# Database Design
## Purpose
This app uses a relational database to support five main concerns:
1. Raw material pricing and unit conversion.
2. Mix definitions and mix costing.
3. Product-level formulas and product costing.
4. Mix calculator session history.
5. Access control for both internal users and client users.
The backend is written with SQLAlchemy models in `backend/app/models`. The schema is created automatically at startup, and lightweight migration/patch logic lives in `backend/app/db/migrations.py`.
## Design Principles
- `tenant_id` is the tenancy boundary for most business tables.
- Reference/master data is stored separately from transactional/session data.
- Product costing is built from raw materials -> formulas -> products -> outputs.
- The mix calculator now prefers product-specific ingredient formulas over the shared mix master.
- The database is designed to run on both SQLite locally and Postgres in production.
## High-Level Domains
### 1. Raw Materials
These tables store ingredients and their price history.
- `raw_materials`
- One row per ingredient/raw material.
- Stores name, supplier, unit of measure, `kg_per_unit`, status, and notes.
- Example: `Hulled Oats`, `White French Millet`, `Pano`.
- `raw_material_price_versions`
- One-to-many from `raw_materials`.
- Stores `market_value`, `waste_percentage`, `effective_date`, and status.
- Lets the system keep historical prices instead of overwriting one current value.
Relationship:
- `raw_materials.id` -> `raw_material_price_versions.raw_material_id`
### 2. Mix Master
These tables store shared mix definitions.
- `mixes`
- One row per named mix.
- Stores client name, mix name, version, status, and notes.
- This is the shared mix/master-recipe layer.
- `mix_ingredients`
- One-to-many from `mixes`.
- One row per raw material inside a mix.
- Stores `quantity_kg` for that mix.
Relationships:
- `mixes.id` -> `mix_ingredients.mix_id`
- `raw_materials.id` -> `mix_ingredients.raw_material_id`
Important note:
- This table is still used by mix master pages and as a fallback.
- It is no longer the primary source for mix calculator formulas when product-specific formulas exist.
### 3. Products
These tables describe saleable products and their formula rows.
- `products`
- One row per sellable product/SKU.
- Stores client name, product name, optional `item_id`, packaging/unit info, margins, and linked mix.
- `mix_id` links the product to the shared mix master entry.
- `product_ingredients`
- One-to-many from `products`.
- One row per raw material required for that products formula.
- Stores `quantity_kg`, `sort_order`, and optional notes.
- This is now the key table for the mix calculator.
Relationships:
- `products.mix_id` -> `mixes.id`
- `products.id` -> `product_ingredients.product_id`
- `raw_materials.id` -> `product_ingredients.raw_material_id`
Why both `mix_ingredients` and `product_ingredients` exist:
- `mix_ingredients` represents a shared recipe.
- `product_ingredients` represents the actual formula used for a specific product.
- Multiple products can point at the same mix name but still require product-specific formula rows.
- This solves the workbook case where product labels like `Budgie Mix 20kg` map to a formula/mix name like `Hunter - Budgie Mix`.
### 4. Costing Assumptions
These tables hold non-ingredient costs used in product costing.
- `process_cost_rules`
- Holds grading, bagging, and cracking costs by `process_name`.
- `packaging_cost_rules`
- Holds bag cost by `sale_type`, `unit_of_measure`, and `own_bag`.
- `freight_cost_rules`
- Holds freight cost by `sale_type` and `unit_of_measure`.
These tables are read during product cost calculation after ingredient cost has been resolved.
### 5. Scenarios and Stored Outputs
- `scenarios`
- Named pricing/costing scenarios.
- Stores `overrides` as JSON.
- `costing_results`
- One-to-many from `scenarios`.
- Stores calculated output per product for a scenario.
- Includes prices, warnings, and calculation details as JSON.
Relationships:
- `scenarios.id` -> `costing_results.scenario_id`
- `products.id` -> `costing_results.product_id`
### 6. Mix Calculator Sessions
These tables store saved calculator runs.
- `mix_calculator_sessions`
- Header row for a calculator run.
- Stores product, mix, batch size, total bags, total kg, prepared by, and timestamps.
- `mix_calculator_session_lines`
- One-to-many from `mix_calculator_sessions`.
- Snapshot of the scaled ingredient rows shown to the user at save time.
- Stores `required_kg`, `mix_percentage`, unit, and display name.
Relationships:
- `mix_calculator_sessions.id` -> `mix_calculator_session_lines.session_id`
- `products.id` -> `mix_calculator_sessions.product_id`
- `mixes.id` -> `mix_calculator_sessions.mix_id`
Important note:
- Session lines are denormalized snapshots.
- They are intentionally stored separately so historical saved runs do not change if product formulas are updated later.
### 7. Client Access / Tenant Administration
These tables manage customer-facing users and feature/module access.
- `client_accounts`
- One row per client/tenant account.
- `client_users`
- One-to-many from `client_accounts`.
- Customer-side users tied to a client account.
- `client_feature_access`
- One-to-many from `client_accounts`.
- Feature flags per client account.
- `client_user_module_permissions`
- One-to-many from `client_users`.
- Module-level access levels per client user.
- `client_access_audit_events`
- One-to-many from `client_accounts`.
- Audit log for client-access changes.
Relationships:
- `client_accounts.id` -> `client_users.client_account_id`
- `client_accounts.id` -> `client_feature_access.client_account_id`
- `client_accounts.id` -> `client_access_audit_events.client_account_id`
- `client_users.id` -> `client_user_module_permissions.client_user_id`
### 8. Internal Access Control
These tables are for internal staff login and permissions.
- `users`
- Internal users.
- Stores per-user `password_hash`, role link, and active flag.
- `roles`
- Named roles like `Admin`, `Operations`, `Full Access`.
- `permissions`
- Atomic permission keys like `view_mix_calculator`.
- `role_permissions`
- Many-to-many join table between roles and permissions.
Relationships:
- `roles.id` -> `users.role_id`
- `roles.id` <-> `permissions.id` through `role_permissions`
## Core Costing Flow
### Raw Material Cost
The system calculates ingredient cost from:
- `market_value`
- `waste_percentage`
- `kg_per_unit`
This produces:
- loss cost
- adjusted cost per unit
- cost per kg
### Mix Cost
There are now two formula sources:
1. Preferred: `product_ingredients`
2. Fallback: `mix_ingredients`
For mix calculator and product costing:
- if a product has rows in `product_ingredients`, use them
- otherwise use the linked shared mix from `mix_ingredients`
### Product Cost
Product cost is built from:
1. ingredient formula cost
2. process costs
3. packaging cost
4. freight cost
5. optional distributor / wholesale margin
## Workbook Import Design
The seed/import logic is in `backend/app/seed.py`.
There are now two workbook roles:
- Legacy costing workbook:
- `C- Raw Products Costs`
- `M - All`
- `Product Cost - Price`
- Product-formula workbook:
- `input_data/1.xlsx`
- sheet `mix_quantites_per_client_per_pr`
### Current Import Behaviour
- Raw materials are seeded from the legacy costing workbook.
- Shared mixes are seeded from the legacy costing workbook.
- Products are seeded from the legacy costing workbook.
- Product-specific formulas are seeded from `mix_quantites_per_client_per_pr`.
### Formula Matching Rule
Workbook formula rows are attached to products using:
1. `(client_name, product.name)` if it matches directly.
2. `(client_name, product.mix.name)` if the workbook row uses the mix/formula name instead of the sellable product label.
This is important for cases like:
- workbook formula: `HunterBird / Hunter - Budgie Mix`
- product row: `HunterBird / Budgie Mix 20kg`
Both product SKUs can inherit the same formula through the linked mix name.
## Tenancy
Most business tables include `tenant_id`.
This includes:
- raw materials
- price versions
- mixes
- mix ingredients
- product ingredients
- products
- scenarios
- costing results
- mix calculator sessions and lines
- client-access tables
- assumption tables
Startup migration logic backfills `tenant_id` where possible by deriving it from related parent tables.
## Visibility Rules
The `products.visible` flag is used to hide client/product rows from normal UI paths.
Startup migration logic also auto-hides products for a configured list of client names in `backend/app/db/migrations.py`.
This means:
- rows can exist in the database
- but not be offered in normal mix calculator/product selection flows
## Transaction vs Reference Data
Reference/master data:
- `raw_materials`
- `raw_material_price_versions`
- `mixes`
- `mix_ingredients`
- `products`
- `product_ingredients`
- `process_cost_rules`
- `packaging_cost_rules`
- `freight_cost_rules`
- access-control tables
Transactional/snapshot data:
- `mix_calculator_sessions`
- `mix_calculator_session_lines`
- `scenarios`
- `costing_results`
- `client_access_audit_events`
## Important Constraints
- `mix_ingredients` is unique on `(mix_id, raw_material_id)`.
- `product_ingredients` is unique on `(product_id, raw_material_id)`.
- `client_users` is unique on `(client_account_id, email)`.
- `client_feature_access` is unique on `(client_account_id, feature_key)`.
- `client_user_module_permissions` is unique on `(client_user_id, module_key)`.
- `mix_calculator_sessions` is unique on `(tenant_id, session_number)`.
These constraints prevent duplicate ingredient or access rows within the same parent scope.
## Known Tradeoffs
- `RawMaterial.name` is globally unique, not tenant-scoped. That is simple for now, but stricter than a multi-tenant design usually wants.
- `Product.mix_id` is still required even though product-specific formulas now exist. That is useful for compatibility and navigation, but it means a product currently has both a shared mix link and potentially its own formula rows.
- Some calculation outputs are denormalized into session/result tables for stability and history.
- Migration logic is startup-driven and pragmatic rather than using a full migration framework like Alembic.
## Recommended Mental Model
Use this as the working model of the schema:
- `raw_materials` = ingredients
- `raw_material_price_versions` = ingredient pricing history
- `mixes` = shared recipe labels
- `mix_ingredients` = shared recipe lines
- `products` = saleable SKUs
- `product_ingredients` = actual formula for a SKU
- `mix_calculator_sessions` + `lines` = saved production calculations
- `scenarios` + `costing_results` = stored what-if pricing outputs
- `client_*` tables = client account access
- `users / roles / permissions` = internal staff access
## Files To Read Alongside This Document
- [backend/app/models/raw_material.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/raw_material.py:1)
- [backend/app/models/mix.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/mix.py:1)
- [backend/app/models/product.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/product.py:1)
- [backend/app/models/mix_calculator.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/mix_calculator.py:1)
- [backend/app/models/scenario.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/scenario.py:1)
- [backend/app/models/assumption.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/assumption.py:1)
- [backend/app/models/client_access.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/client_access.py:1)
- [backend/app/models/access.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/access.py:1)
- [backend/app/db/migrations.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/db/migrations.py:1)
- [backend/app/seed.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/seed.py:1)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

+55 -1
View File
@@ -88,12 +88,27 @@ If your server already has a host-level nginx handling domains and TLS, use `dep
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
```
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key.
To deploy with password-based SSH auth instead of a key:
```powershell
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Password 'your-password'
```
Password auth requires `sshpass` on your local `PATH`.
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key, `-Password` for password-based SSH auth.
If a release adds or changes database-backed workbook formula structures, deploy with `-Seed` so the server refreshes seeded reference/formula data after the backend starts. For the product-formula change, this is required so Postgres receives the new `product_ingredients` rows sourced from `input_data/1.xlsx`.
5. **Database**: the backend reads `DATABASE_URL`. The production compose file synthesises it as `postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}` so you only need to set the three `POSTGRES_*` vars. Override `DATABASE_URL` directly if you point at a managed Postgres (e.g. DigitalOcean managed databases).
The schema is auto-managed — `app/db/migrations.py` runs at backend startup and is idempotent across SQLite and Postgres. To migrate alpha SQLite data into the new Postgres instance, dump tables to CSV from the alpha container and import via `\copy` in `psql`; there is no automatic SQLite → Postgres path.
For this repos current schema, new tables such as `product_ingredients` are created automatically on backend startup in both SQLite and Postgres. Existing data refreshes still depend on seeding, so schema deployment and data deployment are separate concerns:
- backend startup creates missing tables/columns
- `-Seed` repopulates workbook-driven rows inside those tables
## Backend
Create a virtual environment, install dependencies, then run:
@@ -116,6 +131,45 @@ pytest
The backend defaults to SQLite for the prototype and can be switched with the
`DATABASE_URL` environment variable.
For local non-Docker runs, the default SQLite database is
`backend/data_entry_app.db` regardless of which directory you launch the
backend from. This avoids accidentally creating multiple local SQLite files
with different login data.
The internal login screen at `/` uses the seeded Hunter Stock Feeds users:
- `admin@hunterstockfeeds.com`
- `ops@hunterstockfeeds.com`
- `craig@hunterstockfeeds.com`
Unless you override `ADMIN_PASSWORD` before the first seed, those local
internal users are seeded with the default password `lean101-admin`.
### Backend logging
The backend now uses a shared console logger with a styled startup banner, concise request logs, and clean shutdown summaries.
Useful logging controls:
```bash
APP_ENV=production
LOG_LEVEL=INFO
LOG_VERBOSE=1
NO_COLOR=1
```
- `LOG_LEVEL` sets the base Python log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`).
- `LOG_VERBOSE=1` enables extra startup and route detail without changing normal request noise.
- `NO_COLOR=1` disables colours automatically for plain terminals, Docker log collection, or CI output.
- Colours are also disabled automatically when output is not a TTY.
Typical local development run:
```bash
cd backend
LOG_VERBOSE=1 uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
## Frontend
Install dependencies and start the dev server:
+335
View File
@@ -0,0 +1,335 @@
# Tech Debt Audit & Remediation Plan
> Status: **Plan / not yet started.** Audit performed 2026-06-03 against `main`.
> Context: app has been through 11 versions. Dev runs on **SQLite (Windows)**;
> production is mid-migration to **PostgreSQL**. Six modules: Mix Calculator,
> Product Costing, Editor, Throughput, Reporting, Settings.
>
> Decisions already taken:
> - **Migrations:** adopt **Alembic** (replaces the startup `create_all` + ad-hoc `ALTER` scheme).
> - **Approach:** full audit first (this document), then execute in phases.
---
## Findings, ranked by severity
### P0 — correctness / data integrity
#### P0.1 — Money is stored as `Float` everywhere
Every cost / price / margin column is SQLAlchemy `Float`:
- `backend/app/models/product_costing.py``cleaned_product_cost_per_kg`, `grading_cost_per_kg`,
`bagging_cost_per_kg`, `cracking_cost_per_kg`, `bag_cost_per_unit`, `freight_cost_per_unit`,
`finished_product_delivered_cost`, `distributor_price`, `wholesale_price`, `cost_per_kg`,
`distributor_margin`, `wholesale_margin`, `cost`, …
- `backend/app/models/assumption.py``grading_cost`, `bagging_cost`, `cracking_cost`,
`bag_cost`, `cost_per_unit`.
- `backend/app/models/mix.py``quantity_kg`.
- `backend/app/models/mix_calculator.py``batch_size_kg`, `total_bags`, `total_kg`,
`product_unit_size_kg`, `required_kg`, `mix_percentage`.
- `backend/app/models/product.py``distributor_margin`, `wholesale_margin`, `quantity_kg`.
**Why it matters:** for a costing-and-pricing tool, binary floating point introduces rounding
drift in money. It is also a **SQLite ↔ Postgres divergence point** — SQLite stores loose floats,
Postgres `Numeric` is exact, so the same calculation can produce different stored/displayed values
across environments.
**Fix:** migrate money/quantity columns to `Numeric(12, 4)` (tune precision/scale per field) and use
`Decimal` in the calculation engine. Guard with the existing formula-parity tests.
#### P0.2 — Frontend silently shows mock data when the API fails
`frontend/src/lib/api.ts``fetchJson(path, fallback, ...)` returns the `fallback` (mock data) on
**any** fetch error:
- `api.ts:151` and `api.ts:158``return fallback;` on failure.
- Fallbacks are real mock datasets: `mockRawMaterials`, `mockCosts`, `mockProducts`, `mockMixes`,
`mockScenarios`, `mockMixCalculatorOptions`, `mockMixCalculatorSessions`, `mockClientAccess`
imported from `$lib/mock` (`api.ts:4-13`, used at `api.ts:296+`).
**Why it matters:** a backend hiccup makes the UI display **fabricated prices/costs** with no error
shown to the user. In a pricing application this is the most dangerous item in this audit — a user
could quote or decide off fake numbers.
**Fix:** remove the mock-on-error fallback path. Surface real API errors in the UI (error/empty
states). Keep `mock.ts` for tests only.
#### P0.3 — Schema management is `create_all` + ad-hoc `ALTER`, no versioning
`backend/app/db/migrations.py` runs on **every startup** via `bootstrap_schema()`
(`backend/app/main.py:105`, inside `ensure_database_ready()`):
- `ensure_metadata_tables()``metadata.create_all()` for any missing tables.
- `ensure_tenant_columns()` → adds `tenant_id` to a hardcoded `TENANT_TABLES` list.
- `ensure_legacy_columns()` → a hand-maintained `_LEGACY_COLUMN_PATCHES` tuple of
`ALTER TABLE ... ADD COLUMN` statements.
- `sync_tenant_ids()` → ~250 lines of near-identical `UPDATE` backfills.
- `sync_product_visibility()` → data backfill.
**Why it matters:** this can only **create tables and add columns**. It can never change a column
type, add an index / constraint / FK, drop a column, or do a NOT-NULL backfill in a controlled way.
A fresh Postgres gets the *current* model via `create_all`, while an upgraded SQLite has columns
bolted on by `ALTER` in whatever order/type they accreted — the two **drift apart silently**, and
SQLite's loose typing hides mismatches until production. Across 11 versions the only escape hatch
has been appending more manual patches (unbounded, fragile).
The one-shot SQLite→Postgres move (`deploy/migrate-to-postgres.sh`) uses
`SET session_replication_role` and manual sequence resets — fine for a single cutover, but not a
repeatable/testable migration path.
**Fix:** adopt **Alembic** (see Phase 1).
---
### P1 — maintainability
#### P1.1 — Copy-pasted helpers, no shared util
No shared formatting/number module. Duplicated implementations:
- `formatDate`**9** files: `lib/components/ClientAccessWorkspace.svelte`,
`lib/components/mix-calculator/MixCalculatorResultsPanel.svelte`,
`lib/components/MixCalculatorPrintDocument.svelte`, `routes/+page.svelte`,
`routes/admin/+page.svelte`, `routes/client-access/+page.svelte`,
`routes/mix-calculator/+page.svelte`, `routes/raw-materials/+page.svelte`,
`routes/throughput/+page.svelte`.
- `formatNumber`**5** files: `lib/components/mix-calculator/MixCalculatorEditor.svelte`,
`lib/components/mix-calculator/MixCalculatorResultsPanel.svelte`,
`lib/components/MixCalculatorPrintDocument.svelte`, `routes/mix-calculator/+page.svelte`,
`routes/throughput/+page.svelte`.
- `toNum`**2** files: `routes/throughput/+page.svelte`, `routes/throughput/add/+page.svelte`.
**Symptom already hit:** the `toNum` `value.trim is not a function` bug (Svelte coerces
`<input type="number">` bindings to `number`/`null`, not `string`). Fixed in both files
2026-06-03, but this class of bug will recur until there is a single source of truth.
**Fix:** add `frontend/src/lib/format.ts` (`formatDate`, `formatNumber`, `formatCurrency`, `toNum`)
and replace the duplicates.
#### P1.2 — Monolith route files
Largest route components (LOC):
| File | LOC |
| --- | --- |
| `routes/+page.svelte` (dashboard) | 2238 |
| `routes/product-costing/+page.svelte` | 1557 |
| `routes/throughput/+page.svelte` | 1232 |
| `routes/editor/+page.svelte` | 1163 |
| `routes/raw-materials/+page.svelte` | 1062 |
| `routes/client-access/+page.svelte` | 851 |
| `routes/reporting/+page.svelte` | 518 |
**Why it matters:** hard to test, hard to change safely, encourages more copy-paste.
**Fix:** decompose incrementally — extract components and `+page.ts` load logic. Dashboard and
product-costing first.
#### P1.3 — `migrations.py` conflates DDL + data backfill
Schema DDL (`ensure_*`) and data backfill (`sync_*`) live in one module, including ~250 lines of
near-identical `UPDATE` blocks in `sync_tenant_ids()`. Alembic will absorb most of this into
versioned schema steps + explicit data-migration steps.
---
### P2 — hygiene
- **P2.1** — `backend/tests/_repro_throughput_post.py` is a debug repro, not a real test. Remove.
- **P2.2** — ~20 `TODO`/`FIXME`/legacy markers across `backend/app` and `frontend/src`. Triage and burn down.
- **P2.3** — `backend/app/seed.py` is 1325 LOC. Split by module.
- **P2.4** — `CLAUDE.md` still says "PostgreSQL recommended / SQLite acceptable only for prototype",
stale vs the live Postgres migration. Refresh.
---
## P0.4 — Three overlapping authentication / user-type systems
The app has accreted **three separate auth systems**, with **two cookies**, **three login
endpoints**, **three role namespaces**, and two parallel permission models. They overlap awkwardly
and one of them is already dead in the UI. This is the single largest piece of structural debt in
the codebase.
### The three systems
**1. Internal / "lean" system**`users` / `roles` / `permissions` / `role_permissions`
- Code: `app/core/access.py`, `app/models/access.py`, `app/api/access.py` (`/api/access/*`),
`app/seed_access.py`.
- Per-user password hash (`User.password_hash`, PBKDF2). Role → permission keys
(`view_raw_materials`, `edit_products`, …). Fail-closed `require_permission(...)` dependencies.
- Token carries `sub=INTERNAL_USER_SUBJECT`; session `role="internal"`.
- Tenant is **hardcoded** to a constant: `INTERNAL_USER_TENANT_ID = "hunter-premium-produce"`
(`core/access.py:37`).
- Uses `CLIENT_AUTH_COOKIE`.
- **This is the actual primary login.** The root page (`routes/+page.svelte:57`) calls
`api.internalLogin()``/api/access/login`.
**2. Client-portal system**`client_accounts` / `client_users` / `client_feature_access` /
`client_user_module_permissions` / `client_access_audit_events`
- Code: `app/models/client_access.py`, `app/services/client_access_service.py`,
`app/api/auth.py` (`/api/auth/client/*`), `app/api/client_access.py`.
- **Multi-tenant** (`tenant_id` on every table), per-account feature flags, per-user module
access levels (`none`/`view`/`edit`/`manage`), `client_role` in {superadmin, admin, viewer, …}.
- Session `role="client"`. Uses `CLIENT_AUTH_COOKIE`.
- **Authentication is broken-by-design: a single shared password.** `client_login` checks
`payload.password != settings.client_password` (`api/auth.py:79`) — *one* password for *all*
client users; the per-user record only supplies identity, not a credential.
- **The login UI is dead.** `api.clientLogin` is referenced only by `api.ts` (definition) and
`api.test.ts` — no component calls it. The endpoints, tables, tenant plumbing, and the
`require_client_session` / `module_access_map` path are all still live and still wired into the
shared route dependencies.
**3. Admin system** — environment-variable single credential
- Code: `app/api/auth.py` (`/api/auth/admin/*`), `require_admin_session` in `app/api/deps.py`.
- No DB row. Authenticates against `settings.admin_email` / `settings.admin_password`.
Session `role="admin"`**blanket access** (`session.ts:105` `hasModuleAccess` returns `true`
for admin; `require_admin_session` gates the admin-only routes).
- Uses a **second cookie**, `ADMIN_AUTH_COOKIE`.
- Drives the separate `/admin` + `/admin/client-access` UI (`routes/admin/+page.svelte` calls
`api.adminLogin()`), which exists to manage client users / feature flags / Power BI preview —
i.e. the "management behind the scenes" layer we no longer want.
### How they tangle
- **Frontend** routes by URL: `/admin*``AdminShell`, everything else → `ClientShell`
(`routes/+layout.svelte:15,42-49`). Two `localStorage` session stores
(`data-entry-app-client-session`, `data-entry-app-admin-session`) in `session.ts`.
- **Shared route deps bend to accept two token shapes.** `require_client_session` and
`require_client_module_access` (`api/deps.py:97-184`) special-case `role=="internal"` to skip the
`ClientUser` DB lookup and read permissions from a role-derived map, while still supporting
`role=="client"`. `core/access.py:_PERMISSION_TO_MODULE_LEVEL` exists purely to translate the
internal permission keys into the legacy client module/level shape so the same routes accept both.
- **Two permission models run in parallel**: role→permission-keys (internal) vs
per-user module→access-level rows + per-account feature flags (client). `permissions_to_module_map`
bridges them.
- **`tenant_id` is smeared across ~25 tables and ~25 backend files** (heaviest:
`db/migrations.py` 70 refs, `seed.py` 52, `api/product_costing.py` 36, plus every service/model),
for multi-tenancy we no longer want.
### Target architecture (per product direction)
> One login for everyone. User type `lean` = full access. `client` = its own permissions.
> No multi-tenant. No separate behind-the-scenes management app, except `lean` may have a few
> extra settings (e.g. change logo).
- **One login endpoint + one login page** for all users.
- **One user store**: keep `users` / `roles` / `permissions` / `role_permissions`. Everyone is a
`User` with a role. Add a **`lean`** role = all permissions (full access, including the extra
settings like logo). Define a **`client`** role with its own permission set. Operations etc. stay
as additional roles.
- **One cookie**, one session shape, one frontend session store.
- **Remove multi-tenancy**: drop `tenant_id` from models/queries/migrations/seed; collapse to a
single implicit tenant.
- **Retire the env-var admin login** and the **dead client-portal login** + its tables/service,
folding any still-needed capability (e.g. managing users) into permission-gated routes inside the
single app. `lean`-only settings (logo, etc.) become permission-gated, not a separate shell.
### Decoupling / migration approach (proposed)
1. **Confirm the dead path is dead** (done: `clientLogin` has no UI caller) and snapshot any client
data worth keeping (`client_users`, module permissions) so it can be re-expressed as `users` +
`roles` if needed.
2. **Unify on the `users`/`roles`/`permissions` model.** Introduce `lean` and `client` roles in
`seed_access.py` with the right permission sets. Migrate any real client users into `users`.
3. **Single login**: make `/api/access/login` the only login; one cookie; one session store; one
login page. Remove `/api/auth/admin/*`, `/api/auth/client/*`, `ADMIN_AUTH_COOKIE`, the
admin/client localStorage split, and the `/admin*` shell routing (fold any surviving admin
screens into permission-gated routes in the main app).
4. **Collapse the dual permission model**: drop `_PERMISSION_TO_MODULE_LEVEL` bridging and the
`role=="internal"` / `role=="client"` special-casing in `deps.py`; every route depends on
`require_permission(...)` (or a thin module-level wrapper) only.
5. **Drop multi-tenancy**: Alembic migration removing `tenant_id` columns (or leaving them nullable
and unused first, then dropping), plus removing `tenant_id` filters from services/queries and the
`sync_tenant_ids` backfill. **Sequence this on top of Phase 1 (Alembic)** so the column drops are
versioned and run identically on SQLite and Postgres.
6. **Delete the client-portal subsystem** once nothing references it: `models/client_access.py`,
`client_access_service.py`, `api/auth.py`, `api/client_access.py`, related schemas, and the
`ClientShell`/`AdminShell` split.
### Risk notes
- This touches **authentication** — stage it carefully behind tests; do not delete the old endpoints
until the unified login is proven in dev against both SQLite and (a Postgres copy of) prod.
- The shared password (`P0.4`/system 2) and the env-admin credential should be considered a
**security cleanup**, not just structure: per-user hashed passwords for everyone is the target.
- Dropping `tenant_id` is irreversible data-wise — do it as a dedicated, reviewed Alembic step with
a backup, after the login unification has settled.
---
## Remediation plan (phased)
### Phase 0 — Safety net (no behavior change)
- Add a schema-parity smoke test: fresh-SQLite `create_all` vs `Base.metadata` so later phases
cannot silently drift.
- Remove `backend/tests/_repro_throughput_post.py`.
### Phase 1 — Adopt Alembic *(foundation; most moving parts)*
- Add `alembic` to `backend/pyproject.toml`; `alembic init`.
- Wire `env.py` to read `DATABASE_URL` (via `app.core.config.settings`) and `Base.metadata`.
- **Critical for this setup:** enable `render_as_batch=True` so `ALTER` works on **SQLite (Windows dev)**;
Postgres handles `ALTER` natively.
- Autogenerate a **`0001_baseline`** migration from current models.
- `alembic stamp 0001_baseline` on existing dev **and** prod DBs so they are recognized without rebuilding.
- Fold `_LEGACY_COLUMN_PATCHES`, `sync_tenant_ids`, and `sync_product_visibility` into versioned
migrations (schema steps + explicit data-migration steps).
- Replace the startup `bootstrap_schema(...)` call (`main.py:105`) with `alembic upgrade head`
(or an explicit deploy step).
- Update `deploy/migrate-to-postgres.sh` **Phase 5** to run `alembic upgrade head` instead of
calling `bootstrap_schema`.
### Phase 2 — Money correctness
- `Float → Numeric(12, 4)` (tune per field) across the money/quantity columns listed in P0.1.
- Use `Decimal` in `services/costing_engine.py` and `services/product_costing_service.py`.
- Dedicated Alembic migration; guard with `tests/test_costing_engine.py` formula-parity tests.
### Phase 3 — Frontend shared utils
- New `frontend/src/lib/format.ts`: `formatDate`, `formatNumber`, `formatCurrency`, `toNum`.
- Replace the 9 / 5 / 2 duplicate implementations. Eliminates the `toNum`-style bug class.
### Phase 4 — Remove mock-on-error fallback *(quick, high-value correctness fix)*
- Remove the `fallback` return path in `api.ts` `fetchJson`.
- Surface real API errors / empty states in the UI.
- Keep `mock.ts` for tests only.
### Phase 5 — Unify authentication & user types *(addresses P0.4; large, cross-cutting)*
Sits on top of Phase 1 (Alembic) because the column drops must be versioned. Order within the phase:
1. Snapshot/migrate any real client users into `users` + `roles`; add `lean` and `client` roles in
`seed_access.py`.
2. Single login: make `/api/access/login` the only login; one cookie; one session store; one login
page. Remove `/api/auth/admin/*`, `/api/auth/client/*`, `ADMIN_AUTH_COOKIE`, and the `/admin*`
shell split.
3. Collapse the dual permission model — every route on `require_permission(...)`; delete the
`internal`/`client` special-casing and `_PERMISSION_TO_MODULE_LEVEL` bridge in `deps.py`/`access.py`.
4. Drop multi-tenancy (`tenant_id`) via a dedicated Alembic migration + query cleanup; remove
`sync_tenant_ids`.
5. Delete the dead client-portal subsystem (`models/client_access.py`, `client_access_service.py`,
`api/auth.py`, `api/client_access.py`, `AdminShell`).
6. `lean`-only extras (logo change, etc.) become permission-gated settings in the single app.
### Phase 6 — Decompose monolith routes
- Incrementally extract components + `+page.ts` load logic. Start with dashboard (`+page.svelte`)
and product-costing.
### Phase 7 — Hygiene
- Burn down `TODO`/legacy markers.
- Split `seed.py` by module.
- Refresh `CLAUDE.md` DB guidance.
---
## Suggested sequencing note
Phase 1 (Alembic) is the foundation the SQLite-dev / Postgres-prod split most depends on. However,
**Phase 4 (mock-on-error)** is the scariest correctness bug and a ~20-minute fix — a reasonable
quick win to do first, before Phase 1.
## Progress log
- 2026-06-03 — Audit completed; plan written. `toNum` bug fixed in
`routes/throughput/+page.svelte` and `routes/throughput/add/+page.svelte` (precursor to Phase 3).
- 2026-06-03 — Auth/user-type investigation added (P0.4 + Phase 5). Found three overlapping auth
systems; the client-portal login (`/api/auth/client/login`, shared password) is already dead in
the UI (`clientLogin` has no component caller). Target: single login, `lean`/`client` roles, no
multi-tenant, no separate admin shell.
- 2026-06-04 — Phase 4 quick win started: removed production `api.ts` mock-on-error fallback so
failed reads throw normalized API errors instead of returning fabricated mock pricing/costing data.
Removed `backend/tests/_repro_throughput_post.py` debug repro file.
- 2026-06-04 — Phase 0 safety net started: added a fresh SQLite schema smoke test that checks
model metadata tables and columns are created as declared.
- 2026-06-04 — Phase 3 shared utils started: added `frontend/src/lib/format.ts`, covered it with
unit tests, and replaced the duplicated `toNum` helper plus the mix-calculator/throughput number
and date formatters touched in recent work.
+11 -1
View File
@@ -5,11 +5,21 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY backend /app
COPY ["input_data/1.xlsx", "/app/input_data/1.xlsx"]
COPY ["input_data/Operations Throughput.xlsx", "/app/input_data/Operations Throughput.xlsx"]
COPY ["Input Cost Spreadsheet(1).xlsx", "/app/Input Cost Spreadsheet(1).xlsx"]
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir .
pip install --no-cache-dir . && \
chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+590 -38
View File
@@ -7,9 +7,9 @@ the current user has, then use those keys to hide/show navigation items.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.core.access import (
@@ -21,12 +21,19 @@ from app.core.access import (
require_permission,
)
from app.core.config import settings
from app.core.http import CLIENT_AUTH_COOKIE
from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key
from app.core.security_logging import log_security_event
from app.core.security import hash_password, issue_token, verify_password
from app.db.session import get_db
from app.models.access import Permission, Role, User
router = APIRouter(prefix="/api/access", tags=["access"])
login_rate_limiter = SlidingWindowRateLimiter(
limit=settings.login_rate_limit_attempts,
window_seconds=settings.login_rate_limit_window_seconds,
)
class LoginRequest(BaseModel):
@@ -58,6 +65,9 @@ class RoleRead(BaseModel):
name: str
description: str | None
permissions: list[str]
module_permissions: dict[str, str]
is_protected: bool = False
user_count: int = 0
class UserRead(BaseModel):
@@ -66,6 +76,273 @@ class UserRead(BaseModel):
name: str
is_active: bool
role: str | None
role_id: int | None
# True when this user can never be deleted (lean owner accounts). The UI
# uses this to disable the delete control rather than re-deriving the rule.
is_protected: bool = False
class AssignableRole(BaseModel):
id: int
name: str
description: str | None
class RoleModuleDefinition(BaseModel):
key: str
label: str
description: str
levels: list[str]
class CreateUserRequest(BaseModel):
email: str
name: str
role_id: int | None = None
is_active: bool = True
password: str | None = None
class AdminUpdateUserRequest(BaseModel):
name: str | None = None
email: str | None = None
role_id: int | None = None
is_active: bool | None = None
class AdminSetPasswordRequest(BaseModel):
new_password: str
class CreateRoleRequest(BaseModel):
name: str
description: str | None = None
module_permissions: dict[str, str] = {}
class UpdateRoleRequest(BaseModel):
name: str | None = None
description: str | None = None
module_permissions: dict[str, str] | None = None
# Lean owner accounts are permanent: they may be edited but never deleted, so a
# tenant can't accidentally lock itself out of the highest level of access.
LEAN_ROLE_NAME = "lean"
ADMIN_ROLE_NAME = "admin"
ROLE_MANAGEMENT_ALLOWED_ROLES = {LEAN_ROLE_NAME, ADMIN_ROLE_NAME}
PROTECTED_ROLE_NAMES = ROLE_MANAGEMENT_ALLOWED_ROLES
ROLE_MODULE_DEFINITIONS: tuple[dict[str, object], ...] = (
{
"key": "dashboard",
"label": "Dashboard",
"description": "Home dashboard visibility.",
"levels": {"none": (), "view": ("view_dashboard",)},
},
{
"key": "mix_calculator",
"label": "Mix Calculator",
"description": "Open the calculator and save sessions.",
"levels": {
"none": (),
"view": ("view_mix_calculator",),
"edit": ("view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"),
},
},
{
"key": "raw_materials",
"label": "Raw Materials",
"description": "View or edit raw materials.",
"levels": {"none": (), "view": ("view_raw_materials",), "edit": ("view_raw_materials", "edit_raw_materials")},
},
{
"key": "products",
"label": "Products",
"description": "View or edit finished products.",
"levels": {"none": (), "view": ("view_products",), "edit": ("view_products", "edit_products")},
},
{
"key": "mix_master",
"label": "Mix Master",
"description": "View or edit mix recipes.",
"levels": {"none": (), "view": ("view_mixes",), "edit": ("view_mixes", "edit_mixes")},
},
{
"key": "operations_throughput",
"label": "Throughput",
"description": "View or edit throughput entries.",
"levels": {"none": (), "view": ("view_throughput",), "edit": ("view_throughput", "edit_throughput")},
},
{
"key": "ordering",
"label": "Ordering",
"description": "Access customer ordering and ordering administration.",
"levels": {
"none": (),
"view": ("view_ordering",),
"edit": ("view_ordering", "edit_ordering"),
"manage": ("view_ordering", "edit_ordering", "manage_ordering"),
},
},
{
"key": "scenarios",
"label": "Scenarios",
"description": "View or run scenarios.",
"levels": {"none": (), "view": ("view_scenarios",), "edit": ("view_scenarios", "edit_scenarios")},
},
{
"key": "client_access",
"label": "Client Access",
"description": "Manage customer portal accounts and access.",
"levels": {"none": (), "manage": ("manage_client_access",)},
},
{
"key": "users",
"label": "Users",
"description": "View or manage internal users.",
"levels": {"none": (), "view": ("view_users",), "manage": ("view_users", "manage_users")},
},
{
"key": "roles",
"label": "Roles",
"description": "Manage roles and permission assignments.",
"levels": {"none": (), "manage": ("manage_permissions",)},
},
{
"key": "settings",
"label": "Settings",
"description": "Open settings and edit system configuration.",
"levels": {"none": (), "view": ("view_settings",), "edit": ("view_settings", "edit_settings")},
},
)
def _serialize_user_read(user: User) -> UserRead:
role_name = user.role.name if user.role else None
return UserRead(
id=user.id,
email=user.email,
name=user.name,
is_active=user.is_active,
role=role_name,
role_id=user.role_id,
is_protected=(role_name or "").lower() == LEAN_ROLE_NAME,
)
def _role_name_lower(role: Role | None) -> str:
return (role.name if role else "").strip().lower()
def _is_protected_role_name(role_name: str | None) -> bool:
return (role_name or "").strip().lower() in PROTECTED_ROLE_NAMES
def _module_definitions_response() -> list[RoleModuleDefinition]:
return [
RoleModuleDefinition(
key=definition["key"],
label=definition["label"],
description=definition["description"],
levels=list(definition["levels"].keys()),
)
for definition in ROLE_MODULE_DEFINITIONS
]
def _permissions_to_role_module_map(permission_keys: set[str]) -> dict[str, str]:
result: dict[str, str] = {}
for definition in ROLE_MODULE_DEFINITIONS:
selected = "none"
levels = definition["levels"]
for level, required in levels.items():
required_keys = set(required)
if not required_keys or required_keys.issubset(permission_keys):
selected = level
result[definition["key"]] = selected
return result
def _role_payload_to_permission_keys(module_permissions: dict[str, str]) -> set[str]:
known_modules = {definition["key"] for definition in ROLE_MODULE_DEFINITIONS}
unknown_modules = sorted(set(module_permissions) - known_modules)
if unknown_modules:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown modules: {unknown_modules}",
)
granted: set[str] = set()
for definition in ROLE_MODULE_DEFINITIONS:
key = definition["key"]
level = module_permissions.get(key, "none")
available_levels: dict[str, tuple[str, ...]] = definition["levels"] # type: ignore[assignment]
if level not in available_levels:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid access level '{level}' for module '{key}'",
)
granted.update(available_levels[level])
return granted
def _serialize_role_read(role: Role, *, user_count: int = 0) -> RoleRead:
permission_keys = {permission.key for permission in role.permissions}
return RoleRead(
id=role.id,
name=role.name,
description=role.description,
permissions=sorted(permission_keys),
module_permissions=_permissions_to_role_module_map(permission_keys),
is_protected=_is_protected_role_name(role.name),
user_count=user_count,
)
def _require_role_management_actor(user: User = Depends(get_current_user)) -> User:
if _role_name_lower(user.role) not in ROLE_MANAGEMENT_ALLOWED_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only lean and admin accounts can manage roles",
)
return user
def _load_role(db: Session, role_id: int) -> Role:
role = db.scalar(
select(Role).where(Role.id == role_id).options(selectinload(Role.permissions))
)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
return role
def _apply_role_updates(
role: Role,
*,
name: str | None,
description: str | None,
module_permissions: dict[str, str] | None,
permissions_by_key: dict[str, Permission],
) -> None:
if name is not None:
trimmed_name = name.strip()
if not trimmed_name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
role.name = trimmed_name
if description is not None:
role.description = description.strip() or None
if module_permissions is not None:
desired_permission_keys = _role_payload_to_permission_keys(module_permissions)
desired = {permissions_by_key[key] for key in desired_permission_keys}
current = set(role.permissions)
for permission in desired - current:
role.permissions.append(permission)
for permission in current - desired:
role.permissions.remove(permission)
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
@@ -75,7 +352,10 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
role_name = user.role.name if user.role else None
token = None
if include_token:
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
token = issue_token(
{"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email},
ttl_seconds=settings.session_ttl_seconds,
)
# role="internal" is a marker the shared auth deps recognise so internal
# users can hit the same routes as client-portal users without being
# confused with them. Display name lives in role_name / client_role.
@@ -96,16 +376,14 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
@router.post("/login", response_model=UserSession)
def login(payload: LoginRequest, db: Session = Depends(get_db)):
def login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
"""Internal-user login.
Authenticates against a shared internal password (``ADMIN_PASSWORD``) and
looks up the user by email. Inactive or unknown users are rejected with
a generic 401 to avoid leaking which emails are valid.
Authenticates against the per-user password hash stored on ``users``.
Inactive or unknown users are rejected with a generic 401 to avoid
leaking which emails are valid.
"""
if payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
login_rate_limiter.hit(request_client_key(request, suffix="internal-login"))
email = payload.email.strip().lower()
user = db.scalar(
select(User)
@@ -113,15 +391,26 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None or not user.is_active:
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not (
verify_password(payload.password, user.password_hash)
or (user.password_hash is None and payload.password == settings.admin_password)
):
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return _serialize_session(user, include_token=True)
session = _serialize_session(user, include_token=True)
if session.token:
CLIENT_AUTH_COOKIE.apply(response, session.token)
log_security_event("auth.login_succeeded", audience="internal", role=user.role.name if user.role else None, user_id=user.id)
return session.model_copy(update={"token": None})
@router.get("/me", response_model=UserSession)
def read_me(user: User = Depends(get_current_user)):
"""Return the current user with permission keys for UI navigation gating."""
return _serialize_session(user)
return _serialize_session(user).model_copy(update={"token": None})
@router.get("/me/permissions", response_model=list[str])
@@ -144,13 +433,11 @@ def update_me(
):
"""Allow an internal user to update their own name, email, or password."""
if payload.new_password:
# Require current password verification before allowing a password change.
# Users who have never set a personal password must supply the shared
# admin password as the current credential.
current_ok = (
verify_password(payload.current_password or "", user.password_hash)
if user.password_hash
else (payload.current_password or "") == settings.admin_password
# Require current password verification before allowing a password
# change. Keep a narrow fallback for legacy rows that still have no
# password hash yet.
current_ok = verify_password(payload.current_password or "", user.password_hash) or (
user.password_hash is None and (payload.current_password or "") == settings.admin_password
)
if not current_ok:
raise HTTPException(
@@ -181,7 +468,14 @@ def update_me(
db.commit()
db.refresh(user)
return _serialize_session(user, include_token=True)
return _serialize_session(user, include_token=True).model_copy(update={"token": None})
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response):
CLIENT_AUTH_COOKIE.clear(response)
response.status_code = status.HTTP_204_NO_CONTENT
return None
# Permission-enforced administrative endpoints. Route bodies should not check
@@ -192,36 +486,294 @@ def list_users(
db: Session = Depends(get_db),
_: User = Depends(require_permission("view_users")), # gated by permission key
):
users = db.scalars(select(User).options(selectinload(User.role))).all()
users = db.scalars(
select(User).options(selectinload(User.role)).order_by(User.name)
).all()
return [_serialize_user_read(user) for user in users]
@router.get("/assignable-roles", response_model=list[AssignableRole])
def list_assignable_roles(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Roles that a user-manager can assign — used to populate the role picker.
Separate from ``/roles`` (which exposes full permission sets and is gated by
``manage_permissions``); managing users only needs the role list itself.
"""
roles = db.scalars(select(Role).order_by(Role.name)).all()
return [
UserRead(
id=user.id,
email=user.email,
name=user.name,
is_active=user.is_active,
role=user.role.name if user.role else None,
)
for user in users
AssignableRole(id=role.id, name=role.name, description=role.description)
for role in roles
]
def _load_managed_user(db: Session, user_id: int) -> User:
user = db.scalar(
select(User).where(User.id == user_id).options(selectinload(User.role))
)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
def _resolve_role(db: Session, role_id: int | None) -> Role | None:
if role_id is None:
return None
role = db.scalar(select(Role).where(Role.id == role_id))
if role is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role")
return role
@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def create_user(
payload: CreateUserRequest,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Create a new internal user.
A user with no password can still sign in with the shared internal password
until they set a personal one in their own settings.
"""
email = payload.email.strip().lower()
if not email or "@" not in email:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
if db.scalar(select(User).where(User.email == email)):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
role = _resolve_role(db, payload.role_id)
password_hash = None
if payload.password:
if len(payload.password) < 8:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Password must be at least 8 characters",
)
password_hash = hash_password(payload.password)
user = User(
email=email,
name=name,
role_id=role.id if role else None,
is_active=payload.is_active,
password_hash=password_hash,
)
db.add(user)
db.commit()
db.refresh(user)
log_security_event("users.created", audience="internal", actor_user_id=actor.id, user_id=user.id)
return _serialize_user_read(user)
@router.patch("/users/{user_id}", response_model=UserRead)
def update_user(
user_id: int,
payload: AdminUpdateUserRequest,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Update another user's name, email, role, or active status."""
user = _load_managed_user(db, user_id)
if payload.is_active is not None:
# Guard against locking yourself out of your own management session.
if user.id == actor.id and not payload.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot deactivate your own account",
)
user.is_active = payload.is_active
if payload.name is not None:
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
user.name = name
if payload.email is not None:
email = payload.email.strip().lower()
if not email or "@" not in email:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
existing = db.scalar(select(User).where(User.email == email, User.id != user.id))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
user.email = email
if payload.role_id is not None:
role = _resolve_role(db, payload.role_id)
user.role_id = role.id if role else None
db.commit()
db.refresh(user)
log_security_event("users.updated", audience="internal", actor_user_id=actor.id, user_id=user.id)
return _serialize_user_read(user)
@router.post("/users/{user_id}/password", response_model=UserRead)
def set_user_password(
user_id: int,
payload: AdminSetPasswordRequest,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Set (reset) another user's password without their current password."""
user = _load_managed_user(db, user_id)
if len(payload.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="New password must be at least 8 characters",
)
user.password_hash = hash_password(payload.new_password)
db.commit()
db.refresh(user)
log_security_event("users.password_reset", audience="internal", actor_user_id=actor.id, user_id=user.id)
return _serialize_user_read(user)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
response: Response,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Delete a user. Lean owner accounts and your own account are protected."""
user = _load_managed_user(db, user_id)
if user.id == actor.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete your own account",
)
if (user.role.name if user.role else "").lower() == LEAN_ROLE_NAME:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Lean accounts cannot be deleted",
)
db.delete(user)
db.commit()
log_security_event("users.deleted", audience="internal", actor_user_id=actor.id, user_id=user_id)
response.status_code = status.HTTP_204_NO_CONTENT
return None
@router.get("/roles", response_model=list[RoleRead])
def list_roles(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
_: User = Depends(_require_role_management_actor),
):
user_counts = dict(
db.execute(select(User.role_id, func.count(User.id)).group_by(User.role_id)).all()
)
roles = db.scalars(
select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
).all()
return [
RoleRead(
id=role.id,
name=role.name,
description=role.description,
permissions=sorted(p.key for p in role.permissions),
return [_serialize_role_read(role, user_count=user_counts.get(role.id, 0)) for role in roles]
@router.get("/role-modules", response_model=list[RoleModuleDefinition])
def list_role_modules(_: User = Depends(_require_role_management_actor)):
return _module_definitions_response()
@router.post("/roles", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
def create_role(
payload: CreateRoleRequest,
db: Session = Depends(get_db),
actor: User = Depends(_require_role_management_actor),
):
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
existing = db.scalar(select(Role).where(func.lower(Role.name) == name.lower()))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
role = Role(name=name, description=None)
db.add(role)
db.flush()
_apply_role_updates(
role,
name=name,
description=payload.description,
module_permissions=payload.module_permissions,
permissions_by_key=permissions_by_key,
)
db.commit()
db.refresh(role)
log_security_event("roles.created", audience="internal", actor_user_id=actor.id, role_id=role.id)
return _serialize_role_read(role, user_count=0)
@router.patch("/roles/{role_id}", response_model=RoleRead)
def update_role(
role_id: int,
payload: UpdateRoleRequest,
db: Session = Depends(get_db),
actor: User = Depends(_require_role_management_actor),
):
role = _load_role(db, role_id)
original_name = role.name
protected = _is_protected_role_name(original_name)
requested_name = payload.name.strip() if payload.name is not None else role.name
if protected and requested_name.lower() != original_name.lower():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Lean and admin roles cannot be renamed",
)
for role in roles
]
if payload.name is not None:
duplicate = db.scalar(
select(Role).where(func.lower(Role.name) == requested_name.lower(), Role.id != role.id)
)
if duplicate:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
_apply_role_updates(
role,
name=payload.name,
description=payload.description,
module_permissions=payload.module_permissions,
permissions_by_key=permissions_by_key,
)
db.commit()
db.refresh(role)
user_count = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
log_security_event("roles.updated", audience="internal", actor_user_id=actor.id, role_id=role.id)
return _serialize_role_read(role, user_count=user_count)
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(
role_id: int,
response: Response,
db: Session = Depends(get_db),
actor: User = Depends(_require_role_management_actor),
):
role = _load_role(db, role_id)
if _is_protected_role_name(role.name):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Lean and admin roles cannot be deleted",
)
assigned_users = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
if assigned_users > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reassign users before deleting this role",
)
db.delete(role)
db.commit()
log_security_event("roles.deleted", audience="internal", actor_user_id=actor.id, role_id=role_id)
response.status_code = status.HTTP_204_NO_CONTENT
return None
@router.get("/permissions", response_model=list[str])
+44 -9
View File
@@ -1,16 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_admin_session, require_client_session
from app.core.config import settings
from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE
from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key
from app.core.security_logging import log_security_event
from app.core.security import issue_token
from app.db.session import get_db
from app.models.client_access import ClientAccount
from app.services.client_access_service import get_client_user_by_email, module_access_map
router = APIRouter(prefix="/api/auth", tags=["auth"])
login_rate_limiter = SlidingWindowRateLimiter(
limit=settings.login_rate_limit_attempts,
window_seconds=settings.login_rate_limit_window_seconds,
)
class LoginRequest(BaseModel):
@@ -27,7 +34,7 @@ class SessionResponse(BaseModel):
user_id: int | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] = Field(default_factory=dict)
token: str
token: str | None = None
def _build_session_response(
@@ -50,7 +57,8 @@ def _build_session_response(
"client_role": client_role,
"user_id": user_id,
"client_account_id": client_account_id,
}
},
ttl_seconds=settings.session_ttl_seconds,
)
return SessionResponse(
name=name,
@@ -66,19 +74,22 @@ def _build_session_response(
@router.post("/client/login", response_model=SessionResponse)
def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
def client_login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
login_rate_limiter.hit(request_client_key(request, suffix="client-login"))
if payload.password != settings.client_password:
log_security_event("auth.login_failed", audience="client", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
user = get_client_user_by_email(db, email=payload.email.strip().lower())
if user is None:
log_security_event("auth.login_failed", audience="client", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
client_account = db.scalar(select(ClientAccount).where(ClientAccount.id == user.client_account_id))
if client_account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured for this user")
return _build_session_response(
session_response = _build_session_response(
name=user.full_name,
email=user.email,
role="client",
@@ -88,14 +99,24 @@ def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
client_account_id=client_account.id,
module_permissions=module_access_map(user),
)
if session_response.token:
CLIENT_AUTH_COOKIE.apply(response, session_response.token)
log_security_event("auth.login_succeeded", audience="client", role="client", user_id=user.id, tenant_id=client_account.tenant_id)
return session_response.model_copy(update={"token": None})
@router.post("/admin/login", response_model=SessionResponse)
def admin_login(payload: LoginRequest):
def admin_login(payload: LoginRequest, response: Response, request: Request):
login_rate_limiter.hit(request_client_key(request, suffix="admin-login"))
if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password:
log_security_event("auth.login_failed", audience="admin", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin email or password")
return _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
session_response = _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
if session_response.token:
ADMIN_AUTH_COOKIE.apply(response, session_response.token)
log_security_event("auth.login_succeeded", audience="admin", role="admin")
return session_response.model_copy(update={"token": None})
@router.get("/client/session", response_model=SessionResponse)
@@ -112,9 +133,23 @@ def read_client_session(session: AuthSession = Depends(require_client_session),
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=module_access_map(user),
)
).model_copy(update={"token": None})
@router.get("/admin/session", response_model=SessionResponse)
def read_admin_session(session: AuthSession = Depends(require_admin_session)):
return _build_session_response(name=session.name, email=session.email, role=session.role)
return _build_session_response(name=session.name, email=session.email, role=session.role).model_copy(update={"token": None})
@router.post("/client/logout", status_code=status.HTTP_204_NO_CONTENT)
def client_logout(response: Response):
CLIENT_AUTH_COOKIE.clear(response)
response.status_code = status.HTTP_204_NO_CONTENT
return None
@router.post("/admin/logout", status_code=status.HTTP_204_NO_CONTENT)
def admin_logout(response: Response):
ADMIN_AUTH_COOKIE.clear(response)
response.status_code = status.HTTP_204_NO_CONTENT
return None
+6 -5
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/client-access", tags=["client-access"])
def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
clients = list_client_accounts(db)
if session.role == "admin":
if session.role in {"admin", "internal"}:
return clients
return [client for client in clients if client.id == session.client_account_id]
@@ -61,12 +61,12 @@ def _read_client_account(db: Session, client_id: int, session: AuthSession) -> d
def _actor_metadata(session: AuthSession) -> dict[str, str]:
if session.role == "admin":
if session.role in {"admin", "internal"}:
return {
"actor_type": "lean_admin",
"actor_name": session.name,
"actor_email": session.email,
"actor_role": "admin",
"actor_role": session.client_role or "admin",
}
return {
"actor_type": "client_superadmin",
@@ -78,10 +78,11 @@ def _actor_metadata(session: AuthSession) -> dict[str, str]:
@router.get("", response_model=list[ClientAccessRead])
def get_client_access(
limit: int = Query(default=100, ge=1, le=200),
db: Session = Depends(get_db),
session: AuthSession = Depends(require_client_access_manager_session),
):
return [serialize_client_account(client) for client in _authorized_client_scope(db, session)]
return [serialize_client_account(client) for client in _authorized_client_scope(db, session)[:limit]]
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
+169 -2
View File
@@ -7,15 +7,20 @@ breakdowns, scenarios, data-quality) and only used summaries from each.
"""
from __future__ import annotations
from datetime import date
import json
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.models.product_costing import ProductCostItem
from app.models.raw_material import RawMaterial
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.services.client_access_service import has_access_level
from app.services.costing_engine import (
calculate_mix_cost,
@@ -33,9 +38,169 @@ def _can(session: AuthSession, module_key: str) -> bool:
return has_access_level(permissions.get(module_key), "view")
def _month_start(today: date) -> date:
return today.replace(day=1)
def _warnings(item: ProductCostItem) -> list[str]:
if not item.warnings:
return []
try:
parsed = json.loads(item.warnings)
return parsed if isinstance(parsed, list) else [str(parsed)]
except json.JSONDecodeError:
return [item.warnings]
def _pricing_key(value: str | None) -> str:
return (value or "").strip().lower()
def _find_pricing_item(
entry: ProductionThroughput,
product: ThroughputProduct | None,
by_item_id: dict[str, ProductCostItem],
by_name: dict[str, ProductCostItem],
) -> ProductCostItem | None:
if product and product.item_id and product.item_id in by_item_id:
return by_item_id[product.item_id]
return by_name.get(_pricing_key(entry.product_name_snapshot)) or by_name.get(_pricing_key(product.name if product else None))
def _operations_summary(session: AuthSession, db: Session) -> dict | None:
if not (_can(session, "operations_throughput") or _can(session, "products") or _can(session, "dashboard")):
return None
today = date.today()
start = _month_start(today)
entries = db.scalars(
select(ProductionThroughput)
.where(
ProductionThroughput.tenant_id == session.tenant_id,
ProductionThroughput.production_date >= start,
ProductionThroughput.production_date <= today,
)
.options(selectinload(ProductionThroughput.product))
.order_by(ProductionThroughput.production_date.desc())
).all()
pricing_items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)).all()
by_item_id = {item.item_id: item for item in pricing_items if item.item_id}
by_name: dict[str, ProductCostItem] = {}
for item in pricing_items:
by_name.setdefault(_pricing_key(item.product_name), item)
by_name.setdefault(_pricing_key(item.mix_product_name), item)
product_totals: dict[str, dict] = {}
client_totals: dict[str, float] = {}
produced_not_priced: dict[str, dict] = {}
total_kg = 0.0
total_bags = 0.0
estimated_wholesale_value = 0.0
wholesale_rows = 0
for entry in entries:
kg = entry.calculated_kg or 0.0
bags = entry.quantity if entry.quantity_type == "bags" else 0.0
total_kg += kg
total_bags += bags
product = entry.product
name = entry.product_name_snapshot or product.name if product else entry.product_name_snapshot
bucket = product_totals.setdefault(
name,
{"product_name": name, "client_name": product.client_name if product else None, "kg": 0.0, "bags": 0.0, "entries": 0},
)
bucket["kg"] += kg
bucket["bags"] += bags
bucket["entries"] += 1
client = product.client_name if product and product.client_name else "Unassigned"
client_totals[client] = client_totals.get(client, 0.0) + kg
pricing = _find_pricing_item(entry, product, by_item_id, by_name)
pricing_warnings = _warnings(pricing) if pricing else ["Missing product pricing"]
wholesale_price = pricing.wholesale_price if pricing else None
unit_kg = pricing.unit_kg if pricing else None
if wholesale_price is not None:
units = kg / unit_kg if unit_kg and unit_kg > 0 else entry.quantity
estimated_wholesale_value += units * wholesale_price
wholesale_rows += 1
if pricing is None or pricing_warnings or wholesale_price is None:
missing = produced_not_priced.setdefault(
name,
{
"product_name": name,
"kg": 0.0,
"status": "Missing pricing" if pricing is None else "Needs review",
"warnings": pricing_warnings[:2],
},
)
missing["kg"] += kg
issue_counts = {
"missing_lookup": 0,
"missing_unit_kg": 0,
"missing_pallet_qty": 0,
"missing_price": 0,
"invalid_margin": 0,
}
for item in pricing_items:
warnings = " ".join(_warnings(item)).lower()
if "lookup" in warnings:
issue_counts["missing_lookup"] += 1
if "unit kg" in warnings:
issue_counts["missing_unit_kg"] += 1
if "pallet" in warnings:
issue_counts["missing_pallet_qty"] += 1
if item.distributor_price is None or item.wholesale_price is None:
issue_counts["missing_price"] += 1
if "margin" in warnings:
issue_counts["invalid_margin"] += 1
top_products = sorted(product_totals.values(), key=lambda row: row["kg"], reverse=True)[:5]
clients = [
{"client_name": client, "kg": round(kg, 2)}
for client, kg in sorted(client_totals.items(), key=lambda item: item[1], reverse=True)[:5]
]
produced_not_priced_rows = sorted(produced_not_priced.values(), key=lambda row: row["kg"], reverse=True)[:5]
return {
"period_label": "This month",
"total_kg": round(total_kg, 2),
"total_bags": round(total_bags, 2),
"entry_count": len(entries),
"estimated_wholesale_value": round(estimated_wholesale_value, 2),
"priced_entry_count": wholesale_rows,
"top_products": [
{
"product_name": row["product_name"],
"client_name": row["client_name"],
"kg": round(row["kg"], 2),
"bags": round(row["bags"], 2),
"entries": row["entries"],
}
for row in top_products
],
"client_totals": clients,
"pricing_issues": {
**issue_counts,
"total": sum(issue_counts.values()),
},
"produced_not_priced": [
{
"product_name": row["product_name"],
"kg": round(row["kg"], 2),
"status": row["status"],
"warnings": row["warnings"],
}
for row in produced_not_priced_rows
],
}
@router.get("/summary")
def dashboard_summary(
session: AuthSession = Depends(require_client_session),
session: AuthSession = Depends(require_client_module_access("dashboard")),
db: Session = Depends(get_db),
):
raw_materials_summary: dict | None = None
@@ -44,6 +209,7 @@ def dashboard_summary(
raw_series: list[float] = []
mix_series: list[float] = []
product_series: list[float] = []
operations_summary = _operations_summary(session, db)
if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars(
@@ -147,4 +313,5 @@ def dashboard_summary(
"mix_cost_per_kg": mix_series,
"product_finished_delivered": product_series,
},
"operations": operations_summary,
}
+43 -7
View File
@@ -2,8 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -13,14 +12,14 @@ from app.core.access import (
get_user_permissions,
permissions_to_module_map,
)
from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token
from app.core.security_logging import log_security_event
from app.core.security import verify_token
from app.db.session import get_db
from app.models.access import Role, User
from app.models.client_access import ClientFeatureAccess, ClientUser
from app.services.client_access_service import has_access_level, module_access_map
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(frozen=True)
class AuthSession:
@@ -67,13 +66,16 @@ def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
def get_auth_session(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
request: Request,
db: Session = Depends(get_db),
) -> AuthSession:
if credentials is None:
token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name) or get_bearer_or_cookie_token(
request, cookie_name=ADMIN_AUTH_COOKIE.name
)
if token is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
payload = verify_token(token)
# Internal Hunter Stock Feeds users get an auth session derived from the
# role/permission tables rather than the client-portal ClientUser tables.
@@ -111,6 +113,7 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) ->
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
if session.role != "admin":
log_security_event("authz.denied", role=session.role, required="admin")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session
@@ -143,6 +146,7 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get(module_key), minimum_level):
log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{module_key} access is not permitted",
@@ -158,10 +162,12 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
)
)
if feature is not None and not feature.enabled:
log_security_event("authz.denied", role=session.role, module=module_key, reason="feature_disabled")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} is disabled for this client")
permissions = module_access_map(user)
if not has_access_level(permissions.get(module_key), minimum_level):
log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted")
return AuthSession(
@@ -178,18 +184,48 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
return dependency
def require_ordering_admin_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
"""Internal-side authorization for managing the ordering portal.
Accepts the single Lean admin (``role == "admin"``) or an internal Hunter
user holding ``manage`` on the ordering module. Client/customer users are
rejected — they use the customer-facing ``require_client_module_access``
dependency instead.
"""
if session.role == "admin":
return session
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("ordering"), "manage"):
log_security_event("authz.denied", role=session.role, module="ordering", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires manage access")
return session
log_security_event("authz.denied", role=session.role, module="ordering", required="admin")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires internal admin access")
def require_client_access_manager_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "admin":
return session
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires Lean access")
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
user = load_current_client_user(db, require_client_session(session))
permissions = module_access_map(user)
if user.role != "superadmin" or not has_access_level(permissions.get("client_access"), "manage"):
log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required")
return AuthSession(
+982
View File
@@ -0,0 +1,982 @@
from fastapi import APIRouter, Depends, HTTPException, Query
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, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.models.change_event import EditorChangeEvent
from app.schemas.editor import (
EditorChangeEventRead,
EditorIngredientCreate,
EditorIngredientRow,
EditorIngredientUpdate,
EditorMixFormulaRead,
EditorMixCreate,
EditorMixFormulaReplace,
EditorMixIngredientCreate,
EditorMixIngredientUpdate,
EditorMixRow,
EditorMixUpdate,
EditorProductFormulaRead,
EditorProductIngredientCreate,
EditorProductIngredientUpdate,
EditorProductRow,
EditorProductUpdate,
EditorResolvedMixFormula,
)
from app.services.change_log import (
ENTITY_INGREDIENT,
ENTITY_MIX,
diff_fields,
list_changes,
record_change,
)
from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product
router = APIRouter(prefix="/api/editor", tags=["editor"])
def _serialize_row(product: Product) -> dict:
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"item_id": product.item_id,
"name": product.name,
"mix_id": product.mix_id,
"mix_client_name": product.mix.client_name if product.mix else "",
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"unit_of_measure": product.unit_of_measure,
"visible": product.visible,
"product_notes": product.notes,
"mix_notes": product.mix.notes if product.mix else None,
}
def _serialize_product_formula(product: Product) -> 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,
"sort_order": ingredient.sort_order,
"notes": ingredient.notes,
}
for ingredient in sorted(product.ingredients, key=lambda item: (item.sort_order, item.raw_material.name if item.raw_material else ""))
]
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"ingredients": ingredients,
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
}
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
# Status is product-driven once a mix has products (Active = at least one
# visible product). A mix with no products yet has nothing to fan out to, so
# it falls back to its own `status` column — that's what lets a brand-new
# mix read as Active instead of being stuck Inactive and hidden.
visible = visible_count > 0 if product_count > 0 else mix.status == "active"
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"visible": visible,
"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 _serialize_change_event(event: EditorChangeEvent) -> dict:
return {
"id": event.id,
"entity_type": event.entity_type,
"entity_id": event.entity_id,
"action": event.action,
"actor_name": event.actor_name,
"actor_email": event.actor_email,
"actor_role": event.actor_role,
"summary": event.summary,
"changes": event.changes or [],
"created_at": event.created_at,
}
def _format_kg(value: float) -> str:
text = f"{value:.4f}".rstrip("0").rstrip(".")
return f"{text or '0'} kg"
def _formula_deltas(
before: list[dict],
after: list[dict],
) -> list[dict]:
"""Per-ingredient before/after deltas between two resolved formulas.
`resolve_editor_mix_formula` returns plain dicts (ingredients are dicts too),
so read the rows by key, not attribute.
"""
before_map = {row["raw_material_name"]: row["quantity_kg"] for row in before}
after_map = {row["raw_material_name"]: row["quantity_kg"] for row in after}
deltas: list[dict] = []
for name in sorted(set(before_map) | set(after_map)):
old = before_map.get(name)
new = after_map.get(name)
if old == new:
continue
deltas.append(
{
"field": name,
"label": name,
"before": _format_kg(old) if old is not None else None,
"after": _format_kg(new) if new is not None else None,
}
)
return deltas
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)
.where(Product.id == product_id, Product.tenant_id == tenant_id)
.options(
joinedload(Product.mix),
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
)
)
def _require_editor_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
raise HTTPException(status_code=403, detail="Lean access is required")
if not has_access_level(permissions.get("products"), "edit"):
raise HTTPException(status_code=403, detail="products edit access is required")
if not has_access_level(permissions.get("mix_master"), "edit"):
raise HTTPException(status_code=403, detail="mix_master edit access is required")
if not session.tenant_id:
raise HTTPException(status_code=403, detail="Internal user context is missing")
return session
raise HTTPException(status_code=403, detail="Lean access is required")
@router.get("/products", response_model=list[EditorProductRow])
def list_editor_products(
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(Product)
.where(Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
.join(Product.mix)
.order_by(Product.client_name, Product.name, Product.id)
.limit(limit)
)
if client_name:
statement = statement.where(Product.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(
or_(
Product.client_name.ilike(term),
Product.name.ilike(term),
Product.item_id.ilike(term),
Product.unit_of_measure.ilike(term),
Mix.name.ilike(term),
)
)
return [_serialize_row(product) for product in db.scalars(statement).all()]
@router.patch("/products/{product_id}", response_model=EditorProductRow)
def update_editor_product(
product_id: int,
payload: EditorProductUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if payload.mix_id is not None:
mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return _serialize_row(product)
@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.post("/mixes", response_model=EditorMixRow, status_code=201)
def create_editor_mix(
payload: EditorMixCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = Mix(
tenant_id=session.tenant_id or "",
client_name=payload.client_name.strip(),
name=payload.name.strip(),
notes=payload.notes,
# Active by default so a freshly created mix shows under the default
# "Active" filter rather than being hidden until it has a visible product.
status="active",
)
db.add(mix)
db.flush()
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix.id,
action="created",
summary=f"Created mix “{mix.name}” for {mix.client_name}",
)
db.commit()
db.refresh(mix)
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
return _serialize_mix_row(mix, visible_count=0, product_count=0)
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix(
mix_id: int,
payload: EditorMixUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: for a mix with products it fans out to the
# visibility of every product; for a product-less mix it maps to the mix's
# own `status` column so the toggle still persists.
visible = updates.pop("visible", None)
product_total = (
db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
)
or 0
)
before = {field: getattr(mix, field) for field in updates}
if visible is not None:
if product_total > 0:
visible_before = db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
)
before["visible"] = bool(visible_before)
else:
before["visible"] = mix.status == "active"
for field, value in updates.items():
setattr(mix, field, value)
if visible is not None:
if product_total > 0:
for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all():
product.visible = visible
else:
mix.status = "active" if visible else "inactive"
after = dict(updates)
if visible is not None:
after["visible"] = visible
deltas = diff_fields(
before,
after,
{"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"},
)
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix.id,
action="updated",
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
changes=deltas,
)
db.commit()
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.delete("/mixes/{mix_id}", status_code=204)
def delete_editor_mix(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""Delete a mix that no product depends on.
A product must reference a mix (`products.mix_id` is NOT NULL), so a mix that
still drives products can't be removed without orphaning them — those should
be marked inactive instead. The mix's own ingredient rows cascade away with
it via the `delete-orphan` relationship.
"""
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
product_total = (
db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
)
or 0
)
if product_total > 0:
raise HTTPException(
status_code=409,
detail=(
f"This mix has {product_total} linked product"
f"{'s' if product_total != 1 else ''}. Mark it inactive or remove its products first."
),
)
db.delete(mix)
db.commit()
return None
@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")
raw_material = db.scalar(
select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)
)
if raw_material 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,
)
)
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_added",
summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})",
changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}],
)
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")
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
updates = payload.model_dump(exclude_unset=True)
before = {field: getattr(ingredient, field) for field in updates}
for field, value in updates.items():
setattr(ingredient, field, value)
deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"})
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_updated",
summary=f"Updated {raw_material_name}",
changes=deltas,
)
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")
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
removed_kg = ingredient.quantity_kg
db.delete(ingredient)
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_removed",
summary=f"Removed {raw_material_name}",
changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}],
)
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("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def get_editor_mix_resolved_formula(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""The mix formula as the Mix Calculator reads it (product-first resolution).
This is what the Mix Editor displays, so the two surfaces show identical
ingredients and quantities. See `resolve_editor_mix_formula`.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def replace_editor_mix_formula(
mix_id: int,
payload: EditorMixFormulaReplace,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""Replace a mix's whole formula in one save.
Writes back to the *same source* the Mix Calculator reads: the representative
product's own formula (`ProductIngredient`) when it has one, otherwise the
shared mix master (`MixIngredient`). Either way the calculator immediately
reflects the edit.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
# Snapshot the formula as it stands so we can diff it against the saved one.
before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
raw_ids = [row.raw_material_id for row in payload.rows]
if len(set(raw_ids)) != len(raw_ids):
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
existing_ids = set(
db.scalars(
select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
).all()
)
missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
if missing:
raise HTTPException(status_code=404, detail="Raw material not found")
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
if product is not None and product.ingredients:
# Replace the representative product's own formula.
for ingredient in list(product.ingredients):
db.delete(ingredient)
db.flush()
for sort_order, row in enumerate(payload.rows, start=1):
db.add(
ProductIngredient(
tenant_id=tenant_id,
product_id=product.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
sort_order=sort_order,
notes=row.notes,
)
)
else:
# No product-specific formula in play: edit the shared mix master, which
# is what the calculator falls back to for this mix.
for ingredient in list(mix.ingredients):
db.delete(ingredient)
db.flush()
for row in payload.rows:
db.add(
MixIngredient(
tenant_id=tenant_id,
mix_id=mix.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
notes=row.notes,
)
)
db.flush()
# Drop now-stale ORM state so the re-resolve reads the rows we just wrote
# rather than the formerly-loaded ingredient collections from the identity map.
db.expire_all()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
deltas = _formula_deltas(before_formula["ingredients"], after_formula["ingredients"])
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="formula_updated",
summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})",
changes=deltas,
)
db.commit()
return after_formula
@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_mix_history(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id)
return [_serialize_change_event(event) for event in events]
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
def get_editor_product_ingredients(
product_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return _serialize_product_formula(product)
@router.post("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead, status_code=201)
def add_editor_product_ingredient(
product_id: int,
payload: EditorProductIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product 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")
next_sort_order = (
db.scalar(
select(func.coalesce(func.max(ProductIngredient.sort_order), 0)).where(ProductIngredient.product_id == product_id)
)
or 0
) + 1
db.add(
ProductIngredient(
tenant_id=session.tenant_id or "",
product_id=product_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
sort_order=next_sort_order,
notes=payload.notes,
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this product") from exc
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.patch("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def update_editor_product_ingredient(
product_id: int,
ingredient_id: int,
payload: EditorProductIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.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()
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.delete("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def delete_editor_product_ingredient(
product_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
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,
"category": material.category,
"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,
category=(payload.category or "").strip() or None,
notes=payload.notes,
)
db.add(material)
try:
db.flush()
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=material.id,
action="created",
summary=f"Created ingredient “{material.name}",
)
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()
if "category" in updates:
updates["category"] = (updates["category"] or "").strip() or None
before = {field: getattr(material, field) for field in updates}
for field, value in updates.items():
setattr(material, field, value)
deltas = diff_fields(
before,
updates,
{
"name": "Name",
"supplier": "Supplier",
"unit_of_measure": "Unit of measure",
"kg_per_unit": "Kg per unit",
"status": "Status",
"rounding_decimals": "Rounding (dp)",
"category": "Category",
"notes": "Notes",
},
)
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=material.id,
action="updated",
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
changes=deltas,
)
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))
@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_ingredient_history(
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id)
return [_serialize_change_event(event) for event in events]
+26 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
@@ -22,7 +22,7 @@ from app.services.mix_calculator_service import (
update_mix_calculator_session,
)
from app.services.mix_calculator_pdf import MixCalculatorPdfUnavailableError, build_mix_calculator_pdf
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename, mix_calculator_preview_pdf_filename
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
@@ -37,10 +37,11 @@ def mix_calculator_options(
@router.get("", response_model=list[MixCalculatorSessionSummaryRead])
def mix_calculator_sessions(
limit: int = Query(default=100, ge=1, le=200),
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
db: Session = Depends(get_db),
):
return list_mix_calculator_sessions(db, auth_session=session)
return list_mix_calculator_sessions(db, auth_session=session, limit=limit)
@router.post("/preview", response_model=MixCalculatorPreviewRead)
@@ -55,6 +56,28 @@ def preview_mix_calculator_session(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post("/preview/pdf")
def preview_mix_calculator_session_pdf(
payload: MixCalculatorSessionCreate,
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
db: Session = Depends(get_db),
):
try:
preview = calculate_mix_calculator_preview(db, tenant_id=session.tenant_id or "", payload=payload)
pdf_bytes = build_mix_calculator_pdf(preview)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except MixCalculatorPdfUnavailableError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
filename = mix_calculator_preview_pdf_filename(MixCalculatorPreviewRead.model_validate(preview))
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("", response_model=MixCalculatorSessionRead, status_code=status.HTTP_201_CREATED)
def create_saved_mix_calculator_session(
payload: MixCalculatorSessionCreate,
+7 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -13,8 +13,12 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
@router.get("", response_model=list[MixRead])
def list_mixes(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
def list_mixes(
limit: int = Query(default=100, ge=1, le=200),
session: AuthSession = Depends(require_client_module_access("mix_master")),
db: Session = Depends(get_db),
):
mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name).limit(limit)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes]
+390
View File
@@ -0,0 +1,390 @@
"""Customer-facing B2B ordering API.
Every endpoint is gated by the ``ordering`` module and scoped to the caller's
own company (``tenant_id`` + ``client_account_id``). Customers can never see
another company's catalogue visibility, prices, or orders.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.ordering import CatalogueProduct, Order, OrderLine
from app.schemas.ordering import (
DraftOrderCreate,
DraftOrderUpdate,
OrderLineInput,
OrderSubmitRequest,
)
from app.services import ordering_service as svc
from app.services.order_notifications import send_order_submitted_notifications
from app.services.order_pdf import OrderPdfUnavailableError, build_order_confirmation_pdf
from app.services.ordering_pricing import resolve_price
router = APIRouter(prefix="/api/ordering", tags=["ordering"])
# --- Helpers -----------------------------------------------------------------
def _require_active_customer(db: Session, session: AuthSession):
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
svc.ensure_customer_active(account)
return account
def _get_visible_product(db: Session, session: AuthSession, product_id: int) -> CatalogueProduct:
hidden = svc.visible_product_ids_for_customer(
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
)
product = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.id == product_id,
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
CatalogueProduct.active.is_(True),
)
)
if product is None or product.id in hidden:
raise HTTPException(status_code=404, detail="Product not available")
return product
def _load_own_order(db: Session, session: AuthSession, order_id: int) -> Order:
order = db.scalar(
select(Order)
.where(
Order.id == order_id,
Order.tenant_id == svc.ORDERING_TENANT,
Order.client_account_id == session.client_account_id,
)
.options(selectinload(Order.lines))
)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
return order
def _rebuild_lines(db: Session, session: AuthSession, order: Order, lines: list[OrderLineInput]) -> None:
"""Replace an order's lines with freshly priced lines (server-side pricing).
Validates product availability and minimum order quantity. Raises 422 on
invalid input.
"""
order.lines.clear()
db.flush()
for index, line_input in enumerate(lines):
product = _get_visible_product(db, session, line_input.product_id)
if line_input.quantity < product.min_order_quantity:
raise HTTPException(
status_code=422,
detail=(
f"{product.name}: minimum order quantity is "
f"{product.min_order_quantity:g} {product.unit_of_measure}"
),
)
resolution = resolve_price(
db,
client_account_id=session.client_account_id,
product=product,
quantity=line_input.quantity,
)
order.lines.append(
OrderLine(
tenant_id=svc.ORDERING_TENANT,
product_id=product.id,
product_name=product.name,
product_sku=product.sku,
quantity=line_input.quantity,
unit_price=resolution.unit_price,
requires_quote=resolution.requires_quote,
price_source=resolution.price_source,
price_rule_id=resolution.price_rule_id,
discount_percent=resolution.discount_percent,
sort_order=index,
notes=line_input.notes,
)
)
db.flush()
svc.recompute_order_totals(order)
# --- Catalogue ---------------------------------------------------------------
@router.get("/catalogue")
def list_catalogue(
category: str | None = Query(default=None),
q: str | None = Query(default=None),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
products = svc.list_visible_products(
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
)
if category:
products = [p for p in products if p.category == category]
if q:
needle = q.lower()
products = [
p
for p in products
if needle in p.name.lower()
or needle in p.sku.lower()
or (p.description and needle in p.description.lower())
]
return [
svc.serialize_product(p, db=db, client_account_id=session.client_account_id)
for p in products
]
@router.get("/catalogue/{product_id}")
def get_catalogue_product(
product_id: int,
quantity: float = Query(default=1.0, gt=0),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
product = _get_visible_product(db, session, product_id)
return svc.serialize_product(
product, db=db, client_account_id=session.client_account_id, quantity=quantity
)
# --- Orders ------------------------------------------------------------------
@router.get("/orders")
def list_orders(
status_filter: str | None = Query(default=None, alias="status"),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
stmt = (
select(Order)
.where(
Order.tenant_id == svc.ORDERING_TENANT,
Order.client_account_id == session.client_account_id,
)
.options(selectinload(Order.lines))
.order_by(Order.created_at.desc())
)
orders = db.scalars(stmt).all()
if status_filter == "draft":
orders = [o for o in orders if o.status == "draft"]
elif status_filter == "submitted":
orders = [o for o in orders if o.status != "draft"]
return [svc.serialize_order(o, for_admin=False) for o in orders]
@router.get("/orders/{order_id}")
def get_order(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
return svc.serialize_order(order, for_admin=False)
@router.post("/orders", status_code=status.HTTP_201_CREATED)
def create_draft_order(
payload: DraftOrderCreate,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
order = Order(
tenant_id=svc.ORDERING_TENANT,
client_account_id=session.client_account_id,
status="draft",
created_by_user_id=session.user_id,
created_by_name=session.name,
purchase_order_number=payload.purchase_order_number,
delivery_notes=payload.delivery_notes,
requested_delivery_date=payload.requested_delivery_date,
fulfilment_method=payload.fulfilment_method,
)
db.add(order)
db.flush()
_rebuild_lines(db, session, order, payload.lines)
svc.record_status_change(
db, order, to_status="draft", actor_type="customer", actor_name=session.name, note="Draft created"
)
svc.audit_order_event(
db, session=session, order=order, action="order.created", summary="Draft order created."
)
db.commit()
db.refresh(order)
return svc.serialize_order(order, for_admin=False)
@router.patch("/orders/{order_id}")
def update_draft_order(
order_id: int,
payload: DraftOrderUpdate,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
if order.status not in svc.CUSTOMER_EDITABLE_STATUSES:
raise HTTPException(
status_code=409,
detail="This order has been submitted and can no longer be edited. Ask an admin to reopen it.",
)
changes = payload.model_dump(exclude_unset=True)
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
if field_name in changes:
setattr(order, field_name, changes[field_name])
if payload.lines is not None:
_rebuild_lines(db, session, order, payload.lines)
svc.audit_order_event(
db, session=session, order=order, action="order.updated", summary="Draft order updated."
)
db.commit()
db.refresh(order)
return svc.serialize_order(order, for_admin=False)
@router.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft_order(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
if order.status != "draft":
raise HTTPException(status_code=409, detail="Only draft orders can be deleted")
db.delete(order)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/orders/{order_id}/submit")
def submit_order(
order_id: int,
payload: OrderSubmitRequest,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
account = _require_active_customer(db, session)
order = _load_own_order(db, session, order_id)
if order.status != "draft":
raise HTTPException(status_code=409, detail="Only draft orders can be submitted")
if not order.lines:
raise HTTPException(status_code=422, detail="Cannot submit an empty order")
# Apply any last-minute header changes supplied on submit.
changes = payload.model_dump(exclude_unset=True)
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
if field_name in changes and changes[field_name] is not None:
setattr(order, field_name, changes[field_name])
# Required PO number, if the tenant configured it.
from app.services.order_notifications import get_or_create_settings
settings = get_or_create_settings(db, order.tenant_id)
if settings.require_po_number and not (order.purchase_order_number or "").strip():
raise HTTPException(status_code=422, detail="A purchase order number is required to submit this order")
# Re-resolve and freeze the exact price used at submission time.
line_inputs = [
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
for line in order.lines
]
_rebuild_lines(db, session, order, line_inputs)
order.order_number = svc.next_order_number(db, order.tenant_id)
order.submitted_at = datetime.utcnow()
svc.record_status_change(
db, order, to_status="submitted", actor_type="customer", actor_name=session.name, note="Submitted by customer"
)
svc.audit_order_event(
db, session=session, order=order, action="order.submitted",
summary=f"Order {order.order_number} submitted (subtotal ex GST {order.subtotal_ex_gst:.2f}).",
)
notifications = send_order_submitted_notifications(db, order)
db.commit()
db.refresh(order)
result = svc.serialize_order(order, for_admin=False)
result["notifications"] = [
{"channel": n.channel, "recipients": n.recipients, "delivered": n.delivered, "detail": n.detail}
for n in notifications
]
return result
@router.post("/orders/{order_id}/reorder", status_code=status.HTTP_201_CREATED)
def reorder(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
source = _load_own_order(db, session, order_id)
new_order = Order(
tenant_id=svc.ORDERING_TENANT,
client_account_id=session.client_account_id,
status="draft",
created_by_user_id=session.user_id,
created_by_name=session.name,
fulfilment_method=source.fulfilment_method,
)
db.add(new_order)
db.flush()
line_inputs = [
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
for line in source.lines
]
# Skip lines whose product is no longer available rather than failing.
available: list[OrderLineInput] = []
for line_input in line_inputs:
product = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.id == line_input.product_id,
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
CatalogueProduct.active.is_(True),
)
)
if product is not None and line_input.quantity >= product.min_order_quantity:
available.append(line_input)
_rebuild_lines(db, session, new_order, available)
svc.record_status_change(
db, new_order, to_status="draft", actor_type="customer", actor_name=session.name,
note=f"Reordered from {source.order_number or source.id}",
)
svc.audit_order_event(
db, session=session, order=new_order, action="order.reordered",
summary=f"Draft created by reordering from {source.order_number or source.id}.",
)
db.commit()
db.refresh(new_order)
return svc.serialize_order(new_order, for_admin=False)
@router.get("/orders/{order_id}/confirmation.pdf")
def order_confirmation_pdf(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
try:
pdf_bytes = build_order_confirmation_pdf(order, account)
except OrderPdfUnavailableError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
filename = f"order-{order.order_number or order.id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.schemas.product_costing import (
ProductCostInputsRead,
ProductCostInputsUpdate,
ProductCostItemCreate,
ProductCostItemRead,
ProductCostItemUpdate,
ProductCostRecalculateAllRead,
)
from app.services.product_costing_service import (
BAG_INPUTS,
FREIGHT_INPUTS,
PROCESS_NAMES,
recalculate_all_product_cost_items,
recalculate_product_cost_item,
serialize_product_cost_item,
)
router = APIRouter(prefix="/api/product-costing", tags=["product-costing"])
def _load_item(db: Session, tenant_id: str, item_id: int) -> ProductCostItem | None:
return db.scalar(select(ProductCostItem).where(ProductCostItem.id == item_id, ProductCostItem.tenant_id == tenant_id))
def _ensure_inputs(db: Session, tenant_id: str) -> ProductCostBaseInput:
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
base = ProductCostBaseInput(tenant_id=tenant_id)
db.add(base)
db.flush()
for process_name in PROCESS_NAMES:
if db.scalar(select(ProductCostProcessInput.id).where(ProductCostProcessInput.tenant_id == tenant_id, ProductCostProcessInput.process_name == process_name)) is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=process_name, cost_per_kg=0.0))
for key, label in BAG_INPUTS.items():
if db.scalar(select(ProductCostBagInput.id).where(ProductCostBagInput.tenant_id == tenant_id, ProductCostBagInput.input_key == key)) is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
for key, label in FREIGHT_INPUTS.items():
if db.scalar(select(ProductCostFreightInput.id).where(ProductCostFreightInput.tenant_id == tenant_id, ProductCostFreightInput.input_key == key)) is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
db.flush()
return base
def _serialize_inputs(db: Session, tenant_id: str) -> dict:
base = _ensure_inputs(db, tenant_id)
return {
"base": {
"grading_per_tonne": base.grading_per_tonne,
"grading_per_kg": base.grading_per_kg,
"cracking_per_tonne": base.cracking_per_tonne,
"cracking_per_kg": base.cracking_per_kg,
},
"processes": [
{"key": row.process_name, "label": row.process_name, "cost": row.cost_per_kg}
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id).order_by(ProductCostProcessInput.process_name)).all()
],
"clients": [
{
"client_category": row.client_category,
"distributor_margin": row.distributor_margin,
"wholesale_margin": row.wholesale_margin,
}
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id).order_by(ProductCostClientInput.client_category)).all()
],
"bags": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id).order_by(ProductCostBagInput.input_key)).all()
],
"freight": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id).order_by(ProductCostFreightInput.input_key)).all()
],
}
@router.get("/items", response_model=list[ProductCostItemRead])
def list_product_cost_items(
q: str | None = Query(default=None),
client_category: str | None = Query(default=None),
limit: int = Query(default=250, ge=1, le=1000),
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
statement = select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)
if client_category:
statement = statement.where(ProductCostItem.client_category == client_category)
if q:
term = f"%{q}%"
statement = statement.where(
or_(
ProductCostItem.client_category.ilike(term),
ProductCostItem.item_id.ilike(term),
ProductCostItem.product_name.ilike(term),
ProductCostItem.mix_product_name.ilike(term),
)
)
items = db.scalars(statement.order_by(ProductCostItem.client_category, ProductCostItem.product_name).limit(limit)).all()
return [serialize_product_cost_item(item) for item in items]
@router.post("/items", response_model=ProductCostItemRead, status_code=status.HTTP_201_CREATED)
def create_product_cost_item(
payload: ProductCostItemCreate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = ProductCostItem(tenant_id=session.tenant_id or "default", **payload.model_dump())
db.add(item)
db.flush()
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.get("/items/{item_id}", response_model=ProductCostItemRead)
def get_product_cost_item(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
return serialize_product_cost_item(item)
@router.patch("/items/{item_id}", response_model=ProductCostItemRead)
def update_product_cost_item(
item_id: int,
payload: ProductCostItemUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/items/{item_id}/recalculate", response_model=ProductCostItemRead)
def recalculate_one(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/recalculate-all", response_model=ProductCostRecalculateAllRead)
def recalculate_all(
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
count = recalculate_all_product_cost_items(db, session.tenant_id or "default")
db.commit()
return {"recalculated": count}
@router.get("/inputs", response_model=ProductCostInputsRead)
def get_product_cost_inputs(
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
return _serialize_inputs(db, session.tenant_id or "default")
@router.patch("/inputs", response_model=ProductCostInputsRead)
def update_product_cost_inputs(
payload: ProductCostInputsUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or "default"
base = _ensure_inputs(db, tenant_id)
if payload.base is not None:
for field, value in payload.base.model_dump().items():
setattr(base, field, value)
if payload.processes is not None:
existing = {row.process_name: row for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()}
for row in payload.processes:
target = existing.get(row.key)
if target is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=row.key, cost_per_kg=row.cost))
else:
target.cost_per_kg = row.cost
if payload.clients is not None:
existing = {row.client_category: row for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()}
for row in payload.clients:
target = existing.get(row.client_category)
if target is None:
db.add(ProductCostClientInput(tenant_id=tenant_id, client_category=row.client_category, distributor_margin=row.distributor_margin, wholesale_margin=row.wholesale_margin))
else:
target.distributor_margin = row.distributor_margin
target.wholesale_margin = row.wholesale_margin
if payload.bags is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()}
for row in payload.bags:
target = existing.get(row.key)
if target is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
if payload.freight is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()}
for row in payload.freight:
target = existing.get(row.key)
if target is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
db.flush()
recalculate_all_product_cost_items(db, tenant_id)
db.commit()
return _serialize_inputs(db, tenant_id)
+8 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -23,6 +23,7 @@ def _serialize_product(product: Product) -> dict:
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"own_bag": product.own_bag,
"visible": product.visible,
"unit_of_measure": product.unit_of_measure,
"items_per_pallet": product.items_per_pallet,
"bagging_process": product.bagging_process,
@@ -34,8 +35,12 @@ def _serialize_product(product: Product) -> dict:
@router.get("", response_model=list[ProductRead])
def list_products(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
def list_products(
limit: int = Query(default=100, ge=1, le=200),
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name).limit(limit)).all()
return [_serialize_product(product) for product in products]
+78
View File
@@ -0,0 +1,78 @@
"""Read-only external data API (`/api/v1`).
A deliberately simple, API-key authenticated surface for Power BI (and any other
external reporting tool). It is intentionally separate from the cookie/JWT
session model used by the operator frontend: external tools cannot hold a
browser session, so they present a single static key instead.
Authentication: send the key either as an ``X-API-Key`` request header or an
``api_key`` query-string parameter (Power BI's Web connector supports both).
The key is configured via the ``POWERBI_API_KEY`` environment variable; when it
is blank the whole API is disabled and every request returns 503.
"""
from __future__ import annotations
import secrets
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security_logging import log_security_event
from app.db.session import get_db
from app.models.throughput import ProductionThroughput
from app.services.throughput_service import serialize_entry
router = APIRouter(prefix="/api/v1", tags=["public-v1"])
_API_KEY_HEADER = "X-API-Key"
def require_powerbi_api_key(request: Request) -> str:
"""Authorize an external request via the static Power BI API key.
Returns the tenant the caller may read. Raises 503 when the API is not
configured, or 401 when the key is missing/incorrect.
"""
configured = settings.powerbi_api_key
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The data API is not configured.",
)
presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or ""
# Constant-time comparison so the endpoint does not leak key length/contents
# through response timing.
if not presented or not secrets.compare_digest(presented, configured):
log_security_event("authz.denied", role="powerbi", reason="invalid_api_key")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
return settings.powerbi_tenant_id
@router.get("/throughput")
def list_throughput(
date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."),
date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."),
limit: int = Query(default=5000, ge=1, le=50000),
tenant_id: str = Depends(require_powerbi_api_key),
db: Session = Depends(get_db),
):
"""Flat list of production throughput entries for Power BI.
One row per packing run, oldest first so incremental refreshes append
naturally. Each row carries the same fields the operator UI shows
(date, product, bag size, quantity, calculated kg, QA flags, staff, notes).
"""
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id)
if date_from is not None:
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
if date_to is not None:
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit)
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
+14 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -34,12 +34,17 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
@router.get("", response_model=list[RawMaterialRead])
def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
def list_raw_materials(
limit: int = Query(default=100, ge=1, le=200),
session: AuthSession = Depends(require_client_module_access("raw_materials")),
db: Session = Depends(get_db),
):
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
.limit(limit)
).all()
return [serialize_raw_material(material) for material in materials]
@@ -130,7 +135,12 @@ def add_price_version(
@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
def get_price_history(
raw_material_id: int,
limit: int = Query(default=100, ge=1, le=200),
session: AuthSession = Depends(require_client_module_access("raw_materials")),
db: Session = Depends(get_db),
):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
@@ -141,6 +151,7 @@ def get_price_history(raw_material_id: int, session: AuthSession = Depends(requi
RawMaterialPriceVersion.tenant_id == session.tenant_id,
)
.order_by(RawMaterialPriceVersion.effective_date.desc())
.limit(limit)
).all()
items = []
for price in prices:
+7 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -12,8 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@router.get("", response_model=list[ScenarioRead])
def list_scenarios(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
def list_scenarios(
limit: int = Query(default=100, ge=1, le=200),
session: AuthSession = Depends(require_client_module_access("scenarios")),
db: Session = Depends(get_db),
):
return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc()).limit(limit)).all()
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
+300
View File
@@ -0,0 +1,300 @@
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.schemas.throughput import (
ThroughputDeleteAllResult,
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"
@router.get("/products", response_model=list[ThroughputProductRead])
def list_products(
include_inactive: bool = Query(default=False),
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
db: Session = Depends(get_db),
):
stmt = select(ThroughputProduct).where(ThroughputProduct.tenant_id == session.tenant_id)
if not include_inactive:
stmt = stmt.where(ThroughputProduct.active.is_(True))
stmt = stmt.order_by(ThroughputProduct.name)
return db.scalars(stmt).all()
@router.post("/products", response_model=ThroughputProductRead, status_code=status.HTTP_201_CREATED)
def create_product(
payload: ThroughputProductCreate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
if payload.item_id:
existing = db.scalar(
select(ThroughputProduct).where(
ThroughputProduct.tenant_id == session.tenant_id,
ThroughputProduct.item_id == payload.item_id,
)
)
if existing is not None:
raise HTTPException(status_code=409, detail="A product with this item_id already exists")
product = ThroughputProduct(
tenant_id=session.tenant_id,
item_id=payload.item_id,
name=payload.name,
default_bag_size=payload.default_bag_size,
is_bulka_default=payload.is_bulka_default,
active=payload.active,
is_stock_item=payload.is_stock_item,
notes=payload.notes,
)
db.add(product)
db.commit()
db.refresh(product)
return product
@router.patch("/products/{product_id}", response_model=ThroughputProductRead)
def update_product(
product_id: int,
payload: ThroughputProductUpdate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
product = db.scalar(
select(ThroughputProduct).where(
ThroughputProduct.id == product_id,
ThroughputProduct.tenant_id == session.tenant_id,
)
)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return product
@router.get("/entries", response_model=list[ThroughputEntryRead])
def list_entries(
date_from: date | None = Query(default=None),
date_to: date | None = Query(default=None),
product_id: int | None = Query(default=None),
staff_name: str | None = Query(default=None),
quantity_type: str | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=1000),
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
db: Session = Depends(get_db),
):
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == session.tenant_id)
if date_from is not None:
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
if date_to is not None:
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
if product_id is not None:
stmt = stmt.where(ProductionThroughput.product_id == product_id)
if staff_name:
stmt = stmt.where(ProductionThroughput.staff_name == staff_name.strip())
if quantity_type in {"bags", "kg"}:
stmt = stmt.where(ProductionThroughput.quantity_type == quantity_type)
stmt = stmt.order_by(ProductionThroughput.production_date.desc(), ProductionThroughput.id.desc()).limit(limit)
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
@router.post("/entries", response_model=ThroughputEntryRead, status_code=status.HTTP_201_CREATED)
def create_entry(
payload: ThroughputEntryCreate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
product = None
if payload.product_id is not None:
product = db.scalar(
select(ThroughputProduct).where(
ThroughputProduct.id == payload.product_id,
ThroughputProduct.tenant_id == session.tenant_id,
)
)
if product is None:
raise HTTPException(status_code=400, detail="product_id does not match an existing product")
snapshot = payload.product_name_snapshot or (product.name if product else None)
if not snapshot:
raise HTTPException(status_code=400, detail="product_name_snapshot or product_id is required")
bag_size = payload.bag_size
if bag_size is None and product is not None:
bag_size = product.default_bag_size
if payload.quantity_type == "bags" and (bag_size is None or bag_size <= 0):
raise HTTPException(status_code=400, detail="bag_size is required when quantity_type is 'bags'")
calculated = calculate_kg(payload.quantity, payload.quantity_type, bag_size)
entry = ProductionThroughput(
tenant_id=session.tenant_id,
production_date=payload.production_date,
product_id=product.id if product else None,
product_name_snapshot=snapshot,
bag_size=bag_size,
scales_checked=payload.scales_checked,
label_correct=payload.label_correct,
bag_sealed=payload.bag_sealed,
pallet_good_condition=payload.pallet_good_condition,
for_order=payload.for_order,
for_stock=payload.for_stock,
job_number=payload.job_number,
stock_quantity=payload.stock_quantity if payload.for_stock else None,
sample_box_no=payload.sample_box_no,
test_weight_1=payload.test_weight_1,
test_weight_2=payload.test_weight_2,
test_weight_3=payload.test_weight_3,
test_weight_4=payload.test_weight_4,
test_weight_5=payload.test_weight_5,
quantity=payload.quantity,
quantity_type=payload.quantity_type,
calculated_kg=calculated,
staff_name=normalise_staff_name(payload.staff_name),
notes=payload.notes,
created_by=session.email,
)
db.add(entry)
db.commit()
db.refresh(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.delete("/entries", response_model=ThroughputDeleteAllResult)
def delete_all_entries(
# Clearing the log is part of correcting a bad import, so it sits at the same
# "edit" level as deleting a single entry. It is scoped to the caller's
# tenant, so one client can never wipe another's data.
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
deleted = db.query(ProductionThroughput).filter(
ProductionThroughput.tenant_id == session.tenant_id
).delete(synchronize_session=False)
db.commit()
return ThroughputDeleteAllResult(entries_deleted=deleted)
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
def get_entry(
entry_id: int,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
db: Session = Depends(get_db),
):
entry = db.scalar(
select(ProductionThroughput).where(
ProductionThroughput.id == entry_id,
ProductionThroughput.tenant_id == session.tenant_id,
)
)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
return serialize_entry(entry)
@router.patch("/entries/{entry_id}", response_model=ThroughputEntryRead)
def update_entry(
entry_id: int,
payload: ThroughputEntryUpdate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
entry = db.scalar(
select(ProductionThroughput).where(
ProductionThroughput.id == entry_id,
ProductionThroughput.tenant_id == session.tenant_id,
)
)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
data = payload.model_dump(exclude_unset=True)
if "staff_name" in data:
data["staff_name"] = normalise_staff_name(data["staff_name"])
for field, value in data.items():
setattr(entry, field, value)
entry.calculated_kg = calculate_kg(entry.quantity, entry.quantity_type, entry.bag_size)
db.commit()
db.refresh(entry)
return serialize_entry(entry)
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry(
entry_id: int,
# 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(
select(ProductionThroughput).where(
ProductionThroughput.id == entry_id,
ProductionThroughput.tenant_id == session.tenant_id,
)
)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
db.delete(entry)
db.commit()
return None
+21 -12
View File
@@ -16,18 +16,16 @@ from __future__ import annotations
from typing import Iterable
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.security import verify_token
from app.core.http import CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token
from app.core.security_logging import log_security_event
from app.db.session import get_db
from app.models.access import Permission, Role, User
bearer_scheme = HTTPBearer(auto_error=False)
# Subject claim used by tokens issued for internal Hunter Stock Feeds users.
# Distinct from the existing client-portal/admin tokens so the two systems
# cannot impersonate each other.
@@ -53,10 +51,17 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"edit_products": ("products", "edit"),
"view_mixes": ("mix_master", "view"),
"edit_mixes": ("mix_master", "edit"),
# Admin-only permissions (view_users, manage_users, manage_permissions,
# view_settings, edit_settings) are intentionally excluded — they don't
# correspond to any of the legacy module keys and remain accessible only
# via the explicit `require_permission(...)` dependency.
"view_throughput": ("operations_throughput", "view"),
"edit_throughput": ("operations_throughput", "edit"),
"view_ordering": ("ordering", "view"),
"edit_ordering": ("ordering", "edit"),
"manage_ordering": ("ordering", "manage"),
"view_scenarios": ("scenarios", "view"),
"edit_scenarios": ("scenarios", "edit"),
"manage_client_access": ("client_access", "manage"),
# User/role/settings permissions are intentionally excluded — they don't
# correspond to legacy module keys and remain accessible only via the
# explicit `require_permission(...)` dependency.
}
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
@@ -103,7 +108,7 @@ def _load_user(db: Session, user_id: int) -> User | None:
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
request: Request,
db: Session = Depends(get_db),
) -> User:
"""Resolve the current internal user from the bearer token.
@@ -111,10 +116,11 @@ def get_current_user(
Raises 401 for missing/invalid tokens or unknown users, 403 for inactive
users.
"""
if credentials is None:
token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name)
if token is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
payload = verify_token(token)
if payload.get("sub") != INTERNAL_USER_SUBJECT:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
@@ -136,6 +142,7 @@ def require_permission(permission_key: str):
def dependency(user: User = Depends(get_current_user)) -> User:
if not user_has_permission(user, permission_key):
log_security_event("authz.denied", role=user.role.name if user.role else None, permission=permission_key)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permission: {permission_key}",
@@ -152,6 +159,7 @@ def require_any_permission(permission_keys: Iterable[str]):
def dependency(user: User = Depends(get_current_user)) -> User:
granted = get_user_permissions(user)
if not any(key in granted for key in keys):
log_security_event("authz.denied", role=user.role.name if user.role else None, permissions=list(keys))
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires any of: {list(keys)}",
@@ -169,6 +177,7 @@ def require_all_permissions(permission_keys: Iterable[str]):
granted = get_user_permissions(user)
missing = [key for key in keys if key not in granted]
if missing:
log_security_event("authz.denied", role=user.role.name if user.role else None, permissions=missing)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {missing}",
+87 -3
View File
@@ -1,5 +1,10 @@
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_SQLITE_PATH = (Path(__file__).resolve().parents[2] / "data_entry_app.db").as_posix()
DEFAULT_DATABASE_URL = f"sqlite:///{DEFAULT_SQLITE_PATH}"
DEFAULT_CORS_ALLOW_ORIGIN_REGEX = (
@@ -16,9 +21,21 @@ def _parse_csv_env(value: str) -> tuple[str, ...]:
return tuple(part.strip() for part in value.split(",") if part.strip())
def _env_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Settings:
app_name: str
app_env: str
host: str
port: int
log_level: str
log_verbose: bool
database_url: str
client_name: str
client_email: str
@@ -30,12 +47,37 @@ class Settings:
auth_secret: str
cors_allow_origins: tuple[str, ...]
cors_allow_origin_regex: str
session_ttl_seconds: int
session_cookie_name: str
admin_session_cookie_name: str
session_cookie_secure: bool
session_cookie_samesite: str
session_cookie_domain: str | None
request_body_max_bytes: int
login_rate_limit_attempts: int
login_rate_limit_window_seconds: int
trusted_hosts: tuple[str, ...]
docs_enabled: bool
# Static API key for the read-only Power BI / external data API (`/api/v1`).
# Blank disables the API entirely (every request returns 503).
powerbi_api_key: str
# Tenant the Power BI API reads from. Defaults to the costing client tenant
# (where internal staff store throughput), so a single key serves Irwin.
powerbi_tenant_id: str
@classmethod
def from_env(cls) -> "Settings":
return cls(
app_name=os.getenv("APP_NAME", "Data Entry App API"),
database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"),
settings = cls(
app_name=os.getenv("APP_NAME", "Hunter App"),
app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")),
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
log_level=os.getenv("LOG_LEVEL", "DEBUG" if os.getenv("LOG_VERBOSE") in {"1", "true", "TRUE", "yes", "on"} else "INFO"),
log_verbose=_env_flag("LOG_VERBOSE"),
# Keep the default SQLite location stable regardless of the current
# working directory so local dev does not silently fork data into
# multiple `data_entry_app.db` files.
database_url=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL),
client_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"),
client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"),
client_password=os.getenv("CLIENT_PASSWORD", "changeme"),
@@ -51,7 +93,49 @@ class Settings:
)
),
cors_allow_origin_regex=os.getenv("CORS_ALLOW_ORIGIN_REGEX", DEFAULT_CORS_ALLOW_ORIGIN_REGEX),
session_ttl_seconds=int(os.getenv("SESSION_TTL_SECONDS", str(60 * 60 * 12))),
session_cookie_name=os.getenv("SESSION_COOKIE_NAME", "client_session"),
admin_session_cookie_name=os.getenv("ADMIN_SESSION_COOKIE_NAME", "admin_session"),
session_cookie_secure=_env_flag("SESSION_COOKIE_SECURE"),
session_cookie_samesite=os.getenv("SESSION_COOKIE_SAMESITE", "lax").lower(),
session_cookie_domain=os.getenv("SESSION_COOKIE_DOMAIN", "").strip() or None,
request_body_max_bytes=int(os.getenv("REQUEST_BODY_MAX_BYTES", str(1024 * 1024))),
login_rate_limit_attempts=int(os.getenv("LOGIN_RATE_LIMIT_ATTEMPTS", "8")),
login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")),
trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")),
docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"),
powerbi_api_key=os.getenv("POWERBI_API_KEY", "").strip(),
powerbi_tenant_id=os.getenv("POWERBI_TENANT_ID", os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce")).strip(),
)
settings._validate()
return settings
def _validate(self) -> None:
if self.session_cookie_samesite not in {"lax", "strict", "none"}:
raise ValueError("SESSION_COOKIE_SAMESITE must be one of: lax, strict, none")
is_production = self.app_env.lower() == "production"
if not is_production:
return
if self.client_password in {"changeme", "", "replace-with-strong-password"}:
raise ValueError("CLIENT_PASSWORD must be set to a non-default value in production")
if self.admin_password in {"lean101-admin", "", "replace-with-strong-password"}:
raise ValueError("ADMIN_PASSWORD must be set to a non-default value in production")
if self.auth_secret in {"lean-101-local-dev-secret", "change-me-in-production", "", "replace-with-a-long-random-secret"}:
raise ValueError("AUTH_SECRET must be set to a strong production secret")
if len(self.auth_secret) < 32:
raise ValueError("AUTH_SECRET must be at least 32 characters in production")
if not self.session_cookie_secure:
raise ValueError("SESSION_COOKIE_SECURE must be enabled in production")
if not self.cors_allow_origins:
raise ValueError("CORS_ALLOW_ORIGINS must explicitly list production origins")
if "localhost" in ",".join(self.cors_allow_origins).lower():
raise ValueError("CORS_ALLOW_ORIGINS cannot include localhost in production")
if self.cors_allow_origin_regex == DEFAULT_CORS_ALLOW_ORIGIN_REGEX:
raise ValueError("CORS_ALLOW_ORIGIN_REGEX must be overridden or blank in production")
if self.docs_enabled:
raise ValueError("DOCS_ENABLED must be false in production")
settings = Settings.from_env()
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from fastapi import Request, Response
from app.core.config import settings
COOKIE_PATH: Final[str] = "/"
@dataclass(frozen=True)
class AuthCookie:
name: str
def apply(self, response: Response, token: str) -> None:
response.set_cookie(
key=self.name,
value=token,
httponly=True,
secure=settings.session_cookie_secure,
samesite=settings.session_cookie_samesite,
domain=settings.session_cookie_domain,
path=COOKIE_PATH,
max_age=settings.session_ttl_seconds,
)
def clear(self, response: Response) -> None:
response.delete_cookie(
key=self.name,
domain=settings.session_cookie_domain,
path=COOKIE_PATH,
)
CLIENT_AUTH_COOKIE = AuthCookie(settings.session_cookie_name)
ADMIN_AUTH_COOKIE = AuthCookie(settings.admin_session_cookie_name)
def get_bearer_or_cookie_token(request: Request, *, cookie_name: str) -> str | None:
authorization = request.headers.get("authorization", "").strip()
if authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
if token:
return token
cookie_value = request.cookies.get(cookie_name)
if cookie_value:
return cookie_value
return None
+372
View File
@@ -0,0 +1,372 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import logging
import os
import sys
import time
from typing import Iterable
try:
from rich.console import Console
from rich.logging import RichHandler
from rich.table import Table
from rich.text import Text
except ImportError: # pragma: no cover - exercised only before dependency install
Console = None
RichHandler = None
Table = None
Text = None
@dataclass(frozen=True)
class LoggingSettings:
app_name: str
app_env: str
host: str
port: int
log_level: str
log_verbose: bool
database_url: str
version: str
@dataclass(frozen=True)
class StartupStatus:
app_name: str
version: str
environment: str
host: str
port: int
database: str
mode: str
started_at: str
local_url: str
network_url: str
class PlainFormatter(logging.Formatter):
default_time_format = "%Y-%m-%d %H:%M:%S"
def format(self, record: logging.LogRecord) -> str:
if not hasattr(record, "component"):
record.component = record.name.rsplit(".", 1)[-1]
return super().format(record)
def _allow_color() -> bool:
if RichHandler is None or Console is None:
return False
if os.getenv("NO_COLOR"):
return False
if os.getenv("TERM") == "dumb":
return False
return hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
def _allow_unicode() -> bool:
encoding = (getattr(sys.stdout, "encoding", None) or "").lower()
if not encoding:
return False
return "utf" in encoding
def _console() -> Console:
return Console(
stderr=True,
soft_wrap=False,
highlight=False,
force_terminal=_allow_color(),
no_color=not _allow_color(),
emoji=False,
)
def _rich_handler(level: str) -> RichHandler:
return RichHandler(
level=level,
console=_console(),
show_time=True,
show_level=True,
show_path=False,
omit_repeated_times=False,
markup=True,
rich_tracebacks=True,
tracebacks_show_locals=False,
log_time_format="%H:%M:%S",
)
def _plain_handler(level: str) -> logging.StreamHandler:
handler = logging.StreamHandler()
handler.setLevel(level)
handler.setFormatter(
PlainFormatter("%(asctime)s | %(levelname)-7s | %(component)-10s | %(message)s")
)
return handler
def _handler(level: str) -> logging.Handler:
return _rich_handler(level) if _allow_color() else _plain_handler(level)
def configure_logging(settings: LoggingSettings) -> None:
level = settings.log_level.upper()
root = logging.getLogger()
root.handlers.clear()
root.setLevel(level)
root.addHandler(_handler(level))
for name in ("uvicorn", "uvicorn.error", "fastapi"):
logger = logging.getLogger(name)
logger.handlers.clear()
logger.setLevel(level)
logger.propagate = True
access_logger = logging.getLogger("uvicorn.access")
access_logger.handlers.clear()
access_logger.propagate = False
access_logger.disabled = True
def get_logger(name: str) -> logging.LoggerAdapter[logging.Logger]:
component = name.rsplit(".", 1)[-1]
return logging.LoggerAdapter(logging.getLogger(name), {"component": component})
def _icon(name: str) -> str:
ascii_icons = {
"app": "#",
"info": "i",
"success": "+",
"warning": "!",
"error": "x",
"debug": ".",
"section": "=",
"url": ">",
"shutdown": "-",
}
unicode_icons = {
"app": "",
"info": "",
"success": "",
"warning": "",
"error": "",
"debug": "",
"section": "",
"url": "",
"shutdown": "",
}
icons = unicode_icons if _allow_unicode() else ascii_icons
return icons[name]
def _style(name: str) -> str:
return {
"info": "bold cyan",
"success": "bold green",
"warning": "bold yellow",
"error": "bold red",
"debug": "dim",
"section": "bold bright_blue",
"muted": "grey62",
}[name]
def section_heading(title: str) -> None:
logger = get_logger("data_entry_app.section")
if _allow_color():
_console().rule(Text(f" {title.upper()} ", style=_style("section")))
return
logger.info("%s %s %s", _icon("section") * 10, title.upper(), _icon("section") * 10)
def startup_banner(status: StartupStatus) -> None:
logger = get_logger("data_entry_app.startup")
if _allow_color():
console = _console()
table = Table.grid(expand=False)
table.add_column(style="bold white", justify="left")
table.add_column(style="white", justify="left")
table.add_row("Environment", status.environment)
table.add_row("Version", status.version)
table.add_row("Host", status.host)
table.add_row("Port", str(status.port))
table.add_row("Database", status.database)
table.add_row("Mode", status.mode)
table.add_row("Started", status.started_at)
console.rule(Text(f" {status.app_name} ", style="bold white"))
console.print(Text("Clean startup. Clear status. Ready.", style="italic cyan"))
console.print(table)
console.print()
console.print(Text("App is running at:", style="bold white"))
console.print(Text(f" Local: {status.local_url}", style="cyan"))
console.print(Text(f" Network: {status.network_url}", style="cyan"))
console.print()
return
logger.info("%s %s", _icon("app"), "Startup banner")
logger.info("App : %s", status.app_name)
logger.info("Environment : %s", status.environment)
logger.info("Version : %s", status.version)
logger.info("Host : %s", status.host)
logger.info("Port : %s", status.port)
logger.info("Database : %s", status.database)
logger.info("Mode : %s", status.mode)
logger.info("Started : %s", status.started_at)
logger.info("Local : %s", status.local_url)
logger.info("Network : %s", status.network_url)
def status_message(level: str, message: str, *args: object, logger_name: str = "data_entry_app.status") -> None:
palette = {
"debug": logging.DEBUG,
"info": logging.INFO,
"success": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
}
labels = {
"debug": f"[{_icon('debug')}]",
"info": f"[{_icon('info')}]",
"success": f"[{_icon('success')}]",
"warning": f"[{_icon('warning')}]",
"error": f"[{_icon('error')}]",
}
styles = {
"debug": _style("debug"),
"info": _style("info"),
"success": _style("success"),
"warning": _style("warning"),
"error": _style("error"),
}
logger = get_logger(logger_name)
rendered = message % args if args else message
if _allow_color():
logger.log(palette[level], f"[{styles[level]}]{labels[level]}[/] {rendered}")
else:
logger.log(palette[level], "%s %s", labels[level], rendered)
def success(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None:
status_message("success", message, *args, logger_name=logger_name)
def warning(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None:
status_message("warning", message, *args, logger_name=logger_name)
def info(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None:
status_message("info", message, *args, logger_name=logger_name)
def debug(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None:
status_message("debug", message, *args, logger_name=logger_name)
def fatal(message: str, *args: object, exc_info: bool = False, logger_name: str = "data_entry_app.status") -> None:
logger = get_logger(logger_name)
rendered = message % args if args else message
if _allow_color():
logger.error(f"[{_style('error')}][{_icon('error')}][/] {rendered}", exc_info=exc_info)
else:
logger.error("[%s] %s", _icon("error"), rendered, exc_info=exc_info)
def shutdown_summary(*, uptime_seconds: float, requests_served: int, host: str, port: int) -> None:
section_heading("Shutdown")
logger = get_logger("data_entry_app.shutdown")
summary = f"Uptime {uptime_seconds:.1f}s | Requests {requests_served} | Endpoint http://{host}:{port}"
if _allow_color():
logger.info(f"[{_style('debug')}]{_icon('shutdown')}[/] {summary}")
else:
logger.info("%s %s", _icon("shutdown"), summary)
def describe_database(url: str) -> str:
if url.startswith("sqlite"):
return "sqlite"
if "postgresql" in url:
return "postgresql"
if "mysql" in url:
return "mysql"
return url.split(":", 1)[0]
def sanitize_database_target(url: str) -> str:
if url.startswith("sqlite:///"):
return url.removeprefix("sqlite:///")
if "@" in url:
return url.split("@", 1)[1]
return url
def startup_status(settings: LoggingSettings) -> StartupStatus:
host = settings.host
local_host = "localhost" if host in {"0.0.0.0", "::"} else host
timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
return StartupStatus(
app_name=settings.app_name,
version=settings.version,
environment=settings.app_env,
host=settings.host,
port=settings.port,
database=f"{describe_database(settings.database_url)} ({sanitize_database_target(settings.database_url)})",
mode="verbose" if settings.log_verbose else "normal",
started_at=timestamp,
local_url=f"http://{local_host}:{settings.port}",
network_url=f"http://{host}:{settings.port}",
)
def route_summary(routes: Iterable[object]) -> tuple[int, list[str]]:
lines: list[str] = []
count = 0
for route in routes:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None)
if not path or not methods:
continue
filtered_methods = sorted(method for method in methods if method not in {"HEAD", "OPTIONS"})
if not filtered_methods:
continue
count += 1
lines.append(f"{','.join(filtered_methods):<7} {path}")
return count, lines
def log_request(
*,
method: str,
path: str,
status_code: int,
duration_ms: float,
client: str,
content_length: str | None,
) -> None:
level = "info"
if status_code >= 500:
level = "error"
elif status_code >= 400:
level = "warning"
elif path == "/health":
level = "debug"
message = (
f"{method:<6} {status_code:>3} {duration_ms:>7.1f}ms "
f"{path:<36} client={client}"
)
if content_length:
message += f" bytes={content_length}"
status_message(level, message, logger_name="data_entry_app.http")
class RequestTimer:
def __init__(self) -> None:
self.started = time.perf_counter()
@property
def elapsed_ms(self) -> float:
return (time.perf_counter() - self.started) * 1000
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import time
from collections import deque
from dataclasses import dataclass
from threading import Lock
from fastapi import HTTPException, Request, status
@dataclass
class SlidingWindowRateLimiter:
limit: int
window_seconds: int
def __post_init__(self) -> None:
self._events: dict[str, deque[float]] = {}
self._lock = Lock()
def hit(self, key: str) -> None:
now = time.time()
floor = now - self.window_seconds
with self._lock:
bucket = self._events.setdefault(key, deque())
while bucket and bucket[0] <= floor:
bucket.popleft()
if len(bucket) >= self.limit:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many requests. Please try again later.",
)
bucket.append(now)
def request_client_key(request: Request, *, suffix: str = "") -> str:
forwarded_for = request.headers.get("x-forwarded-for", "")
client_ip = forwarded_for.split(",", 1)[0].strip() if forwarded_for else (request.client.host if request.client else "unknown")
return f"{client_ip}:{suffix}" if suffix else client_ip
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
import logging
logger = logging.getLogger("data_entry_app.security")
def log_security_event(event: str, **fields: object) -> None:
safe_fields = {
key: value
for key, value in fields.items()
if key not in {"password", "token", "cookie", "authorization"}
}
logger.info("%s | %s", event, safe_fields)
+139 -3
View File
@@ -2,9 +2,19 @@ from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy import MetaData, inspect, text
from sqlalchemy import MetaData, bindparam, inspect, text
from sqlalchemy.engine import Engine
HIDDEN_PRODUCT_CLIENTS = (
"Bird Grits",
"Chaff",
"Hay & Straw",
"Hunter Premium Produce",
"Straight Grain",
"Uncategorized",
"Uncategorised",
)
TENANT_TABLES = {
"client_users": None,
@@ -15,14 +25,39 @@ TENANT_TABLES = {
"raw_material_price_versions": None,
"mixes": None,
"mix_ingredients": None,
"product_ingredients": None,
"mix_calculator_sessions": None,
"mix_calculator_session_lines": None,
"products": None,
"product_cost_items": None,
"product_cost_base_inputs": None,
"product_cost_process_inputs": None,
"product_cost_client_inputs": None,
"product_cost_bag_inputs": None,
"product_cost_freight_inputs": None,
"scenarios": None,
"costing_results": None,
"editor_change_events": None,
"process_cost_rules": None,
"packaging_cost_rules": None,
"freight_cost_rules": None,
"throughput_products": None,
"production_throughput_entries": None,
# B2B ordering portal
"product_categories": None,
"catalogue_products": None,
"customer_product_visibility": None,
"price_lists": None,
"price_list_items": None,
"customer_price_assignments": None,
"customer_product_prices": None,
"price_tiers": None,
"orders": None,
"order_lines": None,
"order_status_history": None,
"order_attachments": None,
"notification_settings": None,
"xero_sync_log": None,
}
@@ -31,9 +66,15 @@ class MigrationReport:
created_tables: tuple[str, ...] = ()
added_columns: tuple[str, ...] = ()
synced_tenant_rows: dict[str, int] = field(default_factory=dict)
resynced_sequences: tuple[str, ...] = ()
def has_changes(self) -> bool:
return bool(self.created_tables or self.added_columns or self.synced_tenant_rows)
return bool(
self.created_tables
or self.added_columns
or self.synced_tenant_rows
or self.resynced_sequences
)
def summary(self) -> str:
parts: list[str] = []
@@ -44,6 +85,8 @@ class MigrationReport:
if self.synced_tenant_rows:
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
parts.append(f"synced tenant rows: {counts}")
if self.resynced_sequences:
parts.append(f"resynced sequences: {', '.join(self.resynced_sequences)}")
return "; ".join(parts) if parts else "schema already up to date"
@@ -88,6 +131,16 @@ def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]:
# introduced on the model. Each entry is (table, column, DDL fragment).
_LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("users", "password_hash", "VARCHAR(255)"),
("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"),
("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"),
("throughput_products", "client_name", "VARCHAR(255)"),
("production_throughput_entries", "for_order", "BOOLEAN NOT NULL DEFAULT FALSE"),
("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"),
("raw_materials", "category", "VARCHAR(128)"),
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
)
@@ -232,6 +285,20 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
"""
),
),
(
"product_ingredients",
text(
"""
UPDATE product_ingredients
SET tenant_id = (
SELECT products.tenant_id
FROM products
WHERE products.id = product_ingredients.product_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
(
"products",
text(
@@ -359,7 +426,76 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
return synced_rows
def sync_product_visibility(engine: Engine) -> int:
if not _table_exists(engine, "products") or not _has_column(engine, "products", "visible"):
return 0
with engine.begin() as connection:
result = connection.execute(
text(
"""
UPDATE products
SET visible = FALSE
WHERE client_name IN :hidden_clients
AND (visible IS NULL OR visible != FALSE)
"""
).bindparams(bindparam("hidden_clients", value=HIDDEN_PRODUCT_CLIENTS, expanding=True))
)
return result.rowcount or 0
def resync_identity_sequences(engine: Engine) -> tuple[str, ...]:
"""Realign Postgres identity sequences with each table's current MAX(id).
After a bulk import that carries original primary keys across (the SQLite
Postgres migration inserts rows with their existing ids), every table's
sequence still points at its starting value. The next INSERT then reuses an
id that already exists and fails with ``duplicate key value violates unique
constraint`` which is why creating a new mix/ingredient/product saved fine
on SQLite but not on production Postgres.
This advances each ``id`` sequence to MAX(id) so the next INSERT continues
cleanly. It is a no-op on SQLite and idempotent on Postgres, so it is safe to
run on every startup. A per-table failure is skipped rather than aborting the
whole boot.
"""
if engine.dialect.name != "postgresql":
return ()
resynced: list[str] = []
inspector = inspect(engine)
with engine.begin() as connection:
for table_name in inspector.get_table_names():
if not any(column["name"] == "id" for column in inspector.get_columns(table_name)):
continue
try:
sequence = connection.execute(
text("SELECT pg_get_serial_sequence(:table, 'id')"),
{"table": table_name},
).scalar()
if not sequence:
continue
max_id = connection.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
if max_id is None:
continue
connection.execute(
text("SELECT setval(:sequence, :value, true)"),
{"sequence": sequence, "value": int(max_id)},
)
resynced.append(table_name)
except Exception:
# A single problematic table must not block startup; the others
# still get realigned.
continue
return tuple(resynced)
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
created_tables = ensure_metadata_tables(engine, metadata)
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
return MigrationReport(created_tables=created_tables, added_columns=added_columns)
resynced_sequences = resync_identity_sequences(engine)
return MigrationReport(
created_tables=created_tables,
added_columns=added_columns,
resynced_sequences=resynced_sequences,
)
+235 -15
View File
@@ -1,35 +1,98 @@
import logging
import os
import re
import sys
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
from threading import Lock
from typing import Final
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi import FastAPI
from fastapi import Request
from fastapi import FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
import uvicorn
from app import models as _models # noqa: F401 - ensure all SQLAlchemy models are registered
from app.api.access import router as access_router
from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_router
from app.api.editor import router as editor_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router
from app.api.ordering import router as ordering_router
from app.api.ordering_admin import router as ordering_admin_router
from app.api.powerbi import router as powerbi_router
from app.api.product_costing import router as product_costing_router
from app.api.products import router as products_router
from app.api.public_v1 import router as public_v1_router
from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router
from app.api.throughput import router as throughput_router
from app.core.config import settings
from app.core.logging import (
LoggingSettings,
RequestTimer,
configure_logging,
debug,
fatal,
info,
log_request,
route_summary,
section_heading,
shutdown_summary,
startup_banner,
startup_status,
success,
)
from app.db.session import Base, engine
from app.db.migrations import MigrationReport, bootstrap_schema, sync_tenant_ids
from app.seed import seed_if_empty
from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids
from app.seed import seed_startup_basics
def _resolve_version() -> str:
try:
return package_version("data-entry-app-backend")
except PackageNotFoundError:
return "0.0.0"
APP_VERSION: Final[str] = _resolve_version()
_logging_settings = LoggingSettings(
app_name=settings.app_name,
app_env=settings.app_env,
host=settings.host,
port=settings.port,
log_level=settings.log_level,
log_verbose=settings.log_verbose,
database_url=settings.database_url,
version=APP_VERSION,
)
configure_logging(_logging_settings)
logger = logging.getLogger("data_entry_app.startup")
_database_ready = False
_database_ready_lock = Lock()
_requests_served = 0
def _origin_is_allowed(origin: str | None) -> bool:
if not origin:
return True
if origin in settings.cors_allow_origins:
return True
if settings.cors_allow_origin_regex:
return re.fullmatch(settings.cors_allow_origin_regex, origin) is not None
return False
def ensure_database_ready() -> MigrationReport:
@@ -43,13 +106,18 @@ def ensure_database_ready() -> MigrationReport:
return MigrationReport()
schema_report = bootstrap_schema(engine, Base.metadata)
seed_if_empty()
seed_startup_basics()
tenant_sync_report = sync_tenant_ids(engine)
hidden_product_count = sync_product_visibility(engine)
report = MigrationReport(
created_tables=schema_report.created_tables,
added_columns=schema_report.added_columns,
synced_tenant_rows=tenant_sync_report,
synced_tenant_rows={
**tenant_sync_report,
**({"products_visibility": hidden_product_count} if hidden_product_count else {}),
},
resynced_sequences=schema_report.resynced_sequences,
)
logger.info("Database startup checks complete: %s", report.summary())
_database_ready = True
@@ -57,32 +125,181 @@ def ensure_database_ready() -> MigrationReport:
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_database_ready()
async def lifespan(app: FastAPI):
started = startup_status(_logging_settings)
launch_time = RequestTimer()
startup_banner(started)
section_heading("Startup")
info("Booting %s", settings.app_name, logger_name="data_entry_app.startup")
section_heading("Configuration")
success("Configuration loaded")
info("CORS origins: %s", ", ".join(settings.cors_allow_origins), logger_name="data_entry_app.config")
if settings.cors_allow_origin_regex:
debug("CORS regex: %s", settings.cors_allow_origin_regex, logger_name="data_entry_app.config")
section_heading("Database")
try:
report = ensure_database_ready()
except Exception:
fatal("Database startup failed", exc_info=True, logger_name="data_entry_app.database")
raise
success("Database connected")
if report.has_changes():
info(report.summary(), logger_name="data_entry_app.database")
else:
debug(report.summary(), logger_name="data_entry_app.database")
section_heading("Routes")
route_count, route_lines = route_summary(app.routes)
success("Routes registered (%s endpoints)", route_count)
if settings.log_verbose:
for route_line in route_lines:
debug(route_line, logger_name="data_entry_app.routes")
section_heading("Services")
success("HTTP API ready")
if settings.docs_enabled:
info("Docs available at /docs", logger_name="data_entry_app.services")
else:
info("Docs disabled in this environment", logger_name="data_entry_app.services")
info("Health probe available at /health", logger_name="data_entry_app.services")
yield
shutdown_summary(
uptime_seconds=launch_time.elapsed_ms / 1000,
requests_served=_requests_served,
host=settings.host,
port=settings.port,
)
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app = FastAPI(
title=settings.app_name,
version=APP_VERSION,
lifespan=lifespan,
docs_url="/docs" if settings.docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.docs_enabled else None,
)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(settings.trusted_hosts) or ["*"])
app.add_middleware(
CORSMiddleware,
allow_origins=list(settings.cors_allow_origins),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_origin_regex=settings.cors_allow_origin_regex or None,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
)
app.include_router(auth_router)
app.include_router(access_router)
app.include_router(client_access_router)
app.include_router(dashboard_router)
app.include_router(editor_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
app.include_router(product_costing_router)
app.include_router(products_router)
app.include_router(scenarios_router)
app.include_router(throughput_router)
app.include_router(ordering_router)
app.include_router(ordering_admin_router)
app.include_router(powerbi_router)
app.include_router(public_v1_router)
@app.middleware("http")
async def log_http_requests(request: Request, call_next):
global _requests_served
timer = RequestTimer()
try:
response = await call_next(request)
except Exception:
log_request(
method=request.method,
path=request.url.path,
status_code=500,
duration_ms=timer.elapsed_ms,
client=request.client.host if request.client else "-",
content_length=None,
)
raise
_requests_served += 1
log_request(
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=timer.elapsed_ms,
client=request.client.host if request.client else "-",
content_length=response.headers.get("content-length"),
)
return response
@app.middleware("http")
async def enforce_request_limits_and_csrf(request: Request, call_next):
content_length = request.headers.get("content-length")
if content_length:
try:
if int(content_length) > settings.request_body_max_bytes:
return JSONResponse(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
content={"detail": "Request body is too large"},
)
except ValueError:
pass
if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.cookies:
origin = request.headers.get("origin")
if not _origin_is_allowed(origin):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Origin is not allowed"},
)
response = await call_next(request)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"font-src 'self' data:; "
"connect-src 'self'; "
# PDF previews/printing load a same-origin blob: URL into an iframe.
# Without an explicit frame-src/child-src these fall back to default-src
# ('self'), which blocks blob: and breaks the in-app print dialog.
"frame-src 'self' blob:; "
"child-src 'self' blob:; "
"frame-ancestors 'self'; "
"base-uri 'self'; "
"form-action 'self'"
)
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
if settings.app_env.lower() == "production":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@app.exception_handler(Exception)
async def unhandled_exception_handler(_: Request, exc: Exception):
fatal("Unhandled server error", exc_info=True, logger_name="data_entry_app.http")
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("/")
@@ -104,7 +321,9 @@ def root():
"mix_calculator": "/api/mix-calculator",
"products": "/api/products",
"scenarios": "/api/scenarios",
"operations_throughput": "/api/throughput",
"client_access": "/api/client-access",
"powerbi_throughput": "/api/v1/throughput",
"docs": "/docs",
},
}
@@ -117,9 +336,10 @@ def healthcheck():
if __name__ == "__main__":
report = ensure_database_ready()
print(f"Database startup checks complete: {report.summary()}")
success("Database startup checks complete: %s", report.summary(), logger_name="data_entry_app.startup")
uvicorn.run(
app,
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
host=settings.host,
port=settings.port,
access_log=False,
)
+51 -1
View File
@@ -1,21 +1,62 @@
from app.models.access import Permission, Role, User, role_permissions
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.change_event import EditorChangeEvent
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
CustomerProductVisibility,
NotificationSetting,
Order,
OrderAttachment,
OrderLine,
OrderStatusHistory,
PriceList,
PriceListItem,
PriceTier,
ProductCategory,
XeroSyncLog,
)
from app.models.product import Product, ProductIngredient
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.scenario import CostingResult, Scenario
from app.models.throughput import ProductionThroughput, ThroughputProduct
__all__ = [
"CatalogueProduct",
"ClientAccount",
"ClientAccessAuditEvent",
"ClientFeatureAccess",
"ClientUser",
"ClientUserModulePermission",
"CostingResult",
"EditorChangeEvent",
"CustomerPriceAssignment",
"CustomerProductPrice",
"CustomerProductVisibility",
"FreightCostRule",
"Mix",
"NotificationSetting",
"Order",
"OrderAttachment",
"OrderLine",
"OrderStatusHistory",
"PriceList",
"PriceListItem",
"PriceTier",
"ProductCategory",
"XeroSyncLog",
"MixCalculatorSession",
"MixCalculatorSessionLine",
"MixIngredient",
@@ -23,6 +64,15 @@ __all__ = [
"Permission",
"ProcessCostRule",
"Product",
"ProductCostBagInput",
"ProductCostBaseInput",
"ProductCostClientInput",
"ProductCostFreightInput",
"ProductCostItem",
"ProductCostProcessInput",
"ProductIngredient",
"ProductionThroughput",
"ThroughputProduct",
"RawMaterial",
"RawMaterialPriceVersion",
"Role",
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class EditorChangeEvent(Base):
"""An audit row recording an edit to a mix or an ingredient.
Written by the editor API whenever a mix or raw material (ingredient) is
created or changed, and read back per-entity by the History buttons on the
Mix Editor and Ingredients Editor. `changes` holds a list of
``{"field", "label", "before", "after"}`` field deltas so the UI can show a
readable before/after for each edit.
"""
__tablename__ = "editor_change_events"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
# "mix" or "ingredient" — the surface the History button lives on.
entity_type: Mapped[str] = mapped_column(String(32), index=True)
entity_id: Mapped[int] = mapped_column(Integer, index=True)
action: Mapped[str] = mapped_column(String(48))
actor_name: Mapped[str] = mapped_column(String(255), default="")
actor_email: Mapped[str] = mapped_column(String(255), default="")
actor_role: Mapped[str | None] = mapped_column(String(64), nullable=True)
summary: Mapped[str] = mapped_column(Text, default="")
changes: Mapped[list | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
+2
View File
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
required_kg: Mapped[float] = mapped_column(Float)
mix_percentage: Mapped[float] = mapped_column(Float)
unit: Mapped[str] = mapped_column(String(64))
# Snapshot of the ingredient's rounding setting at save time.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
+418
View File
@@ -0,0 +1,418 @@
"""B2B ordering portal data model.
This module backs the private customer ordering portal. It deliberately reuses
the existing tenant/customer primitives rather than introducing parallel ones:
* A **customer/company** is an existing :class:`ClientAccount`.
* A **customer user** is an existing :class:`ClientUser` (owner/buyer/viewer/
accounts roles map onto the existing ``ordering`` module access levels).
Everything here is private: every row is tenant-scoped, and customer-facing
rows are additionally scoped to a ``client_account_id``. The catalogue itself is
global to the seller (one tenant), with per-customer visibility and pricing
layered on top.
Money is stored GST-exclusive (see ``CLAUDE.MD`` costing conventions) as floats,
matching the rest of the costing platform.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
# --- Controlled vocabularies -------------------------------------------------
# Plain string constants (not DB enums) keep SQLite migrations trivial and match
# the existing model style.
PRODUCT_CATEGORIES = (
"grains",
"premixed",
"bags",
"bulk_loads",
"custom_blends",
"services",
)
# Full internal order lifecycle. Customers see a simplified subset (see
# ``CUSTOMER_VISIBLE_STATUS`` in the ordering service).
ORDER_STATUSES = (
"draft",
"submitted",
"under_review",
"confirmed",
"sent_to_xero",
"in_production",
"ready_for_pickup",
"dispatched",
"completed",
"cancelled",
)
# How a resolved unit price was derived. Stored on each order line so future
# reporting can always explain which rule applied.
PRICE_SOURCES = (
"fixed", # CustomerProductPrice with rule_type fixed
"contract", # CustomerProductPrice with rule_type contract
"price_list", # PriceListItem via an assigned price list
"tiered", # a quantity tier won over the base rate
"base", # catalogue list price (optionally with customer discount)
"quote", # manual quote required; no automatic price
)
FULFILMENT_METHODS = ("delivery", "pickup")
class ProductCategory(Base):
__tablename__ = "product_categories"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_product_category_slug"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
slug: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class CatalogueProduct(Base):
"""A private catalogue product. Named ``CatalogueProduct`` to avoid clashing
with the existing costing ``Product`` (a separate concern)."""
__tablename__ = "catalogue_products"
__table_args__ = (UniqueConstraint("tenant_id", "sku", name="uq_catalogue_product_sku"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
sku: Mapped[str] = mapped_column(String(64), index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(64), default="grains", index=True)
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
unit_size: Mapped[str | None] = mapped_column(String(64), nullable=True)
unit_of_measure: Mapped[str] = mapped_column(String(64), default="each")
min_order_quantity: Mapped[float] = mapped_column(Float, default=1.0)
# Base/list price, GST-exclusive. Used as the fallback price source and as
# the basis for a customer discount percentage.
base_price: Mapped[float | None] = mapped_column(Float, nullable=True)
stock_status: Mapped[str] = mapped_column(String(32), default="in_stock")
active: Mapped[bool] = mapped_column(Boolean, default=True)
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CustomerProductVisibility(Base):
"""Per-customer override of catalogue visibility.
Products are visible to every customer by default (the catalogue is
"standard globally"). A row here with ``visible=False`` hides a product from
one customer; ``visible=True`` is an explicit allow (no-op unless a future
default flips to opt-in).
"""
__tablename__ = "customer_product_visibility"
__table_args__ = (
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_visibility"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
visible: Mapped[bool] = mapped_column(Boolean, default=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class PriceList(Base):
__tablename__ = "price_lists"
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_price_list_code"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
code: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
items: Mapped[list["PriceListItem"]] = relationship(
back_populates="price_list",
cascade="all, delete-orphan",
)
class PriceListItem(Base):
__tablename__ = "price_list_items"
__table_args__ = (UniqueConstraint("price_list_id", "product_id", name="uq_price_list_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
price_list_id: Mapped[int] = mapped_column(ForeignKey("price_lists.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
unit_price: Mapped[float] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
price_list: Mapped[PriceList] = relationship(back_populates="items")
class CustomerPriceAssignment(Base):
"""Links a customer to an assigned price list and a default discount."""
__tablename__ = "customer_price_assignments"
__table_args__ = (
UniqueConstraint("client_account_id", name="uq_customer_price_assignment"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
price_list_id: Mapped[int | None] = mapped_column(ForeignKey("price_lists.id"), nullable=True, index=True)
# Default percentage discount applied to base prices for this customer when
# no more specific rule (fixed/contract/price-list) applies. 0..100.
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
price_list: Mapped[PriceList | None] = relationship()
class CustomerProductPrice(Base):
"""A fixed or contract price for a specific customer + product. Highest
priority pricing rule."""
__tablename__ = "customer_product_prices"
__table_args__ = (
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_price"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
# "fixed" | "contract" | "quote" (quote forces a manual-quote workflow for
# this customer+product even if the product itself doesn't require one).
rule_type: Mapped[str] = mapped_column(String(32), default="fixed")
contract_reference: Mapped[str | None] = mapped_column(String(128), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class PriceTier(Base):
"""Quantity break for a pricing source. Exactly one of
``customer_product_price_id`` / ``price_list_item_id`` is set."""
__tablename__ = "price_tiers"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
customer_product_price_id: Mapped[int | None] = mapped_column(
ForeignKey("customer_product_prices.id"), nullable=True, index=True
)
price_list_item_id: Mapped[int | None] = mapped_column(
ForeignKey("price_list_items.id"), nullable=True, index=True
)
# Tier applies when ordered quantity >= min_quantity. The highest qualifying
# min_quantity wins.
min_quantity: Mapped[float] = mapped_column(Float)
unit_price: Mapped[float] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
# Human-friendly order reference, assigned on submit (e.g. ORD-000123).
order_number: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
# Who created / submitted it (ClientUser ids; nullable for admin-created).
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True)
created_by_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Customer-supplied order details.
purchase_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
delivery_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
requested_delivery_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
fulfilment_method: Mapped[str] = mapped_column(String(32), default="delivery")
# Admin/internal handling.
admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
reopened: Mapped[bool] = mapped_column(Boolean, default=False)
# Cached totals (authoritative figures live on lines; this is for listing).
subtotal_ex_gst: Mapped[float] = mapped_column(Float, default=0.0)
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
# Xero integration tracking.
xero_status: Mapped[str] = mapped_column(String(32), default="not_sent")
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
lines: Mapped[list["OrderLine"]] = relationship(
back_populates="order",
cascade="all, delete-orphan",
order_by="OrderLine.sort_order",
)
status_history: Mapped[list["OrderStatusHistory"]] = relationship(
back_populates="order",
cascade="all, delete-orphan",
order_by="OrderStatusHistory.created_at",
)
attachments: Mapped[list["OrderAttachment"]] = relationship(
back_populates="order",
cascade="all, delete-orphan",
)
class OrderLine(Base):
__tablename__ = "order_lines"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
# Snapshot of product identity at order time (so later catalogue edits don't
# rewrite historical orders).
product_name: Mapped[str] = mapped_column(String(255))
product_sku: Mapped[str] = mapped_column(String(64))
quantity: Mapped[float] = mapped_column(Float, default=1.0)
# Authoritative unit price, GST-exclusive, computed by the backend pricing
# engine and frozen at submission. Null only while quote-only.
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
line_total: Mapped[float | None] = mapped_column(Float, nullable=True)
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
# Pricing provenance — enough to explain which rule applied in reporting.
price_source: Mapped[str] = mapped_column(String(32), default="base")
price_rule_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
# Admin override of the resolved unit price before confirmation.
admin_override_price: Mapped[float | None] = mapped_column(Float, nullable=True)
admin_override_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
order: Mapped[Order] = relationship(back_populates="lines")
class OrderStatusHistory(Base):
__tablename__ = "order_status_history"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
to_status: Mapped[str] = mapped_column(String(32))
actor_type: Mapped[str] = mapped_column(String(32), default="system")
actor_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
order: Mapped[Order] = relationship(back_populates="status_history")
class OrderAttachment(Base):
__tablename__ = "order_attachments"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
# "purchase_order" | "confirmation_pdf" | "other"
kind: Mapped[str] = mapped_column(String(32), default="other")
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
order: Mapped[Order] = relationship(back_populates="attachments")
class NotificationSetting(Base):
"""Per-tenant notification configuration for the ordering portal."""
__tablename__ = "notification_settings"
__table_args__ = (UniqueConstraint("tenant_id", name="uq_notification_settings_tenant"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
# Comma-separated internal recipients notified on new order submissions.
internal_recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
send_customer_confirmation: Mapped[bool] = mapped_column(Boolean, default=True)
require_po_number: Mapped[bool] = mapped_column(Boolean, default=False)
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class 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"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
# "queued" | "success" | "failed"
status: Mapped[str] = mapped_column(String(32), default="queued")
request_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+24 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
@@ -19,6 +19,7 @@ class Product(Base):
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"))
sale_type: Mapped[str] = mapped_column(String(64), default="standard")
own_bag: Mapped[bool] = mapped_column(Boolean, default=False)
visible: Mapped[bool] = mapped_column(Boolean, default=True)
unit_of_measure: Mapped[str] = mapped_column(String(64), default="20kg bag")
items_per_pallet: Mapped[int] = mapped_column(Integer, default=50)
bagging_process: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -28,7 +29,28 @@ class Product(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
mix: Mapped["Mix"] = relationship(back_populates="products")
ingredients: Mapped[list["ProductIngredient"]] = relationship(
back_populates="product",
cascade="all, delete-orphan",
order_by="ProductIngredient.sort_order",
)
class ProductIngredient(Base):
__tablename__ = "product_ingredients"
__table_args__ = (UniqueConstraint("product_id", "raw_material_id", name="uq_product_ingredient"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
quantity_kg: Mapped[float] = mapped_column(Float)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
product: Mapped[Product] = relationship(back_populates="ingredients")
raw_material: Mapped["RawMaterial"] = relationship()
from app.models.mix import Mix # noqa: E402
from app.models.raw_material import RawMaterial # noqa: E402
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class ProductCostItem(Base):
__tablename__ = "product_cost_items"
__table_args__ = (UniqueConstraint("tenant_id", "item_id", name="uq_product_cost_item_tenant_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_category: Mapped[str] = mapped_column(String(255), index=True)
item_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
product_name: Mapped[str] = mapped_column(String(255), index=True)
mix_product_name: Mapped[str] = mapped_column(String(255), index=True)
unit_type: Mapped[str] = mapped_column(String(32), default="Standard")
own_bag: Mapped[str | None] = mapped_column(String(32), nullable=True)
unit_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
items_per_pallet: Mapped[int | None] = mapped_column(Integer, nullable=True)
bagging_process: Mapped[str | None] = mapped_column(String(128), nullable=True)
manual_distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
manual_wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
cleaned_product_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
grading_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
bagging_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
cracking_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
bag_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
freight_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
finished_product_delivered_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
distributor_price: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_price: Mapped[float | None] = mapped_column(Float, nullable=True)
warnings: Mapped[str | None] = mapped_column(Text, 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 ProductCostBaseInput(Base):
__tablename__ = "product_cost_base_inputs"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", unique=True, index=True)
grading_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
grading_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
cracking_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
cracking_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostProcessInput(Base):
__tablename__ = "product_cost_process_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "process_name", name="uq_product_cost_process_tenant_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
process_name: Mapped[str] = mapped_column(String(128), index=True)
cost_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostClientInput(Base):
__tablename__ = "product_cost_client_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "client_category", name="uq_product_cost_client_tenant_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_category: Mapped[str] = mapped_column(String(255), index=True)
distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostBagInput(Base):
__tablename__ = "product_cost_bag_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_bag_tenant_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
input_key: Mapped[str] = mapped_column(String(64), index=True)
label: Mapped[str] = mapped_column(String(128))
cost: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostFreightInput(Base):
__tablename__ = "product_cost_freight_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_freight_tenant_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
input_key: Mapped[str] = mapped_column(String(64), index=True)
label: Mapped[str] = mapped_column(String(128))
cost: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+7 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
@@ -18,6 +18,12 @@ 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")
# Manually-assigned grouping used to order ingredients in the Mix Calculator
# output (e.g. "Grains", "Additives"). Optional; uncategorised rows sort last.
category: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 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)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class ThroughputProduct(Base):
__tablename__ = "throughput_products"
__table_args__ = (
Index("ix_throughput_products_tenant_item", "tenant_id", "item_id", unique=True),
Index("ix_throughput_products_tenant_name", "tenant_id", "name"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
name: Mapped[str] = mapped_column(String(255))
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
default_bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False)
active: Mapped[bool] = mapped_column(Boolean, default=True)
is_stock_item: Mapped[bool] = mapped_column(Boolean, default=True)
notes: Mapped[str | None] = mapped_column(Text, 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)
entries: Mapped[list["ProductionThroughput"]] = relationship(back_populates="product")
class ProductionThroughput(Base):
__tablename__ = "production_throughput_entries"
__table_args__ = (
Index("ix_throughput_entries_tenant_date", "tenant_id", "production_date"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
production_date: Mapped[date] = mapped_column(Date)
product_id: Mapped[int | None] = mapped_column(ForeignKey("throughput_products.id"), nullable=True, index=True)
product_name_snapshot: Mapped[str] = mapped_column(String(255))
bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
scales_checked: Mapped[bool] = mapped_column(Boolean, default=True)
label_correct: Mapped[bool] = mapped_column(Boolean, default=True)
bag_sealed: Mapped[bool] = mapped_column(Boolean, default=True)
pallet_good_condition: Mapped[bool] = mapped_column(Boolean, default=True)
# Where the run is destined. A run can be for a client order, for stock, or
# split across both. job_number records the Order Circle job for the order
# portion; stock_quantity records how much of a split goes into stock (in the
# same unit as `quantity`).
for_order: Mapped[bool] = mapped_column(Boolean, default=False)
for_stock: Mapped[bool] = mapped_column(Boolean, default=False)
job_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
stock_quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
sample_box_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
test_weight_1: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_2: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_3: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_4: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_5: Mapped[float | None] = mapped_column(Float, nullable=True)
quantity: Mapped[float] = mapped_column(Float, default=0.0)
quantity_type: Mapped[str] = mapped_column(String(8), default="bags")
calculated_kg: Mapped[float] = mapped_column(Float, default=0.0)
staff_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(255), 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)
product: Mapped[ThroughputProduct | None] = relationship(back_populates="entries")
+9 -5
View File
@@ -1,30 +1,34 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
class ClientUserCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_account_id: int
full_name: str
email: str
full_name: str = Field(min_length=1, max_length=255)
email: str = Field(min_length=3, max_length=255)
role: str = "viewer"
status: str = "invited"
is_new_user: bool = True
class ClientUserUpdate(BaseModel):
full_name: str | None = None
email: str | None = None
model_config = ConfigDict(extra="forbid")
full_name: str | None = Field(default=None, min_length=1, max_length=255)
email: str | None = Field(default=None, min_length=3, max_length=255)
role: str | None = None
status: str | None = None
is_new_user: bool | None = None
class ClientFeatureUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool
class ClientUserModulePermissionUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
access_level: str
+248
View File
@@ -0,0 +1,248 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class EditorProductRow(BaseModel):
id: int
tenant_id: str
client_name: str
item_id: str | None
name: str
mix_id: int
mix_client_name: str
mix_name: str
sale_type: str
unit_of_measure: str
visible: bool
product_notes: str | None
mix_notes: str | None
class EditorProductUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
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)
item_id: str | None = Field(default=None, max_length=128)
mix_id: int | None = None
sale_type: str | None = Field(default=None, min_length=1, max_length=64)
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
visible: bool | None = None
notes: str | None = Field(default=None, max_length=2000)
class EditorMixCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_name: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
class EditorMixUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
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 EditorResolvedMixIngredient(BaseModel):
raw_material_id: int
raw_material_name: str
quantity_kg: float
# This row's share of the mix total, matching the Mix Calculator.
mix_percentage: float
unit: str
notes: str | None
class EditorResolvedMixFormula(BaseModel):
"""A mix formula resolved the way the Mix Calculator reads it.
`source` is `product` when the numbers come from a representative product's
own formula, or `mix` when they come from the shared mix master fallback.
The PUT endpoint writes back to whichever source produced these rows.
"""
id: int
tenant_id: str
client_name: str
name: str
source: str
product_id: int | None
ingredients: list[EditorResolvedMixIngredient]
total_kg: float
class EditorMixFormulaRowInput(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 EditorMixFormulaReplace(BaseModel):
"""Full replacement of a mix's formula in one save.
The frontend keeps kilograms as the canonical value (percentages are an
entry aid that resolve back to kg against the total), so the API only needs
the resolved kg per row.
"""
model_config = ConfigDict(extra="forbid")
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
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):
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 EditorProductIngredientUpdate(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 EditorProductIngredientRead(BaseModel):
id: int
raw_material_id: int
raw_material_name: str
quantity_kg: float
sort_order: int
notes: str | None
class EditorProductFormulaRead(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
mix_id: int
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
# Manual grouping used to order ingredients in the Mix Calculator output.
category: str | None
# 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)
category: str | None = Field(default=None, max_length=128)
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)
category: str | None = Field(default=None, max_length=128)
notes: str | None = Field(default=None, max_length=2000)
# --- Change history ----------------------------------------------------------
class EditorChangeFieldDelta(BaseModel):
field: str
label: str
before: str | None = None
after: str | None = None
class EditorChangeEventRead(BaseModel):
id: int
entity_type: str
entity_id: int
action: str
actor_name: str
actor_email: str
actor_role: str | None
summary: str
changes: list[EditorChangeFieldDelta]
created_at: datetime
+12 -9
View File
@@ -4,14 +4,16 @@ from pydantic import BaseModel, ConfigDict, Field
class MixIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = None
notes: str | None = Field(default=None, max_length=1000)
class MixIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
quantity_kg: float | None = Field(default=None, gt=0)
notes: str | None = None
notes: str | None = Field(default=None, max_length=1000)
class MixIngredientRead(BaseModel):
@@ -26,20 +28,22 @@ class MixIngredientRead(BaseModel):
class MixCreate(BaseModel):
client_name: str
name: str
model_config = ConfigDict(extra="forbid")
client_name: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
status: str = "draft"
version: int = 1
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
ingredients: list[MixIngredientCreate]
class MixUpdate(BaseModel):
client_name: str | None = None
name: str | None = None
model_config = ConfigDict(extra="forbid")
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)
status: str | None = None
version: int | None = None
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
class MixRead(BaseModel):
@@ -57,4 +61,3 @@ class MixRead(BaseModel):
mix_cost_per_kg: float | None
warnings: list[str]
model_config = ConfigDict(from_attributes=True)
+9 -4
View File
@@ -26,17 +26,21 @@ class MixCalculatorSessionLineRead(BaseModel):
required_kg: float
mix_percentage: float
unit: str
rounding_decimals: int = 2
# Manual ingredient grouping used to order the calculator output.
category: str | None = None
sort_order: int
class MixCalculatorSessionBase(BaseModel):
model_config = ConfigDict(extra="forbid")
mix_date: date
client_name: str
client_name: str = Field(min_length=1, max_length=255)
product_id: int
batch_size_kg: float = Field(gt=0)
prepared_by_name: str = Field(min_length=1, max_length=255)
status: str = "saved"
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
class MixCalculatorSessionCreate(MixCalculatorSessionBase):
@@ -44,13 +48,14 @@ class MixCalculatorSessionCreate(MixCalculatorSessionBase):
class MixCalculatorSessionUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
mix_date: date | None = None
client_name: str | None = None
client_name: str | None = Field(default=None, min_length=1, max_length=255)
product_id: int | None = None
batch_size_kg: float | None = Field(default=None, gt=0)
prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255)
status: str | None = None
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
class MixCalculatorPreviewRead(BaseModel):
+314
View File
@@ -0,0 +1,314 @@
"""Request schemas for the B2B ordering portal.
Responses are returned as plain dicts (FastAPI encodes them); these models
constrain and validate *inputs* only.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from app.models.ordering import (
FULFILMENT_METHODS,
ORDER_STATUSES,
PRODUCT_CATEGORIES,
)
_CATEGORY_SET = set(PRODUCT_CATEGORIES)
_STATUS_SET = set(ORDER_STATUSES)
_FULFILMENT_SET = set(FULFILMENT_METHODS)
_RULE_TYPES = {"fixed", "contract", "quote"}
_STOCK_STATUSES = {"in_stock", "low_stock", "out_of_stock", "made_to_order"}
# --- Customer ordering -------------------------------------------------------
class OrderLineInput(BaseModel):
product_id: int
quantity: float = Field(gt=0)
notes: str | None = None
class DraftOrderCreate(BaseModel):
lines: list[OrderLineInput] = Field(default_factory=list)
purchase_order_number: str | None = None
delivery_notes: str | None = None
requested_delivery_date: datetime | None = None
fulfilment_method: str = "delivery"
@field_validator("fulfilment_method")
@classmethod
def _check_fulfilment(cls, value: str) -> str:
if value not in _FULFILMENT_SET:
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
return value
class DraftOrderUpdate(BaseModel):
lines: list[OrderLineInput] | None = None
purchase_order_number: str | None = None
delivery_notes: str | None = None
requested_delivery_date: datetime | None = None
fulfilment_method: str | None = None
@field_validator("fulfilment_method")
@classmethod
def _check_fulfilment(cls, value: str | None) -> str | None:
if value is not None and value not in _FULFILMENT_SET:
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
return value
class OrderSubmitRequest(BaseModel):
purchase_order_number: str | None = None
delivery_notes: str | None = None
requested_delivery_date: datetime | None = None
fulfilment_method: str | None = None
@field_validator("fulfilment_method")
@classmethod
def _check_fulfilment(cls, value: str | None) -> str | None:
if value is not None and value not in _FULFILMENT_SET:
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
return value
# --- Admin: catalogue --------------------------------------------------------
class CategoryCreate(BaseModel):
slug: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
sort_order: int = 0
active: bool = True
class CategoryUpdate(BaseModel):
name: str | None = None
description: str | None = None
sort_order: int | None = None
active: bool | None = None
class CatalogueProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
sku: str = Field(min_length=1, max_length=64)
description: str | None = None
category: str = "grains"
image_url: str | None = None
unit_size: str | None = None
unit_of_measure: str = "each"
min_order_quantity: float = Field(default=1.0, ge=0)
base_price: float | None = Field(default=None, ge=0)
stock_status: str = "in_stock"
active: bool = True
requires_quote: bool = False
@field_validator("category")
@classmethod
def _check_category(cls, value: str) -> str:
if value not in _CATEGORY_SET:
raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
return value
@field_validator("stock_status")
@classmethod
def _check_stock(cls, value: str) -> str:
if value not in _STOCK_STATUSES:
raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
return value
class CatalogueProductUpdate(BaseModel):
name: str | None = None
sku: str | None = None
description: str | None = None
category: str | None = None
image_url: str | None = None
unit_size: str | None = None
unit_of_measure: str | None = None
min_order_quantity: float | None = Field(default=None, ge=0)
base_price: float | None = Field(default=None, ge=0)
stock_status: str | None = None
active: bool | None = None
requires_quote: bool | None = None
@field_validator("category")
@classmethod
def _check_category(cls, value: str | None) -> str | None:
if value is not None and value not in _CATEGORY_SET:
raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
return value
@field_validator("stock_status")
@classmethod
def _check_stock(cls, value: str | None) -> str | None:
if value is not None and value not in _STOCK_STATUSES:
raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
return value
class VisibilityUpdate(BaseModel):
product_id: int
visible: bool
# --- Admin: pricing ----------------------------------------------------------
class PriceTierInput(BaseModel):
min_quantity: float = Field(gt=0)
unit_price: float = Field(ge=0)
class PriceListCreate(BaseModel):
code: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
active: bool = True
class PriceListItemUpsert(BaseModel):
product_id: int
unit_price: float = Field(ge=0)
tiers: list[PriceTierInput] | None = None
class CustomerPriceAssignmentUpsert(BaseModel):
price_list_id: int | None = None
discount_percent: float = Field(default=0.0, ge=0, le=100)
class CustomerProductPriceUpsert(BaseModel):
product_id: int
unit_price: float | None = Field(default=None, ge=0)
rule_type: str = "fixed"
contract_reference: str | None = None
notes: str | None = None
active: bool = True
tiers: list[PriceTierInput] | None = None
@field_validator("rule_type")
@classmethod
def _check_rule(cls, value: str) -> str:
if value not in _RULE_TYPES:
raise ValueError(f"rule_type must be one of {sorted(_RULE_TYPES)}")
return value
# --- Admin: orders -----------------------------------------------------------
class OrderStatusUpdate(BaseModel):
to_status: str
note: str | None = None
@field_validator("to_status")
@classmethod
def _check_status(cls, value: str) -> str:
if value not in _STATUS_SET:
raise ValueError(f"to_status must be one of {sorted(_STATUS_SET)}")
return value
class OrderLineOverride(BaseModel):
quantity: float | None = Field(default=None, gt=0)
unit_price: float | None = Field(default=None, ge=0)
reason: str | None = None
class OrderAdminNotesUpdate(BaseModel):
admin_notes: str | None = None
class ReopenOrderRequest(BaseModel):
note: str | None = None
# --- Admin: settings ---------------------------------------------------------
class NotificationSettingsUpdate(BaseModel):
internal_recipients: str | None = None
send_customer_confirmation: bool | None = None
require_po_number: bool | None = None
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"}
_ORDERING_USER_ROLES = {"owner", "buyer", "accounts", "viewer"}
class CustomerCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
client_code: str = Field(min_length=1, max_length=64)
tenant_id: str | None = None
notes: str | None = None
class CustomerUpdate(BaseModel):
name: str | None = None
status: str | None = None
notes: str | None = None
@field_validator("status")
@classmethod
def _check_status(cls, value: str | None) -> str | None:
if value is not None and value not in _CUSTOMER_STATUSES:
raise ValueError(f"status must be one of {sorted(_CUSTOMER_STATUSES)}")
return value
class CustomerUserCreate(BaseModel):
full_name: str = Field(min_length=1, max_length=255)
email: str = Field(min_length=3, max_length=255)
role: str = "buyer"
@field_validator("role")
@classmethod
def _check_role(cls, value: str) -> str:
if value not in _ORDERING_USER_ROLES:
raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
return value
@field_validator("email")
@classmethod
def _normalize_email(cls, value: str) -> str:
return value.strip().lower()
class CustomerUserUpdate(BaseModel):
full_name: str | None = None
role: str | None = None
status: str | None = None
@field_validator("role")
@classmethod
def _check_role(cls, value: str | None) -> str | None:
if value is not None and value not in _ORDERING_USER_ROLES:
raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
return value
@field_validator("status")
@classmethod
def _check_status(cls, value: str | None) -> str | None:
if value is not None and value not in {"active", "invited", "suspended"}:
raise ValueError("status must be one of ['active', 'invited', 'suspended']")
return value
+17 -12
View File
@@ -4,33 +4,37 @@ from pydantic import BaseModel, ConfigDict, Field
class ProductCreate(BaseModel):
client_name: str
item_id: str | None = None
name: str
model_config = ConfigDict(extra="forbid")
client_name: str = Field(min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
name: str = Field(min_length=1, max_length=255)
mix_id: int
sale_type: str = "standard"
own_bag: bool = False
unit_of_measure: str = "20kg bag"
visible: bool = True
unit_of_measure: str = Field(default="20kg bag", min_length=1, max_length=64)
items_per_pallet: int = Field(default=50, gt=0)
bagging_process: str | None = None
bagging_process: str | None = Field(default=None, max_length=128)
distributor_margin: float | None = Field(default=None, gt=0, lt=1)
wholesale_margin: float | None = Field(default=None, gt=0, lt=1)
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
class ProductUpdate(BaseModel):
client_name: str | None = None
item_id: str | None = None
name: str | None = None
model_config = ConfigDict(extra="forbid")
client_name: str | None = Field(default=None, min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
name: str | None = Field(default=None, min_length=1, max_length=255)
mix_id: int | None = None
sale_type: str | None = None
own_bag: bool | None = None
unit_of_measure: str | None = None
visible: bool | None = None
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = None
bagging_process: str | None = Field(default=None, max_length=128)
distributor_margin: float | None = Field(default=None, gt=0, lt=1)
wholesale_margin: float | None = Field(default=None, gt=0, lt=1)
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
class ProductRead(BaseModel):
@@ -43,6 +47,7 @@ class ProductRead(BaseModel):
mix_name: str
sale_type: str
own_bag: bool
visible: bool
unit_of_measure: str
items_per_pallet: int
bagging_process: str | None
+97
View File
@@ -0,0 +1,97 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ProductCostItemBase(BaseModel):
client_category: str = Field(min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
product_name: str = Field(min_length=1, max_length=255)
mix_product_name: str = Field(min_length=1, max_length=255)
unit_type: str = "Standard"
own_bag: str | None = None
unit_kg: float | None = Field(default=None, gt=0)
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = Field(default=None, max_length=128)
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
class ProductCostItemCreate(ProductCostItemBase):
pass
class ProductCostItemUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_category: str | None = Field(default=None, min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
product_name: str | None = Field(default=None, min_length=1, max_length=255)
mix_product_name: str | None = Field(default=None, min_length=1, max_length=255)
unit_type: str | None = None
own_bag: str | None = None
unit_kg: float | None = Field(default=None, gt=0)
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = Field(default=None, max_length=128)
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
class ProductCostItemRead(ProductCostItemBase):
id: int
tenant_id: str
cleaned_product_cost_per_kg: float | None
grading_cost_per_kg: float | None
bagging_cost_per_kg: float | None
cracking_cost_per_kg: float | None
bag_cost_per_unit: float | None
freight_cost_per_unit: float | None
finished_product_delivered_cost: float | None
distributor_price: float | None
wholesale_price: float | None
warnings: list[str]
created_at: datetime
updated_at: datetime
class ProductCostBaseInputRead(BaseModel):
grading_per_tonne: float
grading_per_kg: float
cracking_per_tonne: float
cracking_per_kg: float
class ProductCostBaseInputUpdate(ProductCostBaseInputRead):
pass
class ProductCostNamedInputRead(BaseModel):
key: str
label: str
cost: float
class ProductCostClientInputRead(BaseModel):
client_category: str
distributor_margin: float | None
wholesale_margin: float | None
class ProductCostInputsRead(BaseModel):
base: ProductCostBaseInputRead
processes: list[ProductCostNamedInputRead]
clients: list[ProductCostClientInputRead]
bags: list[ProductCostNamedInputRead]
freight: list[ProductCostNamedInputRead]
class ProductCostInputsUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
base: ProductCostBaseInputUpdate | None = None
processes: list[ProductCostNamedInputRead] | None = None
clients: list[ProductCostClientInputRead] | None = None
bags: list[ProductCostNamedInputRead] | None = None
freight: list[ProductCostNamedInputRead] | None = None
class ProductCostRecalculateAllRead(BaseModel):
recalculated: int
+11 -9
View File
@@ -4,11 +4,12 @@ from pydantic import BaseModel, ConfigDict, Field
class RawMaterialPriceVersionCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
market_value: float = Field(gt=0)
waste_percentage: float = Field(ge=0, default=0.0)
effective_date: date
status: str = "active"
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate):
@@ -21,21 +22,23 @@ class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate):
class RawMaterialCreate(BaseModel):
name: str
supplier: str | None = None
unit_of_measure: str
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 = "active"
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
initial_price: RawMaterialPriceVersionCreate
class RawMaterialUpdate(BaseModel):
supplier: str | None = None
unit_of_measure: str | None = None
model_config = ConfigDict(extra="forbid")
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 = None
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
class RawMaterialRead(BaseModel):
@@ -50,4 +53,3 @@ class RawMaterialRead(BaseModel):
created_at: datetime
current_price: RawMaterialPriceVersionRead | None
model_config = ConfigDict(from_attributes=True)
+3 -2
View File
@@ -6,8 +6,9 @@ from app.schemas.product import ProductCostBreakdown
class ScenarioCreate(BaseModel):
name: str
description: str | None = None
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=2000)
overrides: dict = Field(default_factory=dict)
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
QuantityType = Literal["bags", "kg"]
class ThroughputProductBase(BaseModel):
model_config = ConfigDict(extra="forbid")
item_id: str | None = Field(default=None, max_length=64)
name: str = Field(min_length=1, max_length=255)
client_name: str | None = Field(default=None, max_length=255)
default_bag_size: float | None = Field(default=None, ge=0)
is_bulka_default: bool = False
active: bool = True
is_stock_item: bool = True
notes: str | None = Field(default=None, max_length=2000)
class ThroughputProductCreate(ThroughputProductBase):
pass
class ThroughputProductUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
item_id: str | None = Field(default=None, max_length=64)
name: str | None = Field(default=None, min_length=1, max_length=255)
client_name: str | None = Field(default=None, max_length=255)
default_bag_size: float | None = Field(default=None, ge=0)
is_bulka_default: bool | None = None
active: bool | None = None
is_stock_item: bool | None = None
notes: str | None = Field(default=None, max_length=2000)
class ThroughputProductRead(ThroughputProductBase):
id: int
tenant_id: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ThroughputEntryBase(BaseModel):
model_config = ConfigDict(extra="forbid")
production_date: date
product_id: int | None = None
product_name_snapshot: str | None = Field(default=None, max_length=255)
bag_size: float | None = Field(default=None, ge=0)
scales_checked: bool = True
label_correct: bool = True
bag_sealed: bool = True
pallet_good_condition: bool = True
for_order: bool = False
for_stock: bool = False
job_number: str | None = Field(default=None, max_length=64)
stock_quantity: float | None = Field(default=None, ge=0)
sample_box_no: str | None = Field(default=None, max_length=64)
test_weight_1: float | None = Field(default=None, ge=0)
test_weight_2: float | None = Field(default=None, ge=0)
test_weight_3: float | None = Field(default=None, ge=0)
test_weight_4: float | None = Field(default=None, ge=0)
test_weight_5: float | None = Field(default=None, ge=0)
quantity: float = Field(ge=0)
quantity_type: QuantityType = "bags"
staff_name: str | None = Field(default=None, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
@field_validator("job_number")
@classmethod
def _normalize_job_number(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
@field_validator("staff_name")
@classmethod
def _normalize_staff(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
class ThroughputEntryCreate(ThroughputEntryBase):
pass
class ThroughputEntryUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
production_date: date | None = None
product_id: int | None = None
product_name_snapshot: str | None = Field(default=None, max_length=255)
bag_size: float | None = Field(default=None, ge=0)
scales_checked: bool | None = None
label_correct: bool | None = None
bag_sealed: bool | None = None
pallet_good_condition: bool | None = None
for_order: bool | None = None
for_stock: bool | None = None
job_number: str | None = Field(default=None, max_length=64)
stock_quantity: float | None = Field(default=None, ge=0)
sample_box_no: str | None = Field(default=None, max_length=64)
test_weight_1: float | None = Field(default=None, ge=0)
test_weight_2: float | None = Field(default=None, ge=0)
test_weight_3: float | None = Field(default=None, ge=0)
test_weight_4: float | None = Field(default=None, ge=0)
test_weight_5: float | None = Field(default=None, ge=0)
quantity: float | None = Field(default=None, ge=0)
quantity_type: QuantityType | None = None
staff_name: str | None = Field(default=None, max_length=255)
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 ThroughputDeleteAllResult(BaseModel):
entries_deleted: int
class ThroughputEntryRead(BaseModel):
id: int
tenant_id: str
production_date: date
product_id: int | None
product_name_snapshot: str
bag_size: float | None
scales_checked: bool
label_correct: bool
bag_sealed: bool
pallet_good_condition: bool
for_order: bool
for_stock: bool
job_number: str | None
stock_quantity: float | None
sample_box_no: str | None
test_weight_1: float | None
test_weight_2: float | None
test_weight_3: float | None
test_weight_4: float | None
test_weight_5: float | None
quantity: float
quantity_type: QuantityType
calculated_kg: float
staff_name: str | None
notes: str | None
qa_passed: bool
created_by: str | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+613 -14
View File
@@ -8,23 +8,53 @@ from pathlib import Path
import re
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from app.db.session import Base, SessionLocal, engine
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
from app.services.throughput_service import import_workbook as import_throughput_workbook
from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path
from app.services.product_costing_service import (
BAG_INPUTS,
FREIGHT_INPUTS,
PROCESS_NAMES,
recalculate_all_product_cost_items,
)
TENANT_ID = "hunter-premium-produce"
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
WORKBOOK_SENTINEL_ITEM_ID = "404266"
WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx"
WORKBOOK_FILENAME = "1.xlsx"
LEGACY_WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx"
logger = logging.getLogger("data_entry_app.seed")
HIDDEN_PRODUCT_CLIENTS = frozenset(
{
"Bird Grits",
"Chaff",
"Hay & Straw",
"Hunter Premium Produce",
"Straight Grain",
"Uncategorized",
"Uncategorised",
}
)
def _workbook_candidates() -> list[Path]:
@@ -35,11 +65,18 @@ def _workbook_candidates() -> list[Path]:
candidates = [
Path(env_path) if env_path else None,
repo_root / "input_data" / WORKBOOK_FILENAME,
cwd / "input_data" / WORKBOOK_FILENAME,
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
repo_root / WORKBOOK_FILENAME,
cwd / WORKBOOK_FILENAME,
Path("/app") / WORKBOOK_FILENAME,
Path("/") / WORKBOOK_FILENAME,
repo_root / LEGACY_WORKBOOK_FILENAME,
cwd / LEGACY_WORKBOOK_FILENAME,
Path("/srv/lean101-clients") / LEGACY_WORKBOOK_FILENAME,
Path("/app") / LEGACY_WORKBOOK_FILENAME,
Path("/") / LEGACY_WORKBOOK_FILENAME,
]
ordered: list[Path] = []
@@ -62,9 +99,6 @@ def _resolve_workbook_path() -> Path:
return _workbook_candidates()[0]
WORKBOOK_PATH = _resolve_workbook_path()
def _text(value) -> str | None:
if value is None:
return None
@@ -167,7 +201,21 @@ def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking
return f"{base}_g{int(round(grading_cost * 1000))}_b{int(round(bagging_cost * 1000))}_c{int(round(cracking_cost * 1000))}"
def _load_workbook():
def _load_workbook(*required_sheets: str):
for candidate in _workbook_candidates():
if not candidate.exists():
continue
workbook = load_workbook(candidate, data_only=True)
if all(sheet_name in workbook.sheetnames for sheet_name in required_sheets):
return workbook
if required_sheets:
raise FileNotFoundError(
"No workbook with required sheets found. "
f"Required sheets: {', '.join(required_sheets)}. "
f"Checked: {', '.join(str(path) for path in _workbook_candidates())}"
)
workbook_path = _resolve_workbook_path()
if not workbook_path.exists():
raise FileNotFoundError(
@@ -247,6 +295,44 @@ def _read_mix_rows(workbook) -> dict[tuple[str, str], dict]:
return best_rows
def _read_product_ingredient_rows(workbook) -> dict[tuple[str, str], dict]:
worksheet = workbook["mix_quantites_per_client_per_pr"]
header_row = next(worksheet.iter_rows(min_row=1, max_row=1, values_only=True))
ingredient_names = [_text(value) for value in header_row[3:] if _text(value)]
rows: dict[tuple[str, str], dict] = {}
for row in worksheet.iter_rows(min_row=2, values_only=True):
client_name = _text(row[0])
product_name = _text(row[1])
if not client_name or not product_name:
continue
ingredients = []
for sort_order, (ingredient_name, quantity) in enumerate(zip(ingredient_names, row[3 : 3 + len(ingredient_names)]), start=1):
numeric_quantity = _number(quantity)
if ingredient_name and numeric_quantity and numeric_quantity > 0:
ingredients.append(
{
"raw_material_name": ingredient_name,
"quantity_kg": numeric_quantity,
"sort_order": sort_order,
}
)
if not ingredients:
continue
total_kg = _number(row[2]) or round(sum(item["quantity_kg"] for item in ingredients), 4)
rows[(client_name, product_name)] = {
"client_name": client_name,
"product_name": product_name,
"total_kg": total_kg,
"ingredients": ingredients,
}
return rows
def _read_product_rows(workbook) -> list[dict]:
worksheet = workbook["Product Cost - Price"]
raw_rows: list[dict] = []
@@ -287,6 +373,7 @@ def _read_product_rows(workbook) -> list[dict]:
"wholesale_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[20]),
"process_label": _text(row[8]),
"sheet_own_bag": _text(row[5]),
"visible": (_text(row[0]) or "General") not in HIDDEN_PRODUCT_CLIENTS,
}
)
@@ -569,6 +656,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str],
mix_id=mix.id,
sale_type=row["sale_type"],
own_bag=row["own_bag"],
visible=row["visible"],
unit_of_measure=row["unit_of_measure"],
items_per_pallet=row["items_per_pallet"],
bagging_process=row["bagging_process"],
@@ -584,6 +672,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str],
product.mix_id = mix.id
product.sale_type = row["sale_type"]
product.own_bag = row["own_bag"]
product.visible = row["visible"]
product.unit_of_measure = row["unit_of_measure"]
product.items_per_pallet = row["items_per_pallet"]
product.bagging_process = row["bagging_process"]
@@ -592,6 +681,259 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str],
product.notes = "Seeded from Input Cost Spreadsheet(1).xlsx"
def _upsert_product_ingredients(
db,
*,
product_rows: list[dict],
product_ingredient_rows: dict[tuple[str, str], dict],
raw_material_map: dict[str, RawMaterial],
) -> None:
products = db.scalars(
select(Product).where(Product.tenant_id == TENANT_ID).options(selectinload(Product.mix))
).all()
products_by_formula_key: dict[tuple[str, str], list[Product]] = {}
for product in products:
candidate_keys = {
(product.client_name, product.name),
}
if product.mix is not None:
candidate_keys.add((product.client_name, product.mix.name))
for key in candidate_keys:
products_by_formula_key.setdefault(key, []).append(product)
for key, formula in product_ingredient_rows.items():
matched_products = products_by_formula_key.get(key, [])
if not matched_products:
client_name, formula_name = key
mix_cache: dict[tuple[str, str], Mix] = {}
mix = _upsert_mix(
db,
client_name=client_name,
mix_name=formula_name,
ingredients=formula["ingredients"],
raw_material_map=raw_material_map,
mix_cache=mix_cache,
)
product = Product(
tenant_id=TENANT_ID,
client_name=client_name,
item_id=f"mix-calculator:{_slug(client_name, fallback='client')}:{_slug(formula_name, fallback='mix')}",
name=formula_name,
mix_id=mix.id,
sale_type="standard",
own_bag=True,
visible=True,
unit_of_measure="kg",
items_per_pallet=1,
bagging_process=None,
distributor_margin=None,
wholesale_margin=None,
notes="Seeded as a Mix Calculator source row from workbook formulas",
)
db.add(product)
db.flush()
products_by_formula_key[key] = [product]
matched_products = [product]
for product in matched_products:
existing_ingredients = {
ingredient.raw_material_id: ingredient
for ingredient in db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
}
desired_ids: set[int] = set()
for row in formula["ingredients"]:
raw_material = raw_material_map.get(row["raw_material_name"])
if raw_material is None:
continue
desired_ids.add(raw_material.id)
ingredient = existing_ingredients.get(raw_material.id)
if ingredient is None:
db.add(
ProductIngredient(
tenant_id=TENANT_ID,
product_id=product.id,
raw_material_id=raw_material.id,
quantity_kg=row["quantity_kg"],
sort_order=row["sort_order"],
)
)
else:
ingredient.quantity_kg = row["quantity_kg"]
ingredient.sort_order = row["sort_order"]
for raw_material_id, ingredient in existing_ingredients.items():
if raw_material_id not in desired_ids:
db.delete(ingredient)
def seed_product_ingredients_from_workbook(db) -> dict[str, int]:
"""Backfill row-specific product formulas for databases seeded before this table existed."""
try:
formula_workbook = _load_workbook("mix_quantites_per_client_per_pr")
except FileNotFoundError:
logger.info("Skipping product ingredient backfill because formula workbook is missing")
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
product_ingredient_rows = _read_product_ingredient_rows(formula_workbook)
if not product_ingredient_rows:
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
raw_material_map = {
material.name: material
for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == TENANT_ID)).all()
}
if not raw_material_map:
return {"formulas": len(product_ingredient_rows), "products_with_formulas": 0, "backfilled": 0}
had_product_ingredients = (
db.scalar(select(ProductIngredient.id).where(ProductIngredient.tenant_id == TENANT_ID).limit(1)) is not None
)
_upsert_product_ingredients(
db,
product_rows=[],
product_ingredient_rows=product_ingredient_rows,
raw_material_map=raw_material_map,
)
db.flush()
products_with_formulas = db.scalar(
select(func.count(func.distinct(ProductIngredient.product_id))).where(ProductIngredient.tenant_id == TENANT_ID)
)
return {
"formulas": len(product_ingredient_rows),
"products_with_formulas": int(products_with_formulas or 0),
"backfilled": 0 if had_product_ingredients else int(products_with_formulas or 0),
}
def _infer_throughput_bag_size(product: Product) -> float | None:
if product.sale_type == "bulka":
return None
unit = (product.unit_of_measure or "").strip().lower()
match = re.search(r"(\d+(?:\.\d+)?)\s*kg", unit)
if match:
return float(match.group(1))
if unit == "kg":
return 1.0
if unit == "tonne":
return 1000.0
return None
def _infer_throughput_bulka_default(product: Product) -> bool:
unit = (product.unit_of_measure or "").lower()
return product.sale_type == "bulka" or "bulka" in product.name.lower() or "bulka" in unit
def seed_throughput_products_from_costing(db) -> dict[str, int]:
"""Mirror costing products into the throughput product dropdown."""
costing_products = db.scalars(
select(Product)
.where(Product.tenant_id == TENANT_ID)
.order_by(Product.name, Product.id)
).all()
if not costing_products:
return {"created": 0, "updated": 0, "skipped": 0}
throughput_products = db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == TENANT_ID)
).all()
by_item = {
throughput_product.item_id: throughput_product
for throughput_product in throughput_products
if throughput_product.item_id
}
by_name = {
throughput_product.name.strip().lower(): throughput_product
for throughput_product in throughput_products
if throughput_product.name
}
created = 0
updated = 0
skipped = 0
seen_item_ids: set[str] = set()
seen_names: set[str] = set()
for costing_product in costing_products:
name = (costing_product.name or "").strip()
if not name:
skipped += 1
continue
item_id = (costing_product.item_id or "").strip() or None
name_key = name.lower()
if item_id and item_id in seen_item_ids:
skipped += 1
continue
if not item_id and name_key in seen_names:
skipped += 1
continue
if item_id:
seen_item_ids.add(item_id)
seen_names.add(name_key)
default_bag_size = _infer_throughput_bag_size(costing_product)
is_bulka_default = _infer_throughput_bulka_default(costing_product)
client_name = (costing_product.client_name or "").strip() or None
product = (by_item.get(item_id) if item_id else None) or by_name.get(name_key)
if product is None:
product = ThroughputProduct(
tenant_id=TENANT_ID,
item_id=item_id,
name=name,
client_name=client_name,
default_bag_size=default_bag_size,
is_bulka_default=is_bulka_default,
# Every costing SKU should be selectable in the throughput picker
# (the Client filter + search keep the long list manageable).
active=True,
is_stock_item=True,
notes="Seeded from costing products",
)
db.add(product)
created += 1
if item_id:
by_item[item_id] = product
by_name[name_key] = product
continue
changed = False
if item_id and product.item_id != item_id:
product.item_id = item_id
changed = True
if product.name != name:
old_name_key = product.name.strip().lower() if product.name else None
product.name = name
if old_name_key:
by_name.pop(old_name_key, None)
by_name[name_key] = product
changed = True
if product.default_bag_size != default_bag_size:
product.default_bag_size = default_bag_size
changed = True
if product.is_bulka_default != is_bulka_default:
product.is_bulka_default = is_bulka_default
changed = True
if product.client_name != client_name:
product.client_name = client_name
changed = True
if product.active is not True:
product.active = True
changed = True
if product.is_stock_item is not True:
product.is_stock_item = True
changed = True
if product.notes in {None, "", "Seeded from costing products"}:
product.notes = "Seeded from costing products"
if changed:
updated += 1
db.flush()
return {"created": created, "updated": updated, "skipped": skipped}
def seed_client_access(db):
existing = db.scalar(select(ClientAccount.id))
if existing is not None:
@@ -653,7 +995,7 @@ def seed_client_access(db):
)
enabled_feature_map = {
TENANT_ID: {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"},
TENANT_ID: {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access", "operations_throughput"},
"loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
}
@@ -699,10 +1041,13 @@ def seed_client_access(db):
def seed_costing_workspace(db):
workbook = _load_workbook()
raw_material_rows = _read_raw_material_rows(workbook)
mix_rows = _read_mix_rows(workbook)
product_rows = _read_product_rows(workbook)
costing_workbook = _load_workbook("C- Raw Products Costs", "M - All", "Product Cost - Price")
formula_workbook = _load_workbook("mix_quantites_per_client_per_pr")
raw_material_rows = _read_raw_material_rows(costing_workbook)
mix_rows = _read_mix_rows(costing_workbook)
product_rows = _read_product_rows(costing_workbook)
product_ingredient_rows = _read_product_ingredient_rows(formula_workbook)
raw_material_map = _upsert_raw_materials(db, raw_material_rows)
_upsert_process_rules(db, product_rows)
@@ -721,9 +1066,244 @@ def seed_costing_workspace(db):
mix_cache[(mix_row["client_name"], mix_row["name"])] = mix
_upsert_products(db, product_rows, mix_cache, raw_material_map)
_upsert_product_ingredients(
db,
product_rows=product_rows,
product_ingredient_rows=product_ingredient_rows,
raw_material_map=raw_material_map,
)
def seed_if_empty():
def seed_throughput_workbook(db):
"""Import the Operations Throughput workbook on first run if tables are empty."""
has_products = db.scalar(select(ThroughputProduct.id)) is not None
has_entries = db.scalar(select(ProductionThroughput.id)) is not None
if not has_products and not has_entries:
workbook_path = resolve_throughput_workbook_path()
if workbook_path is None:
logger.info("Operations Throughput workbook not found; seeding throughput products from costing products")
else:
try:
report = import_throughput_workbook(db, workbook_path, TENANT_ID)
except Exception:
logger.exception("Failed to seed Operations Throughput workbook from %s", workbook_path)
else:
logger.info("Operations Throughput seeded from %s: %s", workbook_path, report)
report = seed_throughput_products_from_costing(db)
if any(report.values()):
logger.info("Throughput products synced from costing products: %s", report)
def seed_throughput_products(db):
"""Sync throughput products from costing products without importing historical entries."""
report = seed_throughput_products_from_costing(db)
if any(report.values()):
logger.info("Throughput products synced from costing products: %s", report)
return
def _unit_type_from_product(product: Product) -> str:
sale_type = (product.sale_type or "").lower()
unit = (product.unit_of_measure or "").lower()
if sale_type == "bulka" or "bulka" in unit:
return "Bulka"
if "1.5kg" in unit or "1.5 kg" in unit:
return "1.5 kg"
if sale_type == "per_unit":
return "Per Unit"
return "Standard"
def _own_bag_label(product: Product) -> str | None:
if product.own_bag:
return "No Bag" if "no bag" in (product.unit_of_measure or "").lower() else "Yes"
return None
def seed_product_costing_module(db) -> dict[str, int]:
tenant_id = TENANT_ID
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
process_rules = db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
grading_per_kg = max((rule.grading_cost for rule in process_rules), default=0.0)
cracking_per_kg = max((rule.cracking_cost for rule in process_rules), default=0.0)
base = ProductCostBaseInput(
tenant_id=tenant_id,
grading_per_tonne=round(grading_per_kg * 1000, 4),
grading_per_kg=round(grading_per_kg, 4),
cracking_per_tonne=round(cracking_per_kg * 1000, 4),
cracking_per_kg=round(cracking_per_kg, 4),
)
db.add(base)
existing_processes = {
row.process_name: row
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
}
process_rule_map = {
rule.process_name: rule
for rule in db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
}
for process_name in PROCESS_NAMES:
if process_name in existing_processes:
continue
normalized_key = _build_process_key(process_name, 0.0, 0.0, 0.0)
rule = process_rule_map.get(normalized_key or process_name) or process_rule_map.get(process_name)
db.add(
ProductCostProcessInput(
tenant_id=tenant_id,
process_name=process_name,
cost_per_kg=round(rule.bagging_cost, 4) if rule else 0.0,
)
)
for process_name, rule in process_rule_map.items():
if process_name not in existing_processes:
db.add(
ProductCostProcessInput(
tenant_id=tenant_id,
process_name=process_name,
cost_per_kg=round(rule.bagging_cost, 4),
)
)
bag_defaults = {
"20kg_bag": 0.0,
"bulka_bag": 0.0,
"own_bag_credit": 0.0,
"1_5kg_bagging": 0.0,
"peckish_bag": 0.0,
}
for rule in db.scalars(select(PackagingCostRule).where(PackagingCostRule.tenant_id == tenant_id)).all():
unit = (rule.unit_of_measure or "").lower()
if "1.5kg" in unit or "1.5 kg" in unit:
bag_defaults["1_5kg_bagging"] = max(bag_defaults["1_5kg_bagging"], rule.bag_cost)
elif "peckish" in unit:
bag_defaults["peckish_bag"] = max(bag_defaults["peckish_bag"], rule.bag_cost)
elif "bulka" in unit:
bag_defaults["bulka_bag"] = max(bag_defaults["bulka_bag"], rule.bag_cost)
elif "20kg" in unit:
bag_defaults["20kg_bag"] = max(bag_defaults["20kg_bag"], rule.bag_cost)
existing_bags = {
row.input_key
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
}
for key, label in BAG_INPUTS.items():
if key not in existing_bags:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(bag_defaults.get(key, 0.0), 4)))
freight_defaults = {
"freight_per_pallet": 0.0,
"peckish_freight_per_pallet": 0.0,
"hay_straw_freight_per_pallet": 0.0,
}
for rule in db.scalars(select(FreightCostRule).where(FreightCostRule.tenant_id == tenant_id)).all():
unit = (rule.unit_of_measure or "").lower()
if "peckish" in unit:
freight_defaults["peckish_freight_per_pallet"] = max(freight_defaults["peckish_freight_per_pallet"], rule.cost_per_unit)
elif "hay" in unit or "straw" in unit:
freight_defaults["hay_straw_freight_per_pallet"] = max(freight_defaults["hay_straw_freight_per_pallet"], rule.cost_per_unit)
else:
freight_defaults["freight_per_pallet"] = max(freight_defaults["freight_per_pallet"], rule.cost_per_unit)
existing_freight = {
row.input_key
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
}
for key, label in FREIGHT_INPUTS.items():
if key not in existing_freight:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(freight_defaults.get(key, 0.0), 4)))
existing_clients = {
row.client_category
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
}
products = db.scalars(select(Product).where(Product.tenant_id == tenant_id).options(selectinload(Product.mix))).all()
margins: dict[str, list[tuple[float | None, float | None]]] = {}
for product in products:
margins.setdefault(product.client_name, []).append((product.distributor_margin, product.wholesale_margin))
for client_name, rows in margins.items():
if client_name in existing_clients:
continue
distributor_values = [value for value, _ in rows if value is not None]
wholesale_values = [value for _, value in rows if value is not None]
db.add(
ProductCostClientInput(
tenant_id=tenant_id,
client_category=client_name,
distributor_margin=round(sum(distributor_values) / len(distributor_values), 6) if distributor_values else None,
wholesale_margin=round(sum(wholesale_values) / len(wholesale_values), 6) if wholesale_values else None,
)
)
existing_items = {
item.item_id: item
for item in db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
if item.item_id
}
created = 0
for product in products:
if not product.item_id:
continue
item = existing_items.get(product.item_id)
if item is not None:
continue
item = ProductCostItem(
tenant_id=tenant_id,
client_category=product.client_name,
item_id=product.item_id,
product_name=product.name,
mix_product_name=product.mix.name if product.mix else product.name,
unit_type=_unit_type_from_product(product),
own_bag=_own_bag_label(product),
unit_kg=_infer_throughput_bag_size(product) or 1.0,
items_per_pallet=product.items_per_pallet,
bagging_process=product.bagging_process,
manual_distributor_margin=product.distributor_margin,
manual_wholesale_margin=product.wholesale_margin,
)
db.add(item)
created += 1
db.flush()
recalculated = recalculate_all_product_cost_items(db, tenant_id)
return {"created": created, "recalculated": recalculated}
def seed_startup_basics():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
seed_client_access(db)
seed_access(db)
seed_throughput_workbook(db)
report = seed_product_ingredients_from_workbook(db)
if report["backfilled"]:
logger.info("Product ingredients backfilled from workbook: %s", report)
product_costing_report = seed_product_costing_module(db)
if any(product_costing_report.values()):
logger.info("Product costing module seeded: %s", product_costing_report)
db.commit()
# The ordering-portal seed (catalogue + the Riverside Stockfeeds test
# customer and its buyer user) runs in its own transaction so it always
# commits on a fresh Postgres deploy, independent of the workbook/costing
# seeds above. It is idempotent — safe on every boot.
seed_ordering_basics()
def seed_ordering_basics():
"""Seed the B2B ordering portal (catalogue + test customer). Idempotent."""
from app.seed_ordering import seed_ordering
with SessionLocal() as db:
ordering_report = seed_ordering(db)
if any(ordering_report.values()):
logger.info("Ordering portal seeded: %s", ordering_report)
db.commit()
def seed_all():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
workbook_path = _resolve_workbook_path()
@@ -734,10 +1314,29 @@ def seed_if_empty():
"Skipping costing workspace seed because workbook is missing. Checked: %s",
", ".join(str(path) for path in _workbook_candidates()),
)
seed_throughput_products(db)
seed_client_access(db)
seed_access(db)
db.commit()
def seed_if_empty():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
if db.scalar(select(RawMaterial.id)) is None:
workbook_path = _resolve_workbook_path()
if workbook_path.exists():
seed_costing_workspace(db)
else:
logger.warning(
"Skipping costing workspace seed because workbook is missing. Checked: %s",
", ".join(str(path) for path in _workbook_candidates()),
)
seed_throughput_products(db)
seed_client_access(db)
seed_access(db)
db.commit()
if __name__ == "__main__":
seed_if_empty()
seed_all()
+72 -2
View File
@@ -3,13 +3,18 @@
Re-running this is safe: it upserts permissions, syncs each role's permission
set to the declared list, and creates or updates the seed users without
duplicating rows. Permission grants are the source of truth change them
here (or in the DB) rather than in route code.
here (or in the DB) rather than in route code. Existing password hashes are
left intact; only users with no password hash get the current default
``ADMIN_PASSWORD`` hashed into the row.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.config import settings
from app.core.security import hash_password
from app.db.session import SessionLocal
from app.models.access import Permission, Role, User
@@ -24,6 +29,14 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("edit_products", "Create and edit finished products"),
("view_mixes", "View mix master recipes"),
("edit_mixes", "Create and edit mix master recipes"),
("view_throughput", "View operations throughput"),
("edit_throughput", "Create and edit operations throughput entries"),
("view_ordering", "View the B2B customer ordering portal and orders"),
("edit_ordering", "Manage catalogue, pricing, and process customer orders"),
("manage_ordering", "Full ordering administration: customers, pricing, order lifecycle, Xero"),
("view_scenarios", "View scenario planning"),
("edit_scenarios", "Create, run, approve, and reject scenarios"),
("manage_client_access", "Manage client accounts, users, feature access, and exports"),
("view_users", "View internal users and roles"),
("manage_users", "Create, deactivate, and assign user roles"),
("manage_permissions", "Modify roles and role-permission assignments"),
@@ -43,7 +56,17 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_ordering",
"edit_ordering",
"manage_ordering",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
"view_users",
"manage_users",
"manage_permissions",
@@ -52,11 +75,13 @@ ROLE_DEFINITIONS: dict[str, dict] = {
],
},
"Operations": {
"description": "Mix calculator only — cannot edit raw materials, products, mixes, users, or settings.",
"description": "Mix calculator and operations throughput — cannot edit raw materials, products, mixes, users, or settings.",
"permissions": [
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_throughput",
"edit_throughput",
],
},
"Full Access": {
@@ -72,6 +97,39 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_ordering",
"edit_ordering",
"manage_ordering",
],
},
"lean": {
"description": "Lean owner access with unrestricted view/edit access across every workspace module.",
"permissions": [
"view_dashboard",
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_ordering",
"edit_ordering",
"manage_ordering",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
"view_users",
"manage_users",
"manage_permissions",
"view_settings",
"edit_settings",
],
},
}
@@ -154,6 +212,8 @@ def _upsert_users(db: Session, roles_by_name: dict[str, Role]) -> None:
user.role_id = role.id
if not user.is_active:
user.is_active = True
if user.password_hash is None:
user.password_hash = hash_password(settings.admin_password)
db.flush()
@@ -162,3 +222,13 @@ def seed_access(db: Session) -> None:
permissions_by_key = _upsert_permissions(db)
roles_by_name = _upsert_roles(db, permissions_by_key)
_upsert_users(db, roles_by_name)
def seed_access_from_session() -> None:
with SessionLocal() as db:
seed_access(db)
db.commit()
if __name__ == "__main__":
seed_access_from_session()
+172
View File
@@ -0,0 +1,172 @@
"""Idempotent seed data for the B2B ordering portal.
Creates a small demo catalogue, a demo ordering customer + buyer user, a price
list, and a customer-specific price so the acceptance-criteria flow works
out of the box. Safe to run on every startup it no-ops once seeded.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
NotificationSetting,
PriceList,
PriceListItem,
ProductCategory,
)
from app.services.client_access_service import (
MODULE_INDEX,
ensure_user_module_permissions,
)
logger = logging.getLogger("data_entry_app.seed")
ORDERING_TENANT = settings.client_tenant_id
_CATEGORIES = [
("grains", "Grains", 10),
("premixed", "Premixed Products", 20),
("bags", "Bags", 30),
("bulk_loads", "Bulk Loads", 40),
("custom_blends", "Custom Blends", 50),
("services", "Services & Delivery", 60),
]
_PRODUCTS = [
# (name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote)
("Cracked Maize", "GRN-MAIZE-20", "grains", "20kg bag", "20kg", 1, 24.50, "in_stock", False),
("Whole Barley", "GRN-BARLEY-20", "grains", "20kg bag", "20kg", 1, 21.00, "in_stock", False),
("Layer Premix", "PMX-LAYER-20", "premixed", "20kg bag", "20kg", 1, 32.75, "in_stock", False),
("Calf Starter Premix", "PMX-CALF-20", "premixed", "20kg bag", "20kg", 5, 38.40, "low_stock", False),
("Bulka Bag (1T)", "BAG-BULKA-1T", "bags", "each", "1 tonne", 1, 9.50, "in_stock", False),
("Bulk Maize Load", "BLK-MAIZE-T", "bulk_loads", "tonne", "per tonne", 1, 685.00, "made_to_order", False),
("Custom Horse Blend", "CST-HORSE-20", "custom_blends", "20kg bag", "20kg", 10, None, "made_to_order", True),
("Delivery (per pallet)", "SVC-DELIVERY", "services", "pallet", "1 pallet", 1, 45.00, "in_stock", False),
]
def seed_ordering(db: Session) -> dict[str, int]:
created = {"categories": 0, "products": 0, "customer": 0, "pricing": 0}
# Categories
existing_categories = {
c.slug for c in db.scalars(select(ProductCategory).where(ProductCategory.tenant_id == ORDERING_TENANT)).all()
}
for slug, name, sort_order in _CATEGORIES:
if slug in existing_categories:
continue
db.add(ProductCategory(tenant_id=ORDERING_TENANT, slug=slug, name=name, sort_order=sort_order))
created["categories"] += 1
# Catalogue products
existing_skus = {
p.sku for p in db.scalars(select(CatalogueProduct).where(CatalogueProduct.tenant_id == ORDERING_TENANT)).all()
}
product_by_sku: dict[str, CatalogueProduct] = {}
for name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote in _PRODUCTS:
if sku in existing_skus:
continue
product = CatalogueProduct(
tenant_id=ORDERING_TENANT,
name=name,
sku=sku,
category=category,
unit_of_measure=uom,
unit_size=unit_size,
min_order_quantity=moq,
base_price=base_price,
stock_status=stock,
requires_quote=requires_quote,
)
db.add(product)
product_by_sku[sku] = product
created["products"] += 1
db.flush()
# Notification settings row
if db.scalar(select(NotificationSetting).where(NotificationSetting.tenant_id == ORDERING_TENANT)) is None:
db.add(
NotificationSetting(
tenant_id=ORDERING_TENANT,
internal_recipients=settings.admin_email,
send_customer_confirmation=True,
require_po_number=False,
from_email=settings.admin_email,
)
)
# Demo ordering customer + buyer user
demo = db.scalar(select(ClientAccount).where(ClientAccount.client_code == "RIVERSIDE"))
if demo is None:
demo = ClientAccount(
tenant_id="riverside-stockfeeds",
name="Riverside Stockfeeds",
client_code="RIVERSIDE",
status="active",
notes="Demo B2B ordering customer",
)
db.add(demo)
db.flush()
created["customer"] += 1
info = MODULE_INDEX["ordering"]
db.add(
ClientFeatureAccess(
tenant_id=demo.tenant_id,
client_account_id=demo.id,
feature_key="ordering",
feature_name=info["module_name"],
feature_group=info["module_group"],
description=info["description"],
enabled=True,
)
)
buyer = ClientUser(
tenant_id=demo.tenant_id,
client_account_id=demo.id,
full_name="Riverside Buyer",
email="buyer@riverside.example",
role="buyer",
status="active",
is_new_user=False,
)
db.add(buyer)
db.flush()
ensure_user_module_permissions(db, buyer)
# A customer-specific contract price + a small discount on the rest.
maize = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.tenant_id == ORDERING_TENANT, CatalogueProduct.sku == "GRN-MAIZE-20"
)
)
if maize is not None:
db.add(
CustomerProductPrice(
tenant_id=ORDERING_TENANT,
client_account_id=demo.id,
product_id=maize.id,
unit_price=22.00,
rule_type="contract",
contract_reference="2026 supply agreement",
)
)
db.add(
CustomerPriceAssignment(
tenant_id=ORDERING_TENANT,
client_account_id=demo.id,
price_list_id=None,
discount_percent=5.0,
)
)
created["pricing"] += 1
return created
+97
View File
@@ -0,0 +1,97 @@
"""Recording and reading editor change history.
The Mix Editor and Ingredients Editor write a row here on every create/edit so
each mix and ingredient carries an auditable history (who changed what, when).
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession
from app.models.change_event import EditorChangeEvent
# Entity types — these match the History buttons on the two editors.
ENTITY_MIX = "mix"
ENTITY_INGREDIENT = "ingredient"
def _stringify(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, bool):
return "Yes" if value else "No"
if isinstance(value, float):
# Trim trailing zeros so 12.50 reads as 12.5 and 12.0 as 12.
text = f"{value:.4f}".rstrip("0").rstrip(".")
return text or "0"
return str(value)
def diff_fields(before: dict[str, Any], after: dict[str, Any], labels: dict[str, str]) -> list[dict[str, Any]]:
"""Build a list of ``{field, label, before, after}`` deltas for changed fields.
Only keys present in ``labels`` are considered, and only those whose value
actually changed are emitted.
"""
deltas: list[dict[str, Any]] = []
for field, label in labels.items():
if field not in after:
continue
old = before.get(field)
new = after.get(field)
if old == new:
continue
deltas.append({"field": field, "label": label, "before": _stringify(old), "after": _stringify(new)})
return deltas
def record_change(
db: Session,
*,
session: AuthSession,
entity_type: str,
entity_id: int,
action: str,
summary: str,
changes: list[dict[str, Any]] | None = None,
) -> None:
"""Append a change event. Caller is responsible for committing the session."""
db.add(
EditorChangeEvent(
tenant_id=session.tenant_id or "",
entity_type=entity_type,
entity_id=entity_id,
action=action,
actor_name=session.name or session.email or "Unknown",
actor_email=session.email or "",
actor_role=session.client_role or session.role,
summary=summary,
changes=changes or [],
)
)
def list_changes(
db: Session,
*,
tenant_id: str,
entity_type: str,
entity_id: int,
limit: int = 200,
) -> list[EditorChangeEvent]:
return list(
db.scalars(
select(EditorChangeEvent)
.where(
EditorChangeEvent.tenant_id == tenant_id,
EditorChangeEvent.entity_type == entity_type,
EditorChangeEvent.entity_id == entity_id,
)
.order_by(EditorChangeEvent.created_at.desc(), EditorChangeEvent.id.desc())
.limit(limit)
).all()
)
+16 -4
View File
@@ -20,6 +20,8 @@ MODULE_CATALOG = (
("mix_calculator", "Mix Calculator", "production", "Create and review client-specific mix calculation sessions"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
("ordering", "Ordering Portal", "commerce", "Browse the private catalogue, build and submit B2B orders"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
)
@@ -78,15 +80,25 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower()
if normalized == "superadmin":
return "manage" if module_key in {"client_access", "mix_calculator"} else "edit"
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput", "ordering"} else "edit"
if normalized == "admin":
if module_key == "mix_calculator":
if module_key in {"mix_calculator", "operations_throughput", "ordering"}:
return "manage"
return "edit" if module_key != "client_access" else "none"
if normalized == "operator":
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios"} else "none"
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput", "ordering"} else "none"
if normalized == "viewer":
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export"} else "none"
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput", "ordering"} else "none"
# --- B2B ordering-portal customer roles ---------------------------------
# Ordering customers are scoped to the ordering module (plus a dashboard
# view). They never see costing/admin modules.
if normalized == "owner":
return "manage" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
if normalized == "buyer":
return "edit" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
if normalized == "accounts":
# Accounts users review orders/invoicing but don't place orders.
return "view" if module_key in {"ordering", "dashboard"} else "none"
return "none"
+85 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, selectinload
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@@ -119,6 +119,78 @@ def calculate_mix_cost(db: Session, mix_id: int, overrides: dict | None = None)
}
def _calculate_formula_cost_from_product_ingredients(
product_ingredients: list[ProductIngredient],
overrides: dict | None = None,
) -> dict:
overrides = overrides or {}
total_mix_kg = 0.0
total_mix_cost = 0.0
warnings: list[str] = []
lines: list[dict] = []
for ingredient in product_ingredients:
raw_material = ingredient.raw_material
active_price = get_active_price(raw_material)
if active_price is None:
warnings.append(f"{raw_material.name} has no active price")
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": None,
"line_cost": None,
"notes": ingredient.notes,
}
)
total_mix_kg += ingredient.quantity_kg
continue
market_value = overrides.get("raw_material_market_values", {}).get(str(raw_material.id), active_price.market_value)
waste_percentage = overrides.get("raw_material_waste_percentages", {}).get(str(raw_material.id), active_price.waste_percentage)
price_stub = RawMaterialPriceVersion(
raw_material_id=raw_material.id,
market_value=market_value,
waste_percentage=waste_percentage,
effective_date=active_price.effective_date,
status=active_price.status,
)
price_comp = calculate_raw_material_cost(raw_material, price_stub)
line_cost = round(ingredient.quantity_kg * price_comp.cost_per_kg, 4)
total_mix_kg += ingredient.quantity_kg
total_mix_cost += line_cost
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": price_comp.cost_per_kg,
"line_cost": line_cost,
"notes": ingredient.notes,
}
)
if total_mix_kg == 0:
warnings.append("Mix total kg is zero")
mix_cost_per_kg = None
else:
mix_cost_per_kg = round(total_mix_cost / total_mix_kg, 4)
if not product_ingredients:
warnings.append("Mix has no ingredients")
return {
"ingredients": lines,
"total_mix_kg": round(total_mix_kg, 4),
"total_mix_cost": round(total_mix_cost, 4),
"mix_cost_per_kg": mix_cost_per_kg,
"warnings": warnings,
}
def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -> tuple[float, float, float, list[str]]:
if not process_name:
return 0.0, 0.0, 0.0, ["Missing bagging process"]
@@ -192,12 +264,22 @@ def extract_unit_quantity_kg(unit_of_measure: str) -> float:
def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
overrides = overrides or {}
overrides = {**overrides, "tenant_id": overrides.get("tenant_id")}
product = db.scalar(select(Product).where(Product.id == product_id).options(selectinload(Product.mix)))
product = db.scalar(
select(Product)
.where(Product.id == product_id)
.options(
selectinload(Product.mix),
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material).selectinload(RawMaterial.price_versions),
)
)
if product is None:
raise ValueError(f"Product {product_id} not found")
overrides["tenant_id"] = product.tenant_id
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
if product.ingredients:
mix_result = _calculate_formula_cost_from_product_ingredients(product.ingredients, overrides=overrides)
else:
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
warnings = list(mix_result["warnings"])
sale_unit_kg = extract_unit_quantity_kg(product.unit_of_measure)
@@ -3,8 +3,14 @@ from __future__ import annotations
import re
from app.models.mix_calculator import MixCalculatorSession
from app.schemas.mix_calculator import MixCalculatorPreviewRead
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
def mix_calculator_preview_pdf_filename(preview: MixCalculatorPreviewRead) -> str:
raw = f"MixCalculator_{preview.client_name}_{preview.product_name}_{preview.mix_date}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
+301 -240
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
from io import BytesIO
from math import ceil
from pathlib import Path
from types import SimpleNamespace
from app.models.mix_calculator import MixCalculatorSession
@@ -24,272 +25,332 @@ def _fractional_bag_warning(session_record: MixCalculatorSession) -> str | None:
)
def build_mix_calculator_pdf(session_record: MixCalculatorSession) -> bytes:
def _coerce_pdf_source(source):
if isinstance(source, dict):
lines = [SimpleNamespace(**line) if isinstance(line, dict) else line for line in source.get("lines", [])]
return SimpleNamespace(**{**source, "lines": lines})
return source
def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> bytes:
session_record = _coerce_pdf_source(session_record)
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
except ModuleNotFoundError as exc:
raise MixCalculatorPdfUnavailableError(
"PDF generation is unavailable because 'reportlab' is not installed. "
"Install backend dependencies again to enable PDF export."
) from exc
page_width, page_height = A4
margin = 26
gutter = 10
content_width = page_width - (margin * 2)
page_top = page_height - 40
palette = {
"page": colors.HexColor("#FFFFFF"),
"line": colors.HexColor("#000000"),
"muted": colors.HexColor("#000000"),
"text": colors.HexColor("#000000"),
"warning_bg": colors.HexColor("#FFFFFF"),
"warning_text": colors.HexColor("#000000"),
}
logo_path = Path(__file__).resolve().parents[3] / "frontend" / "static" / "logo-hsf.png"
def clamp(value: float, minimum: float, maximum: float) -> float:
return max(minimum, min(maximum, value))
def fit_text(value: str, font_name: str, font_size: float, max_width: float) -> str:
if stringWidth(value, font_name, font_size) <= max_width:
return value
ellipsis = "..."
available = max_width - stringWidth(ellipsis, font_name, font_size)
trimmed = value
while trimmed and stringWidth(trimmed, font_name, font_size) > available:
trimmed = trimmed[:-1]
return f"{trimmed.rstrip()}{ellipsis}" if trimmed else ellipsis
def wrap_text(value: str, font_name: str, font_size: float, max_width: float, max_lines: int) -> list[str]:
words = value.split()
if not words:
return []
lines: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if stringWidth(candidate, font_name, font_size) <= max_width:
current = candidate
else:
lines.append(current)
current = word
if len(lines) == max_lines - 1:
break
if len(lines) < max_lines:
lines.append(current)
remaining_words = words[len(" ".join(lines).split()) :]
if remaining_words and lines:
lines[-1] = fit_text(f"{lines[-1]} {' '.join(remaining_words)}", font_name, font_size, max_width)
return lines[:max_lines]
def draw_box(pdf: canvas.Canvas, x: float, y_top: float, width: float, height: float):
pdf.setFillColor(palette["page"])
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(1)
pdf.rect(x, y_top - height, width, height, fill=1, stroke=1)
def draw_label_value_card(
pdf: canvas.Canvas,
x: float,
y_top: float,
width: float,
height: float,
label: str,
value: str,
subtitle: str | None = None,
value_font_size: float = 14,
):
draw_box(pdf, x, y_top, width, height)
inset_x = x + 14
label_y = y_top - 16
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7.5)
pdf.drawString(inset_x, label_y, label.upper())
value_y = y_top - 38
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", value_font_size)
pdf.drawString(inset_x, value_y, fit_text(value, "Helvetica-Bold", value_font_size, width - 28))
if subtitle:
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica", 8)
pdf.drawString(inset_x, y_top - height + 14, fit_text(subtitle, "Helvetica", 8, width - 28))
buffer = BytesIO()
document = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
title=f"{session_record.session_number} - {session_record.product_name}",
author="Lean 101 Clients",
pdf = canvas.Canvas(buffer, pagesize=A4)
session_number = getattr(session_record, "session_number", None)
document_title = (
f"{session_number} - {session_record.product_name}"
if session_number
else f"Mix Calculator - {session_record.product_name}"
)
pdf.setTitle(document_title)
pdf.setAuthor("Lean 101 Clients")
styles = getSampleStyleSheet()
eyebrow = ParagraphStyle(
"Eyebrow",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=8,
leading=10,
textColor=colors.HexColor("#62736B"),
spaceAfter=5,
)
title = ParagraphStyle(
"Title",
parent=styles["Heading1"],
fontName="Helvetica-Bold",
fontSize=24,
leading=26,
textColor=colors.HexColor("#21312A"),
spaceAfter=6,
)
subtitle = ParagraphStyle(
"Subtitle",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=10,
leading=13,
textColor=colors.HexColor("#6B7A73"),
)
label = ParagraphStyle(
"Label",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=7,
leading=9,
textColor=colors.HexColor("#6B7A73"),
)
value = ParagraphStyle(
"Value",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=11,
leading=13,
textColor=colors.HexColor("#21312A"),
)
card_value = ParagraphStyle(
"CardValue",
parent=value,
fontSize=16,
leading=18,
)
body = ParagraphStyle(
"Body",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=9,
leading=12,
textColor=colors.HexColor("#304038"),
)
section_title = ParagraphStyle(
"SectionTitle",
parent=styles["Heading2"],
fontName="Helvetica-Bold",
fontSize=13,
leading=15,
textColor=colors.HexColor("#21312A"),
)
pdf.setFillColor(palette["page"])
pdf.rect(0, 0, page_width, page_height, stroke=0, fill=1)
warnings = []
bag_warning = _fractional_bag_warning(session_record)
if bag_warning:
warnings.append(bag_warning)
current_y = page_top
mix_date_label = f"{session_record.mix_date.day} {session_record.mix_date.strftime('%B %Y')}"
story = [
Paragraph(f"Mix Calculator | {session_record.session_number}", eyebrow),
Paragraph(session_record.product_name, title),
Paragraph(f"{session_record.client_name} &nbsp;&middot;&nbsp; {session_record.mix_name}", subtitle),
Spacer(1, 8),
]
if logo_path.exists():
logo_source = str(logo_path)
try:
from PIL import Image
header_table = Table(
[
[
[
Paragraph("Mix date", label),
Paragraph(session_record.mix_date.strftime("%d %b %Y"), value),
],
[
Paragraph("Prepared by", label),
Paragraph(session_record.prepared_by_name, value),
],
[
Paragraph("Status", label),
Paragraph(session_record.status.title(), value),
],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
)
header_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 9),
("BOTTOMPADDING", (0, 0), (-1, -1), 9),
]
logo_source = Image.open(logo_path).convert("L")
except ModuleNotFoundError:
pass
logo_reader = ImageReader(logo_source)
logo_width, logo_height = logo_reader.getSize()
aspect_ratio = logo_height / max(logo_width, 1)
draw_width = 108
draw_height = draw_width * aspect_ratio
pdf.drawImage(
logo_reader,
margin,
current_y - draw_height,
width=draw_width,
height=draw_height,
preserveAspectRatio=True,
mask="auto",
)
)
story.extend([header_table, Spacer(1, 10)])
current_y -= draw_height + 20
summary_table = Table(
[
[
[Paragraph("Batch size", label), Paragraph(f"{_fmt_number(session_record.batch_size_kg)}kg", card_value)],
[Paragraph("Total output", label), Paragraph(f"{_fmt_number(session_record.total_kg)}kg", card_value)],
[Paragraph("Bags", label), Paragraph(_fmt_number(session_record.total_bags), card_value)],
[Paragraph("Unit pack", label), Paragraph(f"{_fmt_number(session_record.product_unit_size_kg)}kg", card_value)],
]
],
colWidths=[43 * mm, 43 * mm, 43 * mm, 43 * mm],
)
summary_table.setStyle(
TableStyle(
[
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F9FBFA")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
story.extend([summary_table, Spacer(1, 10)])
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", 15)
pdf.drawString(margin, current_y, "Calculated Output")
current_y -= 16
detail_table = Table(
[
[
[Paragraph("Mix source", label), Paragraph(session_record.mix_name, value), Paragraph(f"Saved against {session_record.product_unit_of_measure} units.", body)],
[Paragraph("Composition", label), Paragraph(f"{_fmt_number(sum(line.mix_percentage for line in session_record.lines))}%", value), Paragraph(f"{len(session_record.lines)} raw material{'s' if len(session_record.lines) != 1 else ''} in the blend.", body)],
[Paragraph("Estimated pages", label), Paragraph(str(max(1, ceil(len(session_record.lines) / 18))), value), Paragraph("Formatted for A4 PDF export.", body)],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica", 10)
pdf.drawString(margin, current_y, "Snapshot of the scaled raw material requirements.")
current_y -= 20
stat_height = 66
stat_width = (content_width - (gutter * 2)) / 3
draw_label_value_card(
pdf,
margin,
current_y,
stat_width,
stat_height,
"Total kg",
_fmt_number(session_record.total_kg),
"Scaled batch size",
value_font_size=18,
)
detail_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F4F8F5")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
draw_label_value_card(
pdf,
margin + stat_width + gutter,
current_y,
stat_width,
stat_height,
"Total bags",
_fmt_number(session_record.total_bags),
session_record.product_unit_of_measure,
value_font_size=18,
)
story.extend([detail_table, Spacer(1, 10)])
draw_label_value_card(
pdf,
margin + ((stat_width + gutter) * 2),
current_y,
stat_width,
stat_height,
"Prepared by",
session_record.prepared_by_name,
mix_date_label,
value_font_size=10.5,
)
current_y -= stat_height + 12
detail_height = 52
detail_width = (content_width - gutter) / 2
draw_label_value_card(
pdf,
margin,
current_y,
detail_width,
detail_height,
"Client",
session_record.client_name,
value_font_size=12,
)
draw_label_value_card(
pdf,
margin + detail_width + gutter,
current_y,
detail_width,
detail_height,
"Mix",
session_record.product_name,
value_font_size=12,
)
current_y -= detail_height + 8
draw_label_value_card(
pdf,
margin,
current_y,
detail_width,
detail_height,
"Formula source",
session_record.mix_name,
value_font_size=11,
)
draw_label_value_card(
pdf,
margin + detail_width + gutter,
current_y,
detail_width,
detail_height,
"Unit size",
f"{_fmt_number(session_record.product_unit_size_kg)}kg",
value_font_size=12,
)
current_y -= detail_height + 10
warning = _fractional_bag_warning(session_record)
note_lines: list[str] = []
warning_lines: list[str] = []
strip_height = 0
if session_record.notes:
notes_table = Table(
[[Paragraph("Notes", label)], [Paragraph(session_record.notes.replace("\n", "<br/>"), body)]],
colWidths=[172 * mm],
)
notes_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#F4F8F5")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([notes_table, Spacer(1, 10)])
note_lines = wrap_text(session_record.notes.replace("\n", " "), "Helvetica", 7.5, content_width - 28, 2)
strip_height += 30
if warning:
warning_lines = wrap_text(warning, "Helvetica", 7.5, content_width - 28, 2)
strip_height += 30
if strip_height:
strip_height += 6
if warnings:
warning_rows = [[Paragraph("Warnings", label)]]
warning_rows.extend([[Paragraph(warning, body)] for warning in warnings])
warnings_table = Table(warning_rows, colWidths=[172 * mm])
warnings_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#FFF5E6")),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF9EF")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#E8C483")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([warnings_table, Spacer(1, 10)])
table_header_height = 24
table_bottom_padding = 12
table_top = current_y
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
row_count = max(len(session_record.lines), 1)
row_height = clamp(available_table_height / row_count, 16, 32)
table_font_size = clamp(row_height * 0.44, 8.5, 12.5)
table_height = table_header_height + (row_height * row_count)
table_bottom = table_top - table_height
story.extend(
[
Paragraph("Required Raw Materials", label),
Paragraph("Blend composition", section_title),
Paragraph(f"{session_record.product_unit_of_measure} · {_fmt_number(session_record.product_unit_size_kg)}kg per unit", subtitle),
Spacer(1, 6),
]
)
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 8.5)
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(0.8)
pdf.line(margin, table_top - table_header_height, margin + content_width, table_top - table_header_height)
left_col_x = margin + 6
right_col_x = margin + content_width - 190
y_cursor = table_top - table_header_height
table_rows = [["Raw material", "Mix %", "Required kg", "Unit"]]
for line in session_record.lines:
table_rows.append(
[
Paragraph(f"<b>{line.raw_material_name}</b>", body),
Paragraph(f"{_fmt_number(line.mix_percentage)}%", body),
Paragraph(f"{_fmt_number(line.required_kg)}kg", body),
Paragraph(line.unit, body),
]
)
y_cursor -= row_height
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(0.6)
pdf.line(margin, y_cursor, margin + content_width, y_cursor)
composition_table = Table(table_rows, colWidths=[88 * mm, 24 * mm, 34 * mm, 26 * mm], repeatRows=1)
composition_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EEF4F0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#4F6158")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
("TOPPADDING", (0, 0), (-1, 0), 8),
("LEFTPADDING", (0, 0), (-1, -1), 9),
("RIGHTPADDING", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.6, colors.HexColor("#DBE4DE")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBFCFB")]),
]
text_y = y_cursor + (row_height / 2) - (table_font_size * 0.35)
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", table_font_size)
pdf.drawString(
left_col_x,
text_y,
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
)
)
story.append(composition_table)
pdf.setFont("Helvetica", table_font_size)
# 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")
document.build(story)
strip_y = table_bottom - 6
if note_lines:
note_height = 24 if len(note_lines) == 1 else 30
pdf.setFillColor(palette["page"])
pdf.setStrokeColor(palette["line"])
pdf.rect(margin, strip_y - note_height, content_width, note_height, fill=1, stroke=1)
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7)
pdf.drawString(margin + 10, strip_y - 10, "NOTES")
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica", 7.5)
for idx, text in enumerate(note_lines):
pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text)
strip_y -= note_height + 6
if warning_lines:
warning_height = 24 if len(warning_lines) == 1 else 30
pdf.setFillColor(palette["warning_bg"])
pdf.setStrokeColor(palette["line"])
pdf.rect(margin, strip_y - warning_height, content_width, warning_height, fill=1, stroke=1)
pdf.setFillColor(palette["warning_text"])
pdf.setFont("Helvetica-Bold", 7)
pdf.drawString(margin + 10, strip_y - 10, "WARNING")
pdf.setFont("Helvetica", 7.5)
for idx, text in enumerate(warning_lines):
pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text)
pdf.showPage()
pdf.save()
return buffer.getvalue()
+379 -48
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate
from app.services.costing_engine import extract_unit_quantity_kg
@@ -27,11 +27,98 @@ def _build_session_access_query(session: AuthSession):
def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None:
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id)
.options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
.where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True))
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
)
def _category_sort_key(category: str | None) -> tuple[int, str]:
"""Order ingredients by their manual category; uncategorised rows sort last."""
cleaned = (category or "").strip()
if not cleaned:
return (1, "")
return (0, cleaned.lower())
def _order_formula_rows(rows: list[dict]) -> list[dict]:
"""Sort rows by category (then their original order/name) and renumber.
Category is the primary key so the Mix Calculator groups ingredients by their
manually-assigned category. `sort_order` is reassigned sequentially after the
sort so every downstream consumer (lines, PDF) follows the same order.
"""
rows.sort(
key=lambda row: (
_category_sort_key(row.get("category")),
row.get("sort_order") or 0,
row["raw_material_name"].lower(),
)
)
for index, row in enumerate(rows, start=1):
row["sort_order"] = index
return rows
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
if product.ingredients:
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"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,
"category": ingredient.raw_material.category,
"sort_order": ingredient.sort_order,
}
for ingredient in product.ingredients
if ingredient.raw_material is not None
]
elif product.mix is not None:
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"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,
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
"sort_order": index,
}
for index, ingredient in enumerate(product.mix.ingredients, start=1)
]
else:
rows = []
_order_formula_rows(rows)
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
def _mix_formula_rows(mix: Mix) -> tuple[list[dict], float]:
"""Resolve a mix's own (mix-master) formula rows, category-ordered.
Used by the Mix Calculator for mixes that have a formula but no representative
product yet the formula lives directly on the mix.
"""
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"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,
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
"sort_order": index,
}
for index, ingredient in enumerate(mix.ingredients, start=1)
]
_order_formula_rows(rows)
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None:
rounded_bags = round(total_bags)
if abs(total_bags - rounded_bags) < 1e-9:
@@ -42,62 +129,170 @@ def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_mea
)
def calculate_mix_calculator_preview(
db: Session,
def _mix_calculator_label(product: Product) -> str:
return product.mix.name if product.mix else product.name
def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]:
unit_label = (product.unit_of_measure or "").lower()
unit_size = extract_unit_quantity_kg(product.unit_of_measure)
return (
0 if abs(unit_size - 20) < 1e-9 and "bag" in unit_label and "bulka" not in unit_label else 1,
0 if "bulka" not in unit_label else 1,
unit_size if unit_size > 0 else 999999,
product.id,
)
def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None:
"""The single product the Mix Calculator surfaces for a given mix.
The calculator lists one representative product per (client, mix) and reads
its formula. The Mix Editor reuses this so it edits exactly what the
calculator shows. Preference order mirrors `build_mix_calculator_options`:
visible products that already have a product-specific formula, ranked by
`_mix_calculator_option_rank`; then any visible product; then any product.
"""
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id, Product.mix_id == mix_id)
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
).all()
if not products:
return None
with_formula = [product for product in products if product.visible and product.ingredients]
pool = with_formula or [product for product in products if product.visible] or list(products)
return min(pool, key=_mix_calculator_option_rank)
def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict:
"""Resolve a mix's formula the way the calculator does, for the editor.
Returns the resolved ingredient rows (with each row's share of the total as
`mix_percentage`), the total kg, and where the formula lives:
`source='product'` (a representative product's own formula) or `source='mix'`
(the shared mix master fallback). `product_id` names the product that owns the
formula when `source='product'`. The save path writes back to that same source.
"""
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id)
if product is not None and product.ingredients:
rows, total_kg = _resolved_formula_rows(product)
# Carry each ingredient's note through so saving doesn't wipe it.
notes_by_raw_material = {
ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients
}
for row in rows:
row["notes"] = notes_by_raw_material.get(row["raw_material_id"])
source = "product"
product_id = product.id
else:
# No product-specific formula: the calculator reads the shared mix master,
# so the editor shows and edits that.
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"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",
"sort_order": index,
"notes": ingredient.notes,
}
for index, ingredient in enumerate(
sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""),
start=1,
)
]
total_kg = round(sum(row["quantity_kg"] for row in rows), 4)
source = "mix"
product_id = product.id if product is not None else None
ingredients = [
{
"raw_material_id": row["raw_material_id"],
"raw_material_name": row["raw_material_name"],
"quantity_kg": round(row["quantity_kg"], 4),
"mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0,
"unit": row["unit"],
"notes": row.get("notes"),
}
for row in rows
]
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"source": source,
"product_id": product_id,
"ingredients": ingredients,
"total_kg": total_kg,
}
def _scale_preview(
*,
tenant_id: str,
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
):
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
product = _load_product_for_calculation(db, tenant_id, int(values["product_id"]))
if product is None:
raise ValueError("Product not found")
if product.client_name != values["client_name"]:
raise ValueError("Selected product does not belong to the chosen client")
if product.mix is None:
raise ValueError("Product mix is not configured")
source_total_kg = round(sum(ingredient.quantity_kg for ingredient in product.mix.ingredients), 4)
if source_total_kg <= 0:
raise ValueError("Product mix has no source kilograms to scale")
values: dict,
formula_rows: list[dict],
source_total_kg: float,
client_name: str,
product_id: int,
mix_label: str,
mix_id: int,
unit_of_measure: str,
) -> dict:
"""Scale a resolved formula to the requested batch size and shape the preview.
Shared by the product-backed path and the formula-only mix path; only the
inputs (where the formula and unit come from) differ.
"""
batch_size_kg = float(values["batch_size_kg"])
scale_factor = batch_size_kg / source_total_kg
unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure)
unit_size_kg = extract_unit_quantity_kg(unit_of_measure)
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
warnings: list[str] = []
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure)
if bag_warning:
warnings.append(bag_warning)
# A bag warning only makes sense when the unit resolves to a bag size; a
# formula-only mix sells in bulk kg, so there's nothing to round to whole bags.
if unit_size_kg > 0:
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure)
if bag_warning:
warnings.append(bag_warning)
lines = []
for index, ingredient in enumerate(product.mix.ingredients, start=1):
mix_percentage = round((ingredient.quantity_kg / source_total_kg) * 100, 4)
required_kg = round(ingredient.quantity_kg * scale_factor, 4)
raw_material = ingredient.raw_material
for index, ingredient in enumerate(formula_rows, start=1):
mix_percentage = round((ingredient["quantity_kg"] / source_total_kg) * 100, 4)
required_kg = round(ingredient["quantity_kg"] * scale_factor, 4)
lines.append(
{
"raw_material_id": raw_material.id if raw_material is not None else ingredient.raw_material_id,
"raw_material_name": raw_material.name if raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"raw_material_id": ingredient["raw_material_id"],
"raw_material_name": ingredient["raw_material_name"],
"required_kg": required_kg,
"mix_percentage": mix_percentage,
"unit": raw_material.unit_of_measure if raw_material is not None else "kg",
"sort_order": index,
"unit": ingredient["unit"],
"rounding_decimals": ingredient.get("rounding_decimals", 2),
"category": ingredient.get("category"),
"sort_order": ingredient["sort_order"] or index,
}
)
return {
"client_name": product.client_name,
"product_id": product.id,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name,
"client_name": client_name,
"product_id": product_id,
# The source workbook labels this as Product, but for the calculator
# it is the mix/formula being produced.
"product_name": mix_label,
"mix_id": mix_id,
"mix_name": mix_label,
"mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags,
"total_kg": round(batch_size_kg, 4),
"product_unit_of_measure": product.unit_of_measure,
"product_unit_of_measure": unit_of_measure,
"product_unit_size_kg": round(unit_size_kg, 4),
"prepared_by_name": values["prepared_by_name"],
"status": values.get("status") or "saved",
@@ -107,11 +302,81 @@ def calculate_mix_calculator_preview(
}
def _calculate_mix_only_preview(db: Session, *, tenant_id: str, mix_id: int, values: dict) -> dict:
"""Preview for a mix that has a formula but no representative product.
The Mix Calculator surfaces these via a negative `product_id` sentinel
(`-mix_id`); the formula is read straight off the mix master and there's no
product unit, so output is bulk kg with no bag split.
"""
mix = db.scalar(
select(Mix)
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
)
if mix is None:
raise ValueError("Mix not found")
if mix.client_name != values["client_name"]:
raise ValueError("Selected mix does not belong to the chosen client")
formula_rows, source_total_kg = _mix_formula_rows(mix)
if source_total_kg <= 0:
raise ValueError("Mix has no formula to scale")
return _scale_preview(
values=values,
formula_rows=formula_rows,
source_total_kg=source_total_kg,
client_name=mix.client_name,
product_id=-mix.id,
mix_label=mix.name,
mix_id=mix.id,
unit_of_measure="kg",
)
def calculate_mix_calculator_preview(
db: Session,
*,
tenant_id: str,
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
):
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
product_id = int(values["product_id"])
# Negative ids are the sentinel for a formula-only mix (no product yet).
if product_id < 0:
return _calculate_mix_only_preview(db, tenant_id=tenant_id, mix_id=-product_id, values=values)
product = _load_product_for_calculation(db, tenant_id, product_id)
if product is None:
raise ValueError("Product not found")
if product.client_name != values["client_name"]:
raise ValueError("Selected product does not belong to the chosen client")
formula_rows, source_total_kg = _resolved_formula_rows(product)
if source_total_kg <= 0:
raise ValueError("Product has no source kilograms to scale")
return _scale_preview(
values=values,
formula_rows=formula_rows,
source_total_kg=source_total_kg,
client_name=product.client_name,
product_id=product.id,
mix_label=_mix_calculator_label(product),
mix_id=product.mix_id,
unit_of_measure=product.unit_of_measure,
)
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
# Aggregate mix totals in a single query instead of loading every
# ingredient row for every product. The previous implementation was the
# main slow path on first Mix Calculator open — it streamed the entire
# tenant's recipe table just to compute one sum per product.
# Prefer product-specific formulas where present; fall back to the shared
# mix master for legacy rows that have not been migrated yet.
product_totals_rows = db.execute(
select(ProductIngredient.product_id, func.coalesce(func.sum(ProductIngredient.quantity_kg), 0.0))
.join(Product, Product.id == ProductIngredient.product_id)
.where(Product.tenant_id == tenant_id)
.group_by(ProductIngredient.product_id)
).all()
product_totals: dict[int, float] = {product_id: round(total or 0.0, 4) for product_id, total in product_totals_rows}
mix_totals_rows = db.execute(
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
.join(Mix, Mix.id == MixIngredient.mix_id)
@@ -120,28 +385,87 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
).all()
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id)
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id)
.where(
Product.tenant_id == tenant_id,
Product.visible.is_(True),
Product.id.in_(product_ids_with_formulas),
)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name)
).all()
clients = sorted({product.client_name for product in products})
representative_products: dict[tuple[str, str], Product] = {}
for product in products:
mix_label = _mix_calculator_label(product)
key = (product.client_name, mix_label)
current = representative_products.get(key)
if current is None:
representative_products[key] = product
continue
if _mix_calculator_option_rank(product) < _mix_calculator_option_rank(current):
representative_products[key] = product
products = sorted(
representative_products.values(),
key=lambda product: (product.client_name, _mix_calculator_label(product), product.id),
)
product_rows = [
{
"product_id": product.id,
"client_name": product.client_name,
"product_name": product.name,
"product_name": _mix_calculator_label(product),
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"mix_name": _mix_calculator_label(product),
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": mix_totals.get(product.mix_id, 0.0),
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
}
for product in products
]
# Surface mixes that have a formula but no product at all yet (e.g. a freshly
# created mix). They're selected via a negative `product_id` sentinel (-mix_id)
# and calculated straight off the mix master — bulk kg, no bag split. A mix
# whose only product is hidden is intentionally excluded (it HAS a product),
# so check every product, not just the visible representatives.
covered_mix_ids = set(
db.scalars(
select(Product.mix_id).where(Product.tenant_id == tenant_id).distinct()
).all()
)
formula_only_mix_ids = [
mix_id for mix_id, total in mix_totals.items() if total > 0 and mix_id not in covered_mix_ids
]
if formula_only_mix_ids:
formula_only_mixes = db.scalars(
select(Mix).where(
Mix.tenant_id == tenant_id,
Mix.id.in_(formula_only_mix_ids),
Mix.status == "active",
)
).all()
product_rows.extend(
{
"product_id": -mix.id,
"client_name": mix.client_name,
"product_name": mix.name,
"mix_id": mix.id,
"mix_name": mix.name,
"unit_of_measure": "kg",
"unit_size_kg": 0.0,
"mix_total_kg": mix_totals.get(mix.id, 0.0),
}
for mix in formula_only_mixes
)
product_rows.sort(key=lambda row: (row["client_name"], row["product_name"], row["product_id"]))
clients = sorted({row["client_name"] for row in product_rows})
return {"clients": clients, "products": product_rows}
@@ -184,6 +508,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
@@ -191,11 +516,12 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_
}
def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession) -> list[dict]:
def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession, limit: int = 100) -> list[dict]:
sessions = db.scalars(
_build_session_access_query(auth_session)
.options(selectinload(MixCalculatorSession.lines))
.order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc())
.limit(limit)
).all()
return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions]
@@ -225,6 +551,10 @@ def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
if payload.product_id < 0:
# Sessions reference a real product (FK). A formula-only mix has none yet —
# it can still be previewed and printed, just not saved as a session.
raise ValueError("Add a product to this mix before saving a calculator session.")
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
session_record = MixCalculatorSession(
tenant_id=auth_session.tenant_id or "default",
@@ -254,6 +584,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"]
+119
View File
@@ -0,0 +1,119 @@
"""Order notification service (stub interface).
Emits the two notifications the ordering spec requires when an order is
submitted:
* a confirmation to the customer, and
* an internal notification to configured recipients.
Real email delivery is intentionally **stubbed** behind this interface: no SMTP
provider is configured in dev/alpha, so notifications are logged and returned as
structured results. To go live, implement :func:`_deliver_email` (TODO) no
caller changes needed.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.client_access import ClientAccount, ClientUser
from app.models.ordering import NotificationSetting, Order
logger = logging.getLogger("data_entry_app.ordering")
@dataclass
class NotificationResult:
channel: str # "customer" | "internal"
recipients: list[str]
subject: str
delivered: bool
detail: str
def get_or_create_settings(db: Session, tenant_id: str) -> NotificationSetting:
settings = db.scalar(
select(NotificationSetting).where(NotificationSetting.tenant_id == tenant_id)
)
if settings is None:
settings = NotificationSetting(tenant_id=tenant_id)
db.add(settings)
db.flush()
return settings
def _internal_recipients(settings: NotificationSetting) -> list[str]:
if not settings.internal_recipients:
return []
return [part.strip() for part in settings.internal_recipients.split(",") if part.strip()]
def _customer_recipients(db: Session, order: Order) -> list[str]:
users = db.scalars(
select(ClientUser).where(
ClientUser.client_account_id == order.client_account_id,
ClientUser.status == "active",
)
).all()
return [user.email for user in users if user.email]
def _deliver_email(*, to: list[str], subject: str, body: str, from_email: str | None) -> bool:
"""Deliver an email. Stubbed — logs instead of sending.
TODO (go-live): integrate the chosen provider (SMTP / SendGrid / SES) using
credentials from environment variables. Return True only on confirmed
delivery.
"""
logger.info("ordering.email.stub to=%s subject=%s", to, subject)
return False # stub: nothing actually sent
def send_order_submitted_notifications(db: Session, order: Order) -> list[NotificationResult]:
"""Send customer confirmation + internal notification for a submitted order."""
settings = get_or_create_settings(db, order.tenant_id)
customer = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
customer_name = customer.name if customer else "Customer"
order_ref = order.order_number or f"#{order.id}"
results: list[NotificationResult] = []
if settings.send_customer_confirmation:
to = _customer_recipients(db, order)
subject = f"Order {order_ref} received"
body = (
f"Thank you — we have received order {order_ref} for {customer_name}.\n"
f"Lines: {len(order.lines)}. Subtotal (ex GST): {order.subtotal_ex_gst:.2f}.\n"
"Our team will review and confirm shortly."
)
delivered = _deliver_email(to=to, subject=subject, body=body, from_email=settings.from_email) if to else False
results.append(
NotificationResult(
channel="customer",
recipients=to,
subject=subject,
delivered=delivered,
detail="stubbed" if not delivered else "sent",
)
)
internal = _internal_recipients(settings)
subject = f"New order {order_ref} from {customer_name}"
body = (
f"New order {order_ref} submitted by {order.created_by_name or 'a customer user'}.\n"
f"Customer: {customer_name}. Lines: {len(order.lines)}. "
f"Subtotal (ex GST): {order.subtotal_ex_gst:.2f}."
)
delivered = _deliver_email(to=internal, subject=subject, body=body, from_email=settings.from_email) if internal else False
results.append(
NotificationResult(
channel="internal",
recipients=internal,
subject=subject,
delivered=delivered,
detail="no recipients configured" if not internal else ("stubbed" if not delivered else "sent"),
)
)
return results
+112
View File
@@ -0,0 +1,112 @@
"""Order confirmation PDF generation (reportlab)."""
from __future__ import annotations
from datetime import datetime
from io import BytesIO
from app.models.client_access import ClientAccount
from app.models.ordering import Order
class OrderPdfUnavailableError(RuntimeError):
pass
def _fmt_money(value: float | None) -> str:
return "Quote" if value is None else f"${value:,.2f}"
def build_order_confirmation_pdf(order: Order, customer: ClientAccount | None) -> bytes:
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
except ModuleNotFoundError as exc: # pragma: no cover
raise OrderPdfUnavailableError(
"PDF generation is unavailable because 'reportlab' is not installed."
) from exc
buffer = BytesIO()
page_width, page_height = A4
pdf = canvas.Canvas(buffer, pagesize=A4)
margin = 40
y = page_height - margin
pdf.setFont("Helvetica-Bold", 18)
pdf.drawString(margin, y, "Order Confirmation")
y -= 24
pdf.setFont("Helvetica", 10)
order_ref = order.order_number or f"#{order.id}"
pdf.drawString(margin, y, f"Order: {order_ref}")
pdf.drawRightString(page_width - margin, y, datetime.utcnow().strftime("%d %b %Y %H:%M UTC"))
y -= 14
pdf.drawString(margin, y, f"Customer: {customer.name if customer else order.client_account_id}")
y -= 14
pdf.drawString(margin, y, f"Status: {order.status.replace('_', ' ').title()}")
y -= 14
if order.purchase_order_number:
pdf.drawString(margin, y, f"PO Number: {order.purchase_order_number}")
y -= 14
pdf.drawString(margin, y, f"Fulfilment: {order.fulfilment_method.title()}")
y -= 14
if order.requested_delivery_date:
pdf.drawString(margin, y, f"Requested date: {order.requested_delivery_date.strftime('%d %b %Y')}")
y -= 14
y -= 6
# Table header
pdf.setFont("Helvetica-Bold", 9)
pdf.setFillColor(colors.HexColor("#22362d"))
pdf.drawString(margin, y, "Product")
pdf.drawString(margin + 220, y, "SKU")
pdf.drawRightString(margin + 330, y, "Qty")
pdf.drawRightString(margin + 420, y, "Unit (ex GST)")
pdf.drawRightString(page_width - margin, y, "Line total")
y -= 6
pdf.setStrokeColor(colors.HexColor("#cccccc"))
pdf.line(margin, y, page_width - margin, y)
y -= 14
pdf.setFont("Helvetica", 9)
pdf.setFillColor(colors.black)
for line in order.lines:
if y < margin + 60:
pdf.showPage()
y = page_height - margin
pdf.setFont("Helvetica", 9)
unit = line.admin_override_price if line.admin_override_price is not None else line.unit_price
line_total = None if unit is None else round(unit * line.quantity, 2)
pdf.drawString(margin, y, (line.product_name or "")[:38])
pdf.drawString(margin + 220, y, (line.product_sku or "")[:16])
pdf.drawRightString(margin + 330, y, f"{line.quantity:g}")
pdf.drawRightString(margin + 420, y, _fmt_money(unit))
pdf.drawRightString(page_width - margin, y, _fmt_money(line_total))
y -= 14
y -= 4
pdf.line(margin, y, page_width - margin, y)
y -= 16
pdf.setFont("Helvetica-Bold", 10)
pdf.drawRightString(margin + 420, y, "Subtotal (ex GST)")
pdf.drawRightString(page_width - margin, y, _fmt_money(order.subtotal_ex_gst))
y -= 20
if order.requires_quote:
pdf.setFont("Helvetica-Oblique", 9)
pdf.setFillColor(colors.HexColor("#8a5a00"))
pdf.drawString(margin, y, "This order contains quote-only items. Final pricing will be confirmed by our team.")
y -= 14
if order.delivery_notes:
pdf.setFont("Helvetica", 9)
pdf.setFillColor(colors.black)
pdf.drawString(margin, y, f"Notes: {order.delivery_notes[:90]}")
pdf.setFont("Helvetica-Oblique", 8)
pdf.setFillColor(colors.HexColor("#888888"))
pdf.drawString(margin, margin - 12, "Prices exclude GST. This is an order confirmation, not a tax invoice.")
pdf.showPage()
pdf.save()
return buffer.getvalue()
+201
View File
@@ -0,0 +1,201 @@
"""Customer-specific pricing engine for the B2B ordering portal.
The backend is the single source of truth for prices the frontend never
computes a final price. :func:`resolve_price` returns a fully-attributed
:class:`PriceResolution` so every order line, and any future report, can explain
exactly which rule produced the number.
Resolution priority (highest wins):
1. **Quote-only** the product requires a manual quote, or the customer has a
``quote`` price rule for it. No automatic price.
2. **Fixed / contract** a :class:`CustomerProductPrice` for this customer +
product (quantity tiers may refine it).
3. **Price list** a :class:`PriceListItem` from the customer's assigned price
list (quantity tiers may refine it).
4. **Base + discount** the catalogue list price, optionally reduced by the
customer's default discount percentage.
5. **Quote fallback** no resolvable price treated as quote-only.
All prices are GST-exclusive.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
PriceListItem,
PriceTier,
)
@dataclass(frozen=True)
class PriceResolution:
unit_price: float | None
price_source: str # one of ordering.PRICE_SOURCES
price_rule_id: int | None
discount_percent: float
requires_quote: bool
label: str
def line_total(self, quantity: float) -> float | None:
if self.unit_price is None:
return None
return round(self.unit_price * quantity, 4)
_SOURCE_LABELS = {
"fixed": "Fixed price",
"contract": "Contract price",
"price_list": "Price list",
"tiered": "Tiered price",
"base": "List price",
"quote": "Quote required",
}
def _best_tier_price(
db: Session,
*,
customer_product_price_id: int | None = None,
price_list_item_id: int | None = None,
quantity: float,
) -> float | None:
"""Return the unit price of the highest qualifying quantity tier, if any."""
stmt = select(PriceTier).where(PriceTier.min_quantity <= quantity)
if customer_product_price_id is not None:
stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
elif price_list_item_id is not None:
stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
else:
return None
tiers = db.scalars(stmt.order_by(PriceTier.min_quantity.desc())).all()
return tiers[0].unit_price if tiers else None
def _quote(label: str = "Quote required") -> PriceResolution:
return PriceResolution(
unit_price=None,
price_source="quote",
price_rule_id=None,
discount_percent=0.0,
requires_quote=True,
label=label,
)
def get_customer_assignment(
db: Session, *, client_account_id: int
) -> CustomerPriceAssignment | None:
return db.scalar(
select(CustomerPriceAssignment).where(
CustomerPriceAssignment.client_account_id == client_account_id
)
)
def resolve_price(
db: Session,
*,
client_account_id: int,
product: CatalogueProduct,
quantity: float,
) -> PriceResolution:
"""Resolve the GST-exclusive unit price for a customer + product + quantity."""
quantity = max(quantity, 0.0)
# 1. Product-level manual-quote flag.
if product.requires_quote:
return _quote()
# 2. Customer-specific fixed/contract/quote rule.
cpp = db.scalar(
select(CustomerProductPrice).where(
CustomerProductPrice.client_account_id == client_account_id,
CustomerProductPrice.product_id == product.id,
CustomerProductPrice.active.is_(True),
)
)
if cpp is not None:
if cpp.rule_type == "quote":
return _quote()
tier_price = _best_tier_price(
db, customer_product_price_id=cpp.id, quantity=quantity
)
if tier_price is not None:
return PriceResolution(
unit_price=round(tier_price, 4),
price_source="tiered",
price_rule_id=cpp.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["tiered"],
)
if cpp.unit_price is not None:
source = "contract" if cpp.rule_type == "contract" else "fixed"
return PriceResolution(
unit_price=round(cpp.unit_price, 4),
price_source=source,
price_rule_id=cpp.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS[source],
)
assignment = get_customer_assignment(db, client_account_id=client_account_id)
# 3. Assigned price list.
if assignment is not None and assignment.price_list_id is not None:
pli = db.scalar(
select(PriceListItem).where(
PriceListItem.price_list_id == assignment.price_list_id,
PriceListItem.product_id == product.id,
)
)
if pli is not None:
tier_price = _best_tier_price(
db, price_list_item_id=pli.id, quantity=quantity
)
if tier_price is not None:
return PriceResolution(
unit_price=round(tier_price, 4),
price_source="tiered",
price_rule_id=pli.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["tiered"],
)
return PriceResolution(
unit_price=round(pli.unit_price, 4),
price_source="price_list",
price_rule_id=pli.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["price_list"],
)
# 4. Base price (+ optional customer discount).
if product.base_price is not None:
discount = assignment.discount_percent if assignment else 0.0
discount = min(max(discount, 0.0), 100.0)
unit_price = round(product.base_price * (1.0 - discount / 100.0), 4)
label = _SOURCE_LABELS["base"]
if discount:
label = f"List price less {discount:g}%"
return PriceResolution(
unit_price=unit_price,
price_source="base",
price_rule_id=product.id,
discount_percent=discount,
requires_quote=False,
label=label,
)
# 5. No resolvable price ⇒ quote.
return _quote("No price set — quote required")
+313
View File
@@ -0,0 +1,313 @@
"""Shared ordering-portal business logic: lifecycle, visibility, totals,
serialization, and audit.
Customer isolation is enforced by callers passing ``tenant_id`` /
``client_account_id`` into every query; this module never widens that scope.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession
from app.core.config import settings
from app.models.client_access import ClientAccount
from app.models.ordering import (
CatalogueProduct,
CustomerProductVisibility,
Order,
OrderLine,
OrderStatusHistory,
)
from app.services.client_access_service import record_audit_event
from app.services.ordering_pricing import resolve_price
# The ordering catalogue is "standard globally" — it lives under a single
# seller/ordering tenant, while individual customers (ClientAccounts) may have
# their own tenant_id. Customer isolation therefore keys on the globally-unique
# ``client_account_id``, NOT on the customer's session tenant. Admin and
# customer code both store/read ordering data under ORDERING_TENANT.
ORDERING_TENANT = settings.client_tenant_id
# --- Lifecycle ---------------------------------------------------------------
# Full internal lifecycle → the simplified status a customer sees.
CUSTOMER_VISIBLE_STATUS = {
"draft": "draft",
"submitted": "submitted",
"under_review": "processing",
"confirmed": "confirmed",
"sent_to_xero": "confirmed",
"in_production": "processing",
"ready_for_pickup": "ready",
"dispatched": "dispatched",
"completed": "completed",
"cancelled": "cancelled",
}
# Statuses an admin may move an order into from a given status. Cancellation is
# allowed from any non-terminal status (handled separately).
ALLOWED_ADMIN_TRANSITIONS: dict[str, set[str]] = {
"draft": {"submitted", "cancelled"},
"submitted": {"under_review", "confirmed", "cancelled"},
"under_review": {"confirmed", "submitted", "cancelled"},
"confirmed": {"sent_to_xero", "in_production", "cancelled"},
"sent_to_xero": {"in_production", "cancelled"},
"in_production": {"ready_for_pickup", "dispatched", "cancelled"},
"ready_for_pickup": {"dispatched", "completed", "cancelled"},
"dispatched": {"completed", "cancelled"},
"completed": set(),
"cancelled": set(),
}
# Customers may only edit an order while it is a draft.
CUSTOMER_EDITABLE_STATUSES = {"draft"}
def can_admin_transition(from_status: str, to_status: str) -> bool:
if to_status == "cancelled" and from_status not in {"completed", "cancelled"}:
return True
return to_status in ALLOWED_ADMIN_TRANSITIONS.get(from_status, set())
def record_status_change(
db: Session,
order: Order,
*,
to_status: str,
actor_type: str,
actor_name: str | None,
note: str | None = None,
) -> None:
db.add(
OrderStatusHistory(
tenant_id=order.tenant_id,
order_id=order.id,
from_status=order.status,
to_status=to_status,
actor_type=actor_type,
actor_name=actor_name,
note=note,
)
)
order.status = to_status
def next_order_number(db: Session, tenant_id: str) -> str:
# Cheap monotonic-ish numbering based on max id; good enough for v1.
last_id = db.scalar(select(Order.id).order_by(Order.id.desc()).limit(1)) or 0
return f"ORD-{last_id + 1:06d}"
# --- Product visibility ------------------------------------------------------
def visible_product_ids_for_customer(db: Session, *, tenant_id: str, client_account_id: int) -> set[int]:
"""Ids the customer is explicitly *hidden* from (opt-out model)."""
rows = db.scalars(
select(CustomerProductVisibility.product_id).where(
CustomerProductVisibility.client_account_id == client_account_id,
CustomerProductVisibility.tenant_id == tenant_id,
CustomerProductVisibility.visible.is_(False),
)
).all()
return set(rows)
def list_visible_products(
db: Session, *, tenant_id: str, client_account_id: int
) -> list[CatalogueProduct]:
hidden = visible_product_ids_for_customer(
db, tenant_id=tenant_id, client_account_id=client_account_id
)
products = db.scalars(
select(CatalogueProduct)
.where(CatalogueProduct.tenant_id == tenant_id, CatalogueProduct.active.is_(True))
.order_by(CatalogueProduct.category, CatalogueProduct.name)
).all()
return [p for p in products if p.id not in hidden]
# --- Totals ------------------------------------------------------------------
def effective_unit_price(line: OrderLine) -> float | None:
if line.admin_override_price is not None:
return line.admin_override_price
return line.unit_price
def recompute_order_totals(order: Order) -> None:
subtotal = 0.0
requires_quote = False
for line in order.lines:
unit = effective_unit_price(line)
if line.requires_quote or unit is None:
requires_quote = True
line.line_total = None
continue
line.line_total = round(unit * line.quantity, 4)
subtotal += line.line_total
order.subtotal_ex_gst = round(subtotal, 4)
order.requires_quote = requires_quote
# --- Serialization -----------------------------------------------------------
def serialize_product(
product: CatalogueProduct,
*,
db: Session | None = None,
client_account_id: int | None = None,
quantity: float = 1.0,
) -> dict:
"""Serialize a catalogue product. When ``db`` + ``client_account_id`` are
given, attach the resolved customer-specific price + provenance."""
data = {
"id": product.id,
"name": product.name,
"sku": product.sku,
"description": product.description,
"category": product.category,
"image_url": product.image_url,
"unit_size": product.unit_size,
"unit_of_measure": product.unit_of_measure,
"min_order_quantity": product.min_order_quantity,
"stock_status": product.stock_status,
"active": product.active,
"requires_quote": product.requires_quote,
"base_price": product.base_price,
"created_at": product.created_at,
}
if db is not None and client_account_id is not None:
resolution = resolve_price(
db, client_account_id=client_account_id, product=product, quantity=quantity
)
data["price"] = {
"unit_price": resolution.unit_price,
"price_source": resolution.price_source,
"price_rule_id": resolution.price_rule_id,
"discount_percent": resolution.discount_percent,
"requires_quote": resolution.requires_quote,
"label": resolution.label,
}
return data
def serialize_order_line(line: OrderLine, *, for_admin: bool) -> dict:
data = {
"id": line.id,
"product_id": line.product_id,
"product_name": line.product_name,
"product_sku": line.product_sku,
"quantity": line.quantity,
"unit_price": effective_unit_price(line),
"line_total": line.line_total,
"requires_quote": line.requires_quote,
"price_source": line.price_source,
"discount_percent": line.discount_percent,
"notes": line.notes,
}
if for_admin:
data.update(
{
"resolved_unit_price": line.unit_price,
"admin_override_price": line.admin_override_price,
"admin_override_reason": line.admin_override_reason,
"price_rule_id": line.price_rule_id,
}
)
return data
def serialize_order(order: Order, *, for_admin: bool) -> dict:
status = order.status if for_admin else CUSTOMER_VISIBLE_STATUS.get(order.status, order.status)
data = {
"id": order.id,
"order_number": order.order_number,
"status": status,
"client_account_id": order.client_account_id,
"created_by_name": order.created_by_name,
"purchase_order_number": order.purchase_order_number,
"delivery_notes": order.delivery_notes,
"requested_delivery_date": order.requested_delivery_date,
"fulfilment_method": order.fulfilment_method,
"subtotal_ex_gst": order.subtotal_ex_gst,
"requires_quote": order.requires_quote,
"submitted_at": order.submitted_at,
"created_at": order.created_at,
"updated_at": order.updated_at,
"editable": order.status in CUSTOMER_EDITABLE_STATUSES,
"lines": [serialize_order_line(line, for_admin=for_admin) for line in order.lines],
}
if for_admin:
data.update(
{
"raw_status": order.status,
"admin_notes": order.admin_notes,
"reopened": order.reopened,
"xero_status": order.xero_status,
"xero_invoice_id": order.xero_invoice_id,
"status_history": [
{
"id": h.id,
"from_status": h.from_status,
"to_status": h.to_status,
"actor_type": h.actor_type,
"actor_name": h.actor_name,
"note": h.note,
"created_at": h.created_at,
}
for h in order.status_history
],
}
)
return data
# --- Audit -------------------------------------------------------------------
def audit_order_event(
db: Session,
*,
session: AuthSession,
order: Order,
action: str,
summary: str,
target_id: int | None = None,
) -> None:
"""Record an ordering audit event onto the shared audit trail."""
actor_type = "lean_admin" if session.role in {"admin", "internal"} else "customer"
record_audit_event(
db,
tenant_id=order.tenant_id,
client_account_id=order.client_account_id,
actor_type=actor_type,
actor_name=session.name or "",
actor_email=session.email or "",
actor_role=session.client_role or session.role,
action=action,
target_type="order",
target_id=target_id if target_id is not None else order.id,
module_key="ordering",
summary=summary,
)
def get_customer_account(db: Session, *, client_account_id: int) -> ClientAccount | None:
# client_account_id is a global primary key; a customer's own tenant_id may
# differ from ORDERING_TENANT, so we look up by id alone.
return db.scalar(select(ClientAccount).where(ClientAccount.id == client_account_id))
def ensure_customer_active(account: ClientAccount | None) -> None:
from fastapi import HTTPException, status
if account is None:
raise HTTPException(status_code=404, detail="Customer account not found")
if account.status != "active":
raise HTTPException(status_code=403, detail="This customer account is disabled")
@@ -0,0 +1,338 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import math
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.product import Product
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.services.costing_engine import calculate_product_cost
UNIT_TYPES = ("Standard", "Bulka", "1.5 kg", "Per Unit")
OWN_BAG_VALUES = ("Yes", "No Bag")
ZERO_GRADING_CLIENTS = {"PHF Horse Mixes", "Peckish", "Hay & Straw"}
PROCESS_NAMES = ("Bagging + Grading", "Standard Bagging", "PHF Horse Mixes", "Peckish", "Hay & Straw")
BAG_INPUTS = {
"20kg_bag": "20kg bag",
"bulka_bag": "Bulka bag",
"own_bag_credit": "Own bag credit",
"1_5kg_bagging": "1.5kg bagging",
"peckish_bag": "Peckish bag",
}
FREIGHT_INPUTS = {
"freight_per_pallet": "Freight per pallet",
"peckish_freight_per_pallet": "Peckish freight per pallet",
"hay_straw_freight_per_pallet": "Hay & Straw freight per pallet",
}
@dataclass(frozen=True)
class ProductCostInputItem:
client_category: str
product_name: str
mix_product_name: str
unit_type: str
own_bag: str | None
unit_kg: float | None
items_per_pallet: int | None
bagging_process: str | None
manual_distributor_margin: float | None
manual_wholesale_margin: float | None
@dataclass(frozen=True)
class ProductCostAssumptions:
grading_per_kg: float
cracking_per_kg: float
process_costs: dict[str, float]
client_margins: dict[str, dict[str, float | None]]
bag_costs: dict[str, float]
freight_costs: dict[str, float]
@dataclass(frozen=True)
class ProductCostCalculation:
cleaned_product_cost_per_kg: float | None
grading_cost_per_kg: float | None
bagging_cost_per_kg: float | None
cracking_cost_per_kg: float | None
bag_cost_per_unit: float | None
freight_cost_per_unit: float | None
finished_product_delivered_cost: float | None
distributor_price: float | None
wholesale_price: float | None
warnings: list[str]
def _round4(value: float | None) -> float | None:
return None if value is None else round(value, 4)
def _ceil_to(value: float, digits: int) -> float:
factor = 10**digits
return math.ceil((value * factor) - 1e-9) / factor
def _valid_margin(value: float | None, label: str, warnings: list[str]) -> float | None:
if value is None:
return None
if value < 0 or value >= 1:
warnings.append(f"Invalid {label} margin")
return None
return value
def calculate_product_cost_item(
item: ProductCostInputItem,
assumptions: ProductCostAssumptions,
cleaned_product_cost_per_kg: float | None,
) -> ProductCostCalculation:
warnings: list[str] = []
unit_type = item.unit_type or "Standard"
unit_kg = item.unit_kg
items_per_pallet = item.items_per_pallet
if unit_type not in UNIT_TYPES:
warnings.append("Invalid unit type")
if cleaned_product_cost_per_kg is None:
warnings.append("Missing mix/product cost lookup")
if unit_kg is None or unit_kg <= 0:
warnings.append("Missing unit kg")
if items_per_pallet is None or items_per_pallet <= 0:
warnings.append("Missing pallet quantity")
grading_cost_per_kg = 0.0
if item.client_category not in ZERO_GRADING_CLIENTS and item.bagging_process:
grading_cost_per_kg = assumptions.grading_per_kg
bagging_cost_per_kg = assumptions.process_costs.get(item.bagging_process or "", 0.0)
if item.bagging_process and item.bagging_process not in assumptions.process_costs:
warnings.append("Missing bagging process cost")
cracking_cost_per_kg = assumptions.cracking_per_kg if "cracked" in item.product_name.lower() else 0.0
bag_cost_per_unit = 0.0
if item.client_category == "Peckish":
bag_cost_per_unit = assumptions.bag_costs.get("peckish_bag", 0.0)
elif unit_type == "1.5 kg":
bag_cost_per_unit = assumptions.bag_costs.get("1_5kg_bagging", 0.0)
elif item.own_bag == "No Bag":
bag_cost_per_unit = 0.0
elif unit_type == "Standard":
bag_cost_per_unit = assumptions.bag_costs.get("20kg_bag", 0.0)
elif unit_type == "Bulka":
bag_cost_per_unit = assumptions.bag_costs.get("bulka_bag", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
if bag_cost_per_unit is not None and item.own_bag == "Yes":
bag_cost_per_unit -= assumptions.bag_costs.get("own_bag_credit", 0.0)
freight_cost_per_unit: float | None
if item.client_category == "Peckish":
freight_cost_per_unit = assumptions.freight_costs.get("peckish_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif item.client_category == "Hay & Straw":
freight_cost_per_unit = assumptions.freight_costs.get("hay_straw_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif unit_type in {"Standard", "Per Unit"}:
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif unit_type == "Bulka":
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
else:
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / 1000 * unit_kg if unit_kg and unit_kg > 0 else None
finished_cost = None
components = [cleaned_product_cost_per_kg, grading_cost_per_kg, bagging_cost_per_kg, cracking_cost_per_kg, bag_cost_per_unit, freight_cost_per_unit]
if all(value is not None for value in components) and unit_kg and unit_kg > 0:
per_kg_cost = cleaned_product_cost_per_kg + grading_cost_per_kg + bagging_cost_per_kg + cracking_cost_per_kg # type: ignore[operator]
if unit_type == "Standard":
finished_cost = per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
elif unit_type in {"Bulka", "Per Unit"}:
finished_cost = per_kg_cost + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
else:
finished_cost = (per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit) * 8 # type: ignore[operator]
client_margin = assumptions.client_margins.get(item.client_category, {})
distributor_margin = _valid_margin(
item.manual_distributor_margin if item.manual_distributor_margin is not None else client_margin.get("distributor_margin"),
"distributor",
warnings,
)
wholesale_margin = _valid_margin(
item.manual_wholesale_margin if item.manual_wholesale_margin is not None else client_margin.get("wholesale_margin"),
"wholesale",
warnings,
)
distributor_price = finished_cost / (1 - distributor_margin) if finished_cost is not None and distributor_margin is not None else None
wholesale_price = finished_cost / (1 - wholesale_margin) if finished_cost is not None and wholesale_margin is not None else None
if wholesale_price is not None:
wholesale_price = _ceil_to(wholesale_price, 2 if item.client_category == "Straight Grain" and unit_type == "Bulka" else 1)
return ProductCostCalculation(
cleaned_product_cost_per_kg=_round4(cleaned_product_cost_per_kg),
grading_cost_per_kg=_round4(grading_cost_per_kg),
bagging_cost_per_kg=_round4(bagging_cost_per_kg),
cracking_cost_per_kg=_round4(cracking_cost_per_kg),
bag_cost_per_unit=_round4(bag_cost_per_unit),
freight_cost_per_unit=_round4(freight_cost_per_unit),
finished_product_delivered_cost=_round4(finished_cost),
distributor_price=_round4(distributor_price),
wholesale_price=_round4(wholesale_price),
warnings=warnings,
)
def _item_input(item: ProductCostItem) -> ProductCostInputItem:
return ProductCostInputItem(
client_category=item.client_category,
product_name=item.product_name,
mix_product_name=item.mix_product_name,
unit_type=item.unit_type,
own_bag=item.own_bag,
unit_kg=item.unit_kg,
items_per_pallet=item.items_per_pallet,
bagging_process=item.bagging_process,
manual_distributor_margin=item.manual_distributor_margin,
manual_wholesale_margin=item.manual_wholesale_margin,
)
def get_product_costing_assumptions(db: Session, tenant_id: str) -> ProductCostAssumptions:
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
base = ProductCostBaseInput(tenant_id=tenant_id)
db.add(base)
db.flush()
process_costs = {
row.process_name: row.cost_per_kg
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
}
client_margins = {
row.client_category: {
"distributor_margin": row.distributor_margin,
"wholesale_margin": row.wholesale_margin,
}
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
}
bag_costs = {
row.input_key: row.cost
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
}
freight_costs = {
row.input_key: row.cost
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
}
return ProductCostAssumptions(
grading_per_kg=base.grading_per_kg or ((base.grading_per_tonne or 0.0) / 1000),
cracking_per_kg=base.cracking_per_kg or ((base.cracking_per_tonne or 0.0) / 1000),
process_costs=process_costs,
client_margins=client_margins,
bag_costs=bag_costs,
freight_costs=freight_costs,
)
def lookup_cleaned_product_cost_per_kg(db: Session, item: ProductCostItem) -> float | None:
product = db.scalar(
select(Product)
.where(
Product.tenant_id == item.tenant_id,
Product.client_name == item.client_category,
Product.name == item.mix_product_name,
)
.limit(1)
)
if product is None:
product = db.scalar(
select(Product)
.where(
Product.tenant_id == item.tenant_id,
Product.client_name == item.client_category,
Product.name == item.product_name,
)
.limit(1)
)
if product is None:
return None
try:
result = calculate_product_cost(db, product.id)
except ValueError:
return None
mix = (result.get("inputs") or {}).get("mix") or {}
return mix.get("mix_cost_per_kg")
def apply_calculation(item: ProductCostItem, calculation: ProductCostCalculation) -> ProductCostItem:
item.cleaned_product_cost_per_kg = calculation.cleaned_product_cost_per_kg
item.grading_cost_per_kg = calculation.grading_cost_per_kg
item.bagging_cost_per_kg = calculation.bagging_cost_per_kg
item.cracking_cost_per_kg = calculation.cracking_cost_per_kg
item.bag_cost_per_unit = calculation.bag_cost_per_unit
item.freight_cost_per_unit = calculation.freight_cost_per_unit
item.finished_product_delivered_cost = calculation.finished_product_delivered_cost
item.distributor_price = calculation.distributor_price
item.wholesale_price = calculation.wholesale_price
item.warnings = json.dumps(calculation.warnings)
return item
def recalculate_product_cost_item(db: Session, item: ProductCostItem) -> ProductCostItem:
assumptions = get_product_costing_assumptions(db, item.tenant_id)
cleaned_cost = lookup_cleaned_product_cost_per_kg(db, item)
calculation = calculate_product_cost_item(_item_input(item), assumptions, cleaned_cost)
return apply_calculation(item, calculation)
def recalculate_all_product_cost_items(db: Session, tenant_id: str) -> int:
items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
for item in items:
recalculate_product_cost_item(db, item)
return len(items)
def serialize_product_cost_item(item: ProductCostItem) -> dict:
warnings = []
if item.warnings:
try:
warnings = json.loads(item.warnings)
except json.JSONDecodeError:
warnings = [item.warnings]
return {
"id": item.id,
"tenant_id": item.tenant_id,
"client_category": item.client_category,
"item_id": item.item_id,
"product_name": item.product_name,
"mix_product_name": item.mix_product_name,
"unit_type": item.unit_type,
"own_bag": item.own_bag,
"unit_kg": item.unit_kg,
"items_per_pallet": item.items_per_pallet,
"bagging_process": item.bagging_process,
"manual_distributor_margin": item.manual_distributor_margin,
"manual_wholesale_margin": item.manual_wholesale_margin,
"cleaned_product_cost_per_kg": item.cleaned_product_cost_per_kg,
"grading_cost_per_kg": item.grading_cost_per_kg,
"bagging_cost_per_kg": item.bagging_cost_per_kg,
"cracking_cost_per_kg": item.cracking_cost_per_kg,
"bag_cost_per_unit": item.bag_cost_per_unit,
"freight_cost_per_unit": item.freight_cost_per_unit,
"finished_product_delivered_cost": item.finished_product_delivered_cost,
"distributor_price": item.distributor_price,
"wholesale_price": item.wholesale_price,
"warnings": warnings,
"created_at": item.created_at,
"updated_at": item.updated_at,
}
+744
View File
@@ -0,0 +1,744 @@
from __future__ import annotations
import csv
import io
import logging
import os
import re
from datetime import date, datetime
from pathlib import Path
from typing import Iterable
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.throughput import ProductionThroughput, ThroughputProduct
logger = logging.getLogger("data_entry_app.throughput")
PRODUCTION_SHEET = "Production"
NAMES_SHEET = "Names"
# The historical throughput export. Bundled into the image under input_data/ so
# the seed can import it on a fresh deployment (e.g. a new Postgres volume).
WORKBOOK_FILENAME = "Operations Throughput.xlsx"
# Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count.
_BULKA_BAG_SIZE_THRESHOLD = 100.0
def normalise_staff_name(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
# Collapse internal whitespace, title-case for consistency.
cleaned = " ".join(text.split())
return cleaned
def calculate_kg(quantity: float | None, quantity_type: str, bag_size: float | None) -> float:
if quantity is None:
return 0.0
if quantity_type == "kg":
return float(quantity)
if bag_size is None:
return 0.0
return float(quantity) * float(bag_size)
def qa_passed(entry: ProductionThroughput) -> bool:
return bool(entry.scales_checked and entry.label_correct and entry.bag_sealed and entry.pallet_good_condition)
def serialize_entry(entry: ProductionThroughput) -> dict:
return {
"id": entry.id,
"tenant_id": entry.tenant_id,
"production_date": entry.production_date,
"product_id": entry.product_id,
"product_name_snapshot": entry.product_name_snapshot,
"bag_size": entry.bag_size,
"scales_checked": entry.scales_checked,
"label_correct": entry.label_correct,
"bag_sealed": entry.bag_sealed,
"pallet_good_condition": entry.pallet_good_condition,
"for_order": entry.for_order,
"for_stock": entry.for_stock,
"job_number": entry.job_number,
"stock_quantity": entry.stock_quantity,
"sample_box_no": entry.sample_box_no,
"test_weight_1": entry.test_weight_1,
"test_weight_2": entry.test_weight_2,
"test_weight_3": entry.test_weight_3,
"test_weight_4": entry.test_weight_4,
"test_weight_5": entry.test_weight_5,
"quantity": entry.quantity,
"quantity_type": entry.quantity_type,
"calculated_kg": entry.calculated_kg,
"staff_name": entry.staff_name,
"notes": entry.notes,
"qa_passed": qa_passed(entry),
"created_by": entry.created_by,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
def _coerce_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if value is None:
return True
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}:
return True
if text in {"no", "n", "false", "0", "fail"}:
return False
return True
def _coerce_import_bool(value: object, *, default: bool = False) -> bool:
"""Conservative boolean parsing for ad-hoc imports.
Uploaded CSV/XLSX rows often leave destination columns blank, or use text
like "stock" / "order" elsewhere in the row. Those should not silently
become True. Only explicit truthy markers opt in.
"""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if not text:
return default
if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}:
return True
if text in {"no", "n", "false", "0", "fail"}:
return False
return default
def _coerce_float(value: object) -> float | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return float(value)
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "")
if not text:
return None
try:
return float(text)
except ValueError:
return None
def _coerce_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() in {"#value!", "#n/a", "n/a"}:
return None
return text
# Default slash-date preference. The app is Australian, so an ambiguous
# "x/y/z" is read day-first unless a column is detected as month-first.
_DAY_FIRST_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y")
_MONTH_FIRST_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y")
_SLASH_DATE_RE = re.compile(r"^\s*(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\s*$")
def _coerce_date(
value: object, formats: tuple[str, ...] = _DAY_FIRST_FORMATS
) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if not text:
return None
for fmt in formats:
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _detect_slash_date_formats(values: Iterable[object]) -> tuple[str, ...]:
"""Inspect every slash/dash date in a column and decide whether the file is
day-first (D/M/Y) or month-first (M/D/Y), so all rows parse consistently.
A first component > 12 proves day-first; a second component > 12 proves
month-first. If only month-first evidence exists we switch to M/D/Y;
otherwise we keep the Australian day-first default.
"""
day_first = False
month_first = False
for value in values:
if value is None or isinstance(value, (datetime, date)):
continue
match = _SLASH_DATE_RE.match(str(value))
if not match:
continue
first, second = int(match.group(1)), int(match.group(2))
if first > 12:
day_first = True
elif second > 12:
month_first = True
if month_first and not day_first:
return _MONTH_FIRST_FORMATS
return _DAY_FIRST_FORMATS
def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
lowered = name.lower()
if "bulka" in lowered:
return True
if bag_size is None:
return False
return bag_size >= _BULKA_BAG_SIZE_THRESHOLD
def import_names_sheet(db: Session, workbook, tenant_id: str) -> tuple[int, int]:
"""Upsert product master from the Names sheet. Returns (created, updated)."""
if NAMES_SHEET not in workbook.sheetnames:
return (0, 0)
ws = workbook[NAMES_SHEET]
existing: dict[tuple[str, str | None], ThroughputProduct] = {}
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
created = 0
updated = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not row:
continue
name = _coerce_text(row[0] if len(row) > 0 else None)
if not name:
continue
item_id_raw = row[1] if len(row) > 1 else None
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(name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=item_id,
name=name,
default_bag_size=None,
is_bulka_default="bulka" in name.lower(),
active=True,
notes="Imported from Operations Throughput.xlsx",
)
db.add(product)
created += 1
if item_id:
by_item[item_id] = product
by_name[name.lower()] = product
else:
if item_id and not product.item_id:
product.item_id = item_id
if name and product.name != name:
product.name = name
updated += 1
db.flush()
return (created, updated)
def import_production_sheet(db: Session, workbook, tenant_id: str) -> tuple[int, int]:
"""Import the Production sheet. Returns (imported, skipped)."""
if PRODUCTION_SHEET not in workbook.sheetnames:
return (0, 0)
ws = workbook[PRODUCTION_SHEET]
# Header row is row 3 in the sheet (rows 1 and 2 are display banners).
products_by_name: dict[str, ThroughputProduct] = {
product.name.lower(): product
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all()
}
bag_size_seen: dict[int, list[float]] = {}
imported = 0
skipped = 0
for row in ws.iter_rows(min_row=4, values_only=True):
if not row or len(row) < 15:
skipped += 1
continue
production_date = _coerce_date(row[0])
product_name = _coerce_text(row[1])
if production_date is None or not product_name:
skipped += 1
continue
bag_size = _coerce_float(row[2])
scales = _coerce_bool(row[3])
label = _coerce_bool(row[4])
sealed = _coerce_bool(row[5])
pallet = _coerce_bool(row[6])
sample_box = _coerce_text(row[7])
tw1 = _coerce_float(row[8])
tw2 = _coerce_float(row[9])
tw3 = _coerce_float(row[10])
tw4 = _coerce_float(row[11])
tw5 = _coerce_float(row[12])
quantity = _coerce_float(row[13]) or 0.0
staff = normalise_staff_name(row[14])
notes = _coerce_text(row[15]) if len(row) > 15 else None
# Infer quantity_type: 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"
product = products_by_name.get(product_name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=None,
name=product_name,
default_bag_size=bag_size,
is_bulka_default=_infer_bulka_default(product_name, bag_size),
active=True,
notes="Auto-created during Operations Throughput import",
)
db.add(product)
db.flush()
products_by_name[product_name.lower()] = product
if product.id is not None and bag_size is not None and bag_size > 0:
bag_size_seen.setdefault(product.id, []).append(bag_size)
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=scales,
label_correct=label,
bag_sealed=sealed,
pallet_good_condition=pallet,
sample_box_no=sample_box,
test_weight_1=tw1,
test_weight_2=tw2,
test_weight_3=tw3,
test_weight_4=tw4,
test_weight_5=tw5,
quantity=quantity,
quantity_type=quantity_type,
calculated_kg=calculated,
staff_name=staff,
notes=notes,
created_by="workbook-import",
)
db.add(entry)
imported += 1
# Backfill default_bag_size on products that don't have one but appear in entries.
for product_id, sizes in bag_size_seen.items():
product = db.get(ThroughputProduct, product_id)
if product and product.default_bag_size is None:
# Use the most common bag size seen.
common = max(set(sizes), key=sizes.count)
product.default_bag_size = common
if not product.is_bulka_default:
product.is_bulka_default = _infer_bulka_default(product.name, common)
db.flush()
return (imported, skipped)
def import_workbook(db: Session, workbook_path: Path, tenant_id: str) -> dict:
workbook = load_workbook(workbook_path, data_only=True)
products_created, products_updated = import_names_sheet(db, workbook, tenant_id)
entries_imported, entries_skipped = import_production_sheet(db, workbook, tenant_id)
return {
"products_created": products_created,
"products_updated": products_updated,
"entries_imported": entries_imported,
"entries_skipped": entries_skipped,
}
def workbook_candidates() -> Iterable[Path]:
repo_root = Path(__file__).resolve().parents[3]
cwd = Path.cwd()
env_value = os.getenv("THROUGHPUT_WORKBOOK_PATH")
env_path = Path(env_value.strip()) if isinstance(env_value, str) and env_value.strip() else None
# input_data/ is where the workbook is bundled in the image; in the
# container the working directory is /app, so cwd/input_data resolves it.
candidates = [
env_path,
repo_root / "input_data" / WORKBOOK_FILENAME,
cwd / "input_data" / WORKBOOK_FILENAME,
Path("/app") / "input_data" / WORKBOOK_FILENAME,
Path("/srv/lean101-clients") / "input_data" / WORKBOOK_FILENAME,
repo_root / WORKBOOK_FILENAME,
repo_root.parent / WORKBOOK_FILENAME,
cwd / WORKBOOK_FILENAME,
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
Path("/app") / WORKBOOK_FILENAME,
]
seen: set[str] = set()
ordered: list[Path] = []
for candidate in candidates:
if candidate is None:
continue
key = str(candidate)
if key in seen:
continue
seen.add(key)
ordered.append(candidate)
return ordered
def resolve_workbook_path() -> Path | None:
for candidate in workbook_candidates():
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]
# Decide the slash-date order once for the whole file so ambiguous values
# like "12/9/2025" follow the same convention as the unambiguous ones.
date_formats = _detect_slash_date_formats(cell(row, "date") for row in data_rows)
# 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"), date_formats)
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_import_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False
for_stock = _coerce_import_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,
}
+250
View File
@@ -0,0 +1,250 @@
"""Xero integration layer for the ordering portal.
This is a **clean, stubbed interface**. No Xero credentials are hard-coded all
configuration comes from environment variables (``XERO_*``). When credentials
are absent (the default in dev/alpha), the service runs in "stub" mode: it
records what *would* be sent and returns a deterministic fake invoice id so the
rest of the order lifecycle can be exercised end-to-end.
To go live, implement the real HTTP calls inside :func:`_submit_to_xero_api`
(see the TODOs) no caller needs to change.
Service responsibilities (per spec):
* Map a customer (ClientAccount) to a Xero contact.
* Map catalogue products to Xero item codes.
* Create a draft invoice equivalent for a confirmed order.
* Record Xero response ids/status (via XeroSyncLog, written by the caller).
* Handle failures gracefully (never raise into the request path).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime
from app.models.client_access import ClientAccount
from app.models.ordering import Order, XeroContactLink
@dataclass
class XeroConfig:
client_id: str | None = None
client_secret: str | None = None
tenant_id: str | None = None
base_url: str = "https://api.xero.com/api.xro/2.0"
@property
def configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.tenant_id)
@classmethod
def from_env(cls) -> "XeroConfig":
return cls(
client_id=os.getenv("XERO_CLIENT_ID") or None,
client_secret=os.getenv("XERO_CLIENT_SECRET") or None,
tenant_id=os.getenv("XERO_TENANT_ID") or None,
base_url=os.getenv("XERO_API_BASE_URL", "https://api.xero.com/api.xro/2.0"),
)
@dataclass
class XeroSubmissionResult:
status: str # "success" | "failed"
xero_invoice_id: str | None
message: str
request_summary: str
stubbed: bool = False
line_items: list[dict] = field(default_factory=list)
@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 {
"ContactNumber": customer.client_code,
"Name": customer.name,
}
def map_product_to_item_code(product_sku: str) -> str:
"""Map a catalogue SKU onto a Xero item code.
TODO: support an explicit SKUXero item-code mapping table if the codes
diverge. Today the SKU is used directly.
"""
return product_sku
def build_invoice_payload(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> dict:
"""Build the Xero draft-invoice payload for a confirmed order."""
line_items = []
for line in order.lines:
if line.requires_quote or line.unit_price is None:
# Quote-only lines can't carry a price; skip until quoted.
continue
unit_price = line.admin_override_price if line.admin_override_price is not None else line.unit_price
line_items.append(
{
"ItemCode": map_product_to_item_code(line.product_sku),
"Description": line.product_name,
"Quantity": line.quantity,
"UnitAmount": unit_price,
"AccountCode": os.getenv("XERO_SALES_ACCOUNT_CODE", "200"),
# Prices are GST-exclusive throughout the platform.
"TaxType": os.getenv("XERO_TAX_TYPE", "OUTPUT"),
}
)
return {
"Type": "ACCREC",
"Status": "DRAFT",
"Contact": map_customer_to_contact(customer, link),
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
"LineAmountTypes": "Exclusive",
"LineItems": line_items,
}
def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResult:
"""Real Xero submission. Stubbed until credentials/endpoints are wired.
TODO (go-live):
1. Obtain an OAuth2 token (client-credentials or stored refresh token).
2. POST ``payload`` to ``{config.base_url}/Invoices`` with the
``Xero-tenant-id`` header set to ``config.tenant_id``.
3. Parse ``Invoices[0].InvoiceID`` from the response.
4. Map non-2xx responses onto ``status="failed"`` with the error body.
"""
summary = f"{len(payload.get('LineItems', []))} line(s) for {payload['Contact']['Name']}"
# Real call would go here. Intentionally not implemented yet.
return XeroSubmissionResult(
status="failed",
xero_invoice_id=None,
message="Xero live submission is not implemented yet (stub interface).",
request_summary=summary,
stubbed=False,
line_items=payload.get("LineItems", []),
)
def submit_order_to_xero(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> XeroSubmissionResult:
"""Submit a confirmed order to Xero, or stub it when unconfigured.
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, link)
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
if not config.configured:
# Stub mode: deterministic fake invoice id so downstream flows work.
fake_id = f"STUB-INV-{order.id:06d}"
return XeroSubmissionResult(
status="success",
xero_invoice_id=fake_id,
message="Xero not configured — order recorded in stub mode.",
request_summary=summary,
stubbed=True,
line_items=payload["LineItems"],
)
try:
return _submit_to_xero_api(config, payload)
except Exception as exc: # pragma: no cover - defensive: never break the request path
return XeroSubmissionResult(
status="failed",
xero_invoice_id=None,
message=f"Xero submission error: {exc}",
request_summary=summary,
stubbed=False,
line_items=payload["LineItems"],
)
def xero_status_snapshot() -> dict:
config = XeroConfig.from_env()
return {
"configured": config.configured,
"mode": "live" if config.configured else "stub",
"base_url": config.base_url,
"checked_at": datetime.utcnow().isoformat(),
"missing_env": [
name
for name, value in (
("XERO_CLIENT_ID", config.client_id),
("XERO_CLIENT_SECRET", config.client_secret),
("XERO_TENANT_ID", config.tenant_id),
)
if not value
],
}
@@ -1,10 +1,11 @@
Metadata-Version: 2.4
Name: data-entry-app-backend
Version: 0.1.5
Version: 0.1.12
Summary: Costing platform MVP backend
Requires-Python: >=3.11
Requires-Dist: fastapi<1.0,>=0.115
Requires-Dist: openpyxl<4.0,>=3.1
Requires-Dist: rich<15.0,>=13.9
Requires-Dist: uvicorn[standard]<1.0,>=0.30
Requires-Dist: sqlalchemy<3.0,>=2.0
Requires-Dist: pydantic<3.0,>=2.8
@@ -9,16 +9,23 @@ pyproject.toml
./app/api/client_access.py
./app/api/dashboard.py
./app/api/deps.py
./app/api/editor.py
./app/api/mix_calculator.py
./app/api/mixes.py
./app/api/powerbi.py
./app/api/product_costing.py
./app/api/products.py
./app/api/raw_materials.py
./app/api/scenarios.py
./app/api/throughput.py
./app/core/__init__.py
./app/core/access.py
./app/core/config.py
./app/core/http.py
./app/core/logging.py
./app/core/rate_limit.py
./app/core/security.py
./app/core/security_logging.py
./app/db/__init__.py
./app/db/migrations.py
./app/db/session.py
@@ -29,53 +36,96 @@ pyproject.toml
./app/models/mix.py
./app/models/mix_calculator.py
./app/models/product.py
./app/models/product_costing.py
./app/models/raw_material.py
./app/models/scenario.py
./app/models/throughput.py
./app/schemas/__init__.py
./app/schemas/client_access.py
./app/schemas/editor.py
./app/schemas/mix.py
./app/schemas/mix_calculator.py
./app/schemas/product.py
./app/schemas/product_costing.py
./app/schemas/raw_material.py
./app/schemas/scenario.py
./app/schemas/throughput.py
./app/services/__init__.py
./app/services/client_access_service.py
./app/services/costing_engine.py
./app/services/mix_calculator_filenames.py
./app/services/mix_calculator_pdf.py
./app/services/mix_calculator_service.py
./app/services/product_costing_service.py
./app/services/scenario_engine.py
./app/services/throughput_service.py
app/__init__.py
app/main.py
app/seed.py
app/seed_access.py
app/api/__init__.py
app/api/access.py
app/api/auth.py
app/api/client_access.py
app/api/dashboard.py
app/api/deps.py
app/api/editor.py
app/api/mix_calculator.py
app/api/mixes.py
app/api/powerbi.py
app/api/product_costing.py
app/api/products.py
app/api/raw_materials.py
app/api/scenarios.py
app/api/throughput.py
app/core/__init__.py
app/core/access.py
app/core/config.py
app/core/http.py
app/core/logging.py
app/core/rate_limit.py
app/core/security.py
app/core/security_logging.py
app/db/__init__.py
app/db/migrations.py
app/db/session.py
app/models/__init__.py
app/models/access.py
app/models/assumption.py
app/models/client_access.py
app/models/mix.py
app/models/mix_calculator.py
app/models/product.py
app/models/product_costing.py
app/models/raw_material.py
app/models/scenario.py
app/models/throughput.py
app/schemas/__init__.py
app/schemas/client_access.py
app/schemas/editor.py
app/schemas/mix.py
app/schemas/mix_calculator.py
app/schemas/product.py
app/schemas/product_costing.py
app/schemas/raw_material.py
app/schemas/scenario.py
app/schemas/throughput.py
app/services/__init__.py
app/services/client_access_service.py
app/services/costing_engine.py
app/services/mix_calculator_filenames.py
app/services/mix_calculator_pdf.py
app/services/mix_calculator_service.py
app/services/product_costing_service.py
app/services/scenario_engine.py
app/services/throughput_service.py
data_entry_app_backend.egg-info/PKG-INFO
data_entry_app_backend.egg-info/SOURCES.txt
data_entry_app_backend.egg-info/dependency_links.txt
data_entry_app_backend.egg-info/requires.txt
data_entry_app_backend.egg-info/top_level.txt
tests/test_access.py
tests/test_costing_engine.py
tests/test_costing_engine.py
tests/test_product_costing.py
tests/test_schema.py
tests/test_throughput.py
@@ -1,5 +1,6 @@
fastapi<1.0,>=0.115
openpyxl<4.0,>=3.1
rich<15.0,>=13.9
uvicorn[standard]<1.0,>=0.30
sqlalchemy<3.0,>=2.0
pydantic<3.0,>=2.8
+5 -3
View File
@@ -3,13 +3,15 @@ requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "data-entry-app-backend"
version = "0.1.5"
description = "Costing platform MVP backend"
name = "hunter-backend"
version = "0.1.36"
description = "Costing platform MVP backend (API for Hunter)"
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",
"sqlalchemy>=2.0,<3.0",
"pydantic>=2.8,<3.0",
+323 -6
View File
@@ -7,15 +7,18 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.access import router as access_router
from app.core.access import (
INTERNAL_USER_SUBJECT,
get_user_permissions,
permissions_to_module_map,
require_all_permissions,
require_any_permission,
require_permission,
user_has_permission,
)
from app.core.security import issue_token
from app.core.config import settings
from app.core.security import issue_token, verify_password
from app.db.session import Base, get_db
from app.models.access import Permission, Role, User
from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access
@@ -42,6 +45,10 @@ def test_seed_creates_roles_permissions_and_users():
assert {role.name for role in db.query(Role).all()} == set(ROLE_DEFINITIONS.keys())
assert {p.key for p in db.query(Permission).all()} == {key for key, _ in PERMISSION_DEFINITIONS}
assert {user.email for user in db.query(User).all()} == {entry["email"] for entry in SEED_USERS}
for user in db.query(User).all():
assert user.password_hash is not None
assert user.password_hash != settings.admin_password
assert verify_password(settings.admin_password, user.password_hash)
def test_seed_is_idempotent():
@@ -66,19 +73,33 @@ def test_admin_role_permissions_match_spec():
assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"])
assert "manage_users" in granted
assert "manage_permissions" in granted
# Admin spec deliberately excludes edit_products / edit_mixes.
assert "edit_products" not in granted
assert "edit_mixes" not in granted
assert "edit_products" in granted
assert "edit_mixes" in granted
assert "view_scenarios" in granted
assert "edit_scenarios" in granted
assert "manage_client_access" in granted
modules = permissions_to_module_map(granted)
assert modules["products"] == "edit"
assert modules["mix_master"] == "edit"
assert modules["scenarios"] == "edit"
assert modules["client_access"] == "manage"
def test_operations_role_is_mix_calculator_only():
def test_operations_role_is_mix_calculator_and_throughput_only():
db = _build_session()
seed_access(db)
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
granted = get_user_permissions(ops)
assert granted == {"view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"}
assert granted == {
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_throughput",
"edit_throughput",
}
assert not user_has_permission(ops, "edit_raw_materials")
assert not user_has_permission(ops, "view_dashboard")
assert not user_has_permission(ops, "manage_users")
@@ -96,6 +117,30 @@ def test_full_access_role_can_edit_operational_data_but_not_users():
assert "manage_permissions" not in granted
def test_lean_role_has_unrestricted_workspace_permissions():
db = _build_session()
seed_access(db)
lean_role = db.query(Role).filter_by(name="lean").one()
user = User(email="lean@example.com", name="Lean User", role_id=lean_role.id, is_active=True)
db.add(user)
db.flush()
granted = get_user_permissions(user)
assert granted == set(ROLE_DEFINITIONS["lean"]["permissions"])
assert {key for key, _ in PERMISSION_DEFINITIONS} == granted
modules = permissions_to_module_map(granted)
assert modules["dashboard"] == "view"
assert modules["raw_materials"] == "edit"
assert modules["mix_master"] == "edit"
assert modules["mix_calculator"] == "edit"
assert modules["products"] == "edit"
assert modules["operations_throughput"] == "edit"
assert modules["scenarios"] == "edit"
assert modules["client_access"] == "manage"
def test_inactive_user_has_no_permissions():
db = _build_session()
seed_access(db)
@@ -158,6 +203,22 @@ def _token_for(user: User) -> str:
return issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
@pytest.fixture()
def access_app_and_db():
db = _build_session()
seed_access(db)
db.commit()
app = FastAPI()
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
app.include_router(access_router)
return TestClient(app), db
def test_route_allows_user_with_permission(app_and_db):
client, db = app_and_db
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
@@ -234,3 +295,259 @@ def test_require_all_permissions(app_and_db):
denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"})
assert denied.status_code == 403
def test_internal_login_uses_user_password_hash(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
admin.password_hash = issue_token({"not": "a password"})
db.commit()
denied = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert denied.status_code == 401
def test_internal_user_can_change_own_password(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
login_response = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert login_response.status_code == 200
update_response = client.patch(
"/api/access/me",
json={
"current_password": settings.admin_password,
"new_password": "new-personal-password",
},
cookies=login_response.cookies,
)
assert update_response.status_code == 200
db.refresh(admin)
assert admin.password_hash is not None
assert verify_password("new-personal-password", admin.password_hash)
assert not verify_password(settings.admin_password, admin.password_hash)
old_login = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert old_login.status_code == 401
new_login = client.post(
"/api/access/login",
json={"email": admin.email, "password": "new-personal-password"},
)
assert new_login.status_code == 200
# --- Admin user management --------------------------------------------------
def _admin_headers(db: Session) -> dict[str, str]:
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
return {"Authorization": f"Bearer {_token_for(admin)}"}
def test_manage_users_create_update_password_delete(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
full_access_role = db.query(Role).filter_by(name="Full Access").one()
created = client.post(
"/api/access/users",
json={"email": "new.user@hunterstockfeeds.com", "name": "New User", "role_id": full_access_role.id},
headers=headers,
)
assert created.status_code == 201
body = created.json()
assert body["email"] == "new.user@hunterstockfeeds.com"
assert body["role"] == "Full Access"
assert body["is_protected"] is False
user_id = body["id"]
operations_role = db.query(Role).filter_by(name="Operations").one()
updated = client.patch(
f"/api/access/users/{user_id}",
json={"name": "Renamed", "role_id": operations_role.id, "is_active": False},
headers=headers,
)
assert updated.status_code == 200
assert updated.json()["name"] == "Renamed"
assert updated.json()["role"] == "Operations"
assert updated.json()["is_active"] is False
pw = client.post(
f"/api/access/users/{user_id}/password",
json={"new_password": "brand-new-pass"},
headers=headers,
)
assert pw.status_code == 200
db.expire_all()
target = db.query(User).filter_by(id=user_id).one()
assert verify_password("brand-new-pass", target.password_hash)
deleted = client.delete(f"/api/access/users/{user_id}", headers=headers)
assert deleted.status_code == 204
assert db.query(User).filter_by(id=user_id).one_or_none() is None
def test_create_user_rejects_duplicate_email(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
response = client.post(
"/api/access/users",
json={"email": "admin@hunterstockfeeds.com", "name": "Dup"},
headers=headers,
)
assert response.status_code == 409
def test_manage_users_requires_permission(access_app_and_db):
client, db = access_app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
response = client.post(
"/api/access/users",
json={"email": "x@hunterstockfeeds.com", "name": "X"},
headers=headers,
)
assert response.status_code == 403
def test_cannot_deactivate_or_delete_self(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(admin)}"}
deactivate = client.patch(
f"/api/access/users/{admin.id}", json={"is_active": False}, headers=headers
)
assert deactivate.status_code == 400
delete = client.delete(f"/api/access/users/{admin.id}", headers=headers)
assert delete.status_code == 400
def test_lean_users_cannot_be_deleted(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
lean_role = db.query(Role).filter_by(name="lean").one()
lean_user = User(email="owner@hunterstockfeeds.com", name="Owner", role_id=lean_role.id, is_active=True)
db.add(lean_user)
db.commit()
listed = client.get("/api/access/users", headers=headers)
assert listed.status_code == 200
owner_row = next(row for row in listed.json() if row["id"] == lean_user.id)
assert owner_row["is_protected"] is True
response = client.delete(f"/api/access/users/{lean_user.id}", headers=headers)
assert response.status_code == 403
assert db.query(User).filter_by(id=lean_user.id).one_or_none() is not None
def test_assignable_roles_lists_all_roles(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
response = client.get("/api/access/assignable-roles", headers=headers)
assert response.status_code == 200
names = {row["name"] for row in response.json()}
assert names == set(ROLE_DEFINITIONS.keys())
def test_role_management_lists_modules_and_roles_for_admin(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
modules = client.get("/api/access/role-modules", headers=headers)
assert modules.status_code == 200
module_keys = {row["key"] for row in modules.json()}
assert {"dashboard", "mix_calculator", "roles", "settings"} <= module_keys
roles = client.get("/api/access/roles", headers=headers)
assert roles.status_code == 200
admin_role = next(row for row in roles.json() if row["name"] == "Admin")
assert admin_role["is_protected"] is True
assert admin_role["module_permissions"]["ordering"] == "manage"
assert admin_role["module_permissions"]["roles"] == "manage"
def test_role_management_is_blocked_for_non_admin_non_lean_roles(access_app_and_db):
client, db = access_app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
response = client.get("/api/access/roles", headers=headers)
assert response.status_code == 403
assert "lean and admin" in response.json()["detail"]
def test_role_management_create_update_delete_custom_role(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
created = client.post(
"/api/access/roles",
json={
"name": "Reporting Viewer",
"description": "Can review dashboards and reporting inputs",
"module_permissions": {
"dashboard": "view",
"products": "view",
"settings": "view",
},
},
headers=headers,
)
assert created.status_code == 201
created_body = created.json()
assert created_body["module_permissions"]["dashboard"] == "view"
assert created_body["module_permissions"]["products"] == "view"
assert created_body["module_permissions"]["settings"] == "view"
role_id = created_body["id"]
updated = client.patch(
f"/api/access/roles/{role_id}",
json={
"description": "Can review and edit product data",
"module_permissions": {
"dashboard": "view",
"products": "edit",
"settings": "view",
},
},
headers=headers,
)
assert updated.status_code == 200
updated_body = updated.json()
assert updated_body["module_permissions"]["products"] == "edit"
assert "edit_products" in updated_body["permissions"]
deleted = client.delete(f"/api/access/roles/{role_id}", headers=headers)
assert deleted.status_code == 204
assert db.query(Role).filter_by(id=role_id).one_or_none() is None
def test_protected_or_assigned_roles_cannot_be_deleted(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
admin_role = db.query(Role).filter_by(name="Admin").one()
protected = client.delete(f"/api/access/roles/{admin_role.id}", headers=headers)
assert protected.status_code == 403
full_access_role = db.query(Role).filter_by(name="Full Access").one()
assigned = client.delete(f"/api/access/roles/{full_access_role.id}", headers=headers)
assert assigned.status_code == 400
+88
View File
@@ -0,0 +1,88 @@
"""The change log records who edited a mix/ingredient and what changed.
Covers `record_change` / `diff_fields` / `list_changes`: edits are stored with a
field-level before/after diff and read back newest-first per entity.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import AuthSession
from app.db.session import Base
from app.services.change_log import (
ENTITY_INGREDIENT,
ENTITY_MIX,
diff_fields,
list_changes,
record_change,
)
TENANT = "hunter-premium-produce"
LABELS = {"name": "Name", "kg_per_unit": "Kg per unit", "status": "Status"}
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _actor() -> AuthSession:
return AuthSession(role="internal", email="lara@hunter.test", name="Lara", tenant_id=TENANT, client_role="admin")
def test_diff_fields_only_emits_changed_keys():
deltas = diff_fields({"name": "Maize", "kg_per_unit": 25.0}, {"name": "Maize", "kg_per_unit": 30.0}, LABELS)
assert len(deltas) == 1
assert deltas[0]["field"] == "kg_per_unit"
assert deltas[0]["before"] == "25"
assert deltas[0]["after"] == "30"
def test_records_and_lists_changes_newest_first():
db = _session()
session = _actor()
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=7,
action="created",
summary="Created ingredient “Maize”",
)
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=7,
action="updated",
summary="Updated Kg per unit",
changes=diff_fields({"kg_per_unit": 25.0}, {"kg_per_unit": 30.0}, LABELS),
)
# A different entity must not leak into entity 7's history.
record_change(db, session=session, entity_type=ENTITY_MIX, entity_id=7, action="created", summary="Created mix")
db.commit()
events = list_changes(db, tenant_id=TENANT, entity_type=ENTITY_INGREDIENT, entity_id=7)
assert [event.action for event in events] == ["updated", "created"]
assert events[0].actor_name == "Lara"
assert events[0].actor_role == "admin"
assert events[0].changes[0]["label"] == "Kg per unit"
def test_changes_are_tenant_scoped():
db = _session()
record_change(
db,
session=AuthSession(role="internal", email="x@y.test", name="X", tenant_id="other-tenant"),
entity_type=ENTITY_MIX,
entity_id=1,
action="created",
summary="Created mix",
)
db.commit()
assert list_changes(db, tenant_id=TENANT, entity_type=ENTITY_MIX, entity_id=1) == []
+231 -33
View File
@@ -1,23 +1,23 @@
from datetime import date
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, inspect, text
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import settings
from app.db.migrations import bootstrap_schema, sync_tenant_ids
from app.db.migrations import bootstrap_schema, sync_product_visibility, sync_tenant_ids
from app.db.session import Base
from app.main import app
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
from app.schemas.mix_calculator import MixCalculatorSessionCreate
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost, serialize_raw_material
from app.services.mix_calculator_service import calculate_mix_calculator_preview
from app.services.mix_calculator_service import build_mix_calculator_options, calculate_mix_calculator_preview
def build_session() -> Session:
@@ -97,12 +97,13 @@ def test_mix_and_product_cost_breakdown():
assert product_result["wholesale_price"] == 17.3268
def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
def test_mix_calculator_preview_prefers_product_specific_ingredients_and_warns_on_fractional_bags():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley])
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley, wheat])
db.flush()
mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
@@ -128,6 +129,25 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
bagging_process="standard_bagging",
)
db.add(product)
db.flush()
db.add_all(
[
ProductIngredient(
tenant_id="specialty-feeds",
product_id=product.id,
raw_material_id=maize.id,
quantity_kg=300,
sort_order=1,
),
ProductIngredient(
tenant_id="specialty-feeds",
product_id=product.id,
raw_material_id=wheat.id,
quantity_kg=200,
sort_order=2,
),
]
)
db.commit()
preview = calculate_mix_calculator_preview(
@@ -145,12 +165,179 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
assert preview["batch_size_kg"] == 550
assert preview["total_bags"] == 27.5
assert preview["lines"][0]["required_kg"] == 353.5714
assert preview["lines"][1]["required_kg"] == 196.4286
assert [line["raw_material_name"] for line in preview["lines"]] == ["Maize", "Wheat"]
assert preview["lines"][0]["required_kg"] == 330
assert preview["lines"][1]["required_kg"] == 220
assert len(preview["warnings"]) == 1
assert "not a whole-bag quantity" in preview["warnings"][0]
def test_mix_calculator_options_hide_invisible_products_and_clients():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley])
db.flush()
visible_mix = Mix(tenant_id="hunter-premium-produce", client_name="Peckish", name="Visible Mix", status="active", version=1)
hidden_mix = Mix(tenant_id="hunter-premium-produce", client_name="Chaff", name="Hidden Mix", status="active", version=1)
db.add_all([visible_mix, hidden_mix])
db.flush()
db.add_all(
[
MixIngredient(tenant_id="hunter-premium-produce", mix_id=visible_mix.id, raw_material_id=maize.id, quantity_kg=20),
MixIngredient(tenant_id="hunter-premium-produce", mix_id=hidden_mix.id, raw_material_id=maize.id, quantity_kg=20),
]
)
db.flush()
db.add_all(
[
Product(
tenant_id="hunter-premium-produce",
client_name="Peckish",
name="Visible Product",
mix_id=visible_mix.id,
visible=True,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
),
Product(
tenant_id="hunter-premium-produce",
client_name="Chaff",
name="Hidden Product",
mix_id=hidden_mix.id,
visible=False,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
),
]
)
db.flush()
visible_product = db.scalar(select(Product).where(Product.name == "Visible Product"))
assert visible_product is not None
db.add_all(
[
ProductIngredient(
tenant_id="hunter-premium-produce",
product_id=visible_product.id,
raw_material_id=maize.id,
quantity_kg=12,
sort_order=1,
),
ProductIngredient(
tenant_id="hunter-premium-produce",
product_id=visible_product.id,
raw_material_id=barley.id,
quantity_kg=8,
sort_order=2,
),
]
)
db.commit()
options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
assert options["clients"] == ["Peckish"]
assert [product["product_name"] for product in options["products"]] == ["Visible Mix"]
assert options["products"][0]["mix_total_kg"] == 20
def test_calculate_product_cost_prefers_product_specific_ingredients():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
wheat.price_versions.append(RawMaterialPriceVersion(market_value=600, waste_percentage=0.01, effective_date=date(2026, 4, 1)))
db.add_all([maize, barley, wheat])
db.flush()
mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
db.add(mix)
db.flush()
db.add_all(
[
MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100),
]
)
db.add(ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0))
db.add(PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63))
db.add(FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45))
db.flush()
product = Product(
client_name="Specialty Feeds",
name="Specialty Pigeon Breeder 20kg",
mix_id=mix.id,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
bagging_process="standard_bagging",
distributor_margin=0.225,
wholesale_margin=0.18,
)
db.add(product)
db.flush()
db.add_all(
[
ProductIngredient(product_id=product.id, raw_material_id=maize.id, quantity_kg=300, sort_order=1),
ProductIngredient(product_id=product.id, raw_material_id=wheat.id, quantity_kg=200, sort_order=2),
]
)
db.commit()
product_result = calculate_product_cost(db, product.id)
assert product_result["finished_product_delivered"] == 15.192
assert product_result["distributor_price"] == 19.6026
assert product_result["wholesale_price"] == 18.5268
def test_sync_product_visibility_hides_configured_clients():
engine = create_engine("sqlite:///:memory:")
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE products (
id INTEGER PRIMARY KEY,
client_name VARCHAR(255),
visible BOOLEAN NOT NULL DEFAULT TRUE
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO products (id, client_name, visible)
VALUES
(1, 'Chaff', TRUE),
(2, 'Peckish', TRUE),
(3, 'Uncategorized', TRUE)
"""
)
)
updated = sync_product_visibility(engine)
assert updated == 2
with engine.connect() as connection:
rows = connection.execute(text("SELECT client_name, visible FROM products ORDER BY id")).all()
assert rows == [("Chaff", 0), ("Peckish", 1), ("Uncategorized", 0)]
def test_root_and_login_endpoints():
with TestClient(app) as client:
root_response = client.get("/")
@@ -260,16 +447,15 @@ def test_client_access_endpoints():
"/api/auth/admin/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
token = login_response.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
admin_cookies = {settings.admin_session_cookie_name: login_response.cookies.get(settings.admin_session_cookie_name)}
access_response = client.get("/api/client-access", headers=headers)
access_response = client.get("/api/client-access", cookies=admin_cookies)
assert access_response.status_code == 200
assert len(access_response.json()) >= 1
assert "audit_history" in access_response.json()[0]
assert "module_permissions" in access_response.json()[0]["users"][0]
export_response = client.get("/api/powerbi/client-access", headers=headers)
export_response = client.get("/api/powerbi/client-access", cookies=admin_cookies)
assert export_response.status_code == 200
assert "client_rows" in export_response.json()
assert "permission_rows" in export_response.json()
@@ -278,8 +464,8 @@ def test_client_access_endpoints():
"/api/auth/client/login",
json={"email": settings.client_email, "password": settings.client_password},
)
client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"}
superadmin_access_response = client.get("/api/client-access", headers=client_headers)
client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)}
superadmin_access_response = client.get("/api/client-access", cookies=client_cookies)
assert superadmin_access_response.status_code == 200
assert len(superadmin_access_response.json()) == 1
@@ -291,13 +477,23 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
json={"email": settings.client_email, "password": settings.client_password},
)
assert superadmin_login.status_code == 200
superadmin_headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"}
superadmin_cookies = {settings.session_cookie_name: superadmin_login.cookies.get(settings.session_cookie_name)}
options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers)
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
assert options_response.status_code == 200
options_payload = options_response.json()
assert len(options_payload["products"]) >= 100
seeded_product = next(product for product in options_payload["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg")
# 83 product-backed mixes + 1 formula-only mix ("Hi Carb Popcorn", which
# has a mix-master formula but no product yet, surfaced via a negative
# product_id sentinel so a new mix is usable before a product is linked).
assert len(options_payload["products"]) == 84 + 1
formula_only = [product for product in options_payload["products"] if product["product_id"] < 0]
assert len(formula_only) == 1
assert formula_only[0]["unit_size_kg"] == 0
seeded_product = next(
product
for product in options_payload["products"]
if product["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix"
)
assert seeded_product["unit_size_kg"] == 20
create_response = client.post(
@@ -310,7 +506,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
"prepared_by_name": "Amelia Hart",
"notes": "Morning production run",
},
headers=superadmin_headers,
cookies=superadmin_cookies,
)
assert create_response.status_code == 201
created = create_response.json()
@@ -323,7 +519,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
patch_response = client.patch(
f"/api/mix-calculator/{created['id']}",
json={"batch_size_kg": 550},
headers=superadmin_headers,
cookies=superadmin_cookies,
)
assert patch_response.status_code == 200
assert patch_response.json()["total_bags"] == 27.5
@@ -334,13 +530,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password},
)
assert operator_login.status_code == 200
operator_headers = {"Authorization": f"Bearer {operator_login.json()['token']}"}
operator_cookies = {settings.session_cookie_name: operator_login.cookies.get(settings.session_cookie_name)}
operator_list_response = client.get("/api/mix-calculator", headers=operator_headers)
operator_list_response = client.get("/api/mix-calculator", cookies=operator_cookies)
assert operator_list_response.status_code == 200
assert operator_list_response.json() == []
operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", headers=operator_headers)
operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", cookies=operator_cookies)
assert operator_detail_response.status_code == 404
@@ -350,11 +546,13 @@ def test_mix_calculator_pdf_endpoint_returns_pdf():
"/api/auth/client/login",
json={"email": settings.client_email, "password": settings.client_password},
)
headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"}
superadmin_cookies = {settings.session_cookie_name: superadmin_login.cookies.get(settings.session_cookie_name)}
options_response = client.get("/api/mix-calculator/options", headers=headers)
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
seeded_product = next(
product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg"
product
for product in options_response.json()["products"]
if product["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix"
)
create_response = client.post(
@@ -367,11 +565,11 @@ def test_mix_calculator_pdf_endpoint_returns_pdf():
"prepared_by_name": "Amelia Hart",
"notes": "Morning production run",
},
headers=headers,
cookies=superadmin_cookies,
)
created = create_response.json()
pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", headers=headers)
pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", cookies=superadmin_cookies)
assert pdf_response.status_code == 200
assert pdf_response.headers["content-type"] == "application/pdf"
@@ -385,8 +583,8 @@ def test_module_permission_blocks_client_module_access():
"/api/auth/admin/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"}
access_response = client.get("/api/client-access", headers=admin_headers)
admin_cookies = {settings.admin_session_cookie_name: admin_login_response.cookies.get(settings.admin_session_cookie_name)}
access_response = client.get("/api/client-access", cookies=admin_cookies)
first_client = access_response.json()[0]
first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email)
@@ -396,15 +594,15 @@ def test_module_permission_blocks_client_module_access():
client.patch(
f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}",
json={"access_level": "none"},
headers=admin_headers,
cookies=admin_cookies,
)
client_login_response = client.post(
"/api/auth/client/login",
json={"email": settings.client_email, "password": settings.client_password},
)
client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"}
raw_materials_response = client.get("/api/raw-materials", headers=client_headers)
client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)}
raw_materials_response = client.get("/api/raw-materials", cookies=client_cookies)
assert raw_materials_response.status_code == 403
+240
View File
@@ -0,0 +1,240 @@
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
whose product carries its own formula resolves to that product (not the shared
mix master), percentages are computed against the total, and the representative
product is chosen the way the calculator chooses it.
"""
from __future__ import annotations
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
import pytest
from fastapi import HTTPException
from app.api.deps import AuthSession
from app.api.editor import delete_editor_mix, replace_editor_mix_formula
from app.db.session import Base
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 EditorMixFormulaReplace, EditorMixFormulaRowInput
from app.services.mix_calculator_service import (
resolve_editor_mix_formula,
resolve_representative_product,
)
TENANT = "hunter-premium-produce"
def _editor_session() -> AuthSession:
return AuthSession(
role="internal",
email="editor@hunter.test",
name="Editor",
tenant_id=TENANT,
client_role="admin",
user_id=1,
)
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _raw(db: Session, name: str) -> RawMaterial:
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
db.add(material)
db.flush()
return material
def test_resolves_product_formula_not_mix_master():
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
db.add(mix)
db.flush()
# Shared mix master says something different from the product formula.
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add(product)
db.flush()
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "product"
assert formula["product_id"] == product.id
assert formula["total_kg"] == 1320.41
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
assert by_name["Bayley"]["quantity_kg"] == 787.5
# Percentage matches the worked example (787.5 / 1320.41 * 100).
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
def test_falls_back_to_mix_master_when_no_product_formula():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "mix"
assert formula["total_kg"] == 50
assert formula["ingredients"][0]["mix_percentage"] == 100.0
def test_replace_mix_master_formula_returns_fresh_rows():
"""PUT formula on a mix without a product writes the mix master and the
response reflects the just-saved rows (not the stale pre-save collection).
Regression: the diff path read the resolved formula by attribute, but the
resolver returns dicts, which raised AttributeError -> HTTP 500 on save.
"""
db = _session()
maize = _raw(db, "Maize")
barley = _raw(db, "Barley")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100))
db.commit()
# Percentages need not total 100% — kg is canonical.
payload = EditorMixFormulaReplace(
rows=[
EditorMixFormulaRowInput(raw_material_id=maize.id, quantity_kg=330.0, notes=None),
EditorMixFormulaRowInput(raw_material_id=barley.id, quantity_kg=140.0, notes="confirmed"),
]
)
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
assert result["source"] == "mix"
assert result["total_kg"] == 470.0
by_name = {row["raw_material_name"]: row for row in result["ingredients"]}
assert by_name["Maize"]["quantity_kg"] == 330.0
assert by_name["Barley"]["quantity_kg"] == 140.0
persisted = db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all()
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
(maize.id, 330.0),
(barley.id, 140.0),
]
def test_replace_product_formula_writes_product_ingredients():
"""When a representative product owns the formula, PUT replaces the product's
ingredients (the source the calculator reads) and returns the fresh rows."""
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
canola = _raw(db, "Canola")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
db.add(mix)
db.flush()
product = Product(
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
unit_of_measure="20kg bag", visible=True,
)
db.add(product)
db.flush()
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=10, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=10, sort_order=2))
db.commit()
payload = EditorMixFormulaReplace(
rows=[
EditorMixFormulaRowInput(raw_material_id=bayley.id, quantity_kg=600.0, notes=None),
EditorMixFormulaRowInput(raw_material_id=canola.id, quantity_kg=200.0, notes=None),
]
)
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
assert result["source"] == "product"
assert result["product_id"] == product.id
assert result["total_kg"] == 800.0
persisted = db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
# Filler dropped, Canola added; mix master is untouched.
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
(bayley.id, 600.0),
(canola.id, 200.0),
]
def test_delete_mix_without_products_removes_mix_and_ingredients():
"""A product-less mix can be deleted; its ingredient rows cascade away."""
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
mix_id = mix.id
delete_editor_mix(mix_id, session=_editor_session(), db=db)
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is None
assert db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix_id)).first() is None
def test_delete_mix_with_products_is_refused():
"""A mix that still drives products can't be deleted (409) — products must
keep a mix, so the user marks it inactive instead."""
db = _session()
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
db.add(mix)
db.flush()
db.add(
Product(
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
unit_of_measure="20kg bag", visible=True,
)
)
db.commit()
mix_id = mix.id
with pytest.raises(HTTPException) as excinfo:
delete_editor_mix(mix_id, session=_editor_session(), db=db)
assert excinfo.value.status_code == 409
assert "linked product" in excinfo.value.detail
# The mix is left intact.
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is not None
def test_representative_product_prefers_20kg_bag():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
db.add(mix)
db.flush()
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add_all([bulka, bag])
db.flush()
for product in (bulka, bag):
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
db.commit()
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
assert representative is not None
assert representative.unit_of_measure == "20kg bag"
+249
View File
@@ -0,0 +1,249 @@
"""End-to-end ordering flow test exercising the acceptance criteria.
Drives the real FastAPI app via TestClient against an in-memory database, using
directly-issued auth tokens (bypassing the password-based login endpoints).
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.access import INTERNAL_USER_SUBJECT
from app.core.config import settings
from app.core.security import issue_token
from app.db.session import Base, get_db
from app.main import app
from app.models.access import User
from app.models.client_access import ClientUser
from app.seed_access import seed_access
from app.services import ordering_service as svc
ORDERING_TENANT = svc.ORDERING_TENANT
@pytest.fixture()
def client():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
test_client._session_factory = TestingSession # type: ignore[attr-defined]
yield test_client
app.dependency_overrides.clear()
def _admin_headers() -> dict[str, str]:
token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
return {"Authorization": f"Bearer {token}"}
def _customer_headers(db_session_factory, *, account_id: int) -> dict[str, str]:
db = db_session_factory()
user = db.scalar(select(ClientUser).where(ClientUser.client_account_id == account_id))
payload = {
"name": user.full_name,
"email": user.email,
"role": "client",
"tenant_id": user.tenant_id,
"client_role": user.role,
"user_id": user.id,
"client_account_id": user.client_account_id,
}
db.close()
token = issue_token(payload)
return {"Authorization": f"Bearer {token}"}
def test_full_b2b_ordering_flow(client):
admin = _admin_headers()
# 1. Admin creates a customer.
r = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Acme Farms", "client_code": "ACME"})
assert r.status_code == 201, r.text
customer = r.json()
customer_id = customer["id"]
# 2. Admin creates a user for that customer.
r = client.post(
f"/api/ordering-admin/customers/{customer_id}/users",
headers=admin,
json={"full_name": "Buyer Bob", "email": "bob@acme.test", "role": "buyer"},
)
assert r.status_code == 201, r.text
# 3. Admin creates products.
r = client.post(
"/api/ordering-admin/products",
headers=admin,
json={"name": "Maize 1T", "sku": "MAIZE-1T", "category": "grains", "base_price": 100.0, "min_order_quantity": 1},
)
assert r.status_code == 201, r.text
product = r.json()
product_id = product["id"]
r = client.post(
"/api/ordering-admin/products",
headers=admin,
json={"name": "Hidden Blend", "sku": "HIDDEN-1", "category": "custom_blends", "base_price": 50.0},
)
hidden_product_id = r.json()["id"]
# 4. Admin assigns a customer-specific price (contract @ 92.5).
r = client.put(
f"/api/ordering-admin/customers/{customer_id}/product-prices",
headers=admin,
json={"product_id": product_id, "unit_price": 92.5, "rule_type": "contract"},
)
assert r.status_code == 200, r.text
# 4b. Admin hides the second product from this customer.
r = client.put(
f"/api/ordering-admin/customers/{customer_id}/visibility",
headers=admin,
json={"product_id": hidden_product_id, "visible": False},
)
assert r.status_code == 200, r.text
# 5. Customer logs in (token) and sees only their available product + price.
cust = _customer_headers(client._session_factory, account_id=customer_id)
r = client.get("/api/ordering/catalogue", headers=cust)
assert r.status_code == 200, r.text
catalogue = r.json()
skus = {p["sku"] for p in catalogue}
assert skus == {"MAIZE-1T"} # hidden product is not visible
maize = catalogue[0]
assert maize["price"]["unit_price"] == 92.5
assert maize["price"]["price_source"] == "contract"
# 6. Customer creates a draft order.
r = client.post(
"/api/ordering/orders",
headers=cust,
json={"lines": [{"product_id": product_id, "quantity": 3}], "fulfilment_method": "delivery"},
)
assert r.status_code == 201, r.text
draft = r.json()
assert draft["status"] == "draft"
assert draft["subtotal_ex_gst"] == 277.5 # 3 * 92.5
order_id = draft["id"]
# Minimum-order-quantity validation.
r = client.post(
"/api/ordering/orders",
headers=cust,
json={"lines": [{"product_id": product_id, "quantity": 0.5}]},
)
# quantity below MOQ of 1 → caught by schema (gt=0 ok) then MOQ check 422.
assert r.status_code == 422
# 7. Customer submits the order — price is frozen on the line.
r = client.post(f"/api/ordering/orders/{order_id}/submit", headers=cust, json={"purchase_order_number": "PO-77"})
assert r.status_code == 200, r.text
submitted = r.json()
assert submitted["status"] == "submitted"
assert submitted["order_number"].startswith("ORD-")
assert submitted["lines"][0]["unit_price"] == 92.5
assert submitted["purchase_order_number"] == "PO-77"
# Customer can no longer edit a submitted order.
r = client.patch(f"/api/ordering/orders/{order_id}", headers=cust, json={"delivery_notes": "late edit"})
assert r.status_code == 409
# 8. Admin sees the submitted order and manages it.
r = client.get("/api/ordering-admin/orders", headers=admin)
assert r.status_code == 200, r.text
admin_orders = r.json()
assert any(o["id"] == order_id for o in admin_orders)
assert admin_orders[0]["customer_name"] == "Acme Farms"
# Status progression with lifecycle enforcement.
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "confirmed"})
assert r.status_code == 200, r.text
# Illegal jump is rejected.
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "completed"})
assert r.status_code == 409
# 9. Xero submission (stub) succeeds and records an invoice id.
r = client.post(f"/api/ordering-admin/orders/{order_id}/send-to-xero", headers=admin)
assert r.status_code == 200, r.text
xero = r.json()
assert xero["xero_result"]["status"] == "success"
assert xero["xero_result"]["stubbed"] is True
assert xero["xero_invoice_id"].startswith("STUB-INV-")
assert xero["raw_status"] == "sent_to_xero"
def test_customer_isolation_between_companies(client):
admin = _admin_headers()
# Two customers, each with a user.
a = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Alpha", "client_code": "ALPHA"}).json()
b = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Beta", "client_code": "BETA"}).json()
client.post(f"/api/ordering-admin/customers/{a['id']}/users", headers=admin, json={"full_name": "A1", "email": "a1@alpha.test", "role": "owner"})
client.post(f"/api/ordering-admin/customers/{b['id']}/users", headers=admin, json={"full_name": "B1", "email": "b1@beta.test", "role": "owner"})
product = client.post("/api/ordering-admin/products", headers=admin, json={"name": "Barley 1T", "sku": "BAR-1T", "category": "grains", "base_price": 80.0}).json()
headers_a = _customer_headers(client._session_factory, account_id=a["id"])
headers_b = _customer_headers(client._session_factory, account_id=b["id"])
# Alpha creates and submits an order.
order = client.post("/api/ordering/orders", headers=headers_a, json={"lines": [{"product_id": product["id"], "quantity": 2}]}).json()
# Beta must not be able to read Alpha's order.
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_b)
assert r.status_code == 404
# Alpha can.
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_a)
assert r.status_code == 200
def test_internal_staff_can_manage_orders(client):
# Internal Hunter Stock Feeds staff (not the legacy /admin login) manage the
# ordering portal via the client/internal session + manage_ordering perm.
db = client._session_factory()
seed_access(db)
db.commit()
admin_user = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
user_id = admin_user.id
db.close()
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user_id})
headers = {"Authorization": f"Bearer {token}"}
# Can list customers and create catalogue products through the admin API.
assert client.get("/api/ordering-admin/customers", headers=headers).status_code == 200
r = client.post(
"/api/ordering-admin/products",
headers=headers,
json={"name": "Staff Wheat", "sku": "WHEAT-STAFF", "category": "grains", "base_price": 30.0},
)
assert r.status_code == 201, r.text
def test_disabled_customer_cannot_order(client):
admin = _admin_headers()
cust = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Gamma", "client_code": "GAMMA"}).json()
client.post(f"/api/ordering-admin/customers/{cust['id']}/users", headers=admin, json={"full_name": "G1", "email": "g1@gamma.test", "role": "buyer"})
client.post("/api/ordering-admin/products", headers=admin, json={"name": "Oats 1T", "sku": "OATS-1T", "category": "grains", "base_price": 70.0})
# Disable the customer account.
r = client.patch(f"/api/ordering-admin/customers/{cust['id']}", headers=admin, json={"status": "disabled"})
assert r.status_code == 200
headers = _customer_headers(client._session_factory, account_id=cust["id"])
r = client.get("/api/ordering/catalogue", headers=headers)
assert r.status_code == 403
+156
View File
@@ -0,0 +1,156 @@
"""Unit tests for the backend-owned ordering pricing engine."""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.session import Base
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
PriceList,
PriceListItem,
PriceTier,
)
from app.services.ordering_pricing import resolve_price
CUSTOMER_ID = 1
TENANT = "test-tenant"
def _session() -> Session:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _product(db: Session, **overrides) -> CatalogueProduct:
values = {
"tenant_id": TENANT,
"name": "Maize 1T",
"sku": "MAIZE-1T",
"category": "grains",
"base_price": 100.0,
"min_order_quantity": 1.0,
"requires_quote": False,
}
values.update(overrides)
product = CatalogueProduct(**values)
db.add(product)
db.flush()
return product
def test_base_price_with_no_rules():
db = _session()
product = _product(db)
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=2)
assert res.unit_price == 100.0
assert res.price_source == "base"
assert res.requires_quote is False
assert res.line_total(2) == 200.0
def test_customer_discount_applies_to_base():
db = _session()
product = _product(db)
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, discount_percent=10.0))
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.unit_price == 90.0
assert res.price_source == "base"
assert res.discount_percent == 10.0
def test_price_list_overrides_base():
db = _session()
product = _product(db)
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
db.add(pl)
db.flush()
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id, discount_percent=10.0))
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
# Price list wins over base+discount.
assert res.unit_price == 80.0
assert res.price_source == "price_list"
def test_customer_fixed_price_wins_over_price_list():
db = _session()
product = _product(db)
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
db.add(pl)
db.flush()
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id))
db.add(
CustomerProductPrice(
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=72.5, rule_type="contract"
)
)
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.unit_price == 72.5
assert res.price_source == "contract"
def test_quantity_tier_wins_when_threshold_met():
db = _session()
product = _product(db)
cpp = CustomerProductPrice(
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=90.0, rule_type="fixed"
)
db.add(cpp)
db.flush()
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=10, unit_price=85.0))
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=50, unit_price=80.0))
db.flush()
# Below first tier → flat fixed price.
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=5).unit_price == 90.0
# Meets first tier.
mid = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=10)
assert mid.unit_price == 85.0
assert mid.price_source == "tiered"
# Meets highest tier.
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=60).unit_price == 80.0
def test_product_requires_quote():
db = _session()
product = _product(db, requires_quote=True)
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.requires_quote is True
assert res.unit_price is None
assert res.price_source == "quote"
assert res.line_total(5) is None
def test_customer_quote_rule_forces_quote():
db = _session()
product = _product(db)
db.add(
CustomerProductPrice(
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=None, rule_type="quote"
)
)
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.requires_quote is True
assert res.price_source == "quote"
def test_no_price_falls_back_to_quote():
db = _session()
product = _product(db, base_price=None)
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.requires_quote is True
assert res.price_source == "quote"
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
Drives the real FastAPI app via TestClient against an in-memory database, and
swaps the configured API key in by replacing the module-level `settings` object
(the real one is a frozen dataclass).
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api import public_v1
from app.core.config import settings
from app.db.session import Base, get_db
from app.main import app
from app.models.throughput import ProductionThroughput
API_KEY = "test-powerbi-key"
TENANT = "hunter-premium-produce"
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
db.add(
ProductionThroughput(
tenant_id=tenant_id,
production_date=when,
product_name_snapshot=product,
bag_size=20,
quantity=quantity,
quantity_type="bags",
calculated_kg=quantity * 20,
created_by="test",
)
)
@pytest.fixture()
def db_factory():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
yield TestingSession
app.dependency_overrides.clear()
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
# Replace the whole settings object the router reads (frozen dataclass).
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
return TestClient(app)
def test_disabled_when_key_unset(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key="")
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
assert response.status_code == 503
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key=API_KEY)
assert client.get("/api/v1/throughput").status_code == 401
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
def test_returns_entries_for_tenant(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
# An entry in another tenant must never leak through.
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
# Header auth.
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
assert response.status_code == 200
rows = response.json()
names = [row["product_name_snapshot"] for row in rows]
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
assert rows[0]["calculated_kg"] == 200.0
# Query-string auth works too (Power BI Web connector convenience).
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
def test_date_filter(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
+169
View File
@@ -0,0 +1,169 @@
from app.services.product_costing_service import (
ProductCostAssumptions,
ProductCostInputItem,
calculate_product_cost_item,
)
def assumptions() -> ProductCostAssumptions:
return ProductCostAssumptions(
grading_per_kg=0.05,
cracking_per_kg=0.03,
process_costs={
"Bagging + Grading": 0.04,
"Standard Bagging": 0.02,
"PHF Horse Mixes": 0.01,
"Peckish": 0.08,
"Hay & Straw": 0.09,
},
client_margins={
"Specialty": {"distributor_margin": 0.2, "wholesale_margin": 0.1},
"Peckish": {"distributor_margin": 0.25, "wholesale_margin": 0.15},
"Hay & Straw": {"distributor_margin": 0.3, "wholesale_margin": 0.2},
"Straight Grain": {"distributor_margin": 0.1, "wholesale_margin": 0.08},
"PHF Horse Mixes": {"distributor_margin": 0.18, "wholesale_margin": 0.12},
},
bag_costs={
"20kg_bag": 0.6,
"bulka_bag": 22.0,
"own_bag_credit": 0.2,
"1_5kg_bagging": 0.35,
"peckish_bag": 0.4,
},
freight_costs={
"freight_per_pallet": 80.0,
"peckish_freight_per_pallet": 96.0,
"hay_straw_freight_per_pallet": 120.0,
},
)
def item(**overrides) -> ProductCostInputItem:
values = {
"client_category": "Specialty",
"product_name": "Pigeon Mix 20kg",
"mix_product_name": "Pigeon Mix",
"unit_type": "Standard",
"own_bag": None,
"unit_kg": 20.0,
"items_per_pallet": 40,
"bagging_process": "Bagging + Grading",
"manual_distributor_margin": None,
"manual_wholesale_margin": None,
}
values.update(overrides)
return ProductCostInputItem(**values)
def test_standard_product_uses_per_kg_components_unit_bag_freight_and_default_margins():
result = calculate_product_cost_item(item(), assumptions(), 0.5)
assert result.grading_cost_per_kg == 0.05
assert result.bagging_cost_per_kg == 0.04
assert result.bag_cost_per_unit == 0.6
assert result.freight_cost_per_unit == 2.0
assert result.finished_product_delivered_cost == 14.4
assert result.distributor_price == 18.0
assert result.wholesale_price == 16.0
assert result.warnings == []
def test_bulka_uses_per_kg_delivered_formula_and_bulka_bag_and_freight_divided_by_unit_kg():
result = calculate_product_cost_item(item(unit_type="Bulka", unit_kg=1000, items_per_pallet=1), assumptions(), 0.5)
assert result.bag_cost_per_unit == 0.022
assert result.freight_cost_per_unit == 0.08
assert result.finished_product_delivered_cost == 0.692
def test_per_unit_uses_per_kg_delivered_formula_but_standard_pallet_freight():
result = calculate_product_cost_item(item(unit_type="Per Unit", unit_kg=1, items_per_pallet=10), assumptions(), 0.5)
assert result.freight_cost_per_unit == 8.0
assert result.finished_product_delivered_cost == 8.59
def test_peckish_uses_peckish_bag_freight_and_zero_grading():
result = calculate_product_cost_item(
item(client_category="Peckish", bagging_process="Peckish", items_per_pallet=24),
assumptions(),
0.5,
)
assert result.grading_cost_per_kg == 0
assert result.bagging_cost_per_kg == 0.08
assert result.bag_cost_per_unit == 0.4
assert result.freight_cost_per_unit == 4.0
def test_hay_and_straw_uses_hay_freight_and_zero_grading():
result = calculate_product_cost_item(
item(client_category="Hay & Straw", bagging_process="Hay & Straw", items_per_pallet=30),
assumptions(),
0.5,
)
assert result.grading_cost_per_kg == 0
assert result.freight_cost_per_unit == 4.0
def test_phf_horse_mixes_have_zero_grading():
result = calculate_product_cost_item(
item(client_category="PHF Horse Mixes", bagging_process="PHF Horse Mixes"),
assumptions(),
0.5,
)
assert result.grading_cost_per_kg == 0
def test_own_bag_subtracts_credit_and_no_bag_sets_bag_cost_to_zero():
own_bag = calculate_product_cost_item(item(own_bag="Yes"), assumptions(), 0.5)
no_bag = calculate_product_cost_item(item(own_bag="No Bag"), assumptions(), 0.5)
assert own_bag.bag_cost_per_unit == 0.4
assert no_bag.bag_cost_per_unit == 0
def test_one_point_five_kg_branch_multiplies_pack_formula_by_eight():
result = calculate_product_cost_item(item(unit_type="1.5 kg", unit_kg=1.5), assumptions(), 0.5)
assert result.bag_cost_per_unit == 0.35
assert result.finished_product_delivered_cost == 10.84
def test_cracked_product_adds_cracking_cost_and_manual_margins_override_defaults():
result = calculate_product_cost_item(
item(product_name="Cracked Maize 20kg", manual_distributor_margin=0.1, manual_wholesale_margin=0.05),
assumptions(),
0.5,
)
assert result.cracking_cost_per_kg == 0.03
assert result.distributor_price == 16.6667
assert result.wholesale_price == 15.8
def test_straight_grain_bulka_wholesale_rounds_up_to_two_decimals():
result = calculate_product_cost_item(
item(client_category="Straight Grain", unit_type="Bulka", unit_kg=1000, items_per_pallet=1),
assumptions(),
0.5,
)
assert result.wholesale_price == 0.76
def test_missing_lookup_and_invalid_inputs_generate_warnings_without_prices():
result = calculate_product_cost_item(
item(unit_kg=None, items_per_pallet=0, manual_distributor_margin=1.2),
assumptions(),
None,
)
assert "Missing mix/product cost lookup" in result.warnings
assert "Missing unit kg" in result.warnings
assert "Missing pallet quantity" in result.warnings
assert "Invalid distributor margin" in result.warnings
assert result.finished_product_delivered_cost is None
assert result.distributor_price is None
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import create_engine, inspect
from sqlalchemy.pool import StaticPool
import app.models # noqa: F401 - import all model modules before reading metadata
from app.db.session import Base
def test_fresh_sqlite_schema_matches_model_metadata():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
inspector = inspect(engine)
actual_tables = set(inspector.get_table_names())
expected_tables = set(Base.metadata.tables)
assert actual_tables == expected_tables
for table_name, table in Base.metadata.tables.items():
actual_columns = {column["name"] for column in inspector.get_columns(table_name)}
expected_columns = {column.name for column in table.columns}
assert actual_columns == expected_columns
+108
View File
@@ -0,0 +1,108 @@
"""Regression guards for the print/PDF Content-Security-Policy.
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
which silently blocks the frame and breaks printing for every user (regardless of
role). These tests pin the policy on both layers that emit it:
* the FastAPI security middleware (covers every API response), and
* the production nginx config (the source of the *document* CSP that actually
governs ``frame-src`` in the browser).
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.core.security import issue_token
from app.main import app
REPO_ROOT = Path(__file__).resolve().parents[2]
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
# Directives the print flow depends on. blob: must be framable, and that must not
# come at the cost of dropping the same-origin baseline.
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
def _parse_csp(header: str) -> dict[str, set[str]]:
"""Parse a CSP header string into ``{directive: {sources}}``."""
directives: dict[str, set[str]] = {}
for part in header.split(";"):
tokens = part.split()
if not tokens:
continue
directives[tokens[0].lower()] = set(tokens[1:])
return directives
def _assert_blob_framing(header: str) -> None:
csp = _parse_csp(header)
# frame-src must exist and allow self + blob (no falling back to default-src).
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
)
# child-src is the Safari fallback for frame-src; keep it aligned.
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
# We only widened framing: the same-origin default must stay intact.
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
# --- Backend middleware policy ------------------------------------------------
@pytest.fixture()
def client() -> TestClient:
with TestClient(app) as test_client:
yield test_client
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
response = client.get("/health")
assert "content-security-policy" in response.headers
_assert_blob_framing(response.headers["content-security-policy"])
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
"""The policy is identical for anonymous, authenticated, and rejected (401)
requests, so printing can never depend on who is signed in."""
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
responses = [
client.get("/health"), # anonymous
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
]
policies = set()
for response in responses:
header = response.headers.get("content-security-policy")
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
_assert_blob_framing(header)
policies.add(header)
assert len(policies) == 1, "CSP must not vary by authentication state"
# --- Production document policy (nginx) ---------------------------------------
def test_nginx_csp_allows_blob_frames() -> None:
"""Every CSP the production nginx emits must allow blob framing. This guards
the *document* policy, which is what the browser enforces for the print iframe."""
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
for config_path in NGINX_CONFIGS:
assert config_path.exists(), f"missing nginx config: {config_path}"
text = config_path.read_text(encoding="utf-8")
policies = csp_line.findall(text)
assert policies, f"no Content-Security-Policy header found in {config_path}"
for policy in policies:
_assert_blob_framing(policy)
+342
View File
@@ -0,0 +1,342 @@
from datetime import date
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.db.session import Base
from app.models.mix import Mix
from app.models.product import Product
from app.models.raw_material import RawMaterial
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed import seed_throughput_products_from_costing
from app.services.throughput_service import (
calculate_kg,
import_entries_from_file,
import_names_sheet,
import_production_sheet,
normalise_staff_name,
qa_passed,
)
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _costing_mix(db: Session, tenant_id: str = "hunter-premium-produce") -> Mix:
raw_material = RawMaterial(
tenant_id=tenant_id,
name="Maize",
unit_of_measure="tonne",
kg_per_unit=1000,
status="active",
)
db.add(raw_material)
db.flush()
mix = Mix(tenant_id=tenant_id, client_name="Hunter", name="Maize Mix")
db.add(mix)
db.flush()
return mix
def test_calculate_kg_bags():
assert calculate_kg(10, "bags", 20) == 200.0
def test_calculate_kg_kg_ignores_bag_size():
assert calculate_kg(550, "kg", None) == 550.0
assert calculate_kg(550, "kg", 20) == 550.0
def test_calculate_kg_zero_quantity():
assert calculate_kg(0, "bags", 20) == 0.0
assert calculate_kg(None, "bags", 20) == 0.0
def test_staff_name_normalisation():
assert normalise_staff_name(" Jake ") == "Jake"
assert normalise_staff_name("jake smith") == "jake smith"
assert normalise_staff_name("") is None
assert normalise_staff_name(None) is None
def test_qa_passed_flag():
entry = ProductionThroughput(
production_date=date(2026, 1, 1),
product_name_snapshot="X",
quantity=1,
quantity_type="bags",
scales_checked=True,
label_correct=True,
bag_sealed=True,
pallet_good_condition=True,
)
assert qa_passed(entry) is True
entry.bag_sealed = False
assert qa_passed(entry) is False
def _make_workbook() -> BytesIO:
wb = Workbook()
names = wb.active
names.title = "Names"
names.append(["Name", "Item ID"])
names.append(["Whole Wheat 20kg", 1001])
names.append(["Bulka Maize", 1002])
production = wb.create_sheet("Production")
production.append(["#VALUE!", "Operations Throughput"])
production.append([None] * 8 + ["TEST WEIGHT"])
production.append([
"DATE", "GRAIN", "BAG SIZE", "SCALES", "LABEL", "SEALED", "PALLET",
"BOX", 1, 2, 3, 4, 5, "QTY", "STAFF", "NOTES",
])
production.append([date(2026, 4, 1), "Whole Wheat 20kg", 20, True, True, True, True, None, None, None, None, None, None, 100, " Jake ", None])
production.append([date(2026, 4, 1), "Bulka Maize", None, True, True, False, True, "B7", None, None, None, None, None, 1500, "Alex", "ok"])
production.append([date(2026, 4, 2), "Whole Wheat 20kg", 20, False, True, True, True, None, None, None, None, None, None, 50, "Jake", None])
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return buf
def test_import_names_and_production():
from openpyxl import load_workbook
db = _session()
wb = load_workbook(_make_workbook(), data_only=True)
created, _ = import_names_sheet(db, wb, "test-tenant")
assert created == 2
imported, skipped = import_production_sheet(db, wb, "test-tenant")
assert imported == 3
assert skipped == 0
entries = db.scalars(select(ProductionThroughput).order_by(ProductionThroughput.id)).all()
bags_entry = entries[0]
assert bags_entry.quantity_type == "bags"
assert bags_entry.calculated_kg == 2000.0
assert bags_entry.staff_name == "Jake" # whitespace trimmed
bulka_entry = entries[1]
assert bulka_entry.quantity_type == "kg"
assert bulka_entry.calculated_kg == 1500.0
assert qa_passed(bulka_entry) is False # bag_sealed was False
# Product master should have absorbed default_bag_size for the wheat product
wheat = db.scalar(
select(ThroughputProduct).where(ThroughputProduct.name == "Whole Wheat 20kg")
)
assert wheat is not None
assert wheat.default_bag_size == 20
def test_product_name_snapshot_preserved_when_product_renamed():
db = _session()
product = ThroughputProduct(tenant_id="t", name="Original Name", default_bag_size=20)
db.add(product)
db.flush()
entry = ProductionThroughput(
tenant_id="t",
production_date=date(2026, 4, 1),
product_id=product.id,
product_name_snapshot=product.name,
bag_size=20,
quantity=10,
quantity_type="bags",
calculated_kg=200,
)
db.add(entry)
db.flush()
product.name = "Renamed Product"
db.flush()
reloaded = db.scalar(select(ProductionThroughput).where(ProductionThroughput.id == entry.id))
assert reloaded.product_name_snapshot == "Original Name"
def test_seed_throughput_products_from_costing_products():
db = _session()
mix = _costing_mix(db)
db.add_all(
[
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1001",
name="Whole Wheat 20kg",
mix_id=mix.id,
sale_type="standard",
unit_of_measure="20kg bag",
visible=True,
),
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1002",
name="Bulka Maize",
mix_id=mix.id,
sale_type="bulka",
unit_of_measure="tonne",
visible=False,
),
]
)
db.flush()
report = seed_throughput_products_from_costing(db)
assert report == {"created": 2, "updated": 0, "skipped": 0}
products = db.scalars(select(ThroughputProduct).order_by(ThroughputProduct.item_id)).all()
assert [product.name for product in products] == ["Whole Wheat 20kg", "Bulka Maize"]
assert products[0].default_bag_size == 20
assert products[0].is_bulka_default is False
assert products[0].active is True
assert products[0].client_name == "Hunter"
assert products[1].default_bag_size is None
assert products[1].is_bulka_default is True
# Every costing SKU is selectable in the throughput picker, even hidden ones.
assert products[1].active is True
assert products[1].client_name == "Hunter"
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
db = _session()
mix = _costing_mix(db)
db.add(
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1001",
name="Updated Wheat 25kg",
mix_id=mix.id,
sale_type="standard",
unit_of_measure="25kg bag",
visible=True,
)
)
db.add(
ThroughputProduct(
tenant_id="hunter-premium-produce",
item_id="1001",
name="Old Wheat",
default_bag_size=20,
active=False,
notes="Seeded from costing products",
)
)
db.flush()
report = seed_throughput_products_from_costing(db)
assert report == {"created": 0, "updated": 1, "skipped": 0}
products = db.scalars(select(ThroughputProduct)).all()
assert len(products) == 1
assert products[0].name == "Updated Wheat 25kg"
assert products[0].default_bag_size == 25
assert products[0].active is True
def test_upload_import_keeps_blank_destination_flags_false():
db = _session()
csv_bytes = (
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock,Job Number\n"
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,,,\n"
).encode("utf-8")
result = import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
assert result["entries_imported"] == 1
entry = db.scalar(select(ProductionThroughput))
assert entry is not None
assert entry.for_order is False
assert entry.for_stock is False
assert entry.job_number is None
def test_upload_import_detects_month_first_dates_consistently():
# The pasted sheet is US month-first (M/D/Y). "9/23/2025" is unambiguous, so
# the ambiguous "12/9/2025" must follow the same convention: 9 December, not
# 12 September (which the old day-first-by-default parser produced).
db = _session()
csv_bytes = (
"Date,Product,Item ID,Quantity,Type,Bag Size,Packed By\n"
"12/9/2025,Whole Wheat Cleaned & Graded 20kg,373022,156,bags,20,Jake\n"
"9/23/2025,Steam Rolled Barley 20kg,568240,34,bags,20,jake\n"
"9/24/2025,Stock Mix 20kg,540725,153,bags,20,jake\n"
).encode("utf-8")
result = import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
assert result["entries_imported"] == 3
dates = {
e.product_name_snapshot: e.production_date
for e in db.scalars(select(ProductionThroughput)).all()
}
assert dates["Whole Wheat Cleaned & Graded 20kg"] == date(2025, 12, 9)
assert dates["Steam Rolled Barley 20kg"] == date(2025, 9, 23)
assert dates["Stock Mix 20kg"] == date(2025, 9, 24)
def test_upload_import_keeps_day_first_dates_for_australian_sheets():
# A genuinely day-first file (23/9/2025 proves D/M/Y) must stay day-first, so
# 12/9/2025 reads as 12 September.
db = _session()
csv_bytes = (
"Date,Product,Quantity,Type,Bag Size\n"
"23/9/2025,Stock Mix 20kg,10,bags,20\n"
"12/9/2025,Stock Mix 20kg,10,bags,20\n"
).encode("utf-8")
import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
produced = sorted(
e.production_date for e in db.scalars(select(ProductionThroughput)).all()
)
assert produced == [date(2025, 9, 12), date(2025, 9, 23)]
def test_upload_import_does_not_treat_unknown_destination_text_as_true():
db = _session()
csv_bytes = (
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock\n"
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,stock,\n"
).encode("utf-8")
result = import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
assert result["entries_imported"] == 1
entry = db.scalar(select(ProductionThroughput))
assert entry is not None
assert entry.for_order is False
assert entry.for_stock is False
+458 -40
View File
@@ -1,6 +1,6 @@
<#
.SYNOPSIS
Deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
Lean 101 deployment script ships an app to a Digital Ocean droplet over SSH.
.DESCRIPTION
Tars the local source tree, uploads it to the droplet, and runs
@@ -8,14 +8,33 @@
The same script handles first-time setup and subsequent updates.
Designed to be swappable across Lean 101 apps. Override -AppName and
-AppSlug (and any defaults derived from them) to deploy a different app.
.PARAMETER RemoteHost
Hostname or IP of the Digital Ocean droplet. Required.
.PARAMETER RemoteUser
SSH user. Defaults to 'root'.
.PARAMETER AppName
Human-readable app name shown in the banner and log output.
Defaults to 'Clients'.
.PARAMETER AppSlug
Lowercase slug used to derive default remote path, container names, env files,
and archive names. Defaults to 'clients'. Override to retarget the script at
a different Lean 101 app (e.g. -AppSlug 'ops' for the Ops portal).
.PARAMETER RemotePath
Absolute path on the droplet. Defaults to '/srv/lean101-clients'.
Absolute path on the droplet. Defaults to '/srv/lean101-<AppSlug>'.
.PARAMETER BackendContainer
Backend container name to inspect for health. Defaults to 'lean101-<AppSlug>-backend'.
.PARAMETER PortEnvKey
Env var name in the env file that holds the published port.
Defaults to '<APPSLUG_UPPER>_APP_PORT' (e.g. CLIENTS_APP_PORT).
.PARAMETER EnvFile
Local path to the production env file. Defaults to '.env.production'.
@@ -23,6 +42,11 @@
.PARAMETER SshKey
Optional path to an SSH private key.
.PARAMETER Password
Optional SSH password for password-based auth (no key). Requires sshpass on
PATH. The password is handed to ssh/scp via the SSHPASS environment variable
rather than an interactive prompt, because the script redirects ssh's I/O.
.PARAMETER ComposeFile
Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.
@@ -35,54 +59,276 @@
.PARAMETER SkipBuild
Pass --no-build to docker compose (use when only env changed).
.PARAMETER Force
Skip the remote port-availability preflight. Use when you know the port is
held by this same stack and want to redeploy over it regardless.
.PARAMETER NoBanner
Suppress the ASCII banner (useful in CI).
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs
.EXAMPLE
# Password auth instead of an SSH key (requires sshpass on PATH):
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Password 'your-password' -Seed -Logs
.EXAMPLE
# Retarget the script at a different Lean 101 app:
./deploy/Deploy.ps1 -RemoteHost 1.2.3.4 -AppName 'Ops' -AppSlug 'ops'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $RemoteHost,
[string] $RemoteUser = "root",
[string] $RemotePath = "/srv/lean101-clients",
[string] $EnvFile = ".env.production",
[string] $RemoteUser = "root",
[string] $AppName = "Clients",
[string] $AppSlug = "clients",
[string] $RemotePath,
[string] $BackendContainer,
[string] $PortEnvKey,
[string] $EnvFile = ".env.production",
[string] $SshKey,
[string] $ComposeFile = "docker-compose.production.yml",
[string] $Password,
[string] $ComposeFile = "docker-compose.production.yml",
[switch] $Seed,
[switch] $Logs,
[switch] $SkipBuild
[switch] $SkipBuild,
[switch] $Force,
[switch] $NoBanner
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
# ── Helpers ───────────────────────────────────────────────────────────────────
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host "!! $msg" -ForegroundColor Yellow }
# ── App identity (swappable) ──────────────────────────────────────────────────
$AppSlug = $AppSlug.ToLowerInvariant()
$AppStack = "lean101-$AppSlug"
if (-not $RemotePath) { $RemotePath = "/srv/$AppStack" }
if (-not $BackendContainer) { $BackendContainer = "$AppStack-backend" }
if (-not $PortEnvKey) { $PortEnvKey = "$($AppSlug.ToUpperInvariant())_APP_PORT" }
# ── Palette ───────────────────────────────────────────────────────────────────
# ANSI escapes give us truecolor + dim/bold that Write-Host -ForegroundColor cannot.
$Esc = [char]27
$C = @{
Reset = "$Esc[0m"
Dim = "$Esc[2m"
Bold = "$Esc[1m"
Italic = "$Esc[3m"
Magenta = "$Esc[38;5;177m" # soft violet
Pink = "$Esc[38;5;213m"
Cyan = "$Esc[38;5;87m"
Teal = "$Esc[38;5;79m"
Green = "$Esc[38;5;120m"
Yellow = "$Esc[38;5;221m"
Red = "$Esc[38;5;203m"
Grey = "$Esc[38;5;244m"
Blue = "$Esc[38;5;117m"
}
$Glyph = @{
Step = '▸'
OK = '✓'
Warn = '!'
Info = '·'
Fail = '✗'
Arrow = '→'
Spark = '✦'
}
function Write-Banner {
if ($NoBanner) { return }
$line = '─' * 62
$title = "Lean 101 Deployment Script"
$sub = "App: $AppName Slug: $AppSlug Target: $RemoteUser@$RemoteHost"
Write-Host ""
Write-Host ("$($C.Magenta)$line$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ██╗ ███████╗ █████╗ ███╗ ██╗ ███╗ ██████╗ ███╗$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ██║ ██╔════╝██╔══██╗████╗ ██║ ████║██╔═████╗ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ██║ █████╗ ███████║██╔██╗ ██║ ██╔██║██║██╔██║ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ██║ ██╔══╝ ██╔══██║██║╚██╗██║ ╚═╝██║████╔╝██║ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ███████╗███████╗██║ ██║██║ ╚████║ ███████╗╚██████╔╝ ██║$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset)$($C.Bold)$($C.Pink) ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝$($C.Reset)$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Spark) $title$($C.Reset)" + (' ' * (60 - $title.Length - 3)) + "$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$($C.Reset) $($C.Dim)$($C.Grey)$sub$($C.Reset)" + (' ' * [Math]::Max(0, 60 - $sub.Length - 2)) + "$($C.Magenta)$($C.Reset)")
Write-Host ("$($C.Magenta)$line$($C.Reset)")
Write-Host ""
}
# ── Output helpers ────────────────────────────────────────────────────────────
$script:StepIndex = 0
function Write-Step([string]$msg) {
$script:StepIndex++
$num = "{0:D2}" -f $script:StepIndex
Write-Host ("$($C.Dim)$($C.Grey)[$num]$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Step)$($C.Reset) $($C.Bold)$msg$($C.Reset)")
}
function Write-Ok([string]$msg) { Write-Host (" $($C.Green)$($Glyph.OK)$($C.Reset) $($C.Green)$msg$($C.Reset)") }
function Write-Warn([string]$msg) { Write-Host (" $($C.Yellow)$($Glyph.Warn)$($C.Reset) $($C.Yellow)$msg$($C.Reset)") }
function Write-Info([string]$msg) { Write-Host (" $($C.Dim)$($C.Grey)$($Glyph.Info) $msg$($C.Reset)") }
function Write-Fail([string]$msg) { Write-Host (" $($C.Red)$($Glyph.Fail)$($C.Reset) $($C.Red)$msg$($C.Reset)") }
# ── Spinner ───────────────────────────────────────────────────────────────────
$Spinner = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏')
function Invoke-Spinner {
<#
Runs an external process while animating a braille spinner with elapsed time.
Captures stdout/stderr and surfaces them on failure (or via -Quiet:$false).
#>
param(
[Parameter(Mandatory = $true)] [string] $Label,
[Parameter(Mandatory = $true)] [string] $FilePath,
[string[]] $ArgList = @(),
[string] $StdinFile,
[switch] $ShowOutput
)
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
$startParams = @{
FilePath = $FilePath
NoNewWindow = $true
PassThru = $true
RedirectStandardOutput = $outFile
RedirectStandardError = $errFile
}
if ($ArgList.Count -gt 0) { $startParams.ArgumentList = $ArgList }
if ($StdinFile) { $startParams.RedirectStandardInput = $StdinFile }
$proc = Start-Process @startParams
$start = Get-Date
$i = 0
try {
while (-not $proc.HasExited) {
$elapsed = ((Get-Date) - $start).TotalSeconds
$frame = $Spinner[$i % $Spinner.Count]
Write-Host ("`r $($C.Cyan)$frame$($C.Reset) $($C.Dim)$($C.Grey)$Label $($C.Reset)$($C.Teal)$('{0,5:0.0}s' -f $elapsed)$($C.Reset) ") -NoNewline
Start-Sleep -Milliseconds 90
$i++
}
$proc.WaitForExit()
$elapsed = ((Get-Date) - $start).TotalSeconds
$stdout = if (Test-Path $outFile) { Get-Content $outFile -Raw } else { '' }
$stderr = if (Test-Path $errFile) { Get-Content $errFile -Raw } else { '' }
if ($proc.ExitCode -eq 0) {
Write-Host ("`r $($C.Green)$($Glyph.OK)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed)$($C.Reset)" + (' ' * 12))
if ($ShowOutput -and $stdout) {
foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Info $ln } }
}
return $stdout
}
else {
Write-Host ("`r $($C.Red)$($Glyph.Fail)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed) (exit $($proc.ExitCode))$($C.Reset)" + (' ' * 8))
if ($stdout) { foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Host " $($C.Dim)$($C.Grey)$ln$($C.Reset)" } } }
if ($stderr) { foreach ($ln in ($stderr -split "`r?`n")) { if ($ln) { Write-Host " $($C.Red)$ln$($C.Reset)" } } }
throw "$Label failed (exit $($proc.ExitCode))"
}
}
finally {
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
Remove-Item $errFile -Force -ErrorAction SilentlyContinue
}
}
# ── Helpers ───────────────────────────────────────────────────────────────────
function Get-RepoRoot {
$dir = Split-Path -Parent $PSScriptRoot
if (-not $dir) { $dir = (Get-Location).Path }
return $dir
}
function Get-EnvValue([string] $path, [string] $key) {
foreach ($line in Get-Content $path) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith("#")) { continue }
if ($trimmed -notmatch "=") { continue }
$parts = $trimmed -split "=", 2
if ($parts[0].Trim() -eq $key) {
return $parts[1].Trim()
}
}
return $null
}
$RepoRoot = Get-RepoRoot
$SshTarget = "$RemoteUser@$RemoteHost"
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no")
if ($SshKey) { $SshOpts += @("-i", $SshKey) }
function Invoke-Ssh([string] $cmd) {
& ssh @SshOpts $SshTarget $cmd
if ($LASTEXITCODE -ne 0) { throw "Remote command failed (exit $LASTEXITCODE): $cmd" }
# Password auth (no SSH key): the spinner runs ssh/scp with redirected I/O, so
# there is no terminal for an interactive password prompt. Feed the password
# non-interactively via sshpass instead, reading it from the SSHPASS env var.
$SshExe = 'ssh'
$ScpExe = 'scp'
$SshPrefix = @()
$ScpPrefix = @()
if ($Password) {
if (-not (Get-Command sshpass -ErrorAction SilentlyContinue)) {
throw "sshpass is required for -Password but was not found on PATH. Install it (e.g. 'scoop install sshpass') or use an SSH key."
}
$env:SSHPASS = $Password
$SshExe = 'sshpass'; $SshPrefix = @('-e', 'ssh')
$ScpExe = 'sshpass'; $ScpPrefix = @('-e', 'scp')
$SshOpts += @("-o", "PubkeyAuthentication=no", "-o", "PreferredAuthentications=password")
}
function Invoke-Scp([string] $local, [string] $remote) {
& scp @SshOpts $local "${SshTarget}:${remote}"
if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" }
function Write-RemoteScript([string] $path, [string] $content) {
# Remote bash chokes on Windows CRLF line endings (each line gets a trailing
# \r) and on a UTF-8 BOM. Write LF-only, no BOM.
$lf = $content -replace "`r`n", "`n" -replace "`r", "`n"
[System.IO.File]::WriteAllText($path, $lf, (New-Object System.Text.UTF8Encoding($false)))
}
function Invoke-Ssh([string] $cmd, [string] $Label, [switch] $ShowOutput) {
if (-not $Label) { $Label = "ssh $($cmd.Substring(0, [Math]::Min(48, $cmd.Length)))..." }
# Send the command over stdin to `bash -s` rather than as a command-line
# argument. Complex commands (quotes, pipes, $(), newlines) get corrupted
# when passed as an argv token through sshpass's cygwin argument parsing;
# stdin keeps the only argv tokens simple ("bash -s").
$tmp = [System.IO.Path]::GetTempFileName()
try {
Write-RemoteScript $tmp $cmd
Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList (@($SshPrefix) + @($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput:$ShowOutput
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
function Invoke-Scp([string] $local, [string] $remote, [string] $Label) {
if (-not $Label) { $Label = "scp $(Split-Path -Leaf $local) $($Glyph.Arrow) $remote" }
Invoke-Spinner -Label $Label -FilePath $ScpExe -ArgList (@($ScpPrefix) + @($SshOpts) + @($local, "${SshTarget}:${remote}"))
}
function Try-Ssh([string] $cmd) {
$tmp = [System.IO.Path]::GetTempFileName()
try {
Write-RemoteScript $tmp $cmd
Get-Content $tmp -Raw | & $SshExe @SshPrefix @SshOpts $SshTarget "bash -s"
return $LASTEXITCODE
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
function Invoke-SshScript([string] $script, [string] $Label) {
if (-not $Label) { $Label = "ssh (remote script)" }
$tmp = [System.IO.Path]::GetTempFileName()
try {
Write-RemoteScript $tmp $script
Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList (@($SshPrefix) + @($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
# ── Render banner ─────────────────────────────────────────────────────────────
Write-Banner
# ── Resolve paths ─────────────────────────────────────────────────────────────
Push-Location $RepoRoot
try {
@@ -90,15 +336,69 @@ try {
if (-not (Test-Path $EnvPath)) {
throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets."
}
$WorkbookCandidates = @(
(Join-Path $RepoRoot "input_data\\1.xlsx"),
(Join-Path $RepoRoot "Input Cost Spreadsheet(1).xlsx")
)
$WorkbookPath = $WorkbookCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $WorkbookPath) {
throw "Workbook not found. Checked: $($WorkbookCandidates -join ', '). The production seed expects at least one workbook file to exist."
}
$AppPort = Get-EnvValue $EnvPath $PortEnvKey
if (-not $AppPort) { $AppPort = "8081" }
$Origin = Get-EnvValue $EnvPath "ORIGIN"
if (-not $Origin) { $Origin = "https://clients.lean-101.com.au" }
Write-Step "Preflight"
Write-Info "App : $AppName ($AppSlug)"
Write-Info "Remote host : $SshTarget"
Write-Info "Remote path : $RemotePath"
Write-Info "Container : $BackendContainer"
Write-Info "Env file : $EnvPath"
Write-Info "Compose file : $ComposeFile"
Write-Info "Origin : $Origin"
Write-Info "Port ($PortEnvKey) : $AppPort"
# ── Connectivity check ──────────────────────────────────────────────────────
Write-Step "Checking SSH connectivity to $SshTarget"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
Write-Step "Verifying SSH connectivity"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" -Label "ssh handshake" -ShowOutput
# ── Remote port availability ───────────────────────────────────────────────
# A re-deploy of this same stack is expected to replace its containers in
# place, so any container belonging to this stack (name == STACK or STACK-*)
# is treated as "ours". Only a genuinely different service triggers an abort.
if ($Force) {
Write-Step "Skipping remote port check for $AppStack (-Force)"
}
else {
Write-Step "Checking that remote port $AppPort is free for $AppStack"
$portCheckCmd = @'
set -e
PORT='__APP_PORT__'
STACK='__APP_STACK__'
OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true)
if [ -n "$OWNER" ]; then
case "$OWNER" in
"$STACK"|"$STACK"-*) : ;;
*)
echo "Port $PORT is already owned by container: $OWNER" >&2
exit 2 ;;
esac
fi
'@.Replace('__APP_PORT__', $AppPort).Replace('__APP_STACK__', $AppStack)
try {
Invoke-Ssh $portCheckCmd -Label "port $AppPort availability"
}
catch {
throw "Remote port $AppPort is already in use by a different service. Change $PortEnvKey in '$EnvPath', retire the conflicting service, or re-run with -Force to deploy anyway."
}
}
# ── Package source files ────────────────────────────────────────────────────
Write-Step "Packaging source files (excluding node_modules, caches, etc.)"
Write-Step "Packaging source tree (excluding node_modules, caches, secrets)"
$TarFile = Join-Path $env:TEMP "lean101-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
$TarFile = Join-Path $env:TEMP "$AppStack-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
$excludes = @(
"--exclude=./node_modules",
@@ -106,9 +406,14 @@ try {
"--exclude=./frontend/.svelte-kit",
"--exclude=./frontend/build",
"--exclude=./.git",
"--exclude=./.pytest_cache",
"--exclude=./__pycache__",
"--exclude=./backend/__pycache__",
"--exclude=./backend/app/__pycache__",
"--exclude=./backend/.pytest_cache",
"--exclude=./backend/.tmp",
"--exclude=./backend/.venv",
"--exclude=./backend/data_entry_app_backend.egg-info",
"--exclude=./**/__pycache__",
"--exclude=./*.pyc",
"--exclude=./.env",
@@ -118,39 +423,63 @@ try {
"--exclude=./*.db"
)
& tar -czf $TarFile @excludes -C $RepoRoot .
if ($LASTEXITCODE -ne 0) { throw "tar failed" }
Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.'))
$TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 1)
Write-Host " Archive: $TarFile ($TarSize MB)"
Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))"
# ── Upload env file ─────────────────────────────────────────────────────────
Write-Step "Uploading env file"
Invoke-Scp $EnvPath "$RemotePath/.env.production"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
Write-Step "Uploading env file to droplet"
Invoke-Scp $EnvPath "$RemotePath/.env.production" -Label "scp .env.production $($Glyph.Arrow) $RemotePath/"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" -Label "chmod 600 .env.production"
# ── Upload and extract source ────────────────────────────────────────────────
Write-Step "Uploading source archive"
Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz"
Write-Step "Uploading source archive ($TarSize MB)"
Invoke-Scp $TarFile "/tmp/$AppStack-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
Remove-Item $TarFile -Force
Write-Step "Extracting on server"
Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/lean101-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-deploy.tar.gz"
Write-Step "Extracting archive on server"
Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/$AppStack-deploy.tar.gz -C '$RemotePath' && rm /tmp/$AppStack-deploy.tar.gz" -Label "untar into $RemotePath"
# ── Docker compose up ───────────────────────────────────────────────────────
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
Write-Step "Bringing stack up (build=$(-not $SkipBuild))"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
$buildMsg = if ($SkipBuild) { "without rebuild" } else { "with --build" }
Write-Step "Bringing the $AppName stack up $buildMsg"
$composeUpCmd = "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
try {
Invoke-Ssh $composeUpCmd -Label "docker compose up $BuildFlag"
}
catch {
Write-Warn "docker compose up failed; collecting remote status and backend logs"
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=120 backend"
throw
}
# ── Reload nginx config ───────────────────────────────────────────────────
# The nginx config is a bind-mounted file. `docker compose up` only recreates
# a service when its definition changes, not when a mounted file's contents
# change, so a running nginx keeps serving the config it loaded at start. Force
# a reload so edits to clients.lean-101.conf (routing, security headers/CSP)
# actually take effect on every deploy. Non-fatal: stacks without an nginx
# service simply skip this.
Write-Step "Reloading nginx to apply config changes"
$nginxReload = "cd '$RemotePath' && docker compose $ComposeArgs exec -T nginx nginx -t && docker compose $ComposeArgs exec -T nginx nginx -s reload"
if ((Try-Ssh $nginxReload) -eq 0) {
Write-Ok "nginx reloaded"
} else {
Write-Warn "Skipped nginx reload (no nginx service, or config test failed)"
}
# ── Health check ────────────────────────────────────────────────────────────
Write-Step "Waiting for backend health check"
Write-Step "Waiting for backend health check ($BackendContainer)"
$healthScript = @"
set -e
cd '$RemotePath'
for i in `$(seq 1 30); do
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing)
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $BackendContainer 2>/dev/null || echo missing)
case "`$status" in
healthy|running) echo "backend is `$status"; exit 0 ;;
*) printf '.'; sleep 4 ;;
@@ -158,25 +487,114 @@ for i in `$(seq 1 30); do
done
echo; echo 'backend did not become healthy in time' >&2; exit 1
"@
Invoke-Ssh $healthScript
Invoke-SshScript $healthScript -Label "backend health (up to ~2 min)"
# ── Seed access ─────────────────────────────────────────────────────────────
Write-Step "Seeding default internal users and permissions"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed_access" -Label "python -m app.seed_access"
# ── Optional seed ───────────────────────────────────────────────────────────
if ($Seed) {
Write-Step "Seeding reference data"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed" -Label "python -m app.seed"
}
# ── Final status ────────────────────────────────────────────────────────────
Write-Step "Stack status"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" -Label "docker compose ps" -ShowOutput
# ── Frontend asset verification ─────────────────────────────────────────────
Write-Step "Verifying published Inter font asset"
$fontCheckBody = @'
set -e
ORIGIN="__ORIGIN__"
fetch() {
URL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$URL"
return
fi
echo "Neither curl nor wget is available on the remote host." >&2
exit 1
}
fetch_status() {
URL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS -o /dev/null -w "%{http_code}" "$URL"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -S --spider "$URL" 2>&1 | awk '/^ HTTP\// { code=$2 } END { if (code) print code; else print "000" }'
return
fi
echo "000"
}
INDEX_HTML=$(fetch "$ORIGIN")
CSS_PATHS=$(printf '%s' "$INDEX_HTML" | grep -oE '/_app/immutable/assets/[^"]+\.css' | sort -u || true)
if [ -z "$CSS_PATHS" ]; then
echo "Could not find a built CSS asset on $ORIGIN" >&2
exit 1
fi
FONT_URL=""
for CSS_PATH in $CSS_PATHS; do
CSS_URL="${ORIGIN%/}${CSS_PATH}"
CSS_CONTENT=$(fetch "$CSS_URL")
FONT_PATH=$(printf '%s' "$CSS_CONTENT" | grep -oE 'inter-latin-400-normal\.[^")]+\.woff2' | head -n 1 || true)
if [ -n "$FONT_PATH" ]; then
FONT_URL="${CSS_URL%/*}/${FONT_PATH}"
break
fi
done
if [ -z "$FONT_PATH" ]; then
echo "Could not find the Inter Latin 400 woff2 asset in any built CSS asset from $ORIGIN" >&2
exit 1
fi
FONT_STATUS=$(fetch_status "$FONT_URL")
if [ "$FONT_STATUS" != "200" ]; then
echo "Inter font check failed: $FONT_URL returned HTTP $FONT_STATUS" >&2
exit 1
fi
echo "Verified Inter font asset: $FONT_URL"
'@.Replace('__ORIGIN__', $Origin)
Invoke-SshScript $fontCheckBody -Label "Inter font asset check"
if ($Logs) {
Write-Step "Recent logs (last 60 lines)"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" -Label "docker compose logs --tail=60" -ShowOutput
}
Write-Step "Published access"
Write-Info "Container port : $($C.Bold)$AppPort$($C.Reset)"
Write-Info "Origin : $($C.Bold)$($C.Blue)$Origin$($C.Reset)"
if ($AppPort -ne "80" -and $AppPort -ne "443") {
Write-Warn "This stack is published on port $AppPort. The public domain may still point at another service until you swap the reverse proxy or port mapping."
}
# ── Done ────────────────────────────────────────────────────────────────────
$line = '─' * 62
Write-Host ""
Write-Host "Deployment complete -> https://clients.lean-101.com.au" -ForegroundColor Green
Write-Host ("$($C.Green)$line$($C.Reset)")
$done = "$($Glyph.Spark) $AppName deployed $($Glyph.Arrow) $Origin"
$pad = [Math]::Max(0, 60 - ($done.Length - 2)) # subtract 2 for two-byte glyphs
Write-Host ("$($C.Green)$($C.Reset) $($C.Bold)$($C.Green)$done$($C.Reset)" + (' ' * $pad) + "$($C.Green)$($C.Reset)")
Write-Host ("$($C.Green)$line$($C.Reset)")
Write-Host ""
}
catch {
Write-Host ""
Write-Fail "Deployment aborted: $($_.Exception.Message)"
Write-Host ""
throw
}
finally {
Pop-Location
+21 -11
View File
@@ -405,6 +405,8 @@ TABLE_ORDER = [
"costing_results",
"mix_calculator_sessions",
"mix_calculator_session_lines",
"throughput_products",
"production_throughput_entries",
]
def migrate():
@@ -455,19 +457,27 @@ def migrate():
# Re-enable FK checks
dst_conn.execute(text("SET session_replication_role = 'origin'"))
# Reset auto-increment sequences
# Reset auto-increment sequences for EVERY table with an id sequence — not
# just the ones we copied above — so later inserts (e.g. editor_change_events)
# don't collide with pre-existing ids. Leaving a sequence behind MAX(id) is
# what makes "create new mix" fail with a duplicate-key error on Postgres.
print("\n Resetting sequences...")
with dst.begin() as conn:
for table_name in TABLE_ORDER:
try:
conn.execute(text(
f"SELECT setval("
f" pg_get_serial_sequence('{table_name}', 'id'),"
f" COALESCE((SELECT MAX(id) FROM {table_name}), 1)"
f")"
))
except Exception:
pass
all_tables = inspect(dst).get_table_names()
for table_name in all_tables:
sequence = conn.execute(text(
"SELECT pg_get_serial_sequence(:table, 'id')"
), {"table": table_name}).scalar()
if not sequence:
continue
max_id = conn.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
if max_id is None:
continue
conn.execute(text("SELECT setval(:sequence, :value, true)"), {
"sequence": sequence,
"value": int(max_id),
})
print(f" SEQ {table_name:<45} -> {max_id}")
print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.")
return totals
+15 -1
View File
@@ -26,7 +26,10 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# frame-src/child-src allow same-origin blob: URLs so the in-app PDF print
# dialog (an iframe pointed at a blob:) is not blocked by the default-src fallback.
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
location /_app/immutable/ {
expires 1y;
@@ -87,6 +90,17 @@ server {
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
# nginx drops inherited add_header directives once a location defines its own,
# so the security headers (incl. the blob:-aware CSP) are repeated here to
# guarantee the HTML document carries them.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
expires -1;
proxy_pass http://lean101_clients_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
+1 -1
View File
@@ -6,7 +6,7 @@ services:
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
APP_NAME: ${APP_NAME:-Hunter App}
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
+15 -1
View File
@@ -23,7 +23,8 @@ services:
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
APP_NAME: ${APP_NAME:-Hunter App}
APP_ENV: ${APP_ENV:-production}
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
@@ -34,6 +35,19 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au}
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-clients.lean-101.com.au}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-lax}
SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-}
SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-43200}
REQUEST_BODY_MAX_BYTES: ${REQUEST_BODY_MAX_BYTES:-1048576}
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
DOCS_ENABLED: ${DOCS_ENABLED:-false}
# Read-only Power BI data API (/api/v1). Blank disables it.
POWERBI_API_KEY: ${POWERBI_API_KEY:-}
POWERBI_TENANT_ID: ${POWERBI_TENANT_ID:-${CLIENT_TENANT_ID:-hunter-premium-produce}}
depends_on:
db:
condition: service_healthy
+12 -1
View File
@@ -6,7 +6,8 @@ services:
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
APP_NAME: ${APP_NAME:-Hunter App}
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
@@ -17,6 +18,16 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-lean101-admin}
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au}
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-localhost,127.0.0.1}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-lax}
SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-}
SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-43200}
REQUEST_BODY_MAX_BYTES: ${REQUEST_BODY_MAX_BYTES:-1048576}
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
DOCS_ENABLED: ${DOCS_ENABLED:-true}
volumes:
- clients_app_data:/data
healthcheck:
+8
View File
@@ -14,10 +14,18 @@ ENV NODE_ENV=production
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
RUN chown -R app:app /app
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 CMD node -e "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "build"]
+15 -5
View File
@@ -1,13 +1,14 @@
{
"name": "data-entry-app-frontend",
"version": "0.1.5",
"name": "hunter-app",
"version": "0.1.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "data-entry-app-frontend",
"version": "0.1.5",
"name": "hunter-app",
"version": "0.1.36",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
},
"devDependencies": {
@@ -15,7 +16,7 @@
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.7.1",
"svelte": "^5.0.0",
"typescript": "^5.5.4",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vitest": "^4.0.0"
}
@@ -54,6 +55,15 @@
"tslib": "^2.4.0"
}
},
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "data-entry-app-frontend",
"version": "1.5.6",
"name": "hunter-app",
"version": "0.1.36",
"private": true,
"type": "module",
"scripts": {
@@ -14,11 +14,12 @@
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.7.1",
"svelte": "^5.0.0",
"typescript": "^5.5.4",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
}
}
+14
View File
@@ -4,6 +4,20 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" />
<script>
// Resolve the theme before first paint so there is no dark-mode flash.
// Dark mode is strictly opt-in: it loads only when the user has explicitly
// chosen it. Absent preference (or the legacy 'system' value) stays light,
// so the OS scheme never pulls the app into dark on its own.
(function () {
try {
var pref = localStorage.getItem('theme');
document.documentElement.dataset.theme = pref === 'dark' ? 'dark' : 'light';
} catch (e) {
document.documentElement.dataset.theme = 'light';
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+100
View File
@@ -0,0 +1,100 @@
/**
* Modern hover/focus tooltip action.
*
* Renders a styled bubble appended to <body> 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: <button use:tooltip={'Switch to dark mode'}></button>
* <button use:tooltip={{ label: 'Whats new', placement: 'bottom' }}></button>
*/
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<typeof setTimeout> | null = null;
function position() {
if (!bubble) return;
const anchor = node.getBoundingClientRect();
const tip = bubble.getBoundingClientRect();
let left = anchor.left + anchor.width / 2 - tip.width / 2;
left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP));
const top =
current.placement === 'top'
? anchor.top - tip.height - GAP
: anchor.bottom + GAP;
bubble.style.left = `${Math.round(left)}px`;
bubble.style.top = `${Math.round(top)}px`;
}
function show() {
if (bubble || !current.label) return;
bubble = document.createElement('div');
bubble.className = 'app-tooltip';
bubble.setAttribute('role', 'tooltip');
bubble.dataset.placement = current.placement;
bubble.textContent = current.label;
document.body.appendChild(bubble);
position();
// Trigger the fade/translate transition on the next frame.
requestAnimationFrame(() => bubble?.classList.add('is-visible'));
}
function scheduleShow() {
clearTimeout(showTimer ?? undefined);
showTimer = setTimeout(show, current.delay);
}
function hide() {
clearTimeout(showTimer ?? undefined);
showTimer = null;
bubble?.remove();
bubble = null;
}
node.addEventListener('mouseenter', scheduleShow);
node.addEventListener('mouseleave', hide);
node.addEventListener('focus', show);
node.addEventListener('blur', hide);
node.addEventListener('click', hide);
return {
update(next: TooltipOptions) {
current = normalize(next);
if (bubble) {
bubble.textContent = current.label;
bubble.dataset.placement = current.placement;
position();
}
},
destroy() {
hide();
node.removeEventListener('mouseenter', scheduleShow);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('focus', show);
node.removeEventListener('blur', hide);
node.removeEventListener('click', hide);
}
};
}
+1 -1
View File
@@ -54,7 +54,7 @@ describe('api fetch injection', () => {
await expect(call(injectedFetch)).resolves.toEqual(body);
expect(injectedFetch).toHaveBeenCalledTimes(1);
expect(injectedFetch.mock.calls[0]?.[0]).toBe(`http://127.0.0.1:8000${path}`);
expect(injectedFetch.mock.calls[0]?.[0]).toBe(path);
expect(globalFetch).not.toHaveBeenCalled();
});
+453 -75
View File
@@ -1,16 +1,5 @@
import { env } from '$env/dynamic/public';
import { browser } from '$app/environment';
import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
mockMixCalculatorOptions,
mockMixCalculatorSessions,
mockMixes,
mockProducts,
mockRawMaterials,
mockScenarios
} from '$lib/mock';
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
@@ -18,7 +7,28 @@ import type {
ClientUserCreateInput,
ClientUserModulePermission,
ClientUserUpdateInput,
InternalUser,
InternalRoleOption,
InternalRole,
InternalRoleCreateInput,
InternalRoleModuleDefinition,
InternalRoleUpdateInput,
InternalUserCreateInput,
InternalUserUpdateInput,
LoginResponse,
EditorMixCreateInput,
EditorMixUpdateInput,
EditorMixRow,
EditorMixFormula,
EditorResolvedMixFormula,
EditorMixFormulaRowInput,
EditorIngredientRow,
EditorIngredientCreateInput,
EditorIngredientUpdateInput,
EditorChangeEvent,
EditorProductFormula,
EditorProductRow,
EditorProductUpdateInput,
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
@@ -30,14 +40,36 @@ import type {
MixUpdateInput,
Product,
ProductCostBreakdown,
ProductCostingInputs,
ProductCostingItem,
ProductCostingItemUpdateInput,
RawMaterial,
RawMaterialCreateInput,
RawMaterialPriceCreateInput,
Scenario
CatalogueProduct,
CustomerPricing,
CustomerVisibilityRow,
DraftOrderInput,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus,
XeroContactList,
XeroContactLinkRow,
Scenario,
ThroughputDeleteAllResult,
ThroughputEntry,
ThroughputEntryCreateInput,
ThroughputEntryUpdateInput,
ThroughputEntryListParams,
ThroughputImportResult,
ThroughputProduct,
ThroughputProductCreateInput,
ThroughputProductUpdateInput
} from '$lib/types';
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.';
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
@@ -51,40 +83,62 @@ function getApiBaseUrl() {
}
}
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl.replace(/\/+$/, '');
}
if (browser) {
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
try {
const configuredUrl = new URL(configuredBaseUrl, window.location.origin);
// Keep browser API traffic same-origin by default. This avoids CORS,
// CSP `connect-src`, and cookie policy failures when the backend is
// reverse-proxied under `/api` on the same host.
if (configuredUrl.origin === window.location.origin || configuredUrl.hostname === window.location.hostname) {
return '';
}
return configuredUrl.toString().replace(/\/+$/, '');
} catch {
return '';
}
}
return '';
}
return `http://127.0.0.1:${DEFAULT_API_PORT}`;
const defaultApiPort = env.PUBLIC_API_PORT || '8000';
return `http://127.0.0.1:${defaultApiPort}`;
}
function buildApiUrl(path: string) {
return `${getApiBaseUrl()}${path}`;
}
function getToken(auth: AuthMode) {
if (!browser) {
return null;
}
function getSessionFingerprint(auth: AuthMode) {
if (auth === 'client') {
return getStoredClientSession()?.token ?? null;
const session = getStoredClientSession();
return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : '';
}
if (auth === 'admin') {
return getStoredAdminSession()?.token ?? null;
const session = getStoredAdminSession();
return session ? `${session.role}:${session.email}` : '';
}
if (auth === 'manager') {
return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null;
const admin = getStoredAdminSession();
if (admin) {
return `${admin.role}:${admin.email}`;
}
const client = getStoredClientSession();
return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : '';
}
return null;
return '';
}
function resolveRequestUrl(path: string, fetcher: ApiFetch) {
if (fetcher !== fetch) {
return path;
}
return buildApiUrl(path);
}
function normalizeRequestError(error: unknown) {
@@ -105,24 +159,17 @@ function normalizeRequestError(error: unknown) {
return new Error('An unexpected error occurred while contacting the server.');
}
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
async function fetchJson<T>(path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include'
});
if (!response.ok) {
if (auth !== 'none') {
throw new Error(response.statusText || 'Unauthorized');
}
return fallback;
throw new Error(response.statusText || 'Request failed');
}
return (await response.json()) as T;
} catch (error) {
if (auth !== 'none') {
throw normalizeRequestError(error);
}
return fallback;
throw normalizeRequestError(error);
}
}
@@ -136,19 +183,18 @@ const inflightRequests = new Map<string, Promise<unknown>>();
const READ_CACHE_TTL_MS = 30_000;
function makeCacheKey(path: string, auth: AuthMode) {
const token = browser ? getToken(auth) ?? '' : '';
return `${auth}:${token.slice(-8)}:${path}`;
const sessionFingerprint = browser ? getSessionFingerprint(auth) : '';
return `${auth}:${sessionFingerprint}:${path}`;
}
async function cachedFetchJson<T>(
path: string,
fallback: T,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
// Bypass the cache during SSR (no localStorage, no shared session).
if (!browser) {
return fetchJson<T>(path, fallback, auth, fetcher);
return fetchJson<T>(path, auth, fetcher);
}
const key = makeCacheKey(path, auth);
@@ -164,7 +210,7 @@ async function cachedFetchJson<T>(
return existing as Promise<T>;
}
const promise = fetchJson<T>(path, fallback, auth, fetcher)
const promise = fetchJson<T>(path, auth, fetcher)
.then((value) => {
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
return value;
@@ -189,13 +235,12 @@ async function request<T>(
fetcher: ApiFetch = fetch
): Promise<T> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {})
},
credentials: 'include',
...options
});
@@ -218,6 +263,48 @@ async function request<T>(
// after the user creates or updates anything.
clearApiCache();
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
}
}
// Multipart upload. Unlike `request`, we must NOT set Content-Type ourselves —
// the browser sets `multipart/form-data` with the correct boundary when given a
// FormData body. Mirrors `request`'s auth/cache/error handling otherwise.
async function uploadFile<T>(
path: string,
formData: FormData,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
try {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
let message = 'Request failed';
try {
const body = (await response.json()) as { detail?: string };
message = body.detail ?? message;
} catch {
message = response.statusText || message;
}
throw new Error(message);
}
if (browser) {
clearApiCache();
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
@@ -226,13 +313,18 @@ async function request<T>(
async function requestBlob(
path: string,
options: RequestInit = {},
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<Blob> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
const response = await fetcher(resolveRequestUrl(path, fetcher), {
headers: {
'Content-Type': 'application/json',
...(options.headers ?? {})
},
credentials: 'include',
...options
});
if (!response.ok) {
@@ -253,22 +345,27 @@ async function requestBlob(
}
export const api = {
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson<Mix[]>('/api/mixes', 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, 'client', fetcher),
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, {}, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
previewMixCalculatorPdf: (payload: MixCalculatorCreateInput) =>
requestBlob('/api/mix-calculator/preview/pdf', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorSession>('/api/mix-calculator', {
method: 'POST',
@@ -279,26 +376,171 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', 'client', fetcher),
editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.client_name) search.set('client_name', params.client_name);
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products';
return cachedFetchJson<EditorProductRow[]>(path, 'client', fetcher);
},
updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
request<EditorProductRow>(`/api/editor/products/${productId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.client_name) search.set('client_name', params.client_name);
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
},
createEditorMix: (payload: EditorMixCreateInput) =>
request<EditorMixRow>('/api/editor/mixes', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorMix: (mixId: number) =>
request<void>(`/api/editor/mixes/${mixId}`, {
method: 'DELETE'
}, 'client'),
editorMixFormula: (mixId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
// The resolved formula matching the Mix Calculator (product-first), used by
// the Mix Editor ingredient panel.
editorMixResolvedFormula: (mixId: number) =>
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {}, 'client'),
replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) =>
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {
method: 'PUT',
body: JSON.stringify({ rows })
}, 'client'),
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorMixIngredient: (mixId: number, ingredientId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
editorProductFormula: (productId: number) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorProductIngredient: (productId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorProductIngredient: (productId: number, ingredientId: number) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
editorIngredients: (fetcher?: ApiFetch) =>
cachedFetchJson<EditorIngredientRow[]>('/api/editor/ingredients', 'client', fetcher),
createEditorIngredient: (payload: EditorIngredientCreateInput) =>
request<EditorIngredientRow>('/api/editor/ingredients', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) =>
request<EditorIngredientRow>(`/api/editor/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
editorMixHistory: (mixId: number) =>
request<EditorChangeEvent[]>(`/api/editor/mixes/${mixId}/history`, {}, 'client'),
editorIngredientHistory: (ingredientId: number) =>
request<EditorChangeEvent[]>(`/api/editor/ingredients/${ingredientId}/history`, {}, 'client'),
productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostingItem[]>('/api/product-costing/items', 'client', fetcher),
productCostingItemsFresh: () =>
request<ProductCostingItem[]>(`/api/product-costing/items?_=${Date.now()}`, { method: 'GET' }, 'client'),
productCostingInputs: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostingInputs>('/api/product-costing/inputs', 'client', fetcher),
updateProductCostingInputs: (payload: Partial<ProductCostingInputs>) =>
request<ProductCostingInputs>('/api/product-costing/inputs', {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
updateProductCostingItem: (itemId: number, payload: ProductCostingItemUpdateInput) =>
request<ProductCostingItem>(`/api/product-costing/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
recalculateProductCosting: () =>
request<{ recalculated: number }>('/api/product-costing/recalculate-all', { method: 'POST' }, 'client'),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', 'client', fetcher),
throughputProducts: (fetcher?: ApiFetch) =>
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', 'client', fetcher),
throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.date_from) search.set('date_from', params.date_from);
if (params?.date_to) search.set('date_to', params.date_to);
if (params?.product_id != null) search.set('product_id', String(params.product_id));
if (params?.staff_name) search.set('staff_name', params.staff_name);
if (params?.quantity_type) search.set('quantity_type', params.quantity_type);
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries';
return cachedFetchJson<ThroughputEntry[]>(path, 'client', fetcher);
},
createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
request<ThroughputEntry>('/api/throughput/entries', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) =>
request<ThroughputEntry>(`/api/throughput/entries/${entryId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteThroughputEntry: (entryId: number) =>
request<void>(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'),
importThroughputEntries: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
},
deleteAllThroughputEntries: () =>
request<ThroughputDeleteAllResult>('/api/throughput/entries', { method: 'DELETE' }, 'client'),
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
request<ThroughputProduct>('/api/throughput/products', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateThroughputProduct: (productId: number, payload: ThroughputProductUpdateInput) =>
request<ThroughputProduct>(`/api/throughput/products/${productId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', 'manager', fetcher),
clientAccessExport: (fetcher?: ApiFetch) =>
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', 'client', fetcher),
dashboardSummary: (fetcher?: ApiFetch) =>
cachedFetchJson<DashboardSummary>(
'/api/dashboard/summary',
{
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
},
'client',
fetcher
),
cachedFetchJson<DashboardSummary>('/api/dashboard/summary', 'client', fetcher),
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
@@ -319,6 +561,44 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
// --- Internal user management (lean/admin: manage_users) ------------------
accessUsers: (fetcher?: ApiFetch) =>
request<InternalUser[]>('/api/access/users', { method: 'GET' }, 'client', fetcher),
accessAssignableRoles: (fetcher?: ApiFetch) =>
request<InternalRoleOption[]>('/api/access/assignable-roles', { method: 'GET' }, 'client', fetcher),
accessRoles: (fetcher?: ApiFetch) =>
request<InternalRole[]>('/api/access/roles', { method: 'GET' }, 'client', fetcher),
accessRoleModules: (fetcher?: ApiFetch) =>
request<InternalRoleModuleDefinition[]>('/api/access/role-modules', { method: 'GET' }, 'client', fetcher),
createAccessRole: (payload: InternalRoleCreateInput) =>
request<InternalRole>('/api/access/roles', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateAccessRole: (roleId: number, payload: InternalRoleUpdateInput) =>
request<InternalRole>(`/api/access/roles/${roleId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteAccessRole: (roleId: number) =>
request<void>(`/api/access/roles/${roleId}`, { method: 'DELETE' }, 'client'),
createAccessUser: (payload: InternalUserCreateInput) =>
request<InternalUser>('/api/access/users', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateAccessUser: (userId: number, payload: InternalUserUpdateInput) =>
request<InternalUser>(`/api/access/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
setAccessUserPassword: (userId: number, newPassword: string) =>
request<InternalUser>(`/api/access/users/${userId}/password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword })
}, 'client'),
deleteAccessUser: (userId: number) =>
request<void>(`/api/access/users/${userId}`, { method: 'DELETE' }, 'client'),
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',
@@ -326,6 +606,9 @@ export const api = {
}),
clientSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
adminSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher),
clientLogout: () => request<void>('/api/auth/client/logout', { method: 'POST' }, 'client'),
adminLogout: () => request<void>('/api/auth/admin/logout', { method: 'POST' }, 'admin'),
internalLogout: () => request<void>('/api/access/logout', { method: 'POST' }, 'client'),
login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
@@ -384,5 +667,100 @@ export const api = {
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'manager')
}, 'manager'),
// --- B2B ordering portal (customer) ---------------------------------------
ordering: {
catalogue: (params?: { category?: string; q?: string }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.category) search.set('category', params.category);
if (params?.q) search.set('q', params.q);
const qs = search.toString();
return cachedFetchJson<CatalogueProduct[]>(`/api/ordering/catalogue${qs ? `?${qs}` : ''}`, 'client', fetcher);
},
product: (productId: number, quantity = 1, fetcher?: ApiFetch) =>
request<CatalogueProduct>(`/api/ordering/catalogue/${productId}?quantity=${quantity}`, { method: 'GET' }, 'client', fetcher),
orders: (statusFilter?: string, fetcher?: ApiFetch) =>
cachedFetchJson<Order[]>(`/api/ordering/orders${statusFilter ? `?status=${statusFilter}` : ''}`, 'client', fetcher),
order: (orderId: number, fetcher?: ApiFetch) =>
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
createDraft: (payload: DraftOrderInput) =>
request<Order>('/api/ordering/orders', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateDraft: (orderId: number, payload: Partial<DraftOrderInput>) =>
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
deleteDraft: (orderId: number) =>
request<void>(`/api/ordering/orders/${orderId}`, { method: 'DELETE' }, 'client'),
submit: (orderId: number, payload: Partial<DraftOrderInput> = {}) =>
request<Order>(`/api/ordering/orders/${orderId}/submit`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
reorder: (orderId: number) =>
request<Order>(`/api/ordering/orders/${orderId}/reorder`, { method: 'POST' }, 'client'),
confirmationPdf: (orderId: number) =>
requestBlob(`/api/ordering/orders/${orderId}/confirmation.pdf`, {}, 'client')
},
// --- B2B ordering portal (admin) ------------------------------------------
orderingAdmin: {
customers: (fetcher?: ApiFetch) => cachedFetchJson<OrderingCustomer[]>('/api/ordering-admin/customers', 'client', fetcher),
createCustomer: (payload: { name: string; client_code: string; tenant_id?: string; notes?: string }) =>
request<OrderingCustomer>('/api/ordering-admin/customers', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateCustomer: (customerId: number, payload: { name?: string; status?: string; notes?: string }) =>
request<OrderingCustomer>(`/api/ordering-admin/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
customerUsers: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<OrderingCustomerUser[]>(`/api/ordering-admin/customers/${customerId}/users`, 'client', fetcher),
createCustomerUser: (customerId: number, payload: { full_name: string; email: string; role: string }) =>
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateCustomerUser: (customerId: number, userId: number, payload: { full_name?: string; role?: string; status?: string }) =>
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<CatalogueProduct[]>('/api/ordering-admin/products', 'client', fetcher),
createProduct: (payload: Partial<CatalogueProduct>) =>
request<CatalogueProduct>('/api/ordering-admin/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateProduct: (productId: number, payload: Partial<CatalogueProduct>) =>
request<CatalogueProduct>(`/api/ordering-admin/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
visibility: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<CustomerVisibilityRow[]>(`/api/ordering-admin/customers/${customerId}/visibility`, 'client', fetcher),
setVisibility: (customerId: number, payload: { product_id: number; visible: boolean }) =>
request(`/api/ordering-admin/customers/${customerId}/visibility`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
pricing: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/pricing`, 'client', fetcher),
setAssignment: (customerId: number, payload: { price_list_id: number | null; discount_percent: number }) =>
request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/assignment`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
setProductPrice: (
customerId: number,
payload: { product_id: number; unit_price: number | null; rule_type: string; contract_reference?: string | null; notes?: string | null; active?: boolean }
) => request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/product-prices`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
deleteProductPrice: (customerId: number, productId: number) =>
request<void>(`/api/ordering-admin/customers/${customerId}/product-prices/${productId}`, { method: 'DELETE' }, 'client'),
orders: (params?: { status?: string; customer_id?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.status) search.set('status', params.status);
if (params?.customer_id != null) search.set('customer_id', String(params.customer_id));
const qs = search.toString();
return cachedFetchJson<Order[]>(`/api/ordering-admin/orders${qs ? `?${qs}` : ''}`, 'client', fetcher);
},
order: (orderId: number, fetcher?: ApiFetch) =>
request<Order>(`/api/ordering-admin/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
updateStatus: (orderId: number, payload: { to_status: string; note?: string }) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/status`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
overrideLine: (orderId: number, lineId: number, payload: { quantity?: number; unit_price?: number; reason?: string }) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/lines/${lineId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
reopen: (orderId: number, note?: string) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/reopen`, { method: 'POST', body: JSON.stringify({ note }) }, 'client'),
sendToXero: (orderId: number) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/send-to-xero`, { method: 'POST' }, 'client'),
notificationSettings: (fetcher?: ApiFetch) =>
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher),
xeroContacts: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactList>('/api/ordering-admin/xero/contacts', 'client', fetcher),
xeroContactLinks: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactLinkRow[]>('/api/ordering-admin/xero/contact-links', 'client', fetcher),
linkCustomerToXero: (
customerId: number,
payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null }
) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
unlinkCustomerFromXero: (customerId: number) =>
request<void>(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client')
}
};
+196
View File
@@ -0,0 +1,196 @@
import packageInfo from '../../package.json';
/**
* Release notes shown in the "What's new" dialog. This is the single source of
* truth for the changelog: add a new entry at the top of `changelog` whenever
* the version in package.json is bumped, and the dialog will surface it once per
* user on their next login (see $lib/whats-new and WhatsNewDialog.svelte).
*/
export type ChangelogEntry = {
version: string;
/** ISO date (YYYY-MM-DD) the version shipped. */
date: string;
highlights: string[];
};
/** The running app version, read straight from package.json at build time. */
export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [
{
version: '0.1.36',
date: '2026-06-21',
highlights: [
'App: Bug fixes & improvements.',
'App: General improvements.'
]
},
{
version: '0.1.35',
date: '2026-06-21',
highlights: [
'App: Bug fixes & improvements.',
'App: General improvements.'
]
},
{
version: '0.1.34',
date: '2026-06-21',
highlights: [
'App: Bug fixes & improvements.',
'App: General improvements.'
]
},
{
version: '0.1.33',
date: '2026-06-21',
highlights: [
'App: Mix Calculator & Ingredients improvements.',
'App: Bug fixes & improvements.'
]
},
{
version: '0.1.32',
date: '2026-06-21',
highlights: [
'Mix Calculator: Added search.',
'Ingredients: Added ingredient categories.',
'Throughput: Tidy-up and refinements.'
]
},
{
version: '0.1.31',
date: '2026-06-18',
highlights: [
'Mix Editor: Multi-row editing.',
'App: Bug fixes & improvements.'
]
},
{
version: '0.1.30',
date: '2026-06-18',
highlights: [
'Throughput: Overview now shows today-only mix cards.',
'Mix Editor: Fixed an error when saving a mix formula.'
]
},
{
version: '0.1.29',
date: '2026-06-18',
highlights: [
'Mix Editor: Editing a % no longer rebalances the other ingredients.'
]
},
{
version: '0.1.28',
date: '2026-06-17',
highlights: [
'Editor & Throughput: Updates and improvements.'
]
},
{
version: '0.1.27',
date: '2026-06-16',
highlights: [
'Editor: Edit a mixs resolved formula directly, with % and kg entry on each row.',
'Editor: New mix and new ingredient buttons.',
'Throughput: Power BI / external API now live.',
'App: Security hardening on API responses.'
]
},
{
version: '0.1.23',
date: '2026-06-15',
highlights: [
'App: Improvements & bug fixes.'
]
},
{
version: '0.1.22',
date: '2026-06-15',
highlights: [
'Web App - Throughput module is now live.'
]
},
{
version: '0.1.21',
date: '2026-06-14',
highlights: [
'Mix Calculator: Composer restyle.'
]
},
{
version: '0.1.20',
date: '2026-06-13',
highlights: [
'App - Improvements',
'App - Bug fixes'
]
},
{
version: '0.1.19',
date: '2026-06-13',
highlights: [
'Throughput: Overview view added.',
'App: Responsive header.'
]
},
{
version: '0.1.18',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Bug fixes'
]
},
{
version: '0.1.17',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Mix Calculator bug fixes'
]
},
{
version: '0.1.14',
date: '2026-06-11',
highlights: [
'Web App: Improved mix calculator',
'Web App: Improved design'
]
},
{
version: '0.1.12',
date: '2026-06-10',
highlights: [
'Mix Calculator: Changed from selecting Product to Mix.',
'Web app design improved',
'Throughput tab ready for testing',
'Costing Editor tab ready for testing'
]
},
{
version: '0.1.11',
date: '2026-06-03',
highlights: [
'Costing Editor: First release.'
]
}
];
/** The changelog entry matching a specific version, if one exists. */
export function changelogFor(version: string): ChangelogEntry | undefined {
return changelog.find((entry) => entry.version === version);
}
/** The most recent changelog entry, regardless of the running version. */
export const latestChangelog: ChangelogEntry | undefined = changelog[0];
/**
* The entry the "What's new" dialog shows. Prefer an exact match for the running
* version, but fall back to the latest documented entry so the manual button
* always opens something even when the current build's version (e.g. a hotfix
* suffix like `0.1.24b`) isn't itself listed in the changelog.
*/
export const currentChangelog: ChangelogEntry | undefined =
changelogFor(APP_VERSION) ?? latestChangelog;
@@ -1,382 +0,0 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { adminSession, sessionHydrated } from '$lib/session';
const navigation = [
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
{ href: '/admin/client-access', label: 'Client Access', shortLabel: 'CA' }
];
let { children } = $props();
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
function matchesRoute(href: string, pathname: string) {
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
}
function pageTitle(pathname: string) {
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
}
function initials(name: string) {
return name
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
$effect(() => {
const hydrated = $sessionHydrated;
const token = $adminSession?.token ?? null;
if (!hydrated) {
return;
}
if (!token) {
isRestoringSession = false;
restoredToken = null;
return;
}
if (restoredToken === token) {
return;
}
restoredToken = token;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
isRestoringSession = false;
}
});
});
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Lean 101 Admin Panel</title>
</svelte:head>
<div class="admin-shell">
<aside class="admin-sidebar">
<a class="admin-brand" href="/admin">
<span class="brand-mark">L1</span>
<span>Lean 101 Admin Panel</span>
</a>
<p class="admin-copy">
Internal workspace for Lean 101 operators managing client access and controlled workspace changes.
</p>
<nav class="admin-nav" aria-label="Admin navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="admin-footer">
<a href="/">Open client workspace</a>
{#if $adminSession}
<button type="button" onclick={() => adminSession.clear()}>Sign out</button>
{/if}
</div>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div>
<p class="eyebrow">Admin Area</p>
<h1>{pageTitle(page.url.pathname)}</h1>
</div>
{#if !$sessionHydrated}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Checking saved session</strong>
<span>Restoring admin access</span>
</div>
</div>
{:else if $adminSession}
<div class="profile-card">
<span class="profile-avatar">{initials($adminSession.name)}</span>
<div>
<strong>{$adminSession.name}</strong>
<span>{$adminSession.email}</span>
</div>
</div>
{:else}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Admin sign-in required</strong>
<span>Use `/admin` to authenticate</span>
</div>
</div>
{/if}
</header>
<main class="admin-content">
{#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)}
<section class="locked-card loading-card">
<p class="eyebrow">Checking Session</p>
<h2>Restoring the Lean 101 admin workspace.</h2>
<p>Refreshing the current route with the saved operator session before prompting for sign-in.</p>
</section>
{:else if isProtectedRoute && !$adminSession}
<section class="locked-card">
<p class="eyebrow">Restricted</p>
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
<p>Client access controls are only available inside the separate admin workspace.</p>
<a href="/admin">Go to admin sign-in</a>
</section>
{:else}
{@render children()}
{/if}
</main>
</div>
</div>
<style>
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background:
radial-gradient(circle at top left, rgba(167, 217, 190, 0.22), transparent 34%),
linear-gradient(180deg, #f7f8f4 0%, #eef2ea 100%);
color: #203028;
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.1rem;
border-right: 1px solid rgba(34, 54, 45, 0.12);
background: rgba(20, 29, 24, 0.96);
color: #f4f7f1;
}
.admin-brand {
display: inline-flex;
align-items: center;
gap: 0.8rem;
font-size: 1.05rem;
font-weight: 700;
}
.brand-mark,
.nav-icon,
.profile-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand-mark {
width: 2rem;
height: 2rem;
border-radius: 0.72rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
}
.admin-copy {
margin: 0;
color: rgba(244, 247, 241, 0.74);
line-height: 1.55;
}
.admin-nav {
display: grid;
gap: 0.4rem;
}
.admin-nav a {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.78rem;
border-radius: 0.9rem;
color: rgba(244, 247, 241, 0.88);
transition: background-color 140ms ease;
}
.admin-nav a:hover,
.admin-nav a.active {
background: rgba(207, 228, 184, 0.16);
}
.admin-nav a.active {
color: #ffffff;
}
.nav-icon {
width: 1.65rem;
height: 1.65rem;
border-radius: 0.58rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
font-size: 0.7rem;
}
.admin-footer {
margin-top: auto;
display: grid;
gap: 0.6rem;
}
.admin-footer a,
.admin-footer button {
padding: 0.82rem 0.88rem;
border: 1px solid rgba(244, 247, 241, 0.14);
border-radius: 0.88rem;
background: rgba(255, 255, 255, 0.04);
color: inherit;
text-align: left;
cursor: pointer;
}
.admin-main {
min-width: 0;
display: flex;
flex-direction: column;
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem;
border-bottom: 1px solid rgba(34, 54, 45, 0.1);
background: rgba(247, 248, 244, 0.85);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 0.18rem;
color: #66806e;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-topbar h1 {
margin: 0;
font-size: 1.7rem;
}
.profile-card {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.45rem 0.52rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 0.95rem;
background: rgba(255, 255, 255, 0.82);
}
.profile-avatar {
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
color: #ffffff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
}
.profile-card strong,
.profile-card span {
display: block;
}
.profile-card span {
margin-top: 0.14rem;
color: #6b7f72;
font-size: 0.82rem;
}
.guest .profile-avatar {
background: linear-gradient(135deg, #c4d0c8 0%, #7b8b80 100%);
}
.admin-content {
min-width: 0;
padding: 1.4rem;
}
.locked-card {
max-width: 42rem;
padding: 1.35rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
box-shadow: none;
}
.loading-card {
min-height: 10rem;
}
.locked-card h2,
.locked-card p {
margin: 0;
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.8rem, 3vw, 2.3rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: #5d7166;
}
.locked-card a {
display: inline-flex;
margin-top: 1rem;
padding: 0.82rem 0.95rem;
border-radius: 0.9rem;
background: #203028;
color: #ffffff;
font-weight: 600;
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
border-right: none;
border-bottom: 1px solid rgba(34, 54, 45, 0.12);
}
}
@media (max-width: 720px) {
.admin-topbar {
flex-direction: column;
align-items: flex-start;
}
.admin-content {
padding: 1rem;
}
}
</style>
+683
View File
@@ -0,0 +1,683 @@
<script lang="ts">
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte';
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
canManageOrdering as sessionCanManageOrdering,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
getWorkspaceRole,
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
accessControlItem,
baseSearchItems,
buildClientNavEntries,
dashboardItem,
editorItem,
ingredientsEditorItem,
footerLinks,
mixCalculatorItem,
orderingItem,
orderingManageChildren,
orderingManageGroup,
pageMeta,
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
type NavEntry,
type SearchItem,
type NavItem,
workingDocumentItems
} from '$lib/navigation/client-navigation';
import { onMount } from 'svelte';
import packageInfo from '../../../package.json';
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let searchOpen = $state(false);
let searchQuery = $state('');
let searchFocusRequest = $state(0);
let appsFabOpen = $state(false);
let sidebarOpen = $state(true);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let whatsNewOpen = $state(false);
// The user identity we've already run the "what's new" check for this mount,
// so the dialog is evaluated once per login rather than on every navigation.
let whatsNewCheckedFor = $state<string | null>(null);
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchKey = $state<string | null>(null);
let bootDelayDone = $state(false);
let sidebarStateReady = $state(false);
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
const shellPageMeta = $derived(
routeGuardPending
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
: pageMeta(page.url.pathname)
);
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => {
if (item.href === '/mixes') return canOpenMixMaster;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
// Ordering serves two audiences: internal staff get the management console
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
// Internal staff get the collapsible "Order Management" family (queue +
// products/customers/pricing/settings/integrations); customers get the single
// catalogue link. Either way it becomes one NavEntry the rail can render.
const visibleOrderingEntry = $derived<NavEntry | null>(
canManageOrdering
? { kind: 'group', group: orderingManageGroup }
: canOpenCustomerOrdering
? { kind: 'item', item: orderingItem }
: null
);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix
// calculator plus throughput), a "Costing" family (product costing and the
// editors), then the standalone ordering/insights modules. Built from the same
// access-filtered items, so a role only ever sees the families it may open.
const navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
operations: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : [])
],
costing: [
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
const fabItems = $derived.by(() => {
const items = [
visibleDashboardItem,
...visibleWorkingDocumentItems,
visibleMixCalculatorItem,
visibleProductCostingItem,
visibleThroughputItem,
visibleOrderingEntry?.kind === 'item'
? visibleOrderingEntry.item
: visibleOrderingEntry?.group
? {
href: visibleOrderingEntry.group.href ?? '/ordering/manage',
label: visibleOrderingEntry.group.label,
icon: visibleOrderingEntry.group.icon
}
: null,
visibleReportingItem,
visibleEditorItem,
visibleIngredientsEditorItem,
visibleAccessControlItem
].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item));
const seen = new Set<string>();
return items.flatMap((item) => {
if (seen.has(item.href)) {
return [];
}
seen.add(item.href);
return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem];
});
});
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
const workingDocumentsActive = $derived(
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
);
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/ingredients') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
return true;
})
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
const showDesktopSidebar = $derived(!showBottomNav);
function restoreSidebarState() {
if (typeof window === 'undefined') {
return true;
}
try {
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false';
} catch {
return true;
}
}
function persistSidebarState() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen));
} catch {
// Storage failures should not block shell interactions.
}
}
function openSearch(query = '') {
searchQuery = query;
searchOpen = true;
searchFocusRequest += 1;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
function syncViewport() {
showBottomNav = window.innerWidth <= 1180;
if (!showBottomNav) {
navOpen = false;
}
}
async function runSearchItem(item: SearchItem) {
searchOpen = false;
searchQuery = '';
await goto(item.href);
}
async function openSettings() {
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
}
async function signOut() {
try {
if ($clientSession?.role === 'internal') {
await api.internalLogout();
} else {
await api.clientLogout();
}
} catch {
// Clearing the local session remains the safe fallback.
} finally {
clientSession.clear();
}
}
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
$effect(() => {
page.url.pathname;
appsFabOpen = false;
userMenuOpen = false;
searchOpen = false;
searchQuery = '';
navOpen = false;
});
$effect(() => {
if (!sidebarStateReady) {
return;
}
sidebarOpen;
persistSidebarState();
});
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = buildSessionKey($clientSession);
if (!hydrated) {
return;
}
if (!sessionKey) {
isRestoringSession = false;
restoredSessionKey = null;
return;
}
if (restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
isRestoringSession = true;
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
// legacy client-portal users keep using /api/auth/client/session.
const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession();
refresh
.then((session) => {
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredSessionKey = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false;
});
});
// Search items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the search, not on every login or
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const sessionKey = buildSessionKey(session);
const shouldSeed = searchOpen;
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
seededSearchKey = null;
return;
}
if (!shouldSeed || seededSearchKey === sessionKey) {
return;
}
seededSearchKey = sessionKey;
Promise.all([
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([mixes, sessions]) => {
if (seededSearchKey !== sessionKey) {
return;
}
seededSearchItems = [
...mixes.map((mix) => ({
href: `/mixes/${mix.id}`,
label: mix.name,
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
})),
...sessions.map((savedSession) => ({
href: `/mix-calculator/${savedSession.id}`,
label: `${savedSession.session_number} · ${savedSession.product_name}`,
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
}))
];
})
.catch(() => {
if (seededSearchKey === sessionKey) {
seededSearchItems = [];
}
});
});
$effect(() => {
if ($sessionHydrated && !$clientSession && !isRootRoute) {
goto('/', { replaceState: true });
}
});
$effect(() => {
if (!$sessionHydrated || !$clientSession) {
return;
}
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
return;
}
goto(workspaceHomeHref, { replaceState: true });
});
// Surface the release notes once per version per user, right after login.
// hasSeenVersion keeps this to a single appearance: once dismissed (which
// records the version), it won't return until the next version ships.
$effect(() => {
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
return;
}
const userKey = buildSessionKey($clientSession);
if (!userKey) {
return;
}
if (whatsNewCheckedFor === userKey) {
return;
}
whatsNewCheckedFor = userKey;
if (!hasSeenVersion(userKey, currentChangelog.version)) {
whatsNewOpen = true;
}
});
function dismissWhatsNew() {
const userKey = buildSessionKey($clientSession);
if (userKey && currentChangelog) {
markVersionSeen(userKey, currentChangelog.version);
}
whatsNewOpen = false;
}
onMount(() => {
sidebarOpen = restoreSidebarState();
sidebarStateReady = true;
const bootTimer = window.setTimeout(() => {
bootDelayDone = true;
}, 1500);
syncViewport();
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openSearch();
}
if (event.key === 'Escape') {
searchOpen = false;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('resize', syncViewport);
return () => {
window.clearTimeout(bootTimer);
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', syncViewport);
};
});
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w: string) => w[0])
.join('')
.toUpperCase() || '?'
);
</script>
<svelte:head>
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
<div class="signed-out-shell">
{#if isRootRoute}
{@render children()}
{:else if showWorkspaceBoot}
<WorkspaceBootCard />
{:else}
<WorkspaceSignedOutCard />
{/if}
</div>
{:else}
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
{#if showDesktopSidebar}
<ClientPrimaryRail
collapsed={!sidebarOpen}
currentPath={shellPathname}
entries={navEntries}
brandHref={workspaceHomeHref}
footerItems={visibleFooterLinks}
{appVersion}
{currentYear}
{canOpenSettings}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
sessionHydrated={$sessionHydrated}
session={$clientSession}
showSidebarToggle={!showBottomNav}
{sidebarOpen}
{userInitials}
{userMenuOpen}
{canUseWorkspaceSearch}
bind:searchQuery={searchQuery}
bind:searchOpen={searchOpen}
searchFocusRequest={searchFocusRequest}
filteredSearchItems={searchState.filteredItems}
hiddenResultCount={searchState.hiddenResultCount}
{canOpenSettings}
onRunSearchItem={runSearchItem}
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
appsFabOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={signOut}
onShowWhatsNew={() => (whatsNewOpen = true)}
/>
<main class="content">
{#if !routeGuardPending}
<WorkspacePageHeader
category={shellPageMeta.category}
title={shellPageMeta.title}
icon={shellPageMeta.icon}
/>
{/if}
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
detail={
isRestoringSession
? 'Refreshing the saved session before rendering workspace content.'
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
}
>
{@render children()}
</AuthGate>
</main>
</div>
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
</div>
<WorkspaceTabletNav
bind:navOpen
{showBottomNav}
{primaryBottomNavigation}
{visibleDashboardItem}
{visibleMixCalculatorItem}
{visibleProductCostingItem}
{visibleThroughputItem}
{visibleEditorItem}
{visibleReportingItem}
{visibleWorkingDocumentItems}
{visibleFooterLinks}
{orderingManageGroup}
{orderingManageChildren}
{orderingItem}
{canManageOrdering}
{canOpenCustomerOrdering}
{canCreateMixWorksheet}
{canCreateMixSession}
{canOpenSettings}
pagePath={page.url.pathname}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
{#if $clientSession && whatsNewOpen && currentChangelog}
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
<style>
.app-shell {
display: grid;
grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh;
background: var(--color-bg-app);
}
.app-shell.sidebar-collapsed {
grid-template-columns: 4.5rem minmax(0, 1fr);
}
.signed-out-shell {
min-height: 100vh;
padding: 1.5rem;
}
.main-shell {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: var(--color-bg-app);
}
.content {
--content-padding: 1.34rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
padding: var(--content-padding);
overflow: auto;
background: var(--color-bg-app);
}
.main-shell.bottom-nav-layout .content {
padding-bottom: 7.25rem;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.content {
--content-padding: 1rem;
padding: 1rem;
}
}
@media (min-width: 1181px) {
.bottom-nav-layout .content {
padding-bottom: 1.34rem;
}
}
@media (max-width: 720px) {
.content {
--content-padding: 0.92rem;
padding: 0.92rem;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More