Compare commits
2
Commits
cfc193b713
...
84792c0947
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84792c0947 | ||
|
|
2f2466ecac |
+18
-7
@@ -1,17 +1,28 @@
|
|||||||
APP_NAME=Lean 101 Clients API
|
APP_NAME=Lean 101 Clients API
|
||||||
|
APP_ENV=alpha
|
||||||
CLIENT_NAME=Hunter Premium Produce
|
CLIENT_NAME=Hunter Premium Produce
|
||||||
CLIENT_EMAIL=operator@example.com
|
CLIENT_EMAIL=operator@example.com
|
||||||
CLIENT_PASSWORD=changeme
|
CLIENT_PASSWORD=replace-with-strong-password
|
||||||
CLIENT_TENANT_ID=hunter-premium-produce
|
CLIENT_TENANT_ID=hunter-premium-produce
|
||||||
ADMIN_NAME=Lean 101
|
ADMIN_NAME=Lean 101
|
||||||
ADMIN_EMAIL=admin@lean101.local
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=lean101-admin
|
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
|
||||||
ORIGIN=https://clients.lean-101.com.au
|
ORIGIN=https://clients.example.com
|
||||||
PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
|
PUBLIC_API_BASE_URL=https://clients.example.com
|
||||||
INTERNAL_API_BASE_URL=http://backend:8000
|
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
|
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_HISTORY=false
|
||||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||||
DATABASE_URL=sqlite:////data/data_entry_app.db
|
DATABASE_URL=sqlite:////data/data_entry_app.db
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
APP_NAME=Lean 101 Clients API
|
||||||
|
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
|
||||||
+17
-6
@@ -1,26 +1,37 @@
|
|||||||
APP_NAME=Lean 101 Clients API
|
APP_NAME=Lean 101 Clients API
|
||||||
|
APP_ENV=production
|
||||||
CLIENT_NAME=Hunter Premium Produce
|
CLIENT_NAME=Hunter Premium Produce
|
||||||
CLIENT_EMAIL=operator@example.com
|
CLIENT_EMAIL=operator@example.com
|
||||||
CLIENT_PASSWORD=replace-with-strong-password
|
CLIENT_PASSWORD=replace-with-strong-password
|
||||||
CLIENT_TENANT_ID=hunter-premium-produce
|
CLIENT_TENANT_ID=hunter-premium-produce
|
||||||
ADMIN_NAME=Lean 101
|
ADMIN_NAME=Lean 101
|
||||||
ADMIN_EMAIL=admin@lean101.local
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=replace-with-strong-password
|
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
|
# Postgres credentials. The compose file builds DATABASE_URL from these
|
||||||
# so you do not need to set DATABASE_URL explicitly. Override DATABASE_URL
|
# 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.
|
# 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_PASSWORD=replace-with-a-long-random-password
|
||||||
POSTGRES_DB=lean101
|
POSTGRES_DB=lean101
|
||||||
# DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME
|
# DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME
|
||||||
|
|
||||||
ORIGIN=https://clients.lean-101.com.au
|
ORIGIN=https://clients.example.com
|
||||||
PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
|
PUBLIC_API_BASE_URL=https://clients.example.com
|
||||||
INTERNAL_API_BASE_URL=http://backend:8000
|
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
|
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_HISTORY=false
|
||||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||||
|
|||||||
+8
-1
@@ -3,13 +3,20 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
*.log
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
node_modules/
|
node_modules/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
backend/.pytest_cache/
|
backend/.pytest_cache/
|
||||||
|
backend/.tmp/
|
||||||
|
backend/pytest-cache-files-*/
|
||||||
|
backend/tests/__pycache__/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
frontend/.vite/
|
||||||
|
frontend/coverage/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
@@ -17,4 +24,4 @@ frontend/node_modules/
|
|||||||
*.db
|
*.db
|
||||||
.env.production
|
.env.production
|
||||||
.env.alpha
|
.env.alpha
|
||||||
|
.env
|
||||||
|
|||||||
@@ -1,3 +1,112 @@
|
|||||||
|
## Repository operations
|
||||||
|
|
||||||
|
### 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
|
## Spreadsheet analysis summary
|
||||||
|
|
||||||
The workbook is effectively a costing and pricing model with three core calculation layers:
|
The workbook is effectively a costing and pricing model with three core calculation layers:
|
||||||
|
|||||||
@@ -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 product’s 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 |
@@ -90,10 +90,17 @@ If your server already has a host-level nginx handling domains and TLS, use `dep
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
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).
|
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.
|
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 repo’s 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
|
## Backend
|
||||||
|
|
||||||
Create a virtual environment, install dependencies, then run:
|
Create a virtual environment, install dependencies, then run:
|
||||||
@@ -116,6 +123,45 @@ pytest
|
|||||||
The backend defaults to SQLite for the prototype and can be switched with the
|
The backend defaults to SQLite for the prototype and can be switched with the
|
||||||
`DATABASE_URL` environment variable.
|
`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
|
## Frontend
|
||||||
|
|
||||||
Install dependencies and start the dev server:
|
Install dependencies and start the dev server:
|
||||||
|
|||||||
+10
-1
@@ -5,11 +5,20 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system app && adduser --system --ingroup app app
|
||||||
|
|
||||||
COPY backend /app
|
COPY backend /app
|
||||||
|
COPY ["input_data/1.xlsx", "/app/input_data/1.xlsx"]
|
||||||
|
COPY ["Input Cost Spreadsheet(1).xlsx", "/app/Input Cost Spreadsheet(1).xlsx"]
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
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
|
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"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
+43
-19
@@ -7,7 +7,7 @@ the current user has, then use those keys to hide/show navigation items.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
@@ -21,12 +21,19 @@ from app.core.access import (
|
|||||||
require_permission,
|
require_permission,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
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.core.security import hash_password, issue_token, verify_password
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.access import Permission, Role, User
|
from app.models.access import Permission, Role, User
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/access", tags=["access"])
|
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):
|
class LoginRequest(BaseModel):
|
||||||
@@ -75,7 +82,10 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
|
|||||||
role_name = user.role.name if user.role else None
|
role_name = user.role.name if user.role else None
|
||||||
token = None
|
token = None
|
||||||
if include_token:
|
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
|
# role="internal" is a marker the shared auth deps recognise so internal
|
||||||
# users can hit the same routes as client-portal users without being
|
# users can hit the same routes as client-portal users without being
|
||||||
# confused with them. Display name lives in role_name / client_role.
|
# confused with them. Display name lives in role_name / client_role.
|
||||||
@@ -96,16 +106,14 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=UserSession)
|
@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.
|
"""Internal-user login.
|
||||||
|
|
||||||
Authenticates against a shared internal password (``ADMIN_PASSWORD``) and
|
Authenticates against the per-user password hash stored on ``users``.
|
||||||
looks up the user by email. Inactive or unknown users are rejected with
|
Inactive or unknown users are rejected with a generic 401 to avoid
|
||||||
a generic 401 to avoid leaking which emails are valid.
|
leaking which emails are valid.
|
||||||
"""
|
"""
|
||||||
if payload.password != settings.admin_password:
|
login_rate_limiter.hit(request_client_key(request, suffix="internal-login"))
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
|
||||||
|
|
||||||
email = payload.email.strip().lower()
|
email = payload.email.strip().lower()
|
||||||
user = db.scalar(
|
user = db.scalar(
|
||||||
select(User)
|
select(User)
|
||||||
@@ -113,15 +121,26 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
.options(selectinload(User.role).selectinload(Role.permissions))
|
.options(selectinload(User.role).selectinload(Role.permissions))
|
||||||
)
|
)
|
||||||
if user is None or not user.is_active:
|
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")
|
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)
|
@router.get("/me", response_model=UserSession)
|
||||||
def read_me(user: User = Depends(get_current_user)):
|
def read_me(user: User = Depends(get_current_user)):
|
||||||
"""Return the current user with permission keys for UI navigation gating."""
|
"""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])
|
@router.get("/me/permissions", response_model=list[str])
|
||||||
@@ -144,13 +163,11 @@ def update_me(
|
|||||||
):
|
):
|
||||||
"""Allow an internal user to update their own name, email, or password."""
|
"""Allow an internal user to update their own name, email, or password."""
|
||||||
if payload.new_password:
|
if payload.new_password:
|
||||||
# Require current password verification before allowing a password change.
|
# Require current password verification before allowing a password
|
||||||
# Users who have never set a personal password must supply the shared
|
# change. Keep a narrow fallback for legacy rows that still have no
|
||||||
# admin password as the current credential.
|
# password hash yet.
|
||||||
current_ok = (
|
current_ok = verify_password(payload.current_password or "", user.password_hash) or (
|
||||||
verify_password(payload.current_password or "", user.password_hash)
|
user.password_hash is None and (payload.current_password or "") == settings.admin_password
|
||||||
if user.password_hash
|
|
||||||
else (payload.current_password or "") == settings.admin_password
|
|
||||||
)
|
)
|
||||||
if not current_ok:
|
if not current_ok:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -181,7 +198,14 @@ def update_me(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
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
|
# Permission-enforced administrative endpoints. Route bodies should not check
|
||||||
|
|||||||
+44
-9
@@ -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 pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import AuthSession, require_admin_session, require_client_session
|
from app.api.deps import AuthSession, require_admin_session, require_client_session
|
||||||
from app.core.config import settings
|
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.core.security import issue_token
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.client_access import ClientAccount
|
from app.models.client_access import ClientAccount
|
||||||
from app.services.client_access_service import get_client_user_by_email, module_access_map
|
from app.services.client_access_service import get_client_user_by_email, module_access_map
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
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):
|
class LoginRequest(BaseModel):
|
||||||
@@ -27,7 +34,7 @@ class SessionResponse(BaseModel):
|
|||||||
user_id: int | None = None
|
user_id: int | None = None
|
||||||
client_account_id: int | None = None
|
client_account_id: int | None = None
|
||||||
module_permissions: dict[str, str] = Field(default_factory=dict)
|
module_permissions: dict[str, str] = Field(default_factory=dict)
|
||||||
token: str
|
token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _build_session_response(
|
def _build_session_response(
|
||||||
@@ -50,7 +57,8 @@ def _build_session_response(
|
|||||||
"client_role": client_role,
|
"client_role": client_role,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"client_account_id": client_account_id,
|
"client_account_id": client_account_id,
|
||||||
}
|
},
|
||||||
|
ttl_seconds=settings.session_ttl_seconds,
|
||||||
)
|
)
|
||||||
return SessionResponse(
|
return SessionResponse(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -66,19 +74,22 @@ def _build_session_response(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/client/login", response_model=SessionResponse)
|
@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:
|
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")
|
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())
|
user = get_client_user_by_email(db, email=payload.email.strip().lower())
|
||||||
if user is None:
|
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")
|
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))
|
client_account = db.scalar(select(ClientAccount).where(ClientAccount.id == user.client_account_id))
|
||||||
if client_account is None:
|
if client_account is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured for this user")
|
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,
|
name=user.full_name,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
role="client",
|
role="client",
|
||||||
@@ -88,14 +99,24 @@ def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
client_account_id=client_account.id,
|
client_account_id=client_account.id,
|
||||||
module_permissions=module_access_map(user),
|
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)
|
@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:
|
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")
|
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)
|
@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,
|
user_id=user.id,
|
||||||
client_account_id=user.client_account_id,
|
client_account_id=user.client_account_id,
|
||||||
module_permissions=module_access_map(user),
|
module_permissions=module_access_map(user),
|
||||||
)
|
).model_copy(update={"token": None})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/session", response_model=SessionResponse)
|
@router.get("/admin/session", response_model=SessionResponse)
|
||||||
def read_admin_session(session: AuthSession = Depends(require_admin_session)):
|
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
|
||||||
|
|||||||
@@ -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 import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
@@ -78,10 +78,11 @@ def _actor_metadata(session: AuthSession) -> dict[str, str]:
|
|||||||
|
|
||||||
@router.get("", response_model=list[ClientAccessRead])
|
@router.get("", response_model=list[ClientAccessRead])
|
||||||
def get_client_access(
|
def get_client_access(
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: AuthSession = Depends(require_client_access_manager_session),
|
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)
|
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
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.db.session import get_db
|
||||||
from app.models.mix import Mix
|
from app.models.mix import Mix
|
||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
@@ -35,7 +35,7 @@ def _can(session: AuthSession, module_key: str) -> bool:
|
|||||||
|
|
||||||
@router.get("/summary")
|
@router.get("/summary")
|
||||||
def dashboard_summary(
|
def dashboard_summary(
|
||||||
session: AuthSession = Depends(require_client_session),
|
session: AuthSession = Depends(require_client_module_access("dashboard")),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
raw_materials_summary: dict | None = None
|
raw_materials_summary: dict | None = None
|
||||||
|
|||||||
+14
-7
@@ -2,8 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
@@ -13,14 +12,14 @@ from app.core.access import (
|
|||||||
get_user_permissions,
|
get_user_permissions,
|
||||||
permissions_to_module_map,
|
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.core.security import verify_token
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.access import Role, User
|
from app.models.access import Role, User
|
||||||
from app.models.client_access import ClientFeatureAccess, ClientUser
|
from app.models.client_access import ClientFeatureAccess, ClientUser
|
||||||
from app.services.client_access_service import has_access_level, module_access_map
|
from app.services.client_access_service import has_access_level, module_access_map
|
||||||
|
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AuthSession:
|
class AuthSession:
|
||||||
@@ -67,13 +66,16 @@ def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
|
|||||||
|
|
||||||
|
|
||||||
def get_auth_session(
|
def get_auth_session(
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> AuthSession:
|
) -> 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")
|
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
|
# Internal Hunter Stock Feeds users get an auth session derived from the
|
||||||
# role/permission tables rather than the client-portal ClientUser tables.
|
# 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:
|
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
|
||||||
if session.role != "admin":
|
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")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
return session
|
return session
|
||||||
|
|
||||||
@@ -143,6 +146,7 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
|
|||||||
if session.role == "internal":
|
if session.role == "internal":
|
||||||
permissions = session.module_permissions or {}
|
permissions = session.module_permissions or {}
|
||||||
if not has_access_level(permissions.get(module_key), minimum_level):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"{module_key} access is not permitted",
|
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:
|
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")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} is disabled for this client")
|
||||||
|
|
||||||
permissions = module_access_map(user)
|
permissions = module_access_map(user)
|
||||||
if not has_access_level(permissions.get(module_key), minimum_level):
|
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")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted")
|
||||||
|
|
||||||
return AuthSession(
|
return AuthSession(
|
||||||
@@ -190,6 +196,7 @@ def require_client_access_manager_session(
|
|||||||
user = load_current_client_user(db, require_client_session(session))
|
user = load_current_client_user(db, require_client_session(session))
|
||||||
permissions = module_access_map(user)
|
permissions = module_access_map(user)
|
||||||
if user.role != "superadmin" or not has_access_level(permissions.get("client_access"), "manage"):
|
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")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required")
|
||||||
|
|
||||||
return AuthSession(
|
return AuthSession(
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import AuthSession, require_client_module_access
|
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,
|
update_mix_calculator_session,
|
||||||
)
|
)
|
||||||
from app.services.mix_calculator_pdf import MixCalculatorPdfUnavailableError, build_mix_calculator_pdf
|
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"])
|
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
|
||||||
|
|
||||||
@@ -37,10 +37,11 @@ def mix_calculator_options(
|
|||||||
|
|
||||||
@router.get("", response_model=list[MixCalculatorSessionSummaryRead])
|
@router.get("", response_model=list[MixCalculatorSessionSummaryRead])
|
||||||
def mix_calculator_sessions(
|
def mix_calculator_sessions(
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
|
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
|
||||||
db: Session = Depends(get_db),
|
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)
|
@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
|
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)
|
@router.post("", response_model=MixCalculatorSessionRead, status_code=status.HTTP_201_CREATED)
|
||||||
def create_saved_mix_calculator_session(
|
def create_saved_mix_calculator_session(
|
||||||
payload: MixCalculatorSessionCreate,
|
payload: MixCalculatorSessionCreate,
|
||||||
|
|||||||
@@ -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 import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -13,8 +13,12 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[MixRead])
|
@router.get("", response_model=list[MixRead])
|
||||||
def list_mixes(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
|
def list_mixes(
|
||||||
mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
|
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]
|
return [calculate_mix_cost(db, mix.id) for mix in mixes]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ def _serialize_product(product: Product) -> dict:
|
|||||||
"mix_name": product.mix.name if product.mix else "",
|
"mix_name": product.mix.name if product.mix else "",
|
||||||
"sale_type": product.sale_type,
|
"sale_type": product.sale_type,
|
||||||
"own_bag": product.own_bag,
|
"own_bag": product.own_bag,
|
||||||
|
"visible": product.visible,
|
||||||
"unit_of_measure": product.unit_of_measure,
|
"unit_of_measure": product.unit_of_measure,
|
||||||
"items_per_pallet": product.items_per_pallet,
|
"items_per_pallet": product.items_per_pallet,
|
||||||
"bagging_process": product.bagging_process,
|
"bagging_process": product.bagging_process,
|
||||||
@@ -34,8 +35,12 @@ def _serialize_product(product: Product) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ProductRead])
|
@router.get("", response_model=list[ProductRead])
|
||||||
def list_products(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
|
def list_products(
|
||||||
products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
|
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]
|
return [_serialize_product(product) for product in products]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 import select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
@@ -34,12 +34,17 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[RawMaterialRead])
|
@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(
|
materials = db.scalars(
|
||||||
select(RawMaterial)
|
select(RawMaterial)
|
||||||
.where(RawMaterial.tenant_id == session.tenant_id)
|
.where(RawMaterial.tenant_id == session.tenant_id)
|
||||||
.options(selectinload(RawMaterial.price_versions))
|
.options(selectinload(RawMaterial.price_versions))
|
||||||
.order_by(RawMaterial.name)
|
.order_by(RawMaterial.name)
|
||||||
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return [serialize_raw_material(material) for material in materials]
|
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])
|
@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))
|
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
|
||||||
if material is None:
|
if material is None:
|
||||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
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,
|
RawMaterialPriceVersion.tenant_id == session.tenant_id,
|
||||||
)
|
)
|
||||||
.order_by(RawMaterialPriceVersion.effective_date.desc())
|
.order_by(RawMaterialPriceVersion.effective_date.desc())
|
||||||
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
items = []
|
items = []
|
||||||
for price in prices:
|
for price in prices:
|
||||||
|
|||||||
@@ -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 import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -12,8 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ScenarioRead])
|
@router.get("", response_model=list[ScenarioRead])
|
||||||
def list_scenarios(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
|
def list_scenarios(
|
||||||
return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
|
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)
|
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, 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 (
|
||||||
|
ThroughputEntryCreate,
|
||||||
|
ThroughputEntryRead,
|
||||||
|
ThroughputEntryUpdate,
|
||||||
|
ThroughputProductCreate,
|
||||||
|
ThroughputProductRead,
|
||||||
|
ThroughputProductUpdate,
|
||||||
|
)
|
||||||
|
from app.services.throughput_service import (
|
||||||
|
calculate_kg,
|
||||||
|
normalise_staff_name,
|
||||||
|
serialize_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
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.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,
|
||||||
|
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")),
|
||||||
|
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
|
||||||
@@ -16,18 +16,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.core.security import verify_token
|
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.db.session import get_db
|
||||||
from app.models.access import Permission, Role, User
|
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.
|
# Subject claim used by tokens issued for internal Hunter Stock Feeds users.
|
||||||
# Distinct from the existing client-portal/admin tokens so the two systems
|
# Distinct from the existing client-portal/admin tokens so the two systems
|
||||||
# cannot impersonate each other.
|
# cannot impersonate each other.
|
||||||
@@ -53,6 +51,8 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
|
|||||||
"edit_products": ("products", "edit"),
|
"edit_products": ("products", "edit"),
|
||||||
"view_mixes": ("mix_master", "view"),
|
"view_mixes": ("mix_master", "view"),
|
||||||
"edit_mixes": ("mix_master", "edit"),
|
"edit_mixes": ("mix_master", "edit"),
|
||||||
|
"view_throughput": ("operations_throughput", "view"),
|
||||||
|
"edit_throughput": ("operations_throughput", "edit"),
|
||||||
# Admin-only permissions (view_users, manage_users, manage_permissions,
|
# Admin-only permissions (view_users, manage_users, manage_permissions,
|
||||||
# view_settings, edit_settings) are intentionally excluded — they don't
|
# view_settings, edit_settings) are intentionally excluded — they don't
|
||||||
# correspond to any of the legacy module keys and remain accessible only
|
# correspond to any of the legacy module keys and remain accessible only
|
||||||
@@ -103,7 +103,7 @@ def _load_user(db: Session, user_id: int) -> User | None:
|
|||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Resolve the current internal user from the bearer token.
|
"""Resolve the current internal user from the bearer token.
|
||||||
@@ -111,10 +111,11 @@ def get_current_user(
|
|||||||
Raises 401 for missing/invalid tokens or unknown users, 403 for inactive
|
Raises 401 for missing/invalid tokens or unknown users, 403 for inactive
|
||||||
users.
|
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")
|
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:
|
if payload.get("sub") != INTERNAL_USER_SUBJECT:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ def require_permission(permission_key: str):
|
|||||||
|
|
||||||
def dependency(user: User = Depends(get_current_user)) -> User:
|
def dependency(user: User = Depends(get_current_user)) -> User:
|
||||||
if not user_has_permission(user, permission_key):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Missing required permission: {permission_key}",
|
detail=f"Missing required permission: {permission_key}",
|
||||||
@@ -152,6 +154,7 @@ def require_any_permission(permission_keys: Iterable[str]):
|
|||||||
def dependency(user: User = Depends(get_current_user)) -> User:
|
def dependency(user: User = Depends(get_current_user)) -> User:
|
||||||
granted = get_user_permissions(user)
|
granted = get_user_permissions(user)
|
||||||
if not any(key in granted for key in keys):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Requires any of: {list(keys)}",
|
detail=f"Requires any of: {list(keys)}",
|
||||||
@@ -169,6 +172,7 @@ def require_all_permissions(permission_keys: Iterable[str]):
|
|||||||
granted = get_user_permissions(user)
|
granted = get_user_permissions(user)
|
||||||
missing = [key for key in keys if key not in granted]
|
missing = [key for key in keys if key not in granted]
|
||||||
if missing:
|
if missing:
|
||||||
|
log_security_event("authz.denied", role=user.role.name if user.role else None, permissions=missing)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Missing required permissions: {missing}",
|
detail=f"Missing required permissions: {missing}",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
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 = (
|
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())
|
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)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
app_name: str
|
app_name: str
|
||||||
|
app_env: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
log_level: str
|
||||||
|
log_verbose: bool
|
||||||
database_url: str
|
database_url: str
|
||||||
client_name: str
|
client_name: str
|
||||||
client_email: str
|
client_email: str
|
||||||
@@ -30,12 +47,31 @@ class Settings:
|
|||||||
auth_secret: str
|
auth_secret: str
|
||||||
cors_allow_origins: tuple[str, ...]
|
cors_allow_origins: tuple[str, ...]
|
||||||
cors_allow_origin_regex: 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
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
return cls(
|
settings = cls(
|
||||||
app_name=os.getenv("APP_NAME", "Data Entry App API"),
|
app_name=os.getenv("APP_NAME", "Data Entry App API"),
|
||||||
database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"),
|
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_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"),
|
||||||
client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"),
|
client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"),
|
||||||
client_password=os.getenv("CLIENT_PASSWORD", "changeme"),
|
client_password=os.getenv("CLIENT_PASSWORD", "changeme"),
|
||||||
@@ -51,7 +87,47 @@ class Settings:
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
cors_allow_origin_regex=os.getenv("CORS_ALLOW_ORIGIN_REGEX", DEFAULT_CORS_ALLOW_ORIGIN_REGEX),
|
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"),
|
||||||
)
|
)
|
||||||
|
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()
|
settings = Settings.from_env()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -2,9 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from sqlalchemy import MetaData, inspect, text
|
from sqlalchemy import MetaData, bindparam, inspect, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
HIDDEN_PRODUCT_CLIENTS = (
|
||||||
|
"Bird Grits",
|
||||||
|
"Chaff",
|
||||||
|
"Hay & Straw",
|
||||||
|
"Hunter Premium Produce",
|
||||||
|
"Straight Grain",
|
||||||
|
"Uncategorized",
|
||||||
|
"Uncategorised",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TENANT_TABLES = {
|
TENANT_TABLES = {
|
||||||
"client_users": None,
|
"client_users": None,
|
||||||
@@ -15,6 +25,7 @@ TENANT_TABLES = {
|
|||||||
"raw_material_price_versions": None,
|
"raw_material_price_versions": None,
|
||||||
"mixes": None,
|
"mixes": None,
|
||||||
"mix_ingredients": None,
|
"mix_ingredients": None,
|
||||||
|
"product_ingredients": None,
|
||||||
"mix_calculator_sessions": None,
|
"mix_calculator_sessions": None,
|
||||||
"mix_calculator_session_lines": None,
|
"mix_calculator_session_lines": None,
|
||||||
"products": None,
|
"products": None,
|
||||||
@@ -23,6 +34,8 @@ TENANT_TABLES = {
|
|||||||
"process_cost_rules": None,
|
"process_cost_rules": None,
|
||||||
"packaging_cost_rules": None,
|
"packaging_cost_rules": None,
|
||||||
"freight_cost_rules": None,
|
"freight_cost_rules": None,
|
||||||
|
"throughput_products": None,
|
||||||
|
"production_throughput_entries": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +101,8 @@ def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]:
|
|||||||
# introduced on the model. Each entry is (table, column, DDL fragment).
|
# introduced on the model. Each entry is (table, column, DDL fragment).
|
||||||
_LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
|
_LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
|
||||||
("users", "password_hash", "VARCHAR(255)"),
|
("users", "password_hash", "VARCHAR(255)"),
|
||||||
|
("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
||||||
|
("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -232,6 +247,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",
|
"products",
|
||||||
text(
|
text(
|
||||||
@@ -359,6 +388,24 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
|
|||||||
return synced_rows
|
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 bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
|
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
|
||||||
created_tables = ensure_metadata_tables(engine, metadata)
|
created_tables = ensure_metadata_tables(engine, metadata)
|
||||||
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
|
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
|
||||||
|
|||||||
+218
-15
@@ -1,17 +1,23 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import re
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
if __package__ in {None, ""}:
|
if __package__ in {None, ""}:
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
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.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
import uvicorn
|
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.access import router as access_router
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.client_access import router as client_access_router
|
from app.api.client_access import router as client_access_router
|
||||||
@@ -22,14 +28,66 @@ from app.api.powerbi import router as powerbi_router
|
|||||||
from app.api.products import router as products_router
|
from app.api.products import router as products_router
|
||||||
from app.api.raw_materials import router as raw_materials_router
|
from app.api.raw_materials import router as raw_materials_router
|
||||||
from app.api.scenarios import router as scenarios_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.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.session import Base, engine
|
||||||
from app.db.migrations import MigrationReport, bootstrap_schema, sync_tenant_ids
|
from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids
|
||||||
from app.seed import seed_if_empty
|
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")
|
logger = logging.getLogger("data_entry_app.startup")
|
||||||
_database_ready = False
|
_database_ready = False
|
||||||
_database_ready_lock = Lock()
|
_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:
|
def ensure_database_ready() -> MigrationReport:
|
||||||
@@ -43,13 +101,17 @@ def ensure_database_ready() -> MigrationReport:
|
|||||||
return MigrationReport()
|
return MigrationReport()
|
||||||
|
|
||||||
schema_report = bootstrap_schema(engine, Base.metadata)
|
schema_report = bootstrap_schema(engine, Base.metadata)
|
||||||
seed_if_empty()
|
seed_startup_basics()
|
||||||
tenant_sync_report = sync_tenant_ids(engine)
|
tenant_sync_report = sync_tenant_ids(engine)
|
||||||
|
hidden_product_count = sync_product_visibility(engine)
|
||||||
|
|
||||||
report = MigrationReport(
|
report = MigrationReport(
|
||||||
created_tables=schema_report.created_tables,
|
created_tables=schema_report.created_tables,
|
||||||
added_columns=schema_report.added_columns,
|
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 {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
logger.info("Database startup checks complete: %s", report.summary())
|
logger.info("Database startup checks complete: %s", report.summary())
|
||||||
_database_ready = True
|
_database_ready = True
|
||||||
@@ -57,20 +119,75 @@ def ensure_database_ready() -> MigrationReport:
|
|||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
ensure_database_ready()
|
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
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=list(settings.cors_allow_origins),
|
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_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
@@ -82,9 +199,93 @@ app.include_router(mixes_router)
|
|||||||
app.include_router(mix_calculator_router)
|
app.include_router(mix_calculator_router)
|
||||||
app.include_router(products_router)
|
app.include_router(products_router)
|
||||||
app.include_router(scenarios_router)
|
app.include_router(scenarios_router)
|
||||||
|
app.include_router(throughput_router)
|
||||||
app.include_router(powerbi_router)
|
app.include_router(powerbi_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'; "
|
||||||
|
"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("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {
|
return {
|
||||||
@@ -104,6 +305,7 @@ def root():
|
|||||||
"mix_calculator": "/api/mix-calculator",
|
"mix_calculator": "/api/mix-calculator",
|
||||||
"products": "/api/products",
|
"products": "/api/products",
|
||||||
"scenarios": "/api/scenarios",
|
"scenarios": "/api/scenarios",
|
||||||
|
"operations_throughput": "/api/throughput",
|
||||||
"client_access": "/api/client-access",
|
"client_access": "/api/client-access",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
},
|
},
|
||||||
@@ -117,9 +319,10 @@ def healthcheck():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
report = ensure_database_ready()
|
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(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=os.getenv("HOST", "0.0.0.0"),
|
host=settings.host,
|
||||||
port=int(os.getenv("PORT", "8000")),
|
port=settings.port,
|
||||||
|
access_log=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
|
|||||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||||
from app.models.mix import Mix, MixIngredient
|
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.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||||
from app.models.scenario import CostingResult, Scenario
|
from app.models.scenario import CostingResult, Scenario
|
||||||
|
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ClientAccount",
|
"ClientAccount",
|
||||||
@@ -23,6 +24,9 @@ __all__ = [
|
|||||||
"Permission",
|
"Permission",
|
||||||
"ProcessCostRule",
|
"ProcessCostRule",
|
||||||
"Product",
|
"Product",
|
||||||
|
"ProductIngredient",
|
||||||
|
"ProductionThroughput",
|
||||||
|
"ThroughputProduct",
|
||||||
"RawMaterial",
|
"RawMaterial",
|
||||||
"RawMaterialPriceVersion",
|
"RawMaterialPriceVersion",
|
||||||
"Role",
|
"Role",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@@ -19,6 +19,7 @@ class Product(Base):
|
|||||||
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"))
|
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"))
|
||||||
sale_type: Mapped[str] = mapped_column(String(64), default="standard")
|
sale_type: Mapped[str] = mapped_column(String(64), default="standard")
|
||||||
own_bag: Mapped[bool] = mapped_column(Boolean, default=False)
|
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")
|
unit_of_measure: Mapped[str] = mapped_column(String(64), default="20kg bag")
|
||||||
items_per_pallet: Mapped[int] = mapped_column(Integer, default=50)
|
items_per_pallet: Mapped[int] = mapped_column(Integer, default=50)
|
||||||
bagging_process: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
mix: Mapped["Mix"] = relationship(back_populates="products")
|
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.mix import Mix # noqa: E402
|
||||||
|
from app.models.raw_material import RawMaterial # noqa: E402
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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))
|
||||||
|
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)
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -1,30 +1,34 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class ClientUserCreate(BaseModel):
|
class ClientUserCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
client_account_id: int
|
client_account_id: int
|
||||||
full_name: str
|
full_name: str = Field(min_length=1, max_length=255)
|
||||||
email: str
|
email: str = Field(min_length=3, max_length=255)
|
||||||
role: str = "viewer"
|
role: str = "viewer"
|
||||||
status: str = "invited"
|
status: str = "invited"
|
||||||
is_new_user: bool = True
|
is_new_user: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ClientUserUpdate(BaseModel):
|
class ClientUserUpdate(BaseModel):
|
||||||
full_name: str | None = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
email: str | None = None
|
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
|
role: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
is_new_user: bool | None = None
|
is_new_user: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ClientFeatureUpdate(BaseModel):
|
class ClientFeatureUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
class ClientUserModulePermissionUpdate(BaseModel):
|
class ClientUserModulePermissionUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
access_level: str
|
access_level: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
|
|
||||||
class MixIngredientCreate(BaseModel):
|
class MixIngredientCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
raw_material_id: int
|
raw_material_id: int
|
||||||
quantity_kg: float = Field(gt=0)
|
quantity_kg: float = Field(gt=0)
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
class MixIngredientUpdate(BaseModel):
|
class MixIngredientUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
quantity_kg: float | None = Field(default=None, gt=0)
|
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):
|
class MixIngredientRead(BaseModel):
|
||||||
@@ -26,20 +28,22 @@ class MixIngredientRead(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MixCreate(BaseModel):
|
class MixCreate(BaseModel):
|
||||||
client_name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
name: str
|
client_name: str = Field(min_length=1, max_length=255)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
status: str = "draft"
|
status: str = "draft"
|
||||||
version: int = 1
|
version: int = 1
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
ingredients: list[MixIngredientCreate]
|
ingredients: list[MixIngredientCreate]
|
||||||
|
|
||||||
|
|
||||||
class MixUpdate(BaseModel):
|
class MixUpdate(BaseModel):
|
||||||
client_name: str | None = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
name: str | None = None
|
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
|
status: str | None = None
|
||||||
version: int | None = None
|
version: int | None = None
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class MixRead(BaseModel):
|
class MixRead(BaseModel):
|
||||||
@@ -57,4 +61,3 @@ class MixRead(BaseModel):
|
|||||||
mix_cost_per_kg: float | None
|
mix_cost_per_kg: float | None
|
||||||
warnings: list[str]
|
warnings: list[str]
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ class MixCalculatorSessionLineRead(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MixCalculatorSessionBase(BaseModel):
|
class MixCalculatorSessionBase(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
mix_date: date
|
mix_date: date
|
||||||
client_name: str
|
client_name: str = Field(min_length=1, max_length=255)
|
||||||
product_id: int
|
product_id: int
|
||||||
batch_size_kg: float = Field(gt=0)
|
batch_size_kg: float = Field(gt=0)
|
||||||
prepared_by_name: str = Field(min_length=1, max_length=255)
|
prepared_by_name: str = Field(min_length=1, max_length=255)
|
||||||
status: str = "saved"
|
status: str = "saved"
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class MixCalculatorSessionCreate(MixCalculatorSessionBase):
|
class MixCalculatorSessionCreate(MixCalculatorSessionBase):
|
||||||
@@ -44,13 +45,14 @@ class MixCalculatorSessionCreate(MixCalculatorSessionBase):
|
|||||||
|
|
||||||
|
|
||||||
class MixCalculatorSessionUpdate(BaseModel):
|
class MixCalculatorSessionUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
mix_date: date | None = None
|
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
|
product_id: int | None = None
|
||||||
batch_size_kg: float | None = Field(default=None, gt=0)
|
batch_size_kg: float | None = Field(default=None, gt=0)
|
||||||
prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255)
|
prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class MixCalculatorPreviewRead(BaseModel):
|
class MixCalculatorPreviewRead(BaseModel):
|
||||||
|
|||||||
@@ -4,33 +4,37 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
|
|
||||||
class ProductCreate(BaseModel):
|
class ProductCreate(BaseModel):
|
||||||
client_name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
item_id: str | None = None
|
client_name: str = Field(min_length=1, max_length=255)
|
||||||
name: str
|
item_id: str | None = Field(default=None, max_length=128)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
mix_id: int
|
mix_id: int
|
||||||
sale_type: str = "standard"
|
sale_type: str = "standard"
|
||||||
own_bag: bool = False
|
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)
|
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)
|
distributor_margin: float | None = Field(default=None, gt=0, lt=1)
|
||||||
wholesale_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):
|
class ProductUpdate(BaseModel):
|
||||||
client_name: str | None = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
item_id: str | None = None
|
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
name: str | None = None
|
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
|
mix_id: int | None = None
|
||||||
sale_type: str | None = None
|
sale_type: str | None = None
|
||||||
own_bag: bool | 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)
|
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)
|
distributor_margin: float | None = Field(default=None, gt=0, lt=1)
|
||||||
wholesale_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):
|
class ProductRead(BaseModel):
|
||||||
@@ -43,6 +47,7 @@ class ProductRead(BaseModel):
|
|||||||
mix_name: str
|
mix_name: str
|
||||||
sale_type: str
|
sale_type: str
|
||||||
own_bag: bool
|
own_bag: bool
|
||||||
|
visible: bool
|
||||||
unit_of_measure: str
|
unit_of_measure: str
|
||||||
items_per_pallet: int
|
items_per_pallet: int
|
||||||
bagging_process: str | None
|
bagging_process: str | None
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
|
|
||||||
class RawMaterialPriceVersionCreate(BaseModel):
|
class RawMaterialPriceVersionCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
market_value: float = Field(gt=0)
|
market_value: float = Field(gt=0)
|
||||||
waste_percentage: float = Field(ge=0, default=0.0)
|
waste_percentage: float = Field(ge=0, default=0.0)
|
||||||
effective_date: date
|
effective_date: date
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate):
|
class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate):
|
||||||
@@ -21,21 +22,23 @@ class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate):
|
|||||||
|
|
||||||
|
|
||||||
class RawMaterialCreate(BaseModel):
|
class RawMaterialCreate(BaseModel):
|
||||||
name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
supplier: str | None = None
|
name: str = Field(min_length=1, max_length=255)
|
||||||
unit_of_measure: str
|
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)
|
kg_per_unit: float = Field(gt=0)
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
initial_price: RawMaterialPriceVersionCreate
|
initial_price: RawMaterialPriceVersionCreate
|
||||||
|
|
||||||
|
|
||||||
class RawMaterialUpdate(BaseModel):
|
class RawMaterialUpdate(BaseModel):
|
||||||
supplier: str | None = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
unit_of_measure: str | None = None
|
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)
|
kg_per_unit: float | None = Field(default=None, gt=0)
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class RawMaterialRead(BaseModel):
|
class RawMaterialRead(BaseModel):
|
||||||
@@ -50,4 +53,3 @@ class RawMaterialRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
current_price: RawMaterialPriceVersionRead | None
|
current_price: RawMaterialPriceVersionRead | None
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from app.schemas.product import ProductCostBreakdown
|
|||||||
|
|
||||||
|
|
||||||
class ScenarioCreate(BaseModel):
|
class ScenarioCreate(BaseModel):
|
||||||
name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: str | None = None
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
overrides: dict = Field(default_factory=dict)
|
overrides: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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)
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
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("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
|
||||||
|
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 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
|
||||||
|
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)
|
||||||
+332
-13
@@ -9,22 +9,38 @@ import re
|
|||||||
|
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.db.session import Base, SessionLocal, engine
|
from app.db.session import Base, SessionLocal, engine
|
||||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||||
from app.models.mix import Mix, MixIngredient
|
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.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||||
|
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||||
from app.seed_access import seed_access
|
from app.seed_access import seed_access
|
||||||
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
|
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
|
||||||
|
|
||||||
|
|
||||||
TENANT_ID = "hunter-premium-produce"
|
TENANT_ID = "hunter-premium-produce"
|
||||||
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
|
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
|
||||||
WORKBOOK_SENTINEL_ITEM_ID = "404266"
|
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")
|
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]:
|
def _workbook_candidates() -> list[Path]:
|
||||||
@@ -35,11 +51,18 @@ def _workbook_candidates() -> list[Path]:
|
|||||||
|
|
||||||
candidates = [
|
candidates = [
|
||||||
Path(env_path) if env_path else None,
|
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,
|
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
|
||||||
repo_root / WORKBOOK_FILENAME,
|
repo_root / WORKBOOK_FILENAME,
|
||||||
cwd / WORKBOOK_FILENAME,
|
cwd / WORKBOOK_FILENAME,
|
||||||
Path("/app") / WORKBOOK_FILENAME,
|
Path("/app") / WORKBOOK_FILENAME,
|
||||||
Path("/") / 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] = []
|
ordered: list[Path] = []
|
||||||
@@ -62,9 +85,6 @@ def _resolve_workbook_path() -> Path:
|
|||||||
return _workbook_candidates()[0]
|
return _workbook_candidates()[0]
|
||||||
|
|
||||||
|
|
||||||
WORKBOOK_PATH = _resolve_workbook_path()
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value) -> str | None:
|
def _text(value) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -167,7 +187,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))}"
|
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()
|
workbook_path = _resolve_workbook_path()
|
||||||
if not workbook_path.exists():
|
if not workbook_path.exists():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
@@ -247,6 +281,44 @@ def _read_mix_rows(workbook) -> dict[tuple[str, str], dict]:
|
|||||||
return best_rows
|
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]:
|
def _read_product_rows(workbook) -> list[dict]:
|
||||||
worksheet = workbook["Product Cost - Price"]
|
worksheet = workbook["Product Cost - Price"]
|
||||||
raw_rows: list[dict] = []
|
raw_rows: list[dict] = []
|
||||||
@@ -287,6 +359,7 @@ def _read_product_rows(workbook) -> list[dict]:
|
|||||||
"wholesale_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[20]),
|
"wholesale_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[20]),
|
||||||
"process_label": _text(row[8]),
|
"process_label": _text(row[8]),
|
||||||
"sheet_own_bag": _text(row[5]),
|
"sheet_own_bag": _text(row[5]),
|
||||||
|
"visible": (_text(row[0]) or "General") not in HIDDEN_PRODUCT_CLIENTS,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -569,6 +642,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str],
|
|||||||
mix_id=mix.id,
|
mix_id=mix.id,
|
||||||
sale_type=row["sale_type"],
|
sale_type=row["sale_type"],
|
||||||
own_bag=row["own_bag"],
|
own_bag=row["own_bag"],
|
||||||
|
visible=row["visible"],
|
||||||
unit_of_measure=row["unit_of_measure"],
|
unit_of_measure=row["unit_of_measure"],
|
||||||
items_per_pallet=row["items_per_pallet"],
|
items_per_pallet=row["items_per_pallet"],
|
||||||
bagging_process=row["bagging_process"],
|
bagging_process=row["bagging_process"],
|
||||||
@@ -584,6 +658,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str],
|
|||||||
product.mix_id = mix.id
|
product.mix_id = mix.id
|
||||||
product.sale_type = row["sale_type"]
|
product.sale_type = row["sale_type"]
|
||||||
product.own_bag = row["own_bag"]
|
product.own_bag = row["own_bag"]
|
||||||
|
product.visible = row["visible"]
|
||||||
product.unit_of_measure = row["unit_of_measure"]
|
product.unit_of_measure = row["unit_of_measure"]
|
||||||
product.items_per_pallet = row["items_per_pallet"]
|
product.items_per_pallet = row["items_per_pallet"]
|
||||||
product.bagging_process = row["bagging_process"]
|
product.bagging_process = row["bagging_process"]
|
||||||
@@ -592,6 +667,184 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str],
|
|||||||
product.notes = "Seeded from Input Cost Spreadsheet(1).xlsx"
|
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:
|
||||||
|
continue
|
||||||
|
|
||||||
|
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 _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)
|
||||||
|
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,
|
||||||
|
default_bag_size=default_bag_size,
|
||||||
|
is_bulka_default=is_bulka_default,
|
||||||
|
active=costing_product.visible,
|
||||||
|
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.active != costing_product.visible:
|
||||||
|
product.active = costing_product.visible
|
||||||
|
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):
|
def seed_client_access(db):
|
||||||
existing = db.scalar(select(ClientAccount.id))
|
existing = db.scalar(select(ClientAccount.id))
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
@@ -653,7 +906,7 @@ def seed_client_access(db):
|
|||||||
)
|
)
|
||||||
|
|
||||||
enabled_feature_map = {
|
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"},
|
"loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,10 +952,13 @@ def seed_client_access(db):
|
|||||||
|
|
||||||
|
|
||||||
def seed_costing_workspace(db):
|
def seed_costing_workspace(db):
|
||||||
workbook = _load_workbook()
|
costing_workbook = _load_workbook("C- Raw Products Costs", "M - All", "Product Cost - Price")
|
||||||
raw_material_rows = _read_raw_material_rows(workbook)
|
formula_workbook = _load_workbook("mix_quantites_per_client_per_pr")
|
||||||
mix_rows = _read_mix_rows(workbook)
|
|
||||||
product_rows = _read_product_rows(workbook)
|
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)
|
raw_material_map = _upsert_raw_materials(db, raw_material_rows)
|
||||||
_upsert_process_rules(db, product_rows)
|
_upsert_process_rules(db, product_rows)
|
||||||
@@ -721,9 +977,53 @@ def seed_costing_workspace(db):
|
|||||||
mix_cache[(mix_row["client_name"], mix_row["name"])] = mix
|
mix_cache[(mix_row["client_name"], mix_row["name"])] = mix
|
||||||
|
|
||||||
_upsert_products(db, product_rows, mix_cache, raw_material_map)
|
_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 seed_startup_basics():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
seed_client_access(db)
|
||||||
|
seed_access(db)
|
||||||
|
seed_throughput_workbook(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_all():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
workbook_path = _resolve_workbook_path()
|
workbook_path = _resolve_workbook_path()
|
||||||
@@ -734,10 +1034,29 @@ def seed_if_empty():
|
|||||||
"Skipping costing workspace seed because workbook is missing. Checked: %s",
|
"Skipping costing workspace seed because workbook is missing. Checked: %s",
|
||||||
", ".join(str(path) for path in _workbook_candidates()),
|
", ".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_client_access(db)
|
||||||
seed_access(db)
|
seed_access(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
seed_if_empty()
|
seed_all()
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
Re-running this is safe: it upserts permissions, syncs each role's permission
|
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
|
set to the declared list, and creates or updates the seed users without
|
||||||
duplicating rows. Permission grants are the source of truth — change them
|
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 __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
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
|
from app.models.access import Permission, Role, User
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +29,8 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
|
|||||||
("edit_products", "Create and edit finished products"),
|
("edit_products", "Create and edit finished products"),
|
||||||
("view_mixes", "View mix master recipes"),
|
("view_mixes", "View mix master recipes"),
|
||||||
("edit_mixes", "Create and edit 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_users", "View internal users and roles"),
|
("view_users", "View internal users and roles"),
|
||||||
("manage_users", "Create, deactivate, and assign user roles"),
|
("manage_users", "Create, deactivate, and assign user roles"),
|
||||||
("manage_permissions", "Modify roles and role-permission assignments"),
|
("manage_permissions", "Modify roles and role-permission assignments"),
|
||||||
@@ -44,6 +51,8 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
|||||||
"edit_raw_materials",
|
"edit_raw_materials",
|
||||||
"view_products",
|
"view_products",
|
||||||
"view_mixes",
|
"view_mixes",
|
||||||
|
"view_throughput",
|
||||||
|
"edit_throughput",
|
||||||
"view_users",
|
"view_users",
|
||||||
"manage_users",
|
"manage_users",
|
||||||
"manage_permissions",
|
"manage_permissions",
|
||||||
@@ -52,11 +61,13 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"Operations": {
|
"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": [
|
"permissions": [
|
||||||
"view_mix_calculator",
|
"view_mix_calculator",
|
||||||
"use_mix_calculator",
|
"use_mix_calculator",
|
||||||
"save_mix_calculator_session",
|
"save_mix_calculator_session",
|
||||||
|
"view_throughput",
|
||||||
|
"edit_throughput",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"Full Access": {
|
"Full Access": {
|
||||||
@@ -72,6 +83,8 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
|||||||
"edit_products",
|
"edit_products",
|
||||||
"view_mixes",
|
"view_mixes",
|
||||||
"edit_mixes",
|
"edit_mixes",
|
||||||
|
"view_throughput",
|
||||||
|
"edit_throughput",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -154,6 +167,8 @@ def _upsert_users(db: Session, roles_by_name: dict[str, Role]) -> None:
|
|||||||
user.role_id = role.id
|
user.role_id = role.id
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
|
if user.password_hash is None:
|
||||||
|
user.password_hash = hash_password(settings.admin_password)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
@@ -162,3 +177,13 @@ def seed_access(db: Session) -> None:
|
|||||||
permissions_by_key = _upsert_permissions(db)
|
permissions_by_key = _upsert_permissions(db)
|
||||||
roles_by_name = _upsert_roles(db, permissions_by_key)
|
roles_by_name = _upsert_roles(db, permissions_by_key)
|
||||||
_upsert_users(db, roles_by_name)
|
_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()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ MODULE_CATALOG = (
|
|||||||
("mix_calculator", "Mix Calculator", "production", "Create and review client-specific mix calculation sessions"),
|
("mix_calculator", "Mix Calculator", "production", "Create and review client-specific mix calculation sessions"),
|
||||||
("products", "Products", "pricing", "Review finished product pricing"),
|
("products", "Products", "pricing", "Review finished product pricing"),
|
||||||
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
|
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
|
||||||
|
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
|
||||||
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
|
("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"),
|
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
|
||||||
)
|
)
|
||||||
@@ -78,15 +79,15 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
|
|||||||
def default_access_level_for_role(role: str, module_key: str) -> str:
|
def default_access_level_for_role(role: str, module_key: str) -> str:
|
||||||
normalized = role.strip().lower()
|
normalized = role.strip().lower()
|
||||||
if normalized == "superadmin":
|
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"} else "edit"
|
||||||
if normalized == "admin":
|
if normalized == "admin":
|
||||||
if module_key == "mix_calculator":
|
if module_key in {"mix_calculator", "operations_throughput"}:
|
||||||
return "manage"
|
return "manage"
|
||||||
return "edit" if module_key != "client_access" else "none"
|
return "edit" if module_key != "client_access" else "none"
|
||||||
if normalized == "operator":
|
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"} else "none"
|
||||||
if normalized == "viewer":
|
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"} else "none"
|
||||||
return "none"
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
|
|
||||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||||
from app.models.mix import Mix, MixIngredient
|
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.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]]:
|
def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -> tuple[float, float, float, list[str]]:
|
||||||
if not process_name:
|
if not process_name:
|
||||||
return 0.0, 0.0, 0.0, ["Missing bagging process"]
|
return 0.0, 0.0, 0.0, ["Missing bagging process"]
|
||||||
@@ -192,11 +264,21 @@ def extract_unit_quantity_kg(unit_of_measure: str) -> float:
|
|||||||
def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
|
def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
|
||||||
overrides = overrides or {}
|
overrides = overrides or {}
|
||||||
overrides = {**overrides, "tenant_id": overrides.get("tenant_id")}
|
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:
|
if product is None:
|
||||||
raise ValueError(f"Product {product_id} not found")
|
raise ValueError(f"Product {product_id} not found")
|
||||||
overrides["tenant_id"] = product.tenant_id
|
overrides["tenant_id"] = product.tenant_id
|
||||||
|
|
||||||
|
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)
|
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
|
||||||
warnings = list(mix_result["warnings"])
|
warnings = list(mix_result["warnings"])
|
||||||
sale_unit_kg = extract_unit_quantity_kg(product.unit_of_measure)
|
sale_unit_kg = extract_unit_quantity_kg(product.unit_of_measure)
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from app.models.mix_calculator import MixCalculatorSession
|
from app.models.mix_calculator import MixCalculatorSession
|
||||||
|
from app.schemas.mix_calculator import MixCalculatorPreviewRead
|
||||||
|
|
||||||
|
|
||||||
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
|
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
|
||||||
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
|
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
|
||||||
return re.sub(r"[^\w.\-]+", "_", raw)
|
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)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from math import ceil
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.models.mix_calculator import MixCalculatorSession
|
from app.models.mix_calculator import MixCalculatorSession
|
||||||
|
|
||||||
@@ -24,272 +25,329 @@ 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:
|
try:
|
||||||
from reportlab.lib import colors
|
from reportlab.lib import colors
|
||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
from reportlab.lib.utils import ImageReader
|
||||||
from reportlab.lib.units import mm
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
from reportlab.pdfgen import canvas
|
||||||
except ModuleNotFoundError as exc:
|
except ModuleNotFoundError as exc:
|
||||||
raise MixCalculatorPdfUnavailableError(
|
raise MixCalculatorPdfUnavailableError(
|
||||||
"PDF generation is unavailable because 'reportlab' is not installed. "
|
"PDF generation is unavailable because 'reportlab' is not installed. "
|
||||||
"Install backend dependencies again to enable PDF export."
|
"Install backend dependencies again to enable PDF export."
|
||||||
) from exc
|
) 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()
|
buffer = BytesIO()
|
||||||
document = SimpleDocTemplate(
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||||
buffer,
|
session_number = getattr(session_record, "session_number", None)
|
||||||
pagesize=A4,
|
document_title = (
|
||||||
leftMargin=14 * mm,
|
f"{session_number} - {session_record.product_name}"
|
||||||
rightMargin=14 * mm,
|
if session_number
|
||||||
topMargin=14 * mm,
|
else f"Mix Calculator - {session_record.product_name}"
|
||||||
bottomMargin=14 * mm,
|
|
||||||
title=f"{session_record.session_number} - {session_record.product_name}",
|
|
||||||
author="Lean 101 Clients",
|
|
||||||
)
|
)
|
||||||
|
pdf.setTitle(document_title)
|
||||||
|
pdf.setAuthor("Lean 101 Clients")
|
||||||
|
|
||||||
styles = getSampleStyleSheet()
|
pdf.setFillColor(palette["page"])
|
||||||
eyebrow = ParagraphStyle(
|
pdf.rect(0, 0, page_width, page_height, stroke=0, fill=1)
|
||||||
"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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
warnings = []
|
current_y = page_top
|
||||||
bag_warning = _fractional_bag_warning(session_record)
|
mix_date_label = f"{session_record.mix_date.day} {session_record.mix_date.strftime('%B %Y')}"
|
||||||
if bag_warning:
|
|
||||||
warnings.append(bag_warning)
|
|
||||||
|
|
||||||
story = [
|
if logo_path.exists():
|
||||||
Paragraph(f"Mix Calculator | {session_record.session_number}", eyebrow),
|
logo_source = str(logo_path)
|
||||||
Paragraph(session_record.product_name, title),
|
try:
|
||||||
Paragraph(f"{session_record.client_name} · {session_record.mix_name}", subtitle),
|
from PIL import Image
|
||||||
Spacer(1, 8),
|
|
||||||
]
|
|
||||||
|
|
||||||
header_table = Table(
|
logo_source = Image.open(logo_path).convert("L")
|
||||||
[
|
except ModuleNotFoundError:
|
||||||
[
|
pass
|
||||||
[
|
|
||||||
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),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
story.extend([header_table, Spacer(1, 10)])
|
|
||||||
|
|
||||||
summary_table = Table(
|
logo_reader = ImageReader(logo_source)
|
||||||
[
|
logo_width, logo_height = logo_reader.getSize()
|
||||||
[
|
aspect_ratio = logo_height / max(logo_width, 1)
|
||||||
[Paragraph("Batch size", label), Paragraph(f"{_fmt_number(session_record.batch_size_kg)}kg", card_value)],
|
draw_width = 108
|
||||||
[Paragraph("Total output", label), Paragraph(f"{_fmt_number(session_record.total_kg)}kg", card_value)],
|
draw_height = draw_width * aspect_ratio
|
||||||
[Paragraph("Bags", label), Paragraph(_fmt_number(session_record.total_bags), card_value)],
|
pdf.drawImage(
|
||||||
[Paragraph("Unit pack", label), Paragraph(f"{_fmt_number(session_record.product_unit_size_kg)}kg", card_value)],
|
logo_reader,
|
||||||
]
|
margin,
|
||||||
],
|
current_y - draw_height,
|
||||||
colWidths=[43 * mm, 43 * mm, 43 * mm, 43 * mm],
|
width=draw_width,
|
||||||
|
height=draw_height,
|
||||||
|
preserveAspectRatio=True,
|
||||||
|
mask="auto",
|
||||||
)
|
)
|
||||||
summary_table.setStyle(
|
current_y -= draw_height + 20
|
||||||
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)])
|
|
||||||
|
|
||||||
detail_table = Table(
|
pdf.setFillColor(palette["text"])
|
||||||
[
|
pdf.setFont("Helvetica-Bold", 15)
|
||||||
[
|
pdf.drawString(margin, current_y, "Calculated Output")
|
||||||
[Paragraph("Mix source", label), Paragraph(session_record.mix_name, value), Paragraph(f"Saved against {session_record.product_unit_of_measure} units.", body)],
|
current_y -= 16
|
||||||
[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)],
|
pdf.setFillColor(palette["muted"])
|
||||||
]
|
pdf.setFont("Helvetica", 10)
|
||||||
],
|
pdf.drawString(margin, current_y, "Snapshot of the scaled raw material requirements.")
|
||||||
colWidths=[60 * mm, 60 * mm, 52 * mm],
|
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(
|
draw_label_value_card(
|
||||||
TableStyle(
|
pdf,
|
||||||
[
|
margin + stat_width + gutter,
|
||||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F4F8F5")),
|
current_y,
|
||||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
stat_width,
|
||||||
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
stat_height,
|
||||||
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
"Total bags",
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 10),
|
_fmt_number(session_record.total_bags),
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
session_record.product_unit_of_measure,
|
||||||
]
|
value_font_size=18,
|
||||||
)
|
)
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
story.extend([detail_table, Spacer(1, 10)])
|
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,
|
||||||
|
"Product",
|
||||||
|
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,
|
||||||
|
"Mix 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:
|
if session_record.notes:
|
||||||
notes_table = Table(
|
note_lines = wrap_text(session_record.notes.replace("\n", " "), "Helvetica", 7.5, content_width - 28, 2)
|
||||||
[[Paragraph("Notes", label)], [Paragraph(session_record.notes.replace("\n", "<br/>"), body)]],
|
strip_height += 30
|
||||||
colWidths=[172 * mm],
|
if warning:
|
||||||
)
|
warning_lines = wrap_text(warning, "Helvetica", 7.5, content_width - 28, 2)
|
||||||
notes_table.setStyle(
|
strip_height += 30
|
||||||
TableStyle(
|
if strip_height:
|
||||||
[
|
strip_height += 6
|
||||||
("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)])
|
|
||||||
|
|
||||||
if warnings:
|
table_header_height = 24
|
||||||
warning_rows = [[Paragraph("Warnings", label)]]
|
table_bottom_padding = 12
|
||||||
warning_rows.extend([[Paragraph(warning, body)] for warning in warnings])
|
table_top = current_y
|
||||||
warnings_table = Table(warning_rows, colWidths=[172 * mm])
|
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
|
||||||
warnings_table.setStyle(
|
row_count = max(len(session_record.lines), 1)
|
||||||
TableStyle(
|
row_height = clamp(available_table_height / row_count, 16, 32)
|
||||||
[
|
table_font_size = clamp(row_height * 0.36, 7, 11)
|
||||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#FFF5E6")),
|
table_height = table_header_height + (row_height * row_count)
|
||||||
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF9EF")),
|
table_bottom = table_top - table_height
|
||||||
("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)])
|
|
||||||
|
|
||||||
story.extend(
|
pdf.setFillColor(palette["muted"])
|
||||||
[
|
pdf.setFont("Helvetica-Bold", 7.5)
|
||||||
Paragraph("Required Raw Materials", label),
|
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
|
||||||
Paragraph("Blend composition", section_title),
|
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
|
||||||
Paragraph(f"{session_record.product_unit_of_measure} · {_fmt_number(session_record.product_unit_size_kg)}kg per unit", subtitle),
|
|
||||||
Spacer(1, 6),
|
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:
|
for line in session_record.lines:
|
||||||
table_rows.append(
|
y_cursor -= row_height
|
||||||
[
|
pdf.setStrokeColor(palette["line"])
|
||||||
Paragraph(f"<b>{line.raw_material_name}</b>", body),
|
pdf.setLineWidth(0.6)
|
||||||
Paragraph(f"{_fmt_number(line.mix_percentage)}%", body),
|
pdf.line(margin, y_cursor, margin + content_width, y_cursor)
|
||||||
Paragraph(f"{_fmt_number(line.required_kg)}kg", body),
|
|
||||||
Paragraph(line.unit, body),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
composition_table = Table(table_rows, colWidths=[88 * mm, 24 * mm, 34 * mm, 26 * mm], repeatRows=1)
|
text_y = y_cursor + (row_height / 2) - (table_font_size * 0.35)
|
||||||
composition_table.setStyle(
|
pdf.setFillColor(palette["text"])
|
||||||
TableStyle(
|
pdf.setFont("Helvetica-Bold", table_font_size)
|
||||||
[
|
pdf.drawString(
|
||||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EEF4F0")),
|
left_col_x,
|
||||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#4F6158")),
|
text_y,
|
||||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
|
||||||
("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")]),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
)
|
pdf.setFont("Helvetica", table_font_size)
|
||||||
story.append(composition_table)
|
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}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()
|
return buffer.getvalue()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, joinedload, selectinload
|
|||||||
from app.api.deps import AuthSession
|
from app.api.deps import AuthSession
|
||||||
from app.models.mix import Mix, MixIngredient
|
from app.models.mix import Mix, MixIngredient
|
||||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
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.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate
|
||||||
from app.services.costing_engine import extract_unit_quantity_kg
|
from app.services.costing_engine import extract_unit_quantity_kg
|
||||||
|
|
||||||
@@ -27,9 +27,43 @@ def _build_session_access_query(session: AuthSession):
|
|||||||
def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None:
|
def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None:
|
||||||
return db.scalar(
|
return db.scalar(
|
||||||
select(Product)
|
select(Product)
|
||||||
.where(Product.id == product_id, Product.tenant_id == tenant_id)
|
.where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True))
|
||||||
.options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
|
.options(
|
||||||
|
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
|
||||||
|
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"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",
|
||||||
|
"sort_order": index,
|
||||||
|
}
|
||||||
|
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"]))
|
||||||
|
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:
|
def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None:
|
||||||
@@ -54,12 +88,9 @@ def calculate_mix_calculator_preview(
|
|||||||
raise ValueError("Product not found")
|
raise ValueError("Product not found")
|
||||||
if product.client_name != values["client_name"]:
|
if product.client_name != values["client_name"]:
|
||||||
raise ValueError("Selected product does not belong to the chosen client")
|
raise ValueError("Selected product does not belong to the chosen client")
|
||||||
if product.mix is None:
|
formula_rows, source_total_kg = _resolved_formula_rows(product)
|
||||||
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:
|
if source_total_kg <= 0:
|
||||||
raise ValueError("Product mix has no source kilograms to scale")
|
raise ValueError("Product has no source kilograms to scale")
|
||||||
|
|
||||||
batch_size_kg = float(values["batch_size_kg"])
|
batch_size_kg = float(values["batch_size_kg"])
|
||||||
scale_factor = batch_size_kg / source_total_kg
|
scale_factor = batch_size_kg / source_total_kg
|
||||||
@@ -72,18 +103,17 @@ def calculate_mix_calculator_preview(
|
|||||||
warnings.append(bag_warning)
|
warnings.append(bag_warning)
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for index, ingredient in enumerate(product.mix.ingredients, start=1):
|
for index, ingredient in enumerate(formula_rows, start=1):
|
||||||
mix_percentage = round((ingredient.quantity_kg / source_total_kg) * 100, 4)
|
mix_percentage = round((ingredient["quantity_kg"] / source_total_kg) * 100, 4)
|
||||||
required_kg = round(ingredient.quantity_kg * scale_factor, 4)
|
required_kg = round(ingredient["quantity_kg"] * scale_factor, 4)
|
||||||
raw_material = ingredient.raw_material
|
|
||||||
lines.append(
|
lines.append(
|
||||||
{
|
{
|
||||||
"raw_material_id": raw_material.id if raw_material is not None else ingredient.raw_material_id,
|
"raw_material_id": 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_name": ingredient["raw_material_name"],
|
||||||
"required_kg": required_kg,
|
"required_kg": required_kg,
|
||||||
"mix_percentage": mix_percentage,
|
"mix_percentage": mix_percentage,
|
||||||
"unit": raw_material.unit_of_measure if raw_material is not None else "kg",
|
"unit": ingredient["unit"],
|
||||||
"sort_order": index,
|
"sort_order": ingredient["sort_order"] or index,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,7 +122,7 @@ def calculate_mix_calculator_preview(
|
|||||||
"product_id": product.id,
|
"product_id": product.id,
|
||||||
"product_name": product.name,
|
"product_name": product.name,
|
||||||
"mix_id": product.mix_id,
|
"mix_id": product.mix_id,
|
||||||
"mix_name": product.mix.name,
|
"mix_name": product.mix.name if product.mix else product.name,
|
||||||
"mix_date": values["mix_date"],
|
"mix_date": values["mix_date"],
|
||||||
"batch_size_kg": round(batch_size_kg, 4),
|
"batch_size_kg": round(batch_size_kg, 4),
|
||||||
"total_bags": total_bags,
|
"total_bags": total_bags,
|
||||||
@@ -108,10 +138,16 @@ def calculate_mix_calculator_preview(
|
|||||||
|
|
||||||
|
|
||||||
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||||
# Aggregate mix totals in a single query instead of loading every
|
# Prefer product-specific formulas where present; fall back to the shared
|
||||||
# ingredient row for every product. The previous implementation was the
|
# mix master for legacy rows that have not been migrated yet.
|
||||||
# main slow path on first Mix Calculator open — it streamed the entire
|
product_totals_rows = db.execute(
|
||||||
# tenant's recipe table just to compute one sum per product.
|
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(
|
mix_totals_rows = db.execute(
|
||||||
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
|
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
|
||||||
.join(Mix, Mix.id == MixIngredient.mix_id)
|
.join(Mix, Mix.id == MixIngredient.mix_id)
|
||||||
@@ -122,7 +158,7 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
|||||||
|
|
||||||
products = db.scalars(
|
products = db.scalars(
|
||||||
select(Product)
|
select(Product)
|
||||||
.where(Product.tenant_id == tenant_id)
|
.where(Product.tenant_id == tenant_id, Product.visible.is_(True))
|
||||||
.options(joinedload(Product.mix))
|
.options(joinedload(Product.mix))
|
||||||
.order_by(Product.client_name, Product.name)
|
.order_by(Product.client_name, Product.name)
|
||||||
).all()
|
).all()
|
||||||
@@ -137,7 +173,7 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
|||||||
"mix_name": product.mix.name if product.mix else "",
|
"mix_name": product.mix.name if product.mix else "",
|
||||||
"unit_of_measure": product.unit_of_measure,
|
"unit_of_measure": product.unit_of_measure,
|
||||||
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
|
"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
|
for product in products
|
||||||
]
|
]
|
||||||
@@ -191,11 +227,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(
|
sessions = db.scalars(
|
||||||
_build_session_access_query(auth_session)
|
_build_session_access_query(auth_session)
|
||||||
.options(selectinload(MixCalculatorSession.lines))
|
.options(selectinload(MixCalculatorSession.lines))
|
||||||
.order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc())
|
.order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc())
|
||||||
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions]
|
return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
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"
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
"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_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
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(value: object) -> 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 ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
candidates = [
|
||||||
|
repo_root / "Operations Throughput.xlsx",
|
||||||
|
repo_root.parent / "Operations Throughput.xlsx",
|
||||||
|
Path.cwd() / "Operations Throughput.xlsx",
|
||||||
|
Path("/srv/lean101-clients") / "Operations Throughput.xlsx",
|
||||||
|
Path("/app") / "Operations Throughput.xlsx",
|
||||||
|
]
|
||||||
|
seen: set[str] = set()
|
||||||
|
ordered: list[Path] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
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
|
||||||
@@ -4,12 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "data-entry-app-backend"
|
name = "data-entry-app-backend"
|
||||||
version = "0.1.5"
|
version = "0.1.8"
|
||||||
description = "Costing platform MVP backend"
|
description = "Costing platform MVP backend"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1.0",
|
"fastapi>=0.115,<1.0",
|
||||||
"openpyxl>=3.1,<4.0",
|
"openpyxl>=3.1,<4.0",
|
||||||
|
"rich>=13.9,<15.0",
|
||||||
"uvicorn[standard]>=0.30,<1.0",
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
"sqlalchemy>=2.0,<3.0",
|
"sqlalchemy>=2.0,<3.0",
|
||||||
"pydantic>=2.8,<3.0",
|
"pydantic>=2.8,<3.0",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.access import router as access_router
|
||||||
from app.core.access import (
|
from app.core.access import (
|
||||||
INTERNAL_USER_SUBJECT,
|
INTERNAL_USER_SUBJECT,
|
||||||
get_user_permissions,
|
get_user_permissions,
|
||||||
@@ -15,7 +16,8 @@ from app.core.access import (
|
|||||||
require_permission,
|
require_permission,
|
||||||
user_has_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.db.session import Base, get_db
|
||||||
from app.models.access import Permission, Role, User
|
from app.models.access import Permission, Role, User
|
||||||
from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access
|
from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access
|
||||||
@@ -42,6 +44,10 @@ def test_seed_creates_roles_permissions_and_users():
|
|||||||
assert {role.name for role in db.query(Role).all()} == set(ROLE_DEFINITIONS.keys())
|
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 {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}
|
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():
|
def test_seed_is_idempotent():
|
||||||
@@ -71,14 +77,20 @@ def test_admin_role_permissions_match_spec():
|
|||||||
assert "edit_mixes" not in granted
|
assert "edit_mixes" not in granted
|
||||||
|
|
||||||
|
|
||||||
def test_operations_role_is_mix_calculator_only():
|
def test_operations_role_is_mix_calculator_and_throughput_only():
|
||||||
db = _build_session()
|
db = _build_session()
|
||||||
seed_access(db)
|
seed_access(db)
|
||||||
|
|
||||||
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||||
granted = get_user_permissions(ops)
|
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, "edit_raw_materials")
|
||||||
assert not user_has_permission(ops, "view_dashboard")
|
assert not user_has_permission(ops, "view_dashboard")
|
||||||
assert not user_has_permission(ops, "manage_users")
|
assert not user_has_permission(ops, "manage_users")
|
||||||
@@ -158,6 +170,22 @@ def _token_for(user: User) -> str:
|
|||||||
return issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
|
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):
|
def test_route_allows_user_with_permission(app_and_db):
|
||||||
client, db = app_and_db
|
client, db = app_and_db
|
||||||
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
|
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
|
||||||
@@ -234,3 +262,55 @@ def test_require_all_permissions(app_and_db):
|
|||||||
|
|
||||||
denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"})
|
denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"})
|
||||||
assert denied.status_code == 403
|
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
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.core.config import settings
|
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.db.session import Base
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
|
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
|
||||||
from app.schemas.mix_calculator import MixCalculatorSessionCreate
|
from app.schemas.mix_calculator import MixCalculatorSessionCreate
|
||||||
from app.models.mix import Mix, MixIngredient
|
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.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.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.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:
|
def build_session() -> Session:
|
||||||
@@ -97,12 +97,13 @@ def test_mix_and_product_cost_breakdown():
|
|||||||
assert product_result["wholesale_price"] == 17.3268
|
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()
|
db = build_session()
|
||||||
|
|
||||||
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
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")
|
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()
|
db.flush()
|
||||||
|
|
||||||
mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
|
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",
|
bagging_process="standard_bagging",
|
||||||
)
|
)
|
||||||
db.add(product)
|
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()
|
db.commit()
|
||||||
|
|
||||||
preview = calculate_mix_calculator_preview(
|
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["batch_size_kg"] == 550
|
||||||
assert preview["total_bags"] == 27.5
|
assert preview["total_bags"] == 27.5
|
||||||
assert preview["lines"][0]["required_kg"] == 353.5714
|
assert [line["raw_material_name"] for line in preview["lines"]] == ["Maize", "Wheat"]
|
||||||
assert preview["lines"][1]["required_kg"] == 196.4286
|
assert preview["lines"][0]["required_kg"] == 330
|
||||||
|
assert preview["lines"][1]["required_kg"] == 220
|
||||||
assert len(preview["warnings"]) == 1
|
assert len(preview["warnings"]) == 1
|
||||||
assert "not a whole-bag quantity" in preview["warnings"][0]
|
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 Product"]
|
||||||
|
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():
|
def test_root_and_login_endpoints():
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
root_response = client.get("/")
|
root_response = client.get("/")
|
||||||
@@ -260,16 +447,15 @@ def test_client_access_endpoints():
|
|||||||
"/api/auth/admin/login",
|
"/api/auth/admin/login",
|
||||||
json={"email": settings.admin_email, "password": settings.admin_password},
|
json={"email": settings.admin_email, "password": settings.admin_password},
|
||||||
)
|
)
|
||||||
token = login_response.json()["token"]
|
admin_cookies = {settings.admin_session_cookie_name: login_response.cookies.get(settings.admin_session_cookie_name)}
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
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 access_response.status_code == 200
|
||||||
assert len(access_response.json()) >= 1
|
assert len(access_response.json()) >= 1
|
||||||
assert "audit_history" in access_response.json()[0]
|
assert "audit_history" in access_response.json()[0]
|
||||||
assert "module_permissions" in access_response.json()[0]["users"][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 export_response.status_code == 200
|
||||||
assert "client_rows" in export_response.json()
|
assert "client_rows" in export_response.json()
|
||||||
assert "permission_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",
|
"/api/auth/client/login",
|
||||||
json={"email": settings.client_email, "password": settings.client_password},
|
json={"email": settings.client_email, "password": settings.client_password},
|
||||||
)
|
)
|
||||||
client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"}
|
client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)}
|
||||||
superadmin_access_response = client.get("/api/client-access", headers=client_headers)
|
superadmin_access_response = client.get("/api/client-access", cookies=client_cookies)
|
||||||
assert superadmin_access_response.status_code == 200
|
assert superadmin_access_response.status_code == 200
|
||||||
assert len(superadmin_access_response.json()) == 1
|
assert len(superadmin_access_response.json()) == 1
|
||||||
|
|
||||||
@@ -291,9 +477,9 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
json={"email": settings.client_email, "password": settings.client_password},
|
json={"email": settings.client_email, "password": settings.client_password},
|
||||||
)
|
)
|
||||||
assert superadmin_login.status_code == 200
|
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
|
assert options_response.status_code == 200
|
||||||
options_payload = options_response.json()
|
options_payload = options_response.json()
|
||||||
assert len(options_payload["products"]) >= 100
|
assert len(options_payload["products"]) >= 100
|
||||||
@@ -310,7 +496,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
"prepared_by_name": "Amelia Hart",
|
"prepared_by_name": "Amelia Hart",
|
||||||
"notes": "Morning production run",
|
"notes": "Morning production run",
|
||||||
},
|
},
|
||||||
headers=superadmin_headers,
|
cookies=superadmin_cookies,
|
||||||
)
|
)
|
||||||
assert create_response.status_code == 201
|
assert create_response.status_code == 201
|
||||||
created = create_response.json()
|
created = create_response.json()
|
||||||
@@ -323,7 +509,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
patch_response = client.patch(
|
patch_response = client.patch(
|
||||||
f"/api/mix-calculator/{created['id']}",
|
f"/api/mix-calculator/{created['id']}",
|
||||||
json={"batch_size_kg": 550},
|
json={"batch_size_kg": 550},
|
||||||
headers=superadmin_headers,
|
cookies=superadmin_cookies,
|
||||||
)
|
)
|
||||||
assert patch_response.status_code == 200
|
assert patch_response.status_code == 200
|
||||||
assert patch_response.json()["total_bags"] == 27.5
|
assert patch_response.json()["total_bags"] == 27.5
|
||||||
@@ -334,13 +520,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password},
|
json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password},
|
||||||
)
|
)
|
||||||
assert operator_login.status_code == 200
|
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.status_code == 200
|
||||||
assert operator_list_response.json() == []
|
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
|
assert operator_detail_response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -350,9 +536,9 @@ def test_mix_calculator_pdf_endpoint_returns_pdf():
|
|||||||
"/api/auth/client/login",
|
"/api/auth/client/login",
|
||||||
json={"email": settings.client_email, "password": settings.client_password},
|
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(
|
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["product_name"] == "Specialty Pigeon Breeder 20kg"
|
||||||
)
|
)
|
||||||
@@ -367,11 +553,11 @@ def test_mix_calculator_pdf_endpoint_returns_pdf():
|
|||||||
"prepared_by_name": "Amelia Hart",
|
"prepared_by_name": "Amelia Hart",
|
||||||
"notes": "Morning production run",
|
"notes": "Morning production run",
|
||||||
},
|
},
|
||||||
headers=headers,
|
cookies=superadmin_cookies,
|
||||||
)
|
)
|
||||||
created = create_response.json()
|
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.status_code == 200
|
||||||
assert pdf_response.headers["content-type"] == "application/pdf"
|
assert pdf_response.headers["content-type"] == "application/pdf"
|
||||||
@@ -385,8 +571,8 @@ def test_module_permission_blocks_client_module_access():
|
|||||||
"/api/auth/admin/login",
|
"/api/auth/admin/login",
|
||||||
json={"email": settings.admin_email, "password": settings.admin_password},
|
json={"email": settings.admin_email, "password": settings.admin_password},
|
||||||
)
|
)
|
||||||
admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"}
|
admin_cookies = {settings.admin_session_cookie_name: admin_login_response.cookies.get(settings.admin_session_cookie_name)}
|
||||||
access_response = client.get("/api/client-access", headers=admin_headers)
|
access_response = client.get("/api/client-access", cookies=admin_cookies)
|
||||||
first_client = access_response.json()[0]
|
first_client = access_response.json()[0]
|
||||||
first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email)
|
first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email)
|
||||||
|
|
||||||
@@ -396,15 +582,15 @@ def test_module_permission_blocks_client_module_access():
|
|||||||
client.patch(
|
client.patch(
|
||||||
f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}",
|
f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}",
|
||||||
json={"access_level": "none"},
|
json={"access_level": "none"},
|
||||||
headers=admin_headers,
|
cookies=admin_cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
client_login_response = client.post(
|
client_login_response = client.post(
|
||||||
"/api/auth/client/login",
|
"/api/auth/client/login",
|
||||||
json={"email": settings.client_email, "password": settings.client_password},
|
json={"email": settings.client_email, "password": settings.client_password},
|
||||||
)
|
)
|
||||||
client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"}
|
client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)}
|
||||||
raw_materials_response = client.get("/api/raw-materials", headers=client_headers)
|
raw_materials_response = client.get("/api/raw-materials", cookies=client_cookies)
|
||||||
|
|
||||||
assert raw_materials_response.status_code == 403
|
assert raw_materials_response.status_code == 403
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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_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[1].default_bag_size is None
|
||||||
|
assert products[1].is_bulka_default is True
|
||||||
|
assert products[1].active is False
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
+440
-37
@@ -1,6 +1,6 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.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
|
.DESCRIPTION
|
||||||
Tars the local source tree, uploads it to the droplet, and runs
|
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.
|
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
|
.PARAMETER RemoteHost
|
||||||
Hostname or IP of the Digital Ocean droplet. Required.
|
Hostname or IP of the Digital Ocean droplet. Required.
|
||||||
|
|
||||||
.PARAMETER RemoteUser
|
.PARAMETER RemoteUser
|
||||||
SSH user. Defaults to 'root'.
|
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
|
.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
|
.PARAMETER EnvFile
|
||||||
Local path to the production env file. Defaults to '.env.production'.
|
Local path to the production env file. Defaults to '.env.production'.
|
||||||
@@ -23,6 +42,11 @@
|
|||||||
.PARAMETER SshKey
|
.PARAMETER SshKey
|
||||||
Optional path to an SSH private key.
|
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
|
.PARAMETER ComposeFile
|
||||||
Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.
|
Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.
|
||||||
|
|
||||||
@@ -35,54 +59,276 @@
|
|||||||
.PARAMETER SkipBuild
|
.PARAMETER SkipBuild
|
||||||
Pass --no-build to docker compose (use when only env changed).
|
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
|
.EXAMPLE
|
||||||
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231
|
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs
|
./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()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)] [string] $RemoteHost,
|
[Parameter(Mandatory = $true)] [string] $RemoteHost,
|
||||||
[string] $RemoteUser = "root",
|
[string] $RemoteUser = "root",
|
||||||
[string] $RemotePath = "/srv/lean101-clients",
|
[string] $AppName = "Clients",
|
||||||
|
[string] $AppSlug = "clients",
|
||||||
|
[string] $RemotePath,
|
||||||
|
[string] $BackendContainer,
|
||||||
|
[string] $PortEnvKey,
|
||||||
[string] $EnvFile = ".env.production",
|
[string] $EnvFile = ".env.production",
|
||||||
[string] $SshKey,
|
[string] $SshKey,
|
||||||
|
[string] $Password,
|
||||||
[string] $ComposeFile = "docker-compose.production.yml",
|
[string] $ComposeFile = "docker-compose.production.yml",
|
||||||
[switch] $Seed,
|
[switch] $Seed,
|
||||||
[switch] $Logs,
|
[switch] $Logs,
|
||||||
[switch] $SkipBuild
|
[switch] $SkipBuild,
|
||||||
|
[switch] $Force,
|
||||||
|
[switch] $NoBanner
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── App identity (swappable) ──────────────────────────────────────────────────
|
||||||
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
$AppSlug = $AppSlug.ToLowerInvariant()
|
||||||
function Write-Warn($msg) { Write-Host "!! $msg" -ForegroundColor Yellow }
|
$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 {
|
function Get-RepoRoot {
|
||||||
$dir = Split-Path -Parent $PSScriptRoot
|
$dir = Split-Path -Parent $PSScriptRoot
|
||||||
if (-not $dir) { $dir = (Get-Location).Path }
|
if (-not $dir) { $dir = (Get-Location).Path }
|
||||||
return $dir
|
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
|
$RepoRoot = Get-RepoRoot
|
||||||
$SshTarget = "$RemoteUser@$RemoteHost"
|
$SshTarget = "$RemoteUser@$RemoteHost"
|
||||||
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no")
|
$SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no")
|
||||||
if ($SshKey) { $SshOpts += @("-i", $SshKey) }
|
if ($SshKey) { $SshOpts += @("-i", $SshKey) }
|
||||||
|
|
||||||
function Invoke-Ssh([string] $cmd) {
|
# Password auth (no SSH key): the spinner runs ssh/scp with redirected I/O, so
|
||||||
& ssh @SshOpts $SshTarget $cmd
|
# there is no terminal for an interactive password prompt. Feed the password
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Remote command failed (exit $LASTEXITCODE): $cmd" }
|
# 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) {
|
function Write-RemoteScript([string] $path, [string] $content) {
|
||||||
& scp @SshOpts $local "${SshTarget}:${remote}"
|
# Remote bash chokes on Windows CRLF line endings (each line gets a trailing
|
||||||
if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" }
|
# \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 ─────────────────────────────────────────────────────────────
|
# ── Resolve paths ─────────────────────────────────────────────────────────────
|
||||||
Push-Location $RepoRoot
|
Push-Location $RepoRoot
|
||||||
try {
|
try {
|
||||||
@@ -90,15 +336,69 @@ try {
|
|||||||
if (-not (Test-Path $EnvPath)) {
|
if (-not (Test-Path $EnvPath)) {
|
||||||
throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets."
|
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 ──────────────────────────────────────────────────────
|
# ── Connectivity check ──────────────────────────────────────────────────────
|
||||||
Write-Step "Checking SSH connectivity to $SshTarget"
|
Write-Step "Verifying SSH connectivity"
|
||||||
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
|
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 ────────────────────────────────────────────────────
|
# ── 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 = @(
|
$excludes = @(
|
||||||
"--exclude=./node_modules",
|
"--exclude=./node_modules",
|
||||||
@@ -106,9 +406,14 @@ try {
|
|||||||
"--exclude=./frontend/.svelte-kit",
|
"--exclude=./frontend/.svelte-kit",
|
||||||
"--exclude=./frontend/build",
|
"--exclude=./frontend/build",
|
||||||
"--exclude=./.git",
|
"--exclude=./.git",
|
||||||
|
"--exclude=./.pytest_cache",
|
||||||
"--exclude=./__pycache__",
|
"--exclude=./__pycache__",
|
||||||
"--exclude=./backend/__pycache__",
|
"--exclude=./backend/__pycache__",
|
||||||
"--exclude=./backend/app/__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=./**/__pycache__",
|
||||||
"--exclude=./*.pyc",
|
"--exclude=./*.pyc",
|
||||||
"--exclude=./.env",
|
"--exclude=./.env",
|
||||||
@@ -118,39 +423,48 @@ try {
|
|||||||
"--exclude=./*.db"
|
"--exclude=./*.db"
|
||||||
)
|
)
|
||||||
|
|
||||||
& tar -czf $TarFile @excludes -C $RepoRoot .
|
Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.'))
|
||||||
if ($LASTEXITCODE -ne 0) { throw "tar failed" }
|
|
||||||
|
|
||||||
$TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 1)
|
$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 ─────────────────────────────────────────────────────────
|
# ── Upload env file ─────────────────────────────────────────────────────────
|
||||||
Write-Step "Uploading env file"
|
Write-Step "Uploading env file to droplet"
|
||||||
Invoke-Scp $EnvPath "$RemotePath/.env.production"
|
Invoke-Scp $EnvPath "$RemotePath/.env.production" -Label "scp .env.production $($Glyph.Arrow) $RemotePath/"
|
||||||
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
|
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" -Label "chmod 600 .env.production"
|
||||||
|
|
||||||
# ── Upload and extract source ────────────────────────────────────────────────
|
# ── Upload and extract source ────────────────────────────────────────────────
|
||||||
Write-Step "Uploading source archive"
|
Write-Step "Uploading source archive ($TarSize MB)"
|
||||||
Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz"
|
Invoke-Scp $TarFile "/tmp/$AppStack-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
|
||||||
Remove-Item $TarFile -Force
|
Remove-Item $TarFile -Force
|
||||||
|
|
||||||
Write-Step "Extracting on server"
|
Write-Step "Extracting archive on server"
|
||||||
Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/lean101-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-deploy.tar.gz"
|
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 ───────────────────────────────────────────────────────
|
# ── Docker compose up ───────────────────────────────────────────────────────
|
||||||
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
|
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
|
||||||
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
|
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
|
||||||
|
|
||||||
Write-Step "Bringing stack up (build=$(-not $SkipBuild))"
|
$buildMsg = if ($SkipBuild) { "without rebuild" } else { "with --build" }
|
||||||
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
|
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
|
||||||
|
}
|
||||||
|
|
||||||
# ── Health check ────────────────────────────────────────────────────────────
|
# ── Health check ────────────────────────────────────────────────────────────
|
||||||
Write-Step "Waiting for backend health check"
|
Write-Step "Waiting for backend health check ($BackendContainer)"
|
||||||
$healthScript = @"
|
$healthScript = @"
|
||||||
set -e
|
set -e
|
||||||
cd '$RemotePath'
|
cd '$RemotePath'
|
||||||
for i in `$(seq 1 30); do
|
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
|
case "`$status" in
|
||||||
healthy|running) echo "backend is `$status"; exit 0 ;;
|
healthy|running) echo "backend is `$status"; exit 0 ;;
|
||||||
*) printf '.'; sleep 4 ;;
|
*) printf '.'; sleep 4 ;;
|
||||||
@@ -158,25 +472,114 @@ for i in `$(seq 1 30); do
|
|||||||
done
|
done
|
||||||
echo; echo 'backend did not become healthy in time' >&2; exit 1
|
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) {
|
if ($Seed) {
|
||||||
Write-Step "Seeding reference data"
|
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 ────────────────────────────────────────────────────────────
|
# ── Final status ────────────────────────────────────────────────────────────
|
||||||
Write-Step "Stack 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) {
|
if ($Logs) {
|
||||||
Write-Step "Recent logs (last 60 lines)"
|
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 ""
|
||||||
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 {
|
finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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;
|
||||||
|
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-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
|
||||||
location /_app/immutable/ {
|
location /_app/immutable/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -87,6 +88,9 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
add_header Pragma "no-cache" always;
|
||||||
|
expires -1;
|
||||||
proxy_pass http://lean101_clients_frontend;
|
proxy_pass http://lean101_clients_frontend;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
|
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
|
||||||
|
APP_ENV: ${APP_ENV:-production}
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}}
|
||||||
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
|
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
|
||||||
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
|
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
|
||||||
@@ -34,6 +35,16 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
|
||||||
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
|
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
|
||||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au}
|
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}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
|
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
|
||||||
|
APP_ENV: ${APP_ENV:-development}
|
||||||
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
|
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
|
||||||
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
|
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
|
||||||
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
|
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
|
||||||
@@ -17,6 +18,16 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-lean101-admin}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-lean101-admin}
|
||||||
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production}
|
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production}
|
||||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au}
|
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:
|
volumes:
|
||||||
- clients_app_data:/data
|
- clients_app_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -14,10 +14,18 @@ ENV NODE_ENV=production
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system app && adduser --system --ingroup app app
|
||||||
|
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/build ./build
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json ./package.json
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
EXPOSE 3000
|
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"]
|
CMD ["node", "build"]
|
||||||
|
|||||||
Generated
+15
-5
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "data-entry-app-frontend",
|
"name": "hunter-app",
|
||||||
"version": "0.1.5",
|
"version": "0.1.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "data-entry-app-frontend",
|
"name": "hunter-app",
|
||||||
"version": "0.1.5",
|
"version": "0.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.2.12",
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
"@sveltejs/kit": "^2.7.1",
|
"@sveltejs/kit": "^2.7.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^4.0.0"
|
"vitest": "^4.0.0"
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "data-entry-app-frontend",
|
"name": "hunter-app",
|
||||||
"version": "1.5.6",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
"@sveltejs/adapter-node": "^5.2.12",
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
"@sveltejs/kit": "^2.7.1",
|
"@sveltejs/kit": "^2.7.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^4.0.0"
|
"vitest": "^4.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe('api fetch injection', () => {
|
|||||||
|
|
||||||
await expect(call(injectedFetch)).resolves.toEqual(body);
|
await expect(call(injectedFetch)).resolves.toEqual(body);
|
||||||
expect(injectedFetch).toHaveBeenCalledTimes(1);
|
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();
|
expect(globalFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+97
-27
@@ -33,11 +33,16 @@ import type {
|
|||||||
RawMaterial,
|
RawMaterial,
|
||||||
RawMaterialCreateInput,
|
RawMaterialCreateInput,
|
||||||
RawMaterialPriceCreateInput,
|
RawMaterialPriceCreateInput,
|
||||||
Scenario
|
Scenario,
|
||||||
|
ThroughputEntry,
|
||||||
|
ThroughputEntryCreateInput,
|
||||||
|
ThroughputEntryListParams,
|
||||||
|
ThroughputProduct,
|
||||||
|
ThroughputProductCreateInput,
|
||||||
|
ThroughputProductUpdateInput
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
|
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.';
|
const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.';
|
||||||
|
|
||||||
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
|
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
|
||||||
@@ -51,40 +56,62 @@ function getApiBaseUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
|
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
|
||||||
if (configuredBaseUrl) {
|
if (configuredBaseUrl) {
|
||||||
return configuredBaseUrl.replace(/\/+$/, '');
|
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 '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (browser) {
|
return '';
|
||||||
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function buildApiUrl(path: string) {
|
||||||
return `${getApiBaseUrl()}${path}`;
|
return `${getApiBaseUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(auth: AuthMode) {
|
function getSessionFingerprint(auth: AuthMode) {
|
||||||
if (!browser) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth === 'client') {
|
if (auth === 'client') {
|
||||||
return getStoredClientSession()?.token ?? null;
|
const session = getStoredClientSession();
|
||||||
|
return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth === 'admin') {
|
if (auth === 'admin') {
|
||||||
return getStoredAdminSession()?.token ?? null;
|
const session = getStoredAdminSession();
|
||||||
|
return session ? `${session.role}:${session.email}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth === 'manager') {
|
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) {
|
function normalizeRequestError(error: unknown) {
|
||||||
@@ -107,9 +134,8 @@ function normalizeRequestError(error: unknown) {
|
|||||||
|
|
||||||
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
|
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const token = getToken(auth);
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
||||||
const response = await fetcher(buildApiUrl(path), {
|
credentials: 'include'
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (auth !== 'none') {
|
if (auth !== 'none') {
|
||||||
@@ -136,8 +162,8 @@ const inflightRequests = new Map<string, Promise<unknown>>();
|
|||||||
const READ_CACHE_TTL_MS = 30_000;
|
const READ_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
function makeCacheKey(path: string, auth: AuthMode) {
|
function makeCacheKey(path: string, auth: AuthMode) {
|
||||||
const token = browser ? getToken(auth) ?? '' : '';
|
const sessionFingerprint = browser ? getSessionFingerprint(auth) : '';
|
||||||
return `${auth}:${token.slice(-8)}:${path}`;
|
return `${auth}:${sessionFingerprint}:${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cachedFetchJson<T>(
|
async function cachedFetchJson<T>(
|
||||||
@@ -189,13 +215,12 @@ async function request<T>(
|
|||||||
fetcher: ApiFetch = fetch
|
fetcher: ApiFetch = fetch
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const token = getToken(auth);
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
||||||
const response = await fetcher(buildApiUrl(path), {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...(options.headers ?? {})
|
...(options.headers ?? {})
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,6 +243,9 @@ async function request<T>(
|
|||||||
// after the user creates or updates anything.
|
// after the user creates or updates anything.
|
||||||
clearApiCache();
|
clearApiCache();
|
||||||
}
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw normalizeRequestError(error);
|
throw normalizeRequestError(error);
|
||||||
@@ -226,13 +254,18 @@ async function request<T>(
|
|||||||
|
|
||||||
async function requestBlob(
|
async function requestBlob(
|
||||||
path: string,
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
auth: AuthMode = 'none',
|
auth: AuthMode = 'none',
|
||||||
fetcher: ApiFetch = fetch
|
fetcher: ApiFetch = fetch
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
try {
|
try {
|
||||||
const token = getToken(auth);
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
||||||
const response = await fetcher(buildApiUrl(path), {
|
headers: {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers ?? {})
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
...options
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -263,12 +296,17 @@ export const api = {
|
|||||||
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
|
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
|
||||||
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
|
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
|
||||||
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
|
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
|
||||||
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, 'client', fetcher),
|
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, {}, 'client', fetcher),
|
||||||
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
||||||
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
|
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}, 'client'),
|
}, 'client'),
|
||||||
|
previewMixCalculatorPdf: (payload: MixCalculatorCreateInput) =>
|
||||||
|
requestBlob('/api/mix-calculator/preview/pdf', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
||||||
request<MixCalculatorSession>('/api/mix-calculator', {
|
request<MixCalculatorSession>('/api/mix-calculator', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -283,6 +321,35 @@ export const api = {
|
|||||||
productCosts: (fetcher?: ApiFetch) =>
|
productCosts: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
|
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
|
||||||
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
|
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, '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'),
|
||||||
|
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', mockClientAccess, 'manager', fetcher),
|
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
|
||||||
clientAccessExport: (fetcher?: ApiFetch) =>
|
clientAccessExport: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
|
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
|
||||||
@@ -326,6 +393,9 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
clientSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
|
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),
|
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) =>
|
login: (email: string, password: string) =>
|
||||||
request<LoginResponse>('/api/auth/client/login', {
|
request<LoginResponse>('/api/auth/client/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { api } from '$lib/api';
|
||||||
import { adminSession, sessionHydrated } from '$lib/session';
|
import { adminSession, sessionHydrated } from '$lib/session';
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let isRestoringSession = $state(false);
|
let isRestoringSession = $state(false);
|
||||||
let restoredToken = $state<string | null>(null);
|
let restoredSessionKey = $state<string | null>(null);
|
||||||
|
|
||||||
function matchesRoute(href: string, pathname: string) {
|
function matchesRoute(href: string, pathname: string) {
|
||||||
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||||
@@ -29,31 +30,41 @@
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
try {
|
||||||
|
await api.adminLogout();
|
||||||
|
} catch {
|
||||||
|
// Clearing the local session remains the safe fallback.
|
||||||
|
} finally {
|
||||||
|
adminSession.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
|
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const hydrated = $sessionHydrated;
|
const hydrated = $sessionHydrated;
|
||||||
const token = $adminSession?.token ?? null;
|
const sessionKey = $adminSession ? `${$adminSession.role}:${$adminSession.email}` : null;
|
||||||
|
|
||||||
if (!hydrated) {
|
if (!hydrated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!sessionKey) {
|
||||||
isRestoringSession = false;
|
isRestoringSession = false;
|
||||||
restoredToken = null;
|
restoredSessionKey = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restoredToken === token) {
|
if (restoredSessionKey === sessionKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
restoredToken = token;
|
restoredSessionKey = sessionKey;
|
||||||
isRestoringSession = true;
|
isRestoringSession = true;
|
||||||
|
|
||||||
invalidateAll().finally(() => {
|
invalidateAll().finally(() => {
|
||||||
if (restoredToken === token) {
|
if (restoredSessionKey === sessionKey) {
|
||||||
isRestoringSession = false;
|
isRestoringSession = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -87,7 +98,7 @@
|
|||||||
<div class="admin-footer">
|
<div class="admin-footer">
|
||||||
<a href="/">Open client workspace</a>
|
<a href="/">Open client workspace</a>
|
||||||
{#if $adminSession}
|
{#if $adminSession}
|
||||||
<button type="button" onclick={() => adminSession.clear()}>Sign out</button>
|
<button type="button" onclick={signOut}>Sign out</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
blocked = false,
|
||||||
|
label = 'Checking Access',
|
||||||
|
title = 'Preparing your workspace.',
|
||||||
|
detail = 'Applying your access rules before rendering this page.',
|
||||||
|
children
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if blocked}
|
||||||
|
<section class="auth-gate-card">
|
||||||
|
<p class="auth-gate-label">{label}</p>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{detail}</p>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-gate-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.35rem 1.4rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-card p:last-child {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import AuthGate from '$lib/components/AuthGate.svelte';
|
||||||
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
|
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
|
||||||
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
|
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
|
||||||
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
|
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
|
||||||
@@ -8,6 +9,24 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
|
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
|
||||||
import { featureFlags } from '$lib/features';
|
import { featureFlags } from '$lib/features';
|
||||||
|
import {
|
||||||
|
canCreateMixSession as sessionCanCreateMixSession,
|
||||||
|
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
|
||||||
|
canOpenClientAccess as sessionCanOpenClientAccess,
|
||||||
|
canOpenDashboard as sessionCanOpenDashboard,
|
||||||
|
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||||
|
canOpenMixMaster as sessionCanOpenMixMaster,
|
||||||
|
canOpenProducts as sessionCanOpenProducts,
|
||||||
|
canOpenRawMaterials as sessionCanOpenRawMaterials,
|
||||||
|
canOpenReporting as sessionCanOpenReporting,
|
||||||
|
canOpenScenarios as sessionCanOpenScenarios,
|
||||||
|
canOpenSettings as sessionCanOpenSettings,
|
||||||
|
canOpenThroughput as sessionCanOpenThroughput,
|
||||||
|
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
|
||||||
|
getWorkspaceRole,
|
||||||
|
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
|
||||||
|
isWorkspaceRouteAllowed
|
||||||
|
} from '$lib/workspace-access';
|
||||||
import {
|
import {
|
||||||
accessControlItem,
|
accessControlItem,
|
||||||
baseSearchItems,
|
baseSearchItems,
|
||||||
@@ -18,6 +37,7 @@
|
|||||||
mixCalculatorItem,
|
mixCalculatorItem,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
reportingItem,
|
reportingItem,
|
||||||
|
throughputItem,
|
||||||
type FooterLink,
|
type FooterLink,
|
||||||
type SearchItem,
|
type SearchItem,
|
||||||
type NavItem,
|
type NavItem,
|
||||||
@@ -44,34 +64,52 @@
|
|||||||
let navOpen = $state(false);
|
let navOpen = $state(false);
|
||||||
let showBottomNav = $state(false);
|
let showBottomNav = $state(false);
|
||||||
let isRestoringSession = $state(false);
|
let isRestoringSession = $state(false);
|
||||||
let restoredToken = $state<string | null>(null);
|
let restoredSessionKey = $state<string | null>(null);
|
||||||
let seededSearchItems = $state<SearchItem[]>([]);
|
let seededSearchItems = $state<SearchItem[]>([]);
|
||||||
let seededSearchToken = $state<string | null>(null);
|
let seededSearchKey = $state<string | null>(null);
|
||||||
let paletteInput: HTMLInputElement | null = $state(null);
|
let paletteInput: HTMLInputElement | null = $state(null);
|
||||||
const appVersion = `v${packageInfo.version}`;
|
const appVersion = `v${packageInfo.version}`;
|
||||||
const releaseStage = 'Beta';
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const visibleDashboardItem = $derived(
|
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
|
||||||
!$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null
|
const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession));
|
||||||
|
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
|
||||||
|
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
|
||||||
|
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
|
||||||
|
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
|
||||||
|
const canOpenProducts = $derived(sessionCanOpenProducts($clientSession));
|
||||||
|
const canOpenScenarios = $derived(sessionCanOpenScenarios($clientSession));
|
||||||
|
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
|
||||||
|
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($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 shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
|
||||||
|
const shellBreadcrumbs = $derived(
|
||||||
|
routeGuardPending ? clientBreadcrumbs(workspaceHomeHref, $clientSession) : clientBreadcrumbs(page.url.pathname, $clientSession)
|
||||||
);
|
);
|
||||||
|
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
|
||||||
const visibleWorkingDocumentItems = $derived(
|
const visibleWorkingDocumentItems = $derived(
|
||||||
!$clientSession
|
!$clientSession
|
||||||
? workingDocumentItems
|
? workingDocumentItems
|
||||||
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
|
: workingDocumentItems.filter((item) => {
|
||||||
);
|
if (item.href === '/raw-materials') return canOpenRawMaterials;
|
||||||
const visibleMixCalculatorItem = $derived(
|
if (item.href === '/mixes') return canOpenMixMaster;
|
||||||
!$clientSession || !mixCalculatorItem.moduleKey || hasModuleAccess($clientSession, mixCalculatorItem.moduleKey)
|
if (item.href === '/products') return canOpenProducts;
|
||||||
? mixCalculatorItem
|
if (item.href === '/scenarios') return canOpenScenarios;
|
||||||
: null
|
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
|
||||||
);
|
})
|
||||||
const visibleReportingItem = $derived(
|
|
||||||
!$clientSession || !reportingItem.moduleKey || hasModuleAccess($clientSession, reportingItem.moduleKey)
|
|
||||||
? reportingItem
|
|
||||||
: null
|
|
||||||
);
|
);
|
||||||
|
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
|
||||||
|
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
|
||||||
|
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
|
||||||
|
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||||
|
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
|
||||||
|
const workspaceRole = $derived(getWorkspaceRole($clientSession));
|
||||||
const visibleFooterLinks = $derived([
|
const visibleFooterLinks = $derived([
|
||||||
...footerLinks,
|
...(!isOperationsUser ? footerLinks : []),
|
||||||
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
|
...(!canOpenClientAccess
|
||||||
? []
|
? []
|
||||||
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
|
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
|
||||||
] as FooterLink[]);
|
] as FooterLink[]);
|
||||||
@@ -85,7 +123,21 @@
|
|||||||
const workingDocumentsActive = $derived(
|
const workingDocumentsActive = $derived(
|
||||||
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
|
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
|
||||||
);
|
);
|
||||||
const searchItems = $derived([...baseSearchItems, ...seededSearchItems]);
|
const visibleBaseSearchItems = $derived(
|
||||||
|
baseSearchItems.filter((item) => {
|
||||||
|
if (item.href === '/') return canOpenDashboard;
|
||||||
|
if (item.href === '/raw-materials') return canOpenRawMaterials;
|
||||||
|
if (item.href === '/mixes') return canOpenMixMaster;
|
||||||
|
if (item.href === '/mixes/new') return canCreateMixWorksheet;
|
||||||
|
if (item.href === '/mix-calculator') return canOpenMixCalculator;
|
||||||
|
if (item.href === '/products') return canOpenProducts;
|
||||||
|
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
|
||||||
|
if (item.href === '/settings') return canOpenSettings;
|
||||||
|
if (item.href === '/scenarios') return canOpenScenarios;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
|
||||||
|
|
||||||
function openPalette(query = '') {
|
function openPalette(query = '') {
|
||||||
paletteQuery = query;
|
paletteQuery = query;
|
||||||
@@ -116,6 +168,20 @@
|
|||||||
await goto('/settings');
|
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 filteredSearchItems = $derived(
|
const filteredSearchItems = $derived(
|
||||||
searchItems.filter((item) => {
|
searchItems.filter((item) => {
|
||||||
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
|
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
|
||||||
@@ -140,23 +206,23 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const hydrated = $sessionHydrated;
|
const hydrated = $sessionHydrated;
|
||||||
const token = $clientSession?.token ?? null;
|
const sessionKey = $clientSession ? `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}` : null;
|
||||||
|
|
||||||
if (!hydrated) {
|
if (!hydrated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!sessionKey) {
|
||||||
isRestoringSession = false;
|
isRestoringSession = false;
|
||||||
restoredToken = null;
|
restoredSessionKey = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restoredToken === token) {
|
if (restoredSessionKey === sessionKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
restoredToken = token;
|
restoredSessionKey = sessionKey;
|
||||||
isRestoringSession = true;
|
isRestoringSession = true;
|
||||||
|
|
||||||
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
|
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
|
||||||
@@ -165,14 +231,12 @@
|
|||||||
|
|
||||||
refresh
|
refresh
|
||||||
.then((session) => {
|
.then((session) => {
|
||||||
// /api/access/me does not re-issue a token; preserve the existing one.
|
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
|
||||||
const nextToken = session.token ?? token;
|
clientSession.set(session);
|
||||||
restoredToken = nextToken;
|
|
||||||
clientSession.set({ ...session, token: nextToken });
|
|
||||||
return invalidateAll();
|
return invalidateAll();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
restoredToken = null;
|
restoredSessionKey = null;
|
||||||
clientSession.clear();
|
clientSession.clear();
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -186,30 +250,30 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const hydrated = $sessionHydrated;
|
const hydrated = $sessionHydrated;
|
||||||
const session = $clientSession;
|
const session = $clientSession;
|
||||||
const token = session?.token ?? null;
|
const sessionKey = session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : null;
|
||||||
const shouldSeed = paletteOpen;
|
const shouldSeed = paletteOpen;
|
||||||
|
|
||||||
if (!hydrated || !session || !token) {
|
if (!hydrated || !session || !sessionKey) {
|
||||||
seededSearchItems = [];
|
seededSearchItems = [];
|
||||||
seededSearchToken = null;
|
seededSearchKey = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldSeed || seededSearchToken === token) {
|
if (!shouldSeed || seededSearchKey === sessionKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
seededSearchToken = token;
|
seededSearchKey = sessionKey;
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]),
|
sessionCanOpenProducts(session) ? api.products() : Promise.resolve([]),
|
||||||
hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]),
|
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
|
||||||
featureFlags.mixCalculatorSessionHistory && hasModuleAccess(session, 'mix_calculator')
|
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
|
||||||
? api.mixCalculatorSessions()
|
? api.mixCalculatorSessions()
|
||||||
: Promise.resolve([])
|
: Promise.resolve([])
|
||||||
])
|
])
|
||||||
.then(([products, mixes, sessions]) => {
|
.then(([products, mixes, sessions]) => {
|
||||||
if (seededSearchToken !== token) {
|
if (seededSearchKey !== sessionKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +299,7 @@
|
|||||||
];
|
];
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (seededSearchToken === token) {
|
if (seededSearchKey === sessionKey) {
|
||||||
seededSearchItems = [];
|
seededSearchItems = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -247,6 +311,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!$sessionHydrated || !$clientSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
goto(workspaceHomeHref, { replaceState: true });
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
syncViewport();
|
syncViewport();
|
||||||
|
|
||||||
@@ -258,7 +334,7 @@
|
|||||||
target instanceof HTMLSelectElement ||
|
target instanceof HTMLSelectElement ||
|
||||||
target?.isContentEditable;
|
target?.isContentEditable;
|
||||||
|
|
||||||
if ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/')) {
|
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openPalette();
|
openPalette();
|
||||||
}
|
}
|
||||||
@@ -291,7 +367,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{pageTitle(page.url.pathname)} | Hunter Premium Produce</title>
|
<title>{shellTitle} | Hunter Premium Produce</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !$clientSession}
|
{#if !$clientSession}
|
||||||
@@ -314,64 +390,84 @@
|
|||||||
|
|
||||||
{#if !showBottomNav}
|
{#if !showBottomNav}
|
||||||
<ClientPrimaryRail
|
<ClientPrimaryRail
|
||||||
currentPath={page.url.pathname}
|
currentPath={shellPathname}
|
||||||
primaryItems={[
|
primaryItems={[
|
||||||
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
||||||
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||||
|
...(visibleThroughputItem ? [visibleThroughputItem] : []),
|
||||||
...(visibleReportingItem ? [visibleReportingItem] : [])
|
...(visibleReportingItem ? [visibleReportingItem] : [])
|
||||||
]}
|
]}
|
||||||
|
brandHref={workspaceHomeHref}
|
||||||
workingDocumentItems={visibleWorkingDocumentItems}
|
workingDocumentItems={visibleWorkingDocumentItems}
|
||||||
footerItems={visibleFooterLinks}
|
footerItems={visibleFooterLinks}
|
||||||
{appVersion}
|
{appVersion}
|
||||||
{releaseStage}
|
|
||||||
{currentYear}
|
{currentYear}
|
||||||
|
{canOpenSettings}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
onSignOut={() => clientSession.clear()}
|
onSignOut={signOut}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
|
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
|
||||||
<ClientTopbar
|
<ClientTopbar
|
||||||
breadcrumbs={clientBreadcrumbs(page.url.pathname)}
|
breadcrumbs={shellBreadcrumbs}
|
||||||
title={pageTitle(page.url.pathname)}
|
title={shellTitle}
|
||||||
sessionHydrated={$sessionHydrated}
|
sessionHydrated={$sessionHydrated}
|
||||||
session={$clientSession}
|
session={$clientSession}
|
||||||
{userInitials}
|
{userInitials}
|
||||||
{userMenuOpen}
|
{userMenuOpen}
|
||||||
onOpenPalette={() => openPalette()}
|
{canUseWorkspaceSearch}
|
||||||
|
{canOpenSettings}
|
||||||
|
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
|
||||||
onToggleUserMenu={() => {
|
onToggleUserMenu={() => {
|
||||||
userMenuOpen = !userMenuOpen;
|
userMenuOpen = !userMenuOpen;
|
||||||
quickMenuOpen = false;
|
quickMenuOpen = false;
|
||||||
}}
|
}}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
onSignOut={() => clientSession.clear()}
|
onSignOut={signOut}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
{#if !isRootRoute && isRestoringSession}
|
<AuthGate
|
||||||
<section class="locked-card loading-card">
|
blocked={routeGuardPending}
|
||||||
<p class="workspace-label">Checking Session</p>
|
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
|
||||||
<h2>Restoring your client workspace.</h2>
|
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
|
||||||
<p>Refreshing the current page with the saved browser session before deciding whether sign-in is required.</p>
|
detail={
|
||||||
</section>
|
isRestoringSession
|
||||||
{:else}
|
? '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()}
|
{@render children()}
|
||||||
{/if}
|
</AuthGate>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="quick-fab-wrap">
|
<div class="quick-fab-wrap">
|
||||||
{#if quickMenuOpen}
|
{#if quickMenuOpen}
|
||||||
<div class="menu-panel quick-fab-panel">
|
<div class="menu-panel quick-fab-panel">
|
||||||
|
{#if canOpenMixMaster}
|
||||||
<a href="/mixes">Open mix costing</a>
|
<a href="/mixes">Open mix costing</a>
|
||||||
|
{/if}
|
||||||
|
{#if canCreateMixWorksheet}
|
||||||
<a href="/mixes/new">Create mix worksheet</a>
|
<a href="/mixes/new">Create mix worksheet</a>
|
||||||
<a href={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>Open mix calculator</a>
|
{/if}
|
||||||
<a href="/mix-calculator/new">Create mix session</a>
|
{#if canOpenMixCalculator}
|
||||||
|
<a href="/mix-calculator">Open mix calculator</a>
|
||||||
|
{/if}
|
||||||
|
{#if canCreateMixSession}
|
||||||
|
<a href="/mix-calculator">Create mix session</a>
|
||||||
|
{/if}
|
||||||
|
{#if canOpenProducts}
|
||||||
<a href="/products">Review delivered pricing</a>
|
<a href="/products">Review delivered pricing</a>
|
||||||
|
{/if}
|
||||||
|
{#if canUseWorkspaceSearch}
|
||||||
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch}
|
||||||
<button
|
<button
|
||||||
aria-expanded={quickMenuOpen}
|
aria-expanded={quickMenuOpen}
|
||||||
aria-label="Open quick access menu"
|
aria-label="Open quick access menu"
|
||||||
@@ -385,6 +481,7 @@
|
|||||||
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
|
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
|
||||||
<span>Quick Access</span>
|
<span>Quick Access</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,12 +502,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if navOpen}
|
{#if navOpen}
|
||||||
<section
|
<div
|
||||||
aria-label="Tablet navigation drawer"
|
aria-label="Tablet navigation drawer"
|
||||||
class="bottom-drawer"
|
class="bottom-drawer"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onclick={(event) => event.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div class="drawer-handle"></div>
|
<div class="drawer-handle"></div>
|
||||||
|
|
||||||
@@ -427,7 +523,7 @@
|
|||||||
<WorkspaceSearchTrigger
|
<WorkspaceSearchTrigger
|
||||||
className="drawer-search"
|
className="drawer-search"
|
||||||
placeholder="Search the workspace..."
|
placeholder="Search the workspace..."
|
||||||
onClick={() => openPalette()}
|
onClick={() => canUseWorkspaceSearch && openPalette()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="drawer-grid">
|
<div class="drawer-grid">
|
||||||
@@ -448,6 +544,15 @@
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleThroughputItem}
|
||||||
|
{@const Icon = visibleThroughputItem.icon}
|
||||||
|
<a class:active={matchesRoute(visibleThroughputItem.href, page.url.pathname)} href={visibleThroughputItem.href} onclick={() => (navOpen = false)}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{visibleThroughputItem.label}</span>
|
||||||
|
{#if visibleThroughputItem.badge}<span class="drawer-badge">{visibleThroughputItem.badge}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if visibleReportingItem}
|
{#if visibleReportingItem}
|
||||||
{@const Icon = visibleReportingItem.icon}
|
{@const Icon = visibleReportingItem.icon}
|
||||||
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
|
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
|
||||||
@@ -470,30 +575,40 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="drawer-section drawer-actions">
|
<div class="drawer-section drawer-actions">
|
||||||
|
{#if canCreateMixWorksheet}
|
||||||
<a href="/mixes/new" onclick={() => (navOpen = false)}>
|
<a href="/mixes/new" onclick={() => (navOpen = false)}>
|
||||||
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
|
||||||
<span>Create mix worksheet</span>
|
<span>Create mix worksheet</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
|
{/if}
|
||||||
|
{#if canCreateMixSession}
|
||||||
|
<a href="/mix-calculator" onclick={() => (navOpen = false)}>
|
||||||
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
|
||||||
<span>Create mix session</span>
|
<span>Create mix session</span>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if canOpenSettings}
|
||||||
<button type="button" onclick={openSettings}>
|
<button type="button" onclick={openSettings}>
|
||||||
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
|
||||||
<span>Change settings</span>
|
<span>Change settings</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canOpenProducts}
|
||||||
<a href="/products" onclick={() => (navOpen = false)}>
|
<a href="/products" onclick={() => (navOpen = false)}>
|
||||||
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
|
||||||
<span>Review delivered pricing</span>
|
<span>Review delivered pricing</span>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if canUseWorkspaceSearch}
|
||||||
<button type="button" onclick={() => openPalette('')}>
|
<button type="button" onclick={() => openPalette('')}>
|
||||||
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
|
||||||
<span>Search the workspace</span>
|
<span>Search the workspace</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
{#if $clientSession}
|
{#if $clientSession}
|
||||||
<button type="button" onclick={() => clientSession.clear()}>
|
<button type="button" onclick={signOut}>
|
||||||
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
||||||
<span>Sign out</span>
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -507,7 +622,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -557,8 +672,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
||||||
|
|
||||||
:global(:root) {
|
:global(:root) {
|
||||||
/* ── Brand ──────────────────────────────────────────────── */
|
/* ── Brand ──────────────────────────────────────────────── */
|
||||||
--color-brand: #15803d;
|
--color-brand: #15803d;
|
||||||
@@ -603,7 +716,7 @@
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: var(--color-bg-app);
|
background: var(--color-bg-app);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-family: Inter, "Segoe UI", sans-serif;
|
font-family: "Inter", "Segoe UI", sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,7 +725,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(h1, h2, h3, h4, h5, h6) {
|
:global(h1, h2, h3, h4, h5, h6) {
|
||||||
font-family: Inter, "Segoe UI", sans-serif;
|
font-family: "Inter", "Segoe UI", sans-serif;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +745,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 252px minmax(0, 1fr);
|
grid-template-columns: 252px minmax(0, 1fr);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signed-out-shell {
|
.signed-out-shell {
|
||||||
@@ -835,15 +949,10 @@
|
|||||||
background: var(--line);
|
background: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-sublist a {
|
.drawer-sublist a {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-icon svg {
|
|
||||||
width: 0.9rem;
|
|
||||||
height: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */
|
/* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */
|
||||||
.nav-icon.muted {
|
.nav-icon.muted {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -857,6 +966,7 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-label {
|
.workspace-label {
|
||||||
@@ -972,6 +1082,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: var(--content-padding);
|
padding: var(--content-padding);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card {
|
.locked-card {
|
||||||
@@ -1002,16 +1113,6 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card a {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.78rem 0.92rem;
|
|
||||||
border-radius: 0.88rem;
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.palette-overlay {
|
.palette-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1259,6 +1360,18 @@
|
|||||||
background: var(--green-soft);
|
background: var(--green-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.08rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fdf0d2;
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-group {
|
.drawer-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
let {
|
let {
|
||||||
session,
|
session,
|
||||||
generatedAt = null,
|
generatedAt = null,
|
||||||
showGeneratedStamp = true
|
showGeneratedStamp = false
|
||||||
}: {
|
}: {
|
||||||
session: MixCalculatorPreview | MixCalculatorSession;
|
session: MixCalculatorPreview | MixCalculatorSession;
|
||||||
generatedAt?: string | null;
|
generatedAt?: string | null;
|
||||||
@@ -35,111 +35,66 @@
|
|||||||
|
|
||||||
const sessionNumber = $derived(hasSessionNumber(session) ? session.session_number : null);
|
const sessionNumber = $derived(hasSessionNumber(session) ? session.session_number : null);
|
||||||
const issuedAt = $derived(generatedAt ?? new Date().toISOString());
|
const issuedAt = $derived(generatedAt ?? new Date().toISOString());
|
||||||
const blendTotal = $derived(session.lines.reduce((sum, line) => sum + line.mix_percentage, 0));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="print-document">
|
<article class="print-document">
|
||||||
<header class="hero">
|
<header class="document-header">
|
||||||
<div class="hero-copy">
|
<img class="brand-mark" src="/logo-hsf.png" alt="Hunter Stock Feeds" />
|
||||||
<div class="hero-kicker">
|
|
||||||
<span>Mix Calculator</span>
|
<div class="title-block">
|
||||||
{#if sessionNumber}
|
<p class="eyebrow">Calculated Output</p>
|
||||||
<strong>{sessionNumber}</strong>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<h1>{session.product_name}</h1>
|
<h1>{session.product_name}</h1>
|
||||||
<p>{session.client_name} · {session.mix_name}</p>
|
<p class="subtitle">Snapshot of the scaled raw material requirements.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-side">
|
<div class="session-block">
|
||||||
<div>
|
{#if sessionNumber}
|
||||||
<span>Mix date</span>
|
<span>Session {sessionNumber}</span>
|
||||||
<strong>{formatDate(session.mix_date)}</strong>
|
{/if}
|
||||||
</div>
|
{#if showGeneratedStamp}
|
||||||
<div>
|
<span>Generated {formatTimestamp(issuedAt)}</span>
|
||||||
<span>Prepared by</span>
|
{/if}
|
||||||
<strong>{session.prepared_by_name}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Status</span>
|
|
||||||
<strong>{session.status}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="summary-band" aria-label="Session summary">
|
<section class="meta-grid" aria-label="Print summary">
|
||||||
<article>
|
<div><span>Total kg</span><strong>{formatNumber(session.total_kg, 2)}</strong><small>Scaled batch size</small></div>
|
||||||
<span>Batch size</span>
|
<div><span>Total bags</span><strong>{formatNumber(session.total_bags, 2)}</strong><small>{session.product_unit_of_measure}</small></div>
|
||||||
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
|
<div><span>Prepared by</span><strong>{session.prepared_by_name}</strong><small>{formatDate(session.mix_date)}</small></div>
|
||||||
</article>
|
<div><span>Client</span><strong>{session.client_name}</strong></div>
|
||||||
<article>
|
<div><span>Product</span><strong>{session.product_name}</strong></div>
|
||||||
<span>Total output</span>
|
<div><span>Mix source</span><strong>{session.mix_name}</strong></div>
|
||||||
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
|
<div><span>Unit size</span><strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong></div>
|
||||||
</article>
|
<div><span>Batch size</span><strong>{formatNumber(session.batch_size_kg, 2)}kg</strong></div>
|
||||||
<article>
|
|
||||||
<span>Bags</span>
|
|
||||||
<strong>{formatNumber(session.total_bags, 2)}</strong>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<span>Unit pack</span>
|
|
||||||
<strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="detail-grid">
|
|
||||||
<article class="detail-card">
|
|
||||||
<span>Mix source</span>
|
|
||||||
<strong>{session.mix_name}</strong>
|
|
||||||
<p>Saved against {session.product_unit_of_measure} units.</p>
|
|
||||||
</article>
|
|
||||||
<article class="detail-card">
|
|
||||||
<span>Composition</span>
|
|
||||||
<strong>{formatNumber(blendTotal, 2)}%</strong>
|
|
||||||
<p>{session.lines.length} raw material{session.lines.length === 1 ? '' : 's'} in the blend.</p>
|
|
||||||
</article>
|
|
||||||
{#if showGeneratedStamp}
|
|
||||||
<article class="detail-card">
|
|
||||||
<span>Generated</span>
|
|
||||||
<strong>{formatTimestamp(issuedAt)}</strong>
|
|
||||||
<p>Prepared for print or PDF export.</p>
|
|
||||||
</article>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if session.notes}
|
{#if session.notes}
|
||||||
<section class="callout notes">
|
<section class="inline-note">
|
||||||
<span>Notes</span>
|
<span>Notes</span>
|
||||||
<p>{session.notes}</p>
|
<p>{session.notes}</p>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if session.warnings.length}
|
{#if session.warnings.length}
|
||||||
<section class="callout warning">
|
<section class="inline-note warning">
|
||||||
<span>Warnings</span>
|
<span>Warnings</span>
|
||||||
<ul>
|
<p>{session.warnings.join(' | ')}</p>
|
||||||
{#each session.warnings as warning}
|
|
||||||
<li>{warning}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="composition-card">
|
<section class="composition-card">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span>Required Raw Materials</span>
|
<span>Raw Material</span>
|
||||||
<h2>Blend composition</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<p>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</p>
|
<p>Required kg</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Raw material</th>
|
<th>Raw material</th>
|
||||||
<th>Mix %</th>
|
|
||||||
<th>Required kg</th>
|
<th>Required kg</th>
|
||||||
<th>Unit</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -148,9 +103,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong>{line.raw_material_name}</strong>
|
<strong>{line.raw_material_name}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatNumber(line.mix_percentage, 2)}%</td>
|
|
||||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
||||||
<td>{line.unit}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -162,232 +115,204 @@
|
|||||||
:global(:root) {
|
:global(:root) {
|
||||||
--print-page-width: 210mm;
|
--print-page-width: 210mm;
|
||||||
--print-page-height: 297mm;
|
--print-page-height: 297mm;
|
||||||
--print-page-padding-x: 14mm;
|
--print-page-padding-x: 8mm;
|
||||||
--print-page-padding-y: 15mm;
|
--print-page-padding-y: 14mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
p,
|
p {
|
||||||
ul {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-document {
|
.print-document {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.4rem;
|
gap: 0.6rem;
|
||||||
width: min(100%, var(--print-page-width));
|
width: min(100%, var(--print-page-width));
|
||||||
min-height: var(--print-page-height);
|
height: var(--print-page-height);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
||||||
border: 1px solid #dbe4de;
|
border: 1px solid #000;
|
||||||
border-radius: 0.8rem;
|
background: #fff;
|
||||||
background:
|
color: #000;
|
||||||
radial-gradient(circle at top right, rgba(21, 128, 61, 0.08), transparent 22rem),
|
box-shadow: none;
|
||||||
linear-gradient(180deg, #fff 0%, #fbfcfb 100%);
|
overflow: hidden;
|
||||||
color: #21312a;
|
box-sizing: border-box;
|
||||||
box-shadow:
|
|
||||||
0 28px 48px rgba(21, 33, 26, 0.08),
|
|
||||||
0 0 0 1px rgba(219, 228, 222, 0.55);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.document-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 17rem;
|
grid-template-columns: auto 1fr auto;
|
||||||
gap: 1.25rem;
|
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding-bottom: 1.3rem;
|
gap: 0.75rem;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--line) 88%, #dce7df);
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-kicker {
|
.brand-mark {
|
||||||
display: inline-flex;
|
width: 32mm;
|
||||||
align-items: center;
|
height: auto;
|
||||||
gap: 0.65rem;
|
object-fit: contain;
|
||||||
margin-bottom: 0.75rem;
|
filter: grayscale(1) contrast(1.2);
|
||||||
color: #62736b;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-kicker strong {
|
.eyebrow,
|
||||||
padding: 0.36rem 0.55rem;
|
.meta-grid span,
|
||||||
border: 1px solid #d5dfd9;
|
.inline-note span,
|
||||||
border-radius: 999px;
|
.section-heading span,
|
||||||
color: #214233;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
max-width: 11ch;
|
|
||||||
font-size: clamp(2rem, 4vw, 3.3rem);
|
|
||||||
line-height: 0.96;
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy p,
|
|
||||||
.section-heading p,
|
|
||||||
.detail-card p {
|
|
||||||
color: #6b7a73;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy p {
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-side,
|
|
||||||
.summary-band,
|
|
||||||
.detail-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-side div,
|
|
||||||
.summary-band article,
|
|
||||||
.detail-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-side span,
|
|
||||||
.summary-band span,
|
|
||||||
.detail-card span,
|
|
||||||
.callout span,
|
|
||||||
th,
|
th,
|
||||||
.section-heading span {
|
.session-block span {
|
||||||
color: #6b7a73;
|
color: #000;
|
||||||
font-size: 0.72rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.09em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-side strong,
|
.title-block {
|
||||||
.detail-card strong {
|
min-width: 0;
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-band {
|
.title-block h1 {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-band article {
|
.subtitle {
|
||||||
min-height: 6.2rem;
|
margin-top: 0.12rem;
|
||||||
padding: 1rem 1.05rem;
|
color: #000;
|
||||||
border: 1px solid #dfe7e2;
|
font-size: 0.72rem;
|
||||||
border-radius: 1.2rem;
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-band strong {
|
.session-block {
|
||||||
margin-top: auto;
|
|
||||||
font-size: clamp(1.4rem, 2.4vw, 2rem);
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-grid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card {
|
|
||||||
min-height: 7rem;
|
|
||||||
padding: 1rem 1.05rem;
|
|
||||||
border-radius: 1.15rem;
|
|
||||||
background: #f6f9f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callout {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
justify-items: end;
|
||||||
padding: 1rem 1.1rem;
|
gap: 0.18rem;
|
||||||
border-radius: 1.15rem;
|
text-align: right;
|
||||||
break-inside: avoid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.callout.notes {
|
.meta-grid {
|
||||||
background: #f6f9f7;
|
display: grid;
|
||||||
border: 1px solid #dfe7e2;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.38rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.callout.warning {
|
.meta-grid div {
|
||||||
background: #fff7ea;
|
display: grid;
|
||||||
border: 1px solid #f0cf97;
|
gap: 0.08rem;
|
||||||
color: #82561b;
|
min-height: 2.1rem;
|
||||||
|
padding: 0.34rem 0.42rem;
|
||||||
|
border: 1px solid #000;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.callout ul {
|
.meta-grid strong {
|
||||||
padding-left: 1rem;
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid small {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-note {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
padding: 0.35rem 0.42rem;
|
||||||
|
border: 1px solid #000;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-note p {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-note.warning {
|
||||||
|
border-color: #000;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composition-card {
|
.composition-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.35rem;
|
||||||
break-inside: avoid;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
align-items: end;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading h2 {
|
.section-heading h2 {
|
||||||
margin-top: 0.32rem;
|
margin-top: 0.05rem;
|
||||||
font-size: 1.5rem;
|
font-size: 0.95rem;
|
||||||
letter-spacing: -0.04em;
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading p {
|
||||||
|
color: #000;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
table-layout: fixed;
|
||||||
border: 1px solid #dfe7e2;
|
border: 1px solid #000;
|
||||||
border-radius: 1.2rem;
|
background: #fff;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.95rem 1rem;
|
padding: 0.28rem 0.42rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #e6ede9;
|
border-bottom: 1px solid #000;
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead {
|
th:last-child,
|
||||||
display: table-header-group;
|
td:last-child {
|
||||||
|
width: 32mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr,
|
|
||||||
td,
|
|
||||||
th {
|
th {
|
||||||
break-inside: avoid;
|
background: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
td strong {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:last-child td {
|
tbody tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td strong {
|
tr,
|
||||||
font-size: 0.98rem;
|
td,
|
||||||
font-weight: 700;
|
th,
|
||||||
color: #203128;
|
section {
|
||||||
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.hero,
|
.meta-grid {
|
||||||
.summary-band,
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
.detail-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +320,7 @@
|
|||||||
:global(html),
|
:global(html),
|
||||||
:global(body) {
|
:global(body) {
|
||||||
width: var(--print-page-width);
|
width: var(--print-page-width);
|
||||||
min-height: var(--print-page-height);
|
height: var(--print-page-height);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
@@ -404,28 +329,13 @@
|
|||||||
|
|
||||||
.print-document {
|
.print-document {
|
||||||
width: var(--print-page-width);
|
width: var(--print-page-width);
|
||||||
min-height: var(--print-page-height);
|
height: var(--print-page-height);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
|
||||||
background: #fff;
|
|
||||||
color: #1e2622;
|
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-band article,
|
|
||||||
.detail-card,
|
|
||||||
table,
|
|
||||||
.callout {
|
|
||||||
border-color: #d5ddd8;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callout.warning {
|
|
||||||
background: #fff8ef;
|
|
||||||
}
|
|
||||||
|
|
||||||
@page {
|
@page {
|
||||||
size: A4 portrait;
|
size: A4 portrait;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
|
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function openPdf() {
|
||||||
|
const blob = await api.mixCalculatorSessionPdf(session.id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadPdf() {
|
async function downloadPdf() {
|
||||||
const blob = await api.mixCalculatorSessionPdf(session.id);
|
const blob = await api.mixCalculatorSessionPdf(session.id);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -29,8 +36,8 @@
|
|||||||
<section class="print-page">
|
<section class="print-page">
|
||||||
<div class="print-toolbar">
|
<div class="print-toolbar">
|
||||||
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
|
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
|
||||||
|
<button class="primary-button" type="button" onclick={openPdf}>Open Styled PDF</button>
|
||||||
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
||||||
<button class="primary-button" type="button" onclick={() => window.print()}>Print / Save PDF</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
|
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
function icon(type: Toast['type']) {
|
function icon(type: Toast['type']) {
|
||||||
if (type === 'success') return '✓';
|
if (type === 'success') return '✓';
|
||||||
if (type === 'error') return '✕';
|
if (type === 'error') return '!';
|
||||||
if (type === 'loading') return null; // spinner shown separately
|
if (type === 'loading') return null; // spinner shown separately
|
||||||
return 'ℹ';
|
return 'ℹ';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
||||||
import { featureFlags } from '$lib/features';
|
import { featureFlags } from '$lib/features';
|
||||||
@@ -11,7 +10,6 @@
|
|||||||
MixCalculatorPreview,
|
MixCalculatorPreview,
|
||||||
MixCalculatorSession
|
MixCalculatorSession
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import MixCalculatorPreviewModal from './MixCalculatorPreviewModal.svelte';
|
|
||||||
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
||||||
|
|
||||||
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
||||||
@@ -19,7 +17,7 @@
|
|||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
function initialClientNameValue() {
|
function initialClientNameValue() {
|
||||||
return initialSession?.client_name ?? options.clients[0] ?? '';
|
return initialSession?.client_name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialProductIdValue() {
|
function initialProductIdValue() {
|
||||||
@@ -54,12 +52,10 @@
|
|||||||
let notes = $state(initialNotesValue());
|
let notes = $state(initialNotesValue());
|
||||||
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
|
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock products.');
|
||||||
let previewLoading = $state(false);
|
let previewLoading = $state(false);
|
||||||
let saveLoading = $state(false);
|
|
||||||
let previewModalOpen = $state(false);
|
|
||||||
|
|
||||||
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
||||||
const isExistingSession = $derived(initialSession !== null);
|
|
||||||
const availableClients = $derived(
|
const availableClients = $derived(
|
||||||
Array.from(new Set([...(options.clients ?? []), ...(initialSession ? [initialSession.client_name] : [])]))
|
Array.from(new Set([...(options.clients ?? []), ...(initialSession ? [initialSession.client_name] : [])]))
|
||||||
);
|
);
|
||||||
@@ -84,18 +80,7 @@
|
|||||||
const selectedProduct = $derived(filteredProducts.find((product) => product.product_id === productId) ?? null);
|
const selectedProduct = $derived(filteredProducts.find((product) => product.product_id === productId) ?? null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!clientName && availableClients.length) {
|
if (!filteredProducts.some((product) => product.product_id === productId)) {
|
||||||
clientName = availableClients[0];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (filteredProducts.length && !filteredProducts.some((product) => product.product_id === productId)) {
|
|
||||||
productId = filteredProducts[0].product_id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filteredProducts.length) {
|
|
||||||
productId = 0;
|
productId = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -116,26 +101,32 @@
|
|||||||
|
|
||||||
function buildPayload(): MixCalculatorCreateInput | null {
|
function buildPayload(): MixCalculatorCreateInput | null {
|
||||||
formError = '';
|
formError = '';
|
||||||
|
formHint = '';
|
||||||
|
|
||||||
const numericBatchSize = Number(batchSizeKg);
|
const numericBatchSize = Number(batchSizeKg);
|
||||||
if (!mixDate) {
|
if (!mixDate) {
|
||||||
formError = 'Select a mix date.';
|
formError = 'Select a mix date.';
|
||||||
return null;
|
formHint = 'Choose the production date before calculating the mix.';
|
||||||
}
|
|
||||||
if (!clientName) {
|
|
||||||
formError = 'Select a client.';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!productId) {
|
|
||||||
formError = 'Select a product.';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
|
|
||||||
formError = 'Enter a batch size greater than zero.';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!preparedByName.trim()) {
|
if (!preparedByName.trim()) {
|
||||||
formError = 'Enter the prepared by name.';
|
formError = 'Enter the prepared by name.';
|
||||||
|
formHint = 'Record the operator or staff member responsible for this mix.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!clientName) {
|
||||||
|
formError = 'Select a client to unlock matching products.';
|
||||||
|
formHint = 'Products stay disabled until a client is selected.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!productId) {
|
||||||
|
formError = 'Select a product.';
|
||||||
|
formHint = 'Pick one of the products available for the selected client.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
|
||||||
|
formError = 'Enter a batch size greater than zero.';
|
||||||
|
formHint = 'Batch size must be a positive number before the mix can be calculated.';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +163,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearForm() {
|
function clearForm() {
|
||||||
clientName = options.clients[0] ?? '';
|
clientName = '';
|
||||||
productId = 0;
|
productId = 0;
|
||||||
mixDate = todayIso;
|
mixDate = todayIso;
|
||||||
batchSizeKg = '';
|
batchSizeKg = '';
|
||||||
@@ -180,22 +171,43 @@
|
|||||||
notes = '';
|
notes = '';
|
||||||
preview = null;
|
preview = null;
|
||||||
formError = '';
|
formError = '';
|
||||||
|
formHint = 'Select a mix date and prepared by name, then choose a client to unlock products.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function printPreview() {
|
$effect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (!clientName) {
|
||||||
window.print();
|
formHint = 'Select a client to unlock the product list.';
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadSessionPdf(sessionId: number) {
|
if (!filteredProducts.length) {
|
||||||
|
formHint = `No products are available for ${clientName}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
formHint = 'Select a product for the chosen client.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function downloadPdf() {
|
||||||
const tid = toast.loading('Generating PDF…');
|
const tid = toast.loading('Generating PDF…');
|
||||||
try {
|
try {
|
||||||
const blob = await api.mixCalculatorSessionPdf(sessionId);
|
const payload = buildPayload();
|
||||||
|
if (!payload) {
|
||||||
|
toast.dismiss(tid);
|
||||||
|
toast.error(formError || 'Complete the mix details first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await api.previewMixCalculatorPdf(payload);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
anchor.download = `mix_calculator_${sessionId}.pdf`;
|
anchor.download = `mix_calculator_${payload.client_name}_${payload.mix_date}.pdf`.replace(/[^\w.-]+/g, '_');
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
@@ -207,43 +219,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPreviewModal() {
|
async function openPdf() {
|
||||||
if (!preview) {
|
const tid = toast.loading('Opening styled PDF…');
|
||||||
return;
|
try {
|
||||||
}
|
|
||||||
|
|
||||||
previewModalOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePreviewModal() {
|
|
||||||
previewModalOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') {
|
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
|
toast.dismiss(tid);
|
||||||
|
toast.error(formError || 'Complete the mix details first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveLoading = true;
|
const blob = await api.previewMixCalculatorPdf(payload);
|
||||||
const tid = toast.loading(mode === 'update' ? 'Saving session…' : 'Creating session…');
|
const url = URL.createObjectURL(blob);
|
||||||
try {
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
const saved =
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
mode === 'update' && initialSession
|
|
||||||
? await api.updateMixCalculatorSession(initialSession.id, payload)
|
|
||||||
: await api.createMixCalculatorSession(payload);
|
|
||||||
|
|
||||||
toast.dismiss(tid);
|
toast.dismiss(tid);
|
||||||
toast.success(mode === 'update' ? 'Session saved' : 'Session created');
|
|
||||||
const target = destination === 'print' ? `/mix-calculator/${saved.id}/print` : `/mix-calculator/${saved.id}`;
|
|
||||||
await goto(target);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.dismiss(tid);
|
toast.dismiss(tid);
|
||||||
toast.error(error instanceof Error ? error.message : 'Unable to save the mix calculator session.');
|
toast.error(error instanceof Error ? error.message : 'Unable to open the styled PDF.');
|
||||||
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
|
|
||||||
saveLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !canEdit && !initialSession}
|
{#if !canEdit && !initialSession}
|
||||||
@@ -262,8 +258,9 @@
|
|||||||
<a class="secondary-button" href="/mix-calculator">Session history</a>
|
<a class="secondary-button" href="/mix-calculator">Session history</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if initialSession}
|
{#if initialSession}
|
||||||
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
|
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Open PDF page</a>
|
||||||
<button class="secondary-button" type="button" onclick={() => downloadSessionPdf(initialSession.id)}>Download PDF</button>
|
<button class="primary-button" type="button" onclick={openPdf}>Open PDF in new tab</button>
|
||||||
|
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -287,15 +284,24 @@
|
|||||||
<p class="message error">{formError}</p>
|
<p class="message error">{formError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !formError && formHint}
|
||||||
|
<p class="message hint">{formHint}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<label>
|
<label>
|
||||||
<span>Mix date</span>
|
<span>Mix date</span>
|
||||||
<input bind:value={mixDate} disabled={!canEdit} type="date" />
|
<input bind:value={mixDate} disabled={!canEdit} type="date" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Prepared by</span>
|
||||||
|
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Client</span>
|
<span>Client</span>
|
||||||
<select bind:value={clientName} disabled={!canEdit}>
|
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching products.">
|
||||||
<option value="">Select a client</option>
|
<option value="">Select a client</option>
|
||||||
{#each availableClients as client}
|
{#each availableClients as client}
|
||||||
<option value={client}>{client}</option>
|
<option value={client}>{client}</option>
|
||||||
@@ -303,9 +309,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="full-width">
|
<label>
|
||||||
<span>Product</span>
|
<span>Product</span>
|
||||||
<select bind:value={productId} disabled={!canEdit || !filteredProducts.length}>
|
<select
|
||||||
|
bind:value={productId}
|
||||||
|
disabled={!canEdit || !clientName || !filteredProducts.length}
|
||||||
|
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No products are available for the selected client.' : 'Select a product.'}
|
||||||
|
>
|
||||||
<option value={0}>Select a product</option>
|
<option value={0}>Select a product</option>
|
||||||
{#each filteredProducts as product}
|
{#each filteredProducts as product}
|
||||||
<option value={product.product_id}>
|
<option value={product.product_id}>
|
||||||
@@ -317,12 +327,7 @@
|
|||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Batch size (kg)</span>
|
<span>Batch size (kg)</span>
|
||||||
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="560" type="number" />
|
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" />
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span>Prepared by</span>
|
|
||||||
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="full-width">
|
<label class="full-width">
|
||||||
@@ -340,38 +345,12 @@
|
|||||||
|
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button class="primary-button" disabled={previewLoading || saveLoading} type="button" onclick={calculatePreview}>
|
<button class="primary-button" disabled={previewLoading} type="button" onclick={calculatePreview}>
|
||||||
<span class="button-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
|
<span class="button-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
|
||||||
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
|
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if featureFlags.mixCalculatorSessionSave}
|
<button class="danger-button" disabled={previewLoading} type="button" onclick={clearForm}>
|
||||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}>
|
|
||||||
{saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create', 'print')}>
|
|
||||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
|
||||||
<span>{saveLoading ? 'Saving...' : 'Save & print'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if initialSession}
|
|
||||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}>
|
|
||||||
Save as new
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={openPreviewModal}>
|
|
||||||
<span>Preview</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}>
|
|
||||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
|
||||||
<span>Print</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button class="secondary-button" disabled={previewLoading || saveLoading} type="button" onclick={clearForm}>
|
|
||||||
<span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span>
|
<span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span>
|
||||||
<span>Clear</span>
|
<span>Clear</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -382,20 +361,12 @@
|
|||||||
<MixCalculatorResultsPanel
|
<MixCalculatorResultsPanel
|
||||||
preview={preview}
|
preview={preview}
|
||||||
sessionNumber={initialSession?.session_number ?? null}
|
sessionNumber={initialSession?.session_number ?? null}
|
||||||
|
onOpenPdf={preview ? openPdf : null}
|
||||||
|
onDownloadPdf={preview ? downloadPdf : null}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if preview}
|
{#if preview}
|
||||||
{#if previewModalOpen}
|
|
||||||
<MixCalculatorPreviewModal
|
|
||||||
preview={preview}
|
|
||||||
sessionId={initialSession?.id ?? null}
|
|
||||||
onClose={closePreviewModal}
|
|
||||||
onPrint={printPreview}
|
|
||||||
onDownloadPdf={downloadSessionPdf}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="print-only" aria-hidden="true">
|
<section class="print-only" aria-hidden="true">
|
||||||
<MixCalculatorPrintDocument session={preview} />
|
<MixCalculatorPrintDocument session={preview} />
|
||||||
</section>
|
</section>
|
||||||
@@ -450,7 +421,7 @@
|
|||||||
.form-card,
|
.form-card,
|
||||||
.locked-card {
|
.locked-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1.3rem;
|
border-radius: 0.8rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
@@ -487,7 +458,7 @@
|
|||||||
gap: 0.14rem;
|
gap: 0.14rem;
|
||||||
padding: 0.72rem 0.82rem;
|
padding: 0.72rem 0.82rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.92rem;
|
border-radius: 0.65rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,9 +495,20 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.78rem 0.82rem;
|
padding: 0.78rem 0.82rem;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: 0.88rem;
|
border-radius: 0.6rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
@@ -538,14 +520,14 @@
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.92rem;
|
padding: 0.92rem;
|
||||||
border-radius: 1rem;
|
border-radius: 0.65rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.75rem 0.85rem;
|
||||||
border-radius: 0.88rem;
|
border-radius: 0.6rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,26 +536,31 @@
|
|||||||
color: #b2463f;
|
color: #b2463f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.hint {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-button {
|
.secondary-button,
|
||||||
|
.danger-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.78rem 0.96rem;
|
padding: 0.78rem 0.96rem;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.6rem;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
transform 140ms ease,
|
background-color 160ms ease,
|
||||||
box-shadow 140ms ease,
|
border-color 160ms ease;
|
||||||
background-color 140ms ease,
|
|
||||||
border-color 140ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
@@ -587,20 +574,35 @@
|
|||||||
color: #304038;
|
color: #304038;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:hover:not(:disabled),
|
.danger-button {
|
||||||
.secondary-button:hover:not(:disabled) {
|
background: #c63d32;
|
||||||
transform: translateY(-1px);
|
color: #fff;
|
||||||
|
border-color: #c63d32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:hover:not(:disabled) {
|
.primary-button:hover:not(:disabled) {
|
||||||
box-shadow: none;
|
background: #126a33;
|
||||||
filter: brightness(1.04);
|
}
|
||||||
|
|
||||||
|
.primary-button:active:not(:disabled) {
|
||||||
|
background: #0f5a2b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button:hover:not(:disabled) {
|
.secondary-button:hover:not(:disabled) {
|
||||||
border-color: #9fb0a6;
|
border-color: var(--color-text-muted);
|
||||||
background: #f6faf7;
|
background: var(--color-bg-app);
|
||||||
box-shadow: none;
|
}
|
||||||
|
|
||||||
|
.danger-button:hover:not(:disabled) {
|
||||||
|
background: #b2352b;
|
||||||
|
border-color: #b2352b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:focus-visible,
|
||||||
|
.secondary-button:focus-visible,
|
||||||
|
.danger-button:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-icon {
|
.button-icon {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
preview,
|
preview,
|
||||||
sessionId = null,
|
sessionId = null,
|
||||||
onClose,
|
onClose,
|
||||||
onPrint,
|
onOpenPdf,
|
||||||
onDownloadPdf
|
onDownloadPdf
|
||||||
}: {
|
}: {
|
||||||
preview: MixCalculatorPreview | MixCalculatorSession;
|
preview: MixCalculatorPreview | MixCalculatorSession;
|
||||||
sessionId?: number | null;
|
sessionId?: number | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPrint: () => void;
|
onOpenPdf: () => void;
|
||||||
onDownloadPdf: (sessionId: number) => void;
|
onDownloadPdf: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
class="preview-modal"
|
class="preview-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Print preview"
|
aria-label="Styled PDF preview"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={(event) => event.stopPropagation()}
|
onclick={(event) => event.stopPropagation()}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
@@ -33,16 +33,16 @@
|
|||||||
>
|
>
|
||||||
<div class="preview-modal-toolbar">
|
<div class="preview-modal-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="preview-modal-kicker">Print Preview</p>
|
<p class="preview-modal-kicker">Styled PDF Preview</p>
|
||||||
<h3>{preview.product_name}</h3>
|
<h3>{preview.product_name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-modal-actions">
|
<div class="preview-modal-actions">
|
||||||
<button class="secondary-button" type="button" onclick={onClose}>Close</button>
|
<button class="secondary-button" type="button" onclick={onClose}>Close</button>
|
||||||
|
<button class="primary-button" type="button" onclick={onOpenPdf}>Open PDF in new tab</button>
|
||||||
{#if sessionId}
|
{#if sessionId}
|
||||||
<a class="secondary-button" href={`/mix-calculator/${sessionId}/print`}>Open page</a>
|
<a class="secondary-button" href={`/mix-calculator/${sessionId}/print`}>Open PDF page</a>
|
||||||
<button class="secondary-button" type="button" onclick={() => onDownloadPdf(sessionId)}>Download PDF</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button class="primary-button" type="button" onclick={onPrint}>Print / Save PDF</button>
|
<button class="secondary-button" type="button" onclick={onDownloadPdf}>Download PDF</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,10 +77,9 @@
|
|||||||
width: min(1180px, 100%);
|
width: min(1180px, 100%);
|
||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100vh - 2rem);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 1.6rem;
|
border-radius: 0.9rem;
|
||||||
background:
|
background: var(--color-bg-surface);
|
||||||
linear-gradient(180deg, rgba(248, 250, 248, 0.96), rgba(240, 246, 242, 0.96));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-modal-toolbar {
|
.preview-modal-toolbar {
|
||||||
@@ -116,9 +115,9 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: start center;
|
place-items: start center;
|
||||||
padding: 1.1rem;
|
padding: 1.1rem;
|
||||||
border-radius: 1.35rem;
|
border: 1px solid var(--color-border);
|
||||||
background:
|
border-radius: 0.8rem;
|
||||||
linear-gradient(135deg, #dfe8e2 0%, #eef3ef 45%, #d7e2db 100%);
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-sheet-scroll {
|
.preview-sheet-scroll {
|
||||||
@@ -135,10 +134,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.78rem 0.96rem;
|
padding: 0.78rem 0.96rem;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.6rem;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
@@ -152,6 +154,25 @@
|
|||||||
color: #304038;
|
color: #304038;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: #126a33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:active {
|
||||||
|
background: #0f5a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover {
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:focus-visible,
|
||||||
|
.secondary-button:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.preview-modal-toolbar {
|
.preview-modal-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Download, Printer } from 'lucide-svelte';
|
||||||
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
preview,
|
preview,
|
||||||
sessionNumber = null
|
sessionNumber = null,
|
||||||
|
onOpenPdf = null,
|
||||||
|
onDownloadPdf = null
|
||||||
}: {
|
}: {
|
||||||
preview: MixCalculatorPreview | MixCalculatorSession | null;
|
preview: MixCalculatorPreview | MixCalculatorSession | null;
|
||||||
sessionNumber?: string | null;
|
sessionNumber?: string | null;
|
||||||
|
onOpenPdf?: (() => void) | null;
|
||||||
|
onDownloadPdf?: (() => void) | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
@@ -89,9 +94,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Raw material</th>
|
<th>Raw material</th>
|
||||||
<th>Mix %</th>
|
|
||||||
<th>Required kg</th>
|
<th>Required kg</th>
|
||||||
<th>Unit</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -100,14 +103,23 @@
|
|||||||
<td data-label="Raw material">
|
<td data-label="Raw material">
|
||||||
<strong>{line.raw_material_name}</strong>
|
<strong>{line.raw_material_name}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
|
|
||||||
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
|
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
|
||||||
<td data-label="Unit">{line.unit}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="output-actions">
|
||||||
|
<button class="primary-button" disabled={!onOpenPdf} type="button" onclick={() => onOpenPdf?.()}>
|
||||||
|
<Printer size={18} strokeWidth={1.9} aria-hidden="true" />
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
<button class="secondary-button" disabled={!onDownloadPdf} type="button" onclick={() => onDownloadPdf?.()}>
|
||||||
|
<Download size={18} strokeWidth={1.9} aria-hidden="true" />
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-shimmer-metrics">
|
<div class="empty-shimmer-metrics">
|
||||||
@@ -127,8 +139,6 @@
|
|||||||
<div class="shimmer-row">
|
<div class="shimmer-row">
|
||||||
<div class="shimmer-line wide"></div>
|
<div class="shimmer-line wide"></div>
|
||||||
<div class="shimmer-line medium"></div>
|
<div class="shimmer-line medium"></div>
|
||||||
<div class="shimmer-line medium"></div>
|
|
||||||
<div class="shimmer-line short"></div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +162,7 @@
|
|||||||
.result-card,
|
.result-card,
|
||||||
.metric-card {
|
.metric-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1.3rem;
|
border-radius: 0.8rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
@@ -179,7 +189,7 @@
|
|||||||
gap: 0.14rem;
|
gap: 0.14rem;
|
||||||
padding: 0.72rem 0.82rem;
|
padding: 0.72rem 0.82rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.92rem;
|
border-radius: 0.65rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +232,9 @@
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.92rem;
|
padding: 0.92rem;
|
||||||
border-radius: 1rem;
|
border-radius: 0.65rem;
|
||||||
background: #fff6e6;
|
background: #fdf6e9;
|
||||||
color: #8b5b1e;
|
color: #8a5a00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@@ -235,7 +245,7 @@
|
|||||||
.summary-grid div {
|
.summary-grid div {
|
||||||
padding: 0.88rem 0.92rem;
|
padding: 0.88rem 0.92rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1rem;
|
border-radius: 0.65rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +261,14 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.output-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 30rem;
|
min-width: 30rem;
|
||||||
@@ -272,6 +290,57 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.78rem 0.96rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
border: none;
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
background: #fff;
|
||||||
|
color: #304038;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover:not(:disabled) {
|
||||||
|
background: #126a33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:active:not(:disabled) {
|
||||||
|
background: #0f5a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:focus-visible,
|
||||||
|
.secondary-button:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -355,7 +424,7 @@
|
|||||||
|
|
||||||
.shimmer-row {
|
.shimmer-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr 1fr 0.75fr;
|
grid-template-columns: 2fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.78rem 1rem;
|
padding: 0.78rem 1rem;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
type?: 'button' | 'link';
|
type?: 'button' | 'link';
|
||||||
|
badge?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -33,14 +34,14 @@
|
|||||||
{#if Icon}
|
{#if Icon}
|
||||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>{#if item.badge}<span class="nav-badge">{item.badge}</span>{/if}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button type="button" class="nav-button" class:active={item.active} onclick={item.onSelect}>
|
<button type="button" class="nav-button" class:active={item.active} onclick={item.onSelect}>
|
||||||
{#if Icon}
|
{#if Icon}
|
||||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>{#if item.badge}<span class="nav-badge">{item.badge}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -105,6 +106,26 @@
|
|||||||
background: var(--nav-item-active-marker, var(--color-brand));
|
background: var(--nav-item-active-marker, var(--color-brand));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.08rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fdf0d2;
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list a.active .nav-badge,
|
||||||
|
.nav-button.active .nav-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -5,23 +5,25 @@
|
|||||||
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
|
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
brandHref,
|
||||||
currentPath,
|
currentPath,
|
||||||
primaryItems,
|
primaryItems,
|
||||||
workingDocumentItems,
|
workingDocumentItems,
|
||||||
footerItems,
|
footerItems,
|
||||||
appVersion,
|
appVersion,
|
||||||
releaseStage,
|
|
||||||
currentYear,
|
currentYear,
|
||||||
|
canOpenSettings,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onSignOut
|
onSignOut
|
||||||
}: {
|
}: {
|
||||||
|
brandHref: string;
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
primaryItems: NavItem[];
|
primaryItems: NavItem[];
|
||||||
workingDocumentItems: NavItem[];
|
workingDocumentItems: NavItem[];
|
||||||
footerItems: FooterLink[];
|
footerItems: FooterLink[];
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
releaseStage: string;
|
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
|
canOpenSettings: boolean;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onSignOut: () => void;
|
onSignOut: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<a class="brand" href="/">
|
<a class="brand" href={brandHref}>
|
||||||
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
|
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
label: item.label,
|
label: item.label,
|
||||||
href: item.href,
|
href: item.href,
|
||||||
icon: item.icon,
|
icon: item.icon,
|
||||||
|
badge: item.badge,
|
||||||
active: matchesRoute(item.href, currentPath)
|
active: matchesRoute(item.href, currentPath)
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
@@ -75,18 +78,22 @@
|
|||||||
<AppNavSection
|
<AppNavSection
|
||||||
ariaLabel="Account actions"
|
ariaLabel="Account actions"
|
||||||
items={[
|
items={[
|
||||||
|
...(canOpenSettings
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
active: currentPath.startsWith('/settings'),
|
active: currentPath.startsWith('/settings'),
|
||||||
onSelect: onOpenSettings,
|
onSelect: onOpenSettings,
|
||||||
type: 'button'
|
type: 'button' as const
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: 'Sign out',
|
label: 'Logout',
|
||||||
icon: LogOut,
|
icon: LogOut,
|
||||||
onSelect: onSignOut,
|
onSelect: onSignOut,
|
||||||
type: 'button'
|
type: 'button' as const
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -97,7 +104,6 @@
|
|||||||
<span class="meta-label">Build</span>
|
<span class="meta-label">Build</span>
|
||||||
<span>{appVersion}</span>
|
<span>{appVersion}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="release-pill">{releaseStage}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-meta-bottom">
|
<div class="sidebar-meta-bottom">
|
||||||
<small>© {currentYear} Hunter Premium Produce</small>
|
<small>© {currentYear} Hunter Premium Produce</small>
|
||||||
@@ -234,18 +240,4 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.2rem 0.52rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: color-mix(in srgb, var(--color-brand) 8%, white);
|
|
||||||
color: var(--color-brand);
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
session,
|
session,
|
||||||
userInitials,
|
userInitials,
|
||||||
userMenuOpen,
|
userMenuOpen,
|
||||||
|
canUseWorkspaceSearch,
|
||||||
|
canOpenSettings,
|
||||||
onOpenPalette,
|
onOpenPalette,
|
||||||
onToggleUserMenu,
|
onToggleUserMenu,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
@@ -23,6 +25,8 @@
|
|||||||
session: AppSession | null;
|
session: AppSession | null;
|
||||||
userInitials: string;
|
userInitials: string;
|
||||||
userMenuOpen: boolean;
|
userMenuOpen: boolean;
|
||||||
|
canUseWorkspaceSearch: boolean;
|
||||||
|
canOpenSettings: boolean;
|
||||||
onOpenPalette: () => void;
|
onOpenPalette: () => void;
|
||||||
onToggleUserMenu: () => void;
|
onToggleUserMenu: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
@@ -47,9 +51,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if canUseWorkspaceSearch}
|
||||||
<div class="topbar-middle">
|
<div class="topbar-middle">
|
||||||
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
|
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="topbar-middle"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<div class="menu-wrap user-menu-wrap">
|
<div class="menu-wrap user-menu-wrap">
|
||||||
@@ -86,10 +94,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if canOpenSettings}
|
||||||
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
|
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
|
||||||
<Settings size={15} strokeWidth={1.75} />
|
<Settings size={15} strokeWidth={1.75} />
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
{#if session}
|
{#if session}
|
||||||
<button type="button" onclick={onSignOut}>Log out</button>
|
<button type="button" onclick={onSignOut}>Log out</button>
|
||||||
{:else if !sessionHydrated}
|
{:else if !sessionHydrated}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
Gauge,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -13,6 +14,10 @@ import {
|
|||||||
import type { ComponentType } from 'svelte';
|
import type { ComponentType } from 'svelte';
|
||||||
|
|
||||||
import { featureFlags } from '$lib/features';
|
import { featureFlags } from '$lib/features';
|
||||||
|
import {
|
||||||
|
canOpenDashboard
|
||||||
|
} from '$lib/workspace-access';
|
||||||
|
import type { AppSession } from '$lib/session';
|
||||||
|
|
||||||
export type SearchItem = {
|
export type SearchItem = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -27,6 +32,7 @@ export type NavItem = {
|
|||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
icon: ComponentType;
|
icon: ComponentType;
|
||||||
moduleKey?: string;
|
moduleKey?: string;
|
||||||
|
badge?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FooterLink = {
|
export type FooterLink = {
|
||||||
@@ -50,7 +56,7 @@ export const dashboardItem: NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const mixCalculatorItem: NavItem = {
|
export const mixCalculatorItem: NavItem = {
|
||||||
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
|
href: '/mix-calculator',
|
||||||
label: 'Mix Calculator',
|
label: 'Mix Calculator',
|
||||||
shortLabel: 'MC',
|
shortLabel: 'MC',
|
||||||
icon: Calculator,
|
icon: Calculator,
|
||||||
@@ -65,6 +71,15 @@ export const reportingItem: NavItem = {
|
|||||||
moduleKey: 'products'
|
moduleKey: 'products'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const throughputItem: NavItem = {
|
||||||
|
href: '/throughput',
|
||||||
|
label: 'Throughput',
|
||||||
|
shortLabel: 'OT',
|
||||||
|
icon: Gauge,
|
||||||
|
moduleKey: 'operations_throughput',
|
||||||
|
badge: 'test'
|
||||||
|
};
|
||||||
|
|
||||||
export const workingDocumentItems: NavItem[] = [
|
export const workingDocumentItems: NavItem[] = [
|
||||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
|
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
|
||||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
|
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
|
||||||
@@ -83,6 +98,7 @@ export const accessControlItem: NavItem = {
|
|||||||
export const clientNavigationItems: NavItem[] = [
|
export const clientNavigationItems: NavItem[] = [
|
||||||
dashboardItem,
|
dashboardItem,
|
||||||
mixCalculatorItem,
|
mixCalculatorItem,
|
||||||
|
throughputItem,
|
||||||
...workingDocumentItems,
|
...workingDocumentItems,
|
||||||
accessControlItem
|
accessControlItem
|
||||||
];
|
];
|
||||||
@@ -128,8 +144,8 @@ export const baseSearchItems: SearchItem[] = [
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
href: '/mix-calculator/new',
|
href: '/mix-calculator',
|
||||||
label: 'Create Mix Calculation',
|
label: 'Open Mix Calculator',
|
||||||
description: 'Run a new client-specific mix calculation session.',
|
description: 'Run a new client-specific mix calculation session.',
|
||||||
keywords: 'new mix calculator session client batch size product bags print'
|
keywords: 'new mix calculator session client batch size product bags print'
|
||||||
},
|
},
|
||||||
@@ -167,26 +183,30 @@ export function pageTitle(pathname: string) {
|
|||||||
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
|
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clientBreadcrumbs(pathname: string): Crumb[] {
|
export function clientBreadcrumbs(pathname: string, session?: AppSession | null): Crumb[] {
|
||||||
const root: Crumb = { label: 'Workspace', href: '/' };
|
const crumbs: Crumb[] = [];
|
||||||
|
|
||||||
|
if (canOpenDashboard(session)) {
|
||||||
|
crumbs.push({ label: 'Workspace', href: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/') {
|
if (pathname === '/') {
|
||||||
return [root, { label: 'Dashboard' }];
|
return crumbs.length ? [...crumbs, { label: 'Dashboard' }] : [{ label: 'Dashboard' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/mix-calculator')) {
|
if (pathname.startsWith('/mix-calculator')) {
|
||||||
const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }];
|
return [...crumbs, { label: 'Mix Calculator' }];
|
||||||
if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' });
|
|
||||||
else if (pathname.endsWith('/print')) trail.push({ label: 'Print' });
|
|
||||||
else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' });
|
|
||||||
return trail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/mixes')) {
|
if (pathname.startsWith('/mixes')) {
|
||||||
const trail: Crumb[] = [root, { label: 'Mix Master', href: '/mixes' }];
|
return [...crumbs, { label: 'Mix Master' }];
|
||||||
if (pathname === '/mixes/new') trail.push({ label: 'New Mix' });
|
}
|
||||||
else if (pathname !== '/mixes') trail.push({ label: 'Detail' });
|
|
||||||
return trail;
|
if (pathname.startsWith('/throughput')) {
|
||||||
|
const base: Crumb[] = [...crumbs, { label: 'Throughput', href: '/throughput' }];
|
||||||
|
if (pathname === '/throughput/add') return [...base, { label: 'Add Entry' }];
|
||||||
|
if (pathname === '/throughput') return base.slice(0, -1).concat([{ label: 'Throughput' }]);
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionMap: Record<string, string> = {
|
const sectionMap: Record<string, string> = {
|
||||||
@@ -195,10 +215,11 @@ export function clientBreadcrumbs(pathname: string): Crumb[] {
|
|||||||
'/scenarios': 'Scenarios',
|
'/scenarios': 'Scenarios',
|
||||||
'/client-access': 'Client Access',
|
'/client-access': 'Client Access',
|
||||||
'/reporting': 'Reporting',
|
'/reporting': 'Reporting',
|
||||||
'/settings': 'Settings'
|
'/settings': 'Settings',
|
||||||
|
'/throughput': 'Throughput'
|
||||||
};
|
};
|
||||||
const section = sectionMap[pathname];
|
const section = sectionMap[pathname];
|
||||||
if (section) return [root, { label: section }];
|
if (section) return [...crumbs, { label: section }];
|
||||||
|
|
||||||
return [root, { label: pageTitle(pathname) }];
|
return [...crumbs, { label: pageTitle(pathname) }];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type AppSession = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
token: string;
|
token?: string | null;
|
||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
client_role?: string | null;
|
client_role?: string | null;
|
||||||
user_id?: number | null;
|
user_id?: number | null;
|
||||||
@@ -59,15 +59,16 @@ function createSessionStore(storageKey: string) {
|
|||||||
return {
|
return {
|
||||||
subscribe: store.subscribe,
|
subscribe: store.subscribe,
|
||||||
set(session: AppSession) {
|
set(session: AppSession) {
|
||||||
|
const storedSession = { ...session, token: null };
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem(storageKey, JSON.stringify(session));
|
localStorage.setItem(storageKey, JSON.stringify(storedSession));
|
||||||
}
|
}
|
||||||
store.set(session);
|
store.set(storedSession);
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
// Drop any cached API responses keyed to the old session token.
|
// Drop any cached API responses keyed to the old session identity.
|
||||||
// Imported lazily so this module stays free of api.ts side-effects.
|
// Imported lazily so this module stays free of api.ts side-effects.
|
||||||
import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {});
|
import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export type Product = {
|
|||||||
mix_name: string;
|
mix_name: string;
|
||||||
sale_type: string;
|
sale_type: string;
|
||||||
own_bag?: boolean;
|
own_bag?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
unit_of_measure: string;
|
unit_of_measure: string;
|
||||||
items_per_pallet?: number;
|
items_per_pallet?: number;
|
||||||
bagging_process?: string | null;
|
bagging_process?: string | null;
|
||||||
@@ -334,7 +335,7 @@ export type LoginResponse = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
token: string;
|
token?: string | null;
|
||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
client_role?: string | null;
|
client_role?: string | null;
|
||||||
user_id?: number | null;
|
user_id?: number | null;
|
||||||
@@ -387,3 +388,89 @@ export type ClientUserUpdateInput = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
is_new_user?: boolean;
|
is_new_user?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThroughputProduct = {
|
||||||
|
id: number;
|
||||||
|
tenant_id: string;
|
||||||
|
item_id: string | null;
|
||||||
|
name: string;
|
||||||
|
default_bag_size: number | null;
|
||||||
|
is_bulka_default: boolean;
|
||||||
|
active: boolean;
|
||||||
|
is_stock_item: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThroughputQuantityType = 'bags' | 'kg';
|
||||||
|
|
||||||
|
export type ThroughputEntry = {
|
||||||
|
id: number;
|
||||||
|
tenant_id: string;
|
||||||
|
production_date: string;
|
||||||
|
product_id: number | null;
|
||||||
|
product_name_snapshot: string;
|
||||||
|
bag_size: number | null;
|
||||||
|
scales_checked: boolean;
|
||||||
|
label_correct: boolean;
|
||||||
|
bag_sealed: boolean;
|
||||||
|
pallet_good_condition: boolean;
|
||||||
|
sample_box_no: string | null;
|
||||||
|
test_weight_1: number | null;
|
||||||
|
test_weight_2: number | null;
|
||||||
|
test_weight_3: number | null;
|
||||||
|
test_weight_4: number | null;
|
||||||
|
test_weight_5: number | null;
|
||||||
|
quantity: number;
|
||||||
|
quantity_type: ThroughputQuantityType;
|
||||||
|
calculated_kg: number;
|
||||||
|
staff_name: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
qa_passed: boolean;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThroughputEntryCreateInput = {
|
||||||
|
production_date: string;
|
||||||
|
product_id?: number | null;
|
||||||
|
product_name_snapshot?: string;
|
||||||
|
bag_size?: number | null;
|
||||||
|
scales_checked?: boolean;
|
||||||
|
label_correct?: boolean;
|
||||||
|
bag_sealed?: boolean;
|
||||||
|
pallet_good_condition?: boolean;
|
||||||
|
sample_box_no?: string | null;
|
||||||
|
test_weight_1?: number | null;
|
||||||
|
test_weight_2?: number | null;
|
||||||
|
test_weight_3?: number | null;
|
||||||
|
test_weight_4?: number | null;
|
||||||
|
test_weight_5?: number | null;
|
||||||
|
quantity: number;
|
||||||
|
quantity_type: ThroughputQuantityType;
|
||||||
|
staff_name?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThroughputEntryListParams = {
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
product_id?: number | null;
|
||||||
|
staff_name?: string;
|
||||||
|
quantity_type?: ThroughputQuantityType;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThroughputProductCreateInput = {
|
||||||
|
item_id?: string | null;
|
||||||
|
name: string;
|
||||||
|
default_bag_size?: number | null;
|
||||||
|
is_bulka_default?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
is_stock_item?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThroughputProductUpdateInput = Partial<ThroughputProductCreateInput>;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { canAccessRoute, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access';
|
||||||
|
|
||||||
|
describe('workspace access policy', () => {
|
||||||
|
const operationsSession = {
|
||||||
|
role: 'internal',
|
||||||
|
role_name: 'Operations',
|
||||||
|
permissions: ['view_mix_calculator', 'use_mix_calculator', 'save_mix_calculator_session'],
|
||||||
|
name: 'Ops User',
|
||||||
|
email: 'ops@example.com',
|
||||||
|
token: 'token'
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminSession = {
|
||||||
|
role: 'internal',
|
||||||
|
role_name: 'Admin',
|
||||||
|
permissions: ['view_dashboard', 'view_mix_calculator', 'use_mix_calculator'],
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
token: 'token'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('classifies operations users and sends them to mix calculator by default', () => {
|
||||||
|
expect(getWorkspaceRole(operationsSession)).toBe('operations');
|
||||||
|
expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents operations users from opening the dashboard route', () => {
|
||||||
|
expect(canAccessRoute(operationsSession, '/')).toBe(false);
|
||||||
|
expect(canAccessRoute(operationsSession, '/mix-calculator')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps dashboard access for admins', () => {
|
||||||
|
expect(getWorkspaceRole(adminSession)).toBe('admin');
|
||||||
|
expect(canAccessRoute(adminSession, '/')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { hasModuleAccess, hasPermission, type AppSession } from '$lib/session';
|
||||||
|
|
||||||
|
export type WorkspaceRole = 'admin' | 'operations' | 'full' | 'client' | 'unknown';
|
||||||
|
|
||||||
|
type RouteAccessRule = {
|
||||||
|
path: string;
|
||||||
|
roles: WorkspaceRole[];
|
||||||
|
matches: (pathname: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasPathPrefix(pathname: string, prefix: string) {
|
||||||
|
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessWorkspaceArea(
|
||||||
|
session: AppSession | null | undefined,
|
||||||
|
moduleKey: string,
|
||||||
|
permissionKeys: string[],
|
||||||
|
minimumLevel: 'view' | 'edit' | 'manage' = 'view'
|
||||||
|
) {
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.role === 'internal') {
|
||||||
|
return permissionKeys.some((permissionKey) => hasPermission(session, permissionKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasModuleAccess(session, moduleKey, minimumLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceRole(session: AppSession | null | undefined): WorkspaceRole {
|
||||||
|
if (!session) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.role === 'admin') {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.role !== 'internal') {
|
||||||
|
return 'client';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.role_name === 'Admin') {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.role_name === 'Operations') {
|
||||||
|
return 'operations';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.role_name === 'Full Access') {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenDashboard(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'dashboard', ['view_dashboard']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenRawMaterials(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'raw_materials', ['view_raw_materials', 'edit_raw_materials']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenMixMaster(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'mix_master', ['view_mixes', 'edit_mixes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCreateMixWorksheet(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'mix_master', ['edit_mixes'], 'edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenMixCalculator(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'mix_calculator', ['view_mix_calculator', 'use_mix_calculator']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCreateMixSession(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'mix_calculator', ['use_mix_calculator', 'save_mix_calculator_session'], 'edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenProducts(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenScenarios(session: AppSession | null | undefined) {
|
||||||
|
return !!session && hasModuleAccess(session, 'scenarios');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenThroughput(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'operations_throughput', ['view_throughput', 'edit_throughput']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canEditThroughput(session: AppSession | null | undefined) {
|
||||||
|
return canAccessWorkspaceArea(session, 'operations_throughput', ['edit_throughput'], 'edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenReporting(session: AppSession | null | undefined) {
|
||||||
|
return canOpenProducts(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenSettings(session: AppSession | null | undefined) {
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.role === 'internal'
|
||||||
|
? hasPermission(session, 'view_settings') || hasPermission(session, 'edit_settings')
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenClientAccess(session: AppSession | null | undefined) {
|
||||||
|
return !!session && hasModuleAccess(session, 'client_access', 'manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeAccessRules: RouteAccessRule[] = [
|
||||||
|
{ path: '/', roles: ['admin', 'full', 'client'], matches: (pathname) => pathname === '/' },
|
||||||
|
{
|
||||||
|
path: '/mix-calculator',
|
||||||
|
roles: ['admin', 'operations', 'full', 'client'],
|
||||||
|
matches: (pathname) => hasPathPrefix(pathname, '/mix-calculator')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/raw-materials',
|
||||||
|
roles: ['admin', 'full', 'client'],
|
||||||
|
matches: (pathname) => hasPathPrefix(pathname, '/raw-materials')
|
||||||
|
},
|
||||||
|
{ path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') },
|
||||||
|
{ path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') },
|
||||||
|
{ path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') },
|
||||||
|
{ path: '/scenarios', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/scenarios') },
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
roles: ['admin', 'full', 'operations', 'client'],
|
||||||
|
matches: (pathname) => hasPathPrefix(pathname, '/settings')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/client-access',
|
||||||
|
roles: ['admin', 'client'],
|
||||||
|
matches: (pathname) => hasPathPrefix(pathname, '/client-access')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/throughput',
|
||||||
|
roles: ['admin', 'operations', 'full', 'client'],
|
||||||
|
matches: (pathname) => hasPathPrefix(pathname, '/throughput')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDefaultRouteForRole(session: AppSession | null | undefined) {
|
||||||
|
const role = getWorkspaceRole(session);
|
||||||
|
|
||||||
|
if (role === 'operations') {
|
||||||
|
return '/mix-calculator';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'admin' || role === 'full' || role === 'client') {
|
||||||
|
if (canOpenDashboard(session)) return '/';
|
||||||
|
if (canOpenMixCalculator(session)) return '/mix-calculator';
|
||||||
|
if (canOpenRawMaterials(session)) return '/raw-materials';
|
||||||
|
if (canOpenMixMaster(session)) return '/mixes';
|
||||||
|
if (canOpenProducts(session)) return '/products';
|
||||||
|
if (canOpenScenarios(session)) return '/scenarios';
|
||||||
|
if (canOpenSettings(session)) return '/settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessRoute(session: AppSession | null | undefined, pathname: string) {
|
||||||
|
const rule = routeAccessRules.find((candidate) => candidate.matches(pathname));
|
||||||
|
if (!rule) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = getWorkspaceRole(session);
|
||||||
|
if (!rule.roles.includes(role)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/') return canOpenDashboard(session);
|
||||||
|
if (pathname.startsWith('/mix-calculator')) return canOpenMixCalculator(session);
|
||||||
|
if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session);
|
||||||
|
if (pathname.startsWith('/mixes')) return canOpenMixMaster(session);
|
||||||
|
if (pathname.startsWith('/products')) return canOpenProducts(session);
|
||||||
|
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
|
||||||
|
if (pathname.startsWith('/reporting')) return canOpenReporting(session);
|
||||||
|
if (pathname.startsWith('/settings')) return canOpenSettings(session);
|
||||||
|
if (pathname.startsWith('/client-access')) return canOpenClientAccess(session);
|
||||||
|
if (pathname.startsWith('/throughput')) return canOpenThroughput(session);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseWorkspaceSearch(session: AppSession | null | undefined) {
|
||||||
|
return (
|
||||||
|
canOpenDashboard(session) ||
|
||||||
|
canOpenRawMaterials(session) ||
|
||||||
|
canOpenMixMaster(session) ||
|
||||||
|
canOpenMixCalculator(session) ||
|
||||||
|
canOpenProducts(session) ||
|
||||||
|
canOpenScenarios(session)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWorkspaceHomeHref = getDefaultRouteForRole;
|
||||||
|
export const isWorkspaceRouteAllowed = canAccessRoute;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { error, status } = $props();
|
||||||
|
|
||||||
|
const title = $derived(status === 404 ? 'Page Not Found' : 'Something Went Wrong');
|
||||||
|
const detail = $derived(
|
||||||
|
status === 404
|
||||||
|
? 'That route does not exist in the Hunter Premium Produce workspace. Check the address or return to login.'
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The workspace hit an unexpected error while loading this page.'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title} | Hunter Premium Produce</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="error-stage">
|
||||||
|
<div class="error-card">
|
||||||
|
<div class="error-backdrop" aria-hidden="true">
|
||||||
|
<span class="glow glow-one"></span>
|
||||||
|
<span class="glow glow-two"></span>
|
||||||
|
<span class="grid-band"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-header">
|
||||||
|
<div class="brand-lockup">
|
||||||
|
<img class="brand-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Workspace Error</p>
|
||||||
|
<strong>Hunter Premium Produce</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill">{status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-copy">
|
||||||
|
<p class="eyebrow">Route Response</p>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{detail}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<a class="primary-link" href="/">Return to Workspace</a>
|
||||||
|
<a class="secondary-link" href="/mix-calculator">Open Mix Calculator</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-stage {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(214, 234, 221, 0.86), transparent 36%),
|
||||||
|
linear-gradient(180deg, #f4f7f2 0%, #eef4ee 100%);
|
||||||
|
color: #1d3528;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: min(100%, 58rem);
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid rgba(32, 52, 41, 0.08);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 24px 60px rgba(39, 63, 52, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(16px);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-one {
|
||||||
|
top: -3rem;
|
||||||
|
right: -2rem;
|
||||||
|
width: 15rem;
|
||||||
|
height: 15rem;
|
||||||
|
background: rgba(160, 199, 124, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-two {
|
||||||
|
bottom: -4rem;
|
||||||
|
left: -3rem;
|
||||||
|
width: 18rem;
|
||||||
|
height: 18rem;
|
||||||
|
background: rgba(214, 166, 90, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-band {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0;
|
||||||
|
height: 8rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(33, 54, 42, 0.07) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(33, 54, 42, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 2rem 2rem;
|
||||||
|
mask-image: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-header,
|
||||||
|
.error-copy,
|
||||||
|
.error-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.22rem;
|
||||||
|
color: #5d7568;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 0.45rem 0.72rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(31, 53, 40, 0.08);
|
||||||
|
color: #244331;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-copy {
|
||||||
|
margin-top: 2.2rem;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2.4rem, 6vw, 4.8rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-copy p:last-child {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: #587063;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-link,
|
||||||
|
.secondary-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.8rem;
|
||||||
|
padding: 0.7rem 1.15rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
transform 140ms ease,
|
||||||
|
box-shadow 140ms ease,
|
||||||
|
background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-link {
|
||||||
|
background: #274934;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 14px 28px rgba(39, 73, 52, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-link {
|
||||||
|
border: 1px solid rgba(39, 73, 52, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #244331;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-link:hover,
|
||||||
|
.secondary-link:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.error-stage {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
padding: 1.35rem;
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import '@fontsource/inter/latin-400.css';
|
||||||
|
import '@fontsource/inter/latin-500.css';
|
||||||
|
import '@fontsource/inter/latin-600.css';
|
||||||
|
import '@fontsource/inter/latin-700.css';
|
||||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import AdminShell from '$lib/components/AdminShell.svelte';
|
import AdminShell from '$lib/components/AdminShell.svelte';
|
||||||
@@ -46,3 +50,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(html, body) {
|
||||||
|
font-family: "Inter", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button),
|
||||||
|
:global(input),
|
||||||
|
:global(select),
|
||||||
|
:global(textarea) {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(h1, h2, h3, h4, h5, h6) {
|
||||||
|
font-family: "Inter", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { clientSession, sessionHydrated } from '$lib/session';
|
import { clientSession, sessionHydrated } from '$lib/session';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import type { DashboardSummary } from '$lib/types';
|
import type { DashboardSummary } from '$lib/types';
|
||||||
|
import { getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
import packageInfo from '../../package.json';
|
import packageInfo from '../../package.json';
|
||||||
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
|
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
type Segment = {
|
type Segment = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -32,11 +35,13 @@
|
|||||||
let email = $state('');
|
let email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let isLoggingIn = $state(false);
|
let isLoggingIn = $state(false);
|
||||||
|
let postLoginRedirecting = $state(false);
|
||||||
let loginError = $state('');
|
let loginError = $state('');
|
||||||
let passwordInput: HTMLInputElement | null = null;
|
let emailInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let passwordInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let loginFocusArmed = $state(true);
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const appVersion = `v${packageInfo.version}`;
|
const appVersion = `v${packageInfo.version}`;
|
||||||
const releaseStage = 'Beta';
|
|
||||||
|
|
||||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'];
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'];
|
||||||
|
|
||||||
@@ -50,11 +55,17 @@
|
|||||||
// system. The response is shape-compatible with the legacy client
|
// system. The response is shape-compatible with the legacy client
|
||||||
// session, so the rest of the app continues to work unchanged.
|
// session, so the rest of the app continues to work unchanged.
|
||||||
const session = await api.internalLogin(email, password);
|
const session = await api.internalLogin(email, password);
|
||||||
|
const targetHref = getWorkspaceHomeHref(session);
|
||||||
|
postLoginRedirecting = targetHref !== '/';
|
||||||
clientSession.set(session);
|
clientSession.set(session);
|
||||||
|
if (targetHref !== '/') {
|
||||||
|
await goto(targetHref, { replaceState: true });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loginError = error instanceof Error ? error.message : 'Unable to sign in';
|
loginError = error instanceof Error ? error.message : 'Unable to sign in';
|
||||||
triggerPasswordShake();
|
triggerPasswordShake();
|
||||||
} finally {
|
} finally {
|
||||||
|
postLoginRedirecting = false;
|
||||||
isLoggingIn = false;
|
isLoggingIn = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +95,18 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($sessionHydrated && !$clientSession) {
|
||||||
|
if (loginFocusArmed && emailInput) {
|
||||||
|
loginFocusArmed = false;
|
||||||
|
tick().then(() => emailInput?.focus());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginFocusArmed = true;
|
||||||
|
});
|
||||||
|
|
||||||
function currency(value: number | null | undefined, digits = 2) {
|
function currency(value: number | null | undefined, digits = 2) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -329,10 +352,7 @@
|
|||||||
<span class="powered-by-label">Powered by Lean 101</span>
|
<span class="powered-by-label">Powered by Lean 101</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-meta">
|
<div class="auth-meta">
|
||||||
<span class="version-badge">
|
<span class="version-badge">{appVersion}</span>
|
||||||
<span>{appVersion}</span>
|
|
||||||
<span class="release-pill">{releaseStage}</span>
|
|
||||||
</span>
|
|
||||||
<span>© {currentYear} Hunter Premium Produce</span>
|
<span>© {currentYear} Hunter Premium Produce</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,19 +371,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="auth-status-row">
|
<div class="auth-status-row">
|
||||||
<span class="auth-status-pill">Secure Workspace Access</span>
|
<span class="auth-status-pill">Secure Workspace Access</span>
|
||||||
<span class="release-pill">{releaseStage}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-copy">
|
<div class="auth-copy">
|
||||||
<h2>Login</h2>
|
<h2>Welcome back</h2>
|
||||||
<p>Enter your username & password below:</p>
|
<p>Sign in with your email and password to continue.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="signin-form auth-form" onsubmit={handleLogin}>
|
<form class="signin-form auth-form" onsubmit={handleLogin}>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" autofocus />
|
<input
|
||||||
|
bind:this={emailInput}
|
||||||
|
bind:value={email}
|
||||||
|
type="email"
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="Email"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-password" class:is-invalid={Boolean(loginError)}>
|
<label class="field field-password" class:is-invalid={Boolean(loginError)}>
|
||||||
@@ -392,15 +417,40 @@
|
|||||||
<span class="powered-by-label">Powered by Lean 101</span>
|
<span class="powered-by-label">Powered by Lean 101</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-meta">
|
<div class="auth-meta">
|
||||||
<span class="version-badge">
|
<span class="version-badge">{appVersion}</span>
|
||||||
<span>{appVersion}</span>
|
|
||||||
<span class="release-pill">{releaseStage}</span>
|
|
||||||
</span>
|
|
||||||
<span>© {currentYear} Lean 101</span>
|
<span>© {currentYear} Lean 101</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{:else if postLoginRedirecting}
|
||||||
|
<section class="auth-stage auth-stage-loading">
|
||||||
|
<div class="auth-card auth-card-loading">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="client-logo-block">
|
||||||
|
<img class="hero-login-logo" src="/logo-hsf.png" alt="Lean 101" />
|
||||||
|
<div class="client-logo-copy">
|
||||||
|
<p class="eyebrow">Opening Workspace</p>
|
||||||
|
<strong>Hunter Premium Produce</strong>
|
||||||
|
<span>Applying your role permissions now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-copy">
|
||||||
|
<h2>Preparing your workspace.</h2>
|
||||||
|
<p>Routing you directly to the first area your role is allowed to open.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-loading-panel">
|
||||||
|
<span class="loading-pulse" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Applying Access Rules</strong>
|
||||||
|
<p>Dashboard access is skipped for roles that do not have permission.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="dashboard-intro">
|
<section class="dashboard-intro">
|
||||||
<div class="greeting-row">
|
<div class="greeting-row">
|
||||||
@@ -748,25 +798,23 @@
|
|||||||
width: min(100%, 38rem);
|
width: min(100%, 38rem);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.35rem;
|
gap: 1.35rem;
|
||||||
padding: 1.5rem;
|
padding: 2.1rem 2rem 1.6rem;
|
||||||
border: 1px solid rgba(212, 226, 218, 0.95);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 1.7rem;
|
border-radius: 1.25rem;
|
||||||
background:
|
background: var(--color-bg-surface);
|
||||||
radial-gradient(circle at top left, rgba(115, 197, 146, 0.16), transparent 32%),
|
|
||||||
radial-gradient(circle at bottom right, rgba(33, 94, 60, 0.1), transparent 30%),
|
|
||||||
rgba(255, 255, 255, 0.96);
|
|
||||||
box-shadow: none;
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Crisp brand accent along the top edge, clipped to the card radius.
|
||||||
|
Replaces the old green radial glow, which read as a muddy shadow. */
|
||||||
.auth-card::before {
|
.auth-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
background:
|
left: 0;
|
||||||
linear-gradient(135deg, rgba(255, 255, 255, 0.42), transparent 35%),
|
right: 0;
|
||||||
linear-gradient(180deg, transparent, rgba(238, 248, 242, 0.55));
|
height: 4px;
|
||||||
|
background: var(--color-brand);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,21 +903,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.28rem 0.62rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-brand-tint);
|
|
||||||
color: var(--color-success);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-copy {
|
.auth-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -936,20 +969,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-form input {
|
.auth-form input {
|
||||||
padding: 1rem 1.05rem;
|
padding: 0.95rem 1rem;
|
||||||
border: 1px solid #d6e3db;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 1rem;
|
border-radius: 0.7rem;
|
||||||
background: rgba(248, 251, 249, 0.94);
|
background: var(--color-bg-app);
|
||||||
|
color: var(--color-text-primary);
|
||||||
transition:
|
transition:
|
||||||
border-color 160ms ease,
|
border-color 160ms ease,
|
||||||
box-shadow 160ms ease,
|
box-shadow 160ms ease,
|
||||||
background-color 160ms ease;
|
background-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-form input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.auth-form input:focus {
|
.auth-form input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-brand);
|
border-color: var(--color-brand);
|
||||||
box-shadow: 0 0 0 0.24rem color-mix(in srgb, var(--color-brand) 12%, transparent);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 22%, transparent);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,6 +1001,26 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 3.35rem;
|
min-height: 3.35rem;
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:hover:not(:disabled) {
|
||||||
|
background: #126a33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:active:not(:disabled) {
|
||||||
|
background: #0f5a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-footer {
|
.auth-footer {
|
||||||
@@ -1163,23 +1221,20 @@
|
|||||||
|
|
||||||
.signin-form {
|
.signin-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 0.75rem;
|
gap: 0.95rem;
|
||||||
width: min(100%, 38rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.signin-form input {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.9rem 0.95rem;
|
|
||||||
border: 1px solid var(--line-strong);
|
|
||||||
border-radius: 0.95rem;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-error {
|
.login-error {
|
||||||
color: #a03737;
|
margin: 0;
|
||||||
|
padding: 0.75rem 0.95rem;
|
||||||
|
border: 1px solid rgba(160, 55, 55, 0.3);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: #fdf2f2;
|
||||||
|
color: #8a1622;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus-card {
|
.focus-card {
|
||||||
@@ -1847,8 +1902,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
padding: 1.15rem;
|
padding: 1.5rem 1.15rem 1.15rem;
|
||||||
border-radius: 1.35rem;
|
border-radius: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-header,
|
.auth-header,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canOpenDashboard, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
import type { DashboardSummary } from '$lib/types';
|
import type { DashboardSummary } from '$lib/types';
|
||||||
|
|
||||||
const EMPTY_SUMMARY: DashboardSummary = {
|
const EMPTY_SUMMARY: DashboardSummary = {
|
||||||
@@ -18,18 +20,9 @@ export function load({ fetch }) {
|
|||||||
return { summary: Promise.resolve(EMPTY_SUMMARY) };
|
return { summary: Promise.resolve(EMPTY_SUMMARY) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip data fetching for sessions that lack any dashboard-eligible module
|
|
||||||
// — the backend would just return nulls anyway.
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
const permissions = session?.module_permissions ?? {};
|
if (!canOpenDashboard(session)) {
|
||||||
const hasAnyDashboardData =
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
session?.role === 'admin' ||
|
|
||||||
permissions.dashboard ||
|
|
||||||
permissions.raw_materials ||
|
|
||||||
permissions.mix_master ||
|
|
||||||
permissions.products;
|
|
||||||
if (!hasAnyDashboardData) {
|
|
||||||
return { summary: Promise.resolve(EMPTY_SUMMARY) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let email = $state('admin@lean101.local');
|
let email = $state('');
|
||||||
let password = $state('lean101-admin');
|
let password = $state('');
|
||||||
let isLoggingIn = $state(false);
|
let isLoggingIn = $state(false);
|
||||||
let loginError = $state('');
|
let loginError = $state('');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canOpenClientAccess, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
function emptyPayload() {
|
function emptyPayload() {
|
||||||
return {
|
return {
|
||||||
@@ -21,6 +23,13 @@ export async function load({ fetch }) {
|
|||||||
return emptyPayload();
|
return emptyPayload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasStoredClientSession()) {
|
||||||
|
const session = getStoredClientSession();
|
||||||
|
if (session && !canOpenClientAccess(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
|
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
|
||||||
return { clients, exportPreview };
|
return { clients, exportPreview };
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ describe('route loaders use the SvelteKit fetch argument', () => {
|
|||||||
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
|
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes fetch through the mix calculator history loader', async () => {
|
it('passes fetch through the mix calculator loader', async () => {
|
||||||
await mixCalculatorLoad({ fetch: fetcher } as never);
|
await mixCalculatorLoad({ fetch: fetcher } as never);
|
||||||
|
|
||||||
expect(apiMocks.mixCalculatorSessions).toHaveBeenCalledWith(fetcher);
|
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes fetch through the new mix calculator loader', async () => {
|
it('passes fetch through the new mix calculator loader', async () => {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { clientSession, hasModuleAccess } from '$lib/session';
|
import { clientSession, hasModuleAccess } from '$lib/session';
|
||||||
import type { MixCalculatorSession } from '$lib/types';
|
import { featureFlags } from '$lib/features';
|
||||||
|
import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
|
||||||
|
import type { MixCalculatorOptions, MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
let { data }: { data: { sessions: MixCalculatorSession[] } } = $props();
|
let { data }: { data: { sessions?: MixCalculatorSession[]; options?: MixCalculatorOptions } } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const sessions = $derived(data.sessions ?? []);
|
||||||
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
@@ -18,6 +22,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if !featureFlags.mixCalculatorSessionHistory}
|
||||||
|
<MixCalculatorEditor options={data.options} />
|
||||||
|
{:else}
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<section class="page-actions">
|
<section class="page-actions">
|
||||||
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
|
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
|
||||||
@@ -27,17 +34,17 @@
|
|||||||
<section class="metric-row">
|
<section class="metric-row">
|
||||||
<article class="metric-card">
|
<article class="metric-card">
|
||||||
<span>Saved Sessions</span>
|
<span>Saved Sessions</span>
|
||||||
<strong>{data.sessions.length}</strong>
|
<strong>{sessions.length}</strong>
|
||||||
<p>Visible under your access scope</p>
|
<p>Visible under your access scope</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="metric-card">
|
<article class="metric-card">
|
||||||
<span>Total Planned Kg</span>
|
<span>Total Planned Kg</span>
|
||||||
<strong>{formatNumber(data.sessions.reduce((sum, session) => sum + session.total_kg, 0), 2)}</strong>
|
<strong>{formatNumber(sessions.reduce((sum, session) => sum + session.total_kg, 0), 2)}</strong>
|
||||||
<p>Across the visible history</p>
|
<p>Across the visible history</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="metric-card">
|
<article class="metric-card">
|
||||||
<span>Sessions With Warnings</span>
|
<span>Sessions With Warnings</span>
|
||||||
<strong>{data.sessions.filter((session) => session.warnings.length).length}</strong>
|
<strong>{sessions.filter((session) => session.warnings.length).length}</strong>
|
||||||
<p>Fractional bag outputs need review</p>
|
<p>Fractional bag outputs need review</p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -50,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.sessions.length}
|
{#if sessions.length}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -65,7 +72,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.sessions as session}
|
{#each sessions as session}
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Session">
|
<td data-label="Session">
|
||||||
<strong>{session.session_number}</strong>
|
<strong>{session.session_number}</strong>
|
||||||
@@ -102,6 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h2,
|
h2,
|
||||||
@@ -140,10 +148,24 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.78rem 0.96rem;
|
padding: 0.78rem 0.96rem;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.6rem;
|
||||||
background: var(--color-brand);
|
background: var(--color-brand);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
transition: background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: #126a33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:active {
|
||||||
|
background: #0f5a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row {
|
.metric-row {
|
||||||
@@ -155,7 +177,7 @@
|
|||||||
.metric-card,
|
.metric-card,
|
||||||
.table-card {
|
.table-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1.3rem;
|
border-radius: 0.8rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
@@ -222,12 +244,12 @@
|
|||||||
|
|
||||||
tbody td:first-child {
|
tbody td:first-child {
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
border-radius: 1rem 0 0 1rem;
|
border-radius: 0.65rem 0 0 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody td:last-child {
|
tbody td:last-child {
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
border-radius: 0 1rem 1rem 0;
|
border-radius: 0 0.65rem 0.65rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
@@ -245,8 +267,8 @@
|
|||||||
margin-left: 0.55rem;
|
margin-left: 0.55rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff6e6;
|
background: #fdf6e9;
|
||||||
color: #8b5b1e;
|
color: #8a5a00;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -257,7 +279,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 1rem;
|
border-radius: 0.65rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +321,7 @@
|
|||||||
tbody tr {
|
tbody tr {
|
||||||
padding: 0.3rem;
|
padding: 0.3rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1rem;
|
border-radius: 0.65rem;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,27 +2,50 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { featureFlags } from '$lib/features';
|
import { featureFlags } from '$lib/features';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canCreateMixSession, canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
|
// Single-page mode (session history disabled): this route IS the calculator.
|
||||||
if (!featureFlags.mixCalculatorSessionHistory) {
|
if (!featureFlags.mixCalculatorSessionHistory) {
|
||||||
throw redirect(307, '/mix-calculator/new');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
return {
|
return { options: { clients: [], products: [] } };
|
||||||
sessions: []
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
if (!canCreateMixSession(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
sessions: hasModuleAccess(session, 'mix_calculator') ? await api.mixCalculatorSessions(fetch) : []
|
options:
|
||||||
|
hasModuleAccess(session, 'mix_calculator', 'edit') || session?.role === 'internal'
|
||||||
|
? await api.mixCalculatorOptions(fetch)
|
||||||
|
: { clients: [], products: [] }
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
return { options: { clients: [], products: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History mode: list saved sessions.
|
||||||
|
if (!hasStoredClientSession()) {
|
||||||
|
return { sessions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getStoredClientSession();
|
||||||
|
if (!canOpenMixCalculator(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
return {
|
return {
|
||||||
sessions: []
|
sessions:
|
||||||
|
hasModuleAccess(session, 'mix_calculator') || session?.role === 'internal'
|
||||||
|
? await api.mixCalculatorSessions(fetch)
|
||||||
|
: []
|
||||||
};
|
};
|
||||||
|
} catch {
|
||||||
|
return { sessions: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{#if featureFlags.mixCalculatorSessionHistory}
|
{#if featureFlags.mixCalculatorSessionHistory}
|
||||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
|
<a class="secondary-button" href="/mix-calculator">New mix session</a>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1.25rem;
|
border-radius: 0.8rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.78rem 0.92rem;
|
padding: 0.78rem 0.92rem;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: 0.88rem;
|
border-radius: 0.6rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #304038;
|
color: #304038;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canCreateMixSession, canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -10,20 +12,17 @@ export async function load({ params, fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
const canView = hasModuleAccess(session, 'mix_calculator');
|
const canView = canOpenMixCalculator(session);
|
||||||
const canEdit = hasModuleAccess(session, 'mix_calculator', 'edit');
|
const canEdit = canCreateMixSession(session);
|
||||||
|
|
||||||
if (!canView) {
|
if (!canView) {
|
||||||
return {
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
session: null,
|
|
||||||
options: { clients: [], products: [] }
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [savedSession, options] = await Promise.all([
|
const [savedSession, options] = await Promise.all([
|
||||||
api.mixCalculatorSession(Number(params.id), fetch),
|
api.mixCalculatorSession(Number(params.id), fetch),
|
||||||
canEdit ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
|
canEdit || session?.role === 'internal' ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{#if featureFlags.mixCalculatorSessionHistory}
|
{#if featureFlags.mixCalculatorSessionHistory}
|
||||||
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
|
<a class="secondary-button" href="/mix-calculator">New mix session</a>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -10,10 +12,8 @@ export async function load({ params, fetch }) {
|
|||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
|
||||||
if (!hasModuleAccess(session, 'mix_calculator')) {
|
if (!canOpenMixCalculator(session)) {
|
||||||
return {
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
session: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canCreateMixSession, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -9,10 +11,13 @@ export async function load({ fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
if (!canCreateMixSession(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
options: hasModuleAccess(session, 'mix_calculator', 'edit')
|
options: hasModuleAccess(session, 'mix_calculator', 'edit') || session?.role === 'internal'
|
||||||
? await api.mixCalculatorOptions(fetch)
|
? await api.mixCalculatorOptions(fetch)
|
||||||
: { clients: [], products: [] }
|
: { clients: [], products: [] }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { canOpenMixMaster, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -9,10 +11,13 @@ export async function load({ fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
if (!canOpenMixMaster(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
mixes: hasModuleAccess(session, 'mix_master') ? await api.mixes(fetch) : []
|
mixes: hasModuleAccess(session, 'mix_master') || session?.role === 'internal' ? await api.mixes(fetch) : []
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canOpenMixMaster, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const mixId = Number(params.id);
|
const mixId = Number(params.id);
|
||||||
@@ -17,17 +18,14 @@ export async function load({ params, fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
if (!hasModuleAccess(session, 'mix_master')) {
|
if (!canOpenMixMaster(session)) {
|
||||||
return {
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
mix: null,
|
|
||||||
rawMaterials: []
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [mix, rawMaterials] = await Promise.all([
|
const [mix, rawMaterials] = await Promise.all([
|
||||||
api.mix(mixId, fetch),
|
api.mix(mixId, fetch),
|
||||||
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([])
|
hasModuleAccess(session, 'raw_materials') || session?.role === 'internal' ? api.rawMaterials(fetch) : Promise.resolve([])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { canCreateMixWorksheet, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -9,10 +11,16 @@ export async function load({ fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
if (!canCreateMixWorksheet(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
rawMaterials: hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials') ? await api.rawMaterials(fetch) : []
|
rawMaterials:
|
||||||
|
(hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials')) || session?.role === 'internal'
|
||||||
|
? await api.rawMaterials(fetch)
|
||||||
|
: []
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { canOpenProducts, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -10,11 +12,14 @@ export async function load({ fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
if (!canOpenProducts(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [products, productCosts] = await Promise.all([
|
const [products, productCosts] = await Promise.all([
|
||||||
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
|
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.products(fetch) : Promise.resolve([]),
|
||||||
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
|
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.productCosts(fetch) : Promise.resolve([])
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
products,
|
products,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { canOpenRawMaterials, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -12,13 +14,16 @@ export async function load({ fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
if (!canOpenRawMaterials(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
|
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
|
||||||
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
|
hasModuleAccess(session, 'raw_materials') || session?.role === 'internal' ? api.rawMaterials(fetch) : Promise.resolve([]),
|
||||||
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
|
hasModuleAccess(session, 'mix_master') || session?.role === 'internal' ? api.mixes(fetch) : Promise.resolve([]),
|
||||||
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
|
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.products(fetch) : Promise.resolve([]),
|
||||||
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
|
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.productCosts(fetch) : Promise.resolve([])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canOpenReporting, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
if (!hasStoredClientSession()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getStoredClientSession();
|
||||||
|
if (session && !canOpenReporting(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({
|
||||||
|
dashboardSummary: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sessionMocks = vi.hoisted(() => ({
|
||||||
|
getStoredClientSession: vi.fn(),
|
||||||
|
hasStoredClientSession: vi.fn(),
|
||||||
|
hasModuleAccess: vi.fn(),
|
||||||
|
hasPermission: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/api', () => ({
|
||||||
|
api: apiMocks
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/session', () => sessionMocks);
|
||||||
|
|
||||||
|
import { load } from './+page';
|
||||||
|
|
||||||
|
describe('root route access', () => {
|
||||||
|
it('redirects operations users away from the dashboard route', () => {
|
||||||
|
sessionMocks.hasStoredClientSession.mockReturnValue(true);
|
||||||
|
sessionMocks.getStoredClientSession.mockReturnValue({
|
||||||
|
role: 'internal',
|
||||||
|
role_name: 'Operations',
|
||||||
|
permissions: ['view_mix_calculator']
|
||||||
|
});
|
||||||
|
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
|
||||||
|
sessionMocks.hasModuleAccess.mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(() => load({ fetch: vi.fn() as typeof fetch })).toThrow(
|
||||||
|
expect.objectContaining({ status: 307, location: '/mix-calculator' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the dashboard summary for users with dashboard access', () => {
|
||||||
|
sessionMocks.hasStoredClientSession.mockReturnValue(true);
|
||||||
|
sessionMocks.getStoredClientSession.mockReturnValue({ role: 'internal', permissions: ['view_dashboard'] });
|
||||||
|
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
|
||||||
|
apiMocks.dashboardSummary.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const result = load({ fetch: vi.fn() as typeof fetch });
|
||||||
|
|
||||||
|
expect(apiMocks.dashboardSummary).toHaveBeenCalled();
|
||||||
|
expect(result).toHaveProperty('summary');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { canOpenScenarios, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
@@ -9,6 +11,9 @@ export async function load({ fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
|
if (!canOpenScenarios(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
...$clientSession!,
|
...$clientSession!,
|
||||||
name: updated.name,
|
name: updated.name,
|
||||||
email: updated.email,
|
email: updated.email,
|
||||||
token: updated.token ?? $clientSession!.token,
|
|
||||||
});
|
});
|
||||||
toast.dismiss(tid);
|
toast.dismiss(tid);
|
||||||
toast.success('Profile updated');
|
toast.success('Profile updated');
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canOpenSettings, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
if (!hasStoredClientSession()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getStoredClientSession();
|
||||||
|
if (!session) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canOpenSettings(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user