Compare commits
21
Commits
84792c0947
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4d9d77e5 | ||
|
|
dc50e0538e | ||
|
|
c9f233dc0e | ||
|
|
87878e70fc | ||
|
|
696f1e7b09 | ||
|
|
10722a65a6 | ||
|
|
1062c038e8 | ||
|
|
e7a7b11589 | ||
|
|
1dd48bc771 | ||
|
|
3f8279af10 | ||
|
|
7db95e2027 | ||
|
|
8f9a7b8193 | ||
|
|
250d6ab6a9 | ||
|
|
8b81f804f7 | ||
|
|
b1c0d3f3da | ||
|
|
2de82776cb | ||
|
|
4ff372d307 | ||
|
|
349e4a4b5b | ||
|
|
daa6e60a69 | ||
|
|
cf968e802b | ||
|
|
f5a588d631 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
APP_NAME=Lean 101 Clients API
|
APP_NAME=Hunter App
|
||||||
CLIENT_NAME=Hunter Premium Produce
|
CLIENT_NAME=Hunter Premium Produce
|
||||||
CLIENT_EMAIL=alex@lean-101.com
|
CLIENT_EMAIL=alex@lean-101.com
|
||||||
CLIENT_PASSWORD=JBBwVCDqmPA7
|
CLIENT_PASSWORD=JBBwVCDqmPA7
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
APP_NAME=Lean 101 Clients API
|
APP_NAME=Hunter App
|
||||||
APP_ENV=alpha
|
APP_ENV=alpha
|
||||||
CLIENT_NAME=Hunter Premium Produce
|
CLIENT_NAME=Hunter Premium Produce
|
||||||
CLIENT_EMAIL=operator@example.com
|
CLIENT_EMAIL=operator@example.com
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
APP_NAME=Lean 101 Clients API
|
APP_NAME=Hunter App
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
CLIENT_NAME=Hunter Premium Produce
|
CLIENT_NAME=Hunter Premium Produce
|
||||||
CLIENT_EMAIL=operator@example.com
|
CLIENT_EMAIL=operator@example.com
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
APP_NAME=Lean 101 Clients API
|
APP_NAME=Hunter App
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
CLIENT_NAME=Hunter Premium Produce
|
CLIENT_NAME=Hunter Premium Produce
|
||||||
CLIENT_EMAIL=operator@example.com
|
CLIENT_EMAIL=operator@example.com
|
||||||
@@ -33,5 +33,12 @@ LOGIN_RATE_LIMIT_ATTEMPTS=8
|
|||||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||||
DOCS_ENABLED=false
|
DOCS_ENABLED=false
|
||||||
|
|
||||||
|
# Read-only Power BI / external data API at /api/v1. Set a long random key to
|
||||||
|
# enable it; leave blank to disable the API entirely. Power BI sends this as an
|
||||||
|
# "X-API-Key" header (or "?api_key=" query parameter).
|
||||||
|
POWERBI_API_KEY=V7BI59yhRBF7VMiPNfgmqPxrsPuNuPFJ
|
||||||
|
# Tenant the Power BI API reads from. Defaults to CLIENT_TENANT_ID.
|
||||||
|
POWERBI_TENANT_ID=
|
||||||
|
|
||||||
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
||||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
## Repository operations
|
## Repository operations
|
||||||
|
|
||||||
|
### RUles for Svelte
|
||||||
|
If a block has its own UI + state + behaviour, make it a component.
|
||||||
|
If logic is reused or long, move it to a .ts utility file.
|
||||||
|
If CSS is over 300–500 lines, split components.
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
Current app dependency entry points:
|
Current app dependency entry points:
|
||||||
|
|||||||
@@ -88,7 +88,15 @@ If your server already has a host-level nginx handling domains and TLS, use `dep
|
|||||||
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
|
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
|
||||||
```
|
```
|
||||||
|
|
||||||
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key.
|
To deploy with password-based SSH auth instead of a key:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Password 'your-password'
|
||||||
|
```
|
||||||
|
|
||||||
|
Password auth requires `sshpass` on your local `PATH`.
|
||||||
|
|
||||||
|
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key, `-Password` for password-based SSH auth.
|
||||||
|
|
||||||
If a release adds or changes database-backed workbook formula structures, deploy with `-Seed` so the server refreshes seeded reference/formula data after the backend starts. For the product-formula change, this is required so Postgres receives the new `product_ingredients` rows sourced from `input_data/1.xlsx`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
# Tech Debt Audit & Remediation Plan
|
||||||
|
|
||||||
|
> Status: **Plan / not yet started.** Audit performed 2026-06-03 against `main`.
|
||||||
|
> Context: app has been through 11 versions. Dev runs on **SQLite (Windows)**;
|
||||||
|
> production is mid-migration to **PostgreSQL**. Six modules: Mix Calculator,
|
||||||
|
> Product Costing, Editor, Throughput, Reporting, Settings.
|
||||||
|
>
|
||||||
|
> Decisions already taken:
|
||||||
|
> - **Migrations:** adopt **Alembic** (replaces the startup `create_all` + ad-hoc `ALTER` scheme).
|
||||||
|
> - **Approach:** full audit first (this document), then execute in phases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings, ranked by severity
|
||||||
|
|
||||||
|
### P0 — correctness / data integrity
|
||||||
|
|
||||||
|
#### P0.1 — Money is stored as `Float` everywhere
|
||||||
|
Every cost / price / margin column is SQLAlchemy `Float`:
|
||||||
|
|
||||||
|
- `backend/app/models/product_costing.py` — `cleaned_product_cost_per_kg`, `grading_cost_per_kg`,
|
||||||
|
`bagging_cost_per_kg`, `cracking_cost_per_kg`, `bag_cost_per_unit`, `freight_cost_per_unit`,
|
||||||
|
`finished_product_delivered_cost`, `distributor_price`, `wholesale_price`, `cost_per_kg`,
|
||||||
|
`distributor_margin`, `wholesale_margin`, `cost`, …
|
||||||
|
- `backend/app/models/assumption.py` — `grading_cost`, `bagging_cost`, `cracking_cost`,
|
||||||
|
`bag_cost`, `cost_per_unit`.
|
||||||
|
- `backend/app/models/mix.py` — `quantity_kg`.
|
||||||
|
- `backend/app/models/mix_calculator.py` — `batch_size_kg`, `total_bags`, `total_kg`,
|
||||||
|
`product_unit_size_kg`, `required_kg`, `mix_percentage`.
|
||||||
|
- `backend/app/models/product.py` — `distributor_margin`, `wholesale_margin`, `quantity_kg`.
|
||||||
|
|
||||||
|
**Why it matters:** for a costing-and-pricing tool, binary floating point introduces rounding
|
||||||
|
drift in money. It is also a **SQLite ↔ Postgres divergence point** — SQLite stores loose floats,
|
||||||
|
Postgres `Numeric` is exact, so the same calculation can produce different stored/displayed values
|
||||||
|
across environments.
|
||||||
|
|
||||||
|
**Fix:** migrate money/quantity columns to `Numeric(12, 4)` (tune precision/scale per field) and use
|
||||||
|
`Decimal` in the calculation engine. Guard with the existing formula-parity tests.
|
||||||
|
|
||||||
|
#### P0.2 — Frontend silently shows mock data when the API fails
|
||||||
|
`frontend/src/lib/api.ts` → `fetchJson(path, fallback, ...)` returns the `fallback` (mock data) on
|
||||||
|
**any** fetch error:
|
||||||
|
|
||||||
|
- `api.ts:151` and `api.ts:158` — `return fallback;` on failure.
|
||||||
|
- Fallbacks are real mock datasets: `mockRawMaterials`, `mockCosts`, `mockProducts`, `mockMixes`,
|
||||||
|
`mockScenarios`, `mockMixCalculatorOptions`, `mockMixCalculatorSessions`, `mockClientAccess` —
|
||||||
|
imported from `$lib/mock` (`api.ts:4-13`, used at `api.ts:296+`).
|
||||||
|
|
||||||
|
**Why it matters:** a backend hiccup makes the UI display **fabricated prices/costs** with no error
|
||||||
|
shown to the user. In a pricing application this is the most dangerous item in this audit — a user
|
||||||
|
could quote or decide off fake numbers.
|
||||||
|
|
||||||
|
**Fix:** remove the mock-on-error fallback path. Surface real API errors in the UI (error/empty
|
||||||
|
states). Keep `mock.ts` for tests only.
|
||||||
|
|
||||||
|
#### P0.3 — Schema management is `create_all` + ad-hoc `ALTER`, no versioning
|
||||||
|
`backend/app/db/migrations.py` runs on **every startup** via `bootstrap_schema()`
|
||||||
|
(`backend/app/main.py:105`, inside `ensure_database_ready()`):
|
||||||
|
|
||||||
|
- `ensure_metadata_tables()` → `metadata.create_all()` for any missing tables.
|
||||||
|
- `ensure_tenant_columns()` → adds `tenant_id` to a hardcoded `TENANT_TABLES` list.
|
||||||
|
- `ensure_legacy_columns()` → a hand-maintained `_LEGACY_COLUMN_PATCHES` tuple of
|
||||||
|
`ALTER TABLE ... ADD COLUMN` statements.
|
||||||
|
- `sync_tenant_ids()` → ~250 lines of near-identical `UPDATE` backfills.
|
||||||
|
- `sync_product_visibility()` → data backfill.
|
||||||
|
|
||||||
|
**Why it matters:** this can only **create tables and add columns**. It can never change a column
|
||||||
|
type, add an index / constraint / FK, drop a column, or do a NOT-NULL backfill in a controlled way.
|
||||||
|
A fresh Postgres gets the *current* model via `create_all`, while an upgraded SQLite has columns
|
||||||
|
bolted on by `ALTER` in whatever order/type they accreted — the two **drift apart silently**, and
|
||||||
|
SQLite's loose typing hides mismatches until production. Across 11 versions the only escape hatch
|
||||||
|
has been appending more manual patches (unbounded, fragile).
|
||||||
|
|
||||||
|
The one-shot SQLite→Postgres move (`deploy/migrate-to-postgres.sh`) uses
|
||||||
|
`SET session_replication_role` and manual sequence resets — fine for a single cutover, but not a
|
||||||
|
repeatable/testable migration path.
|
||||||
|
|
||||||
|
**Fix:** adopt **Alembic** (see Phase 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1 — maintainability
|
||||||
|
|
||||||
|
#### P1.1 — Copy-pasted helpers, no shared util
|
||||||
|
No shared formatting/number module. Duplicated implementations:
|
||||||
|
|
||||||
|
- `formatDate` — **9** files: `lib/components/ClientAccessWorkspace.svelte`,
|
||||||
|
`lib/components/mix-calculator/MixCalculatorResultsPanel.svelte`,
|
||||||
|
`lib/components/MixCalculatorPrintDocument.svelte`, `routes/+page.svelte`,
|
||||||
|
`routes/admin/+page.svelte`, `routes/client-access/+page.svelte`,
|
||||||
|
`routes/mix-calculator/+page.svelte`, `routes/raw-materials/+page.svelte`,
|
||||||
|
`routes/throughput/+page.svelte`.
|
||||||
|
- `formatNumber` — **5** files: `lib/components/mix-calculator/MixCalculatorEditor.svelte`,
|
||||||
|
`lib/components/mix-calculator/MixCalculatorResultsPanel.svelte`,
|
||||||
|
`lib/components/MixCalculatorPrintDocument.svelte`, `routes/mix-calculator/+page.svelte`,
|
||||||
|
`routes/throughput/+page.svelte`.
|
||||||
|
- `toNum` — **2** files: `routes/throughput/+page.svelte`, `routes/throughput/add/+page.svelte`.
|
||||||
|
|
||||||
|
**Symptom already hit:** the `toNum` `value.trim is not a function` bug (Svelte coerces
|
||||||
|
`<input type="number">` bindings to `number`/`null`, not `string`). Fixed in both files
|
||||||
|
2026-06-03, but this class of bug will recur until there is a single source of truth.
|
||||||
|
|
||||||
|
**Fix:** add `frontend/src/lib/format.ts` (`formatDate`, `formatNumber`, `formatCurrency`, `toNum`)
|
||||||
|
and replace the duplicates.
|
||||||
|
|
||||||
|
#### P1.2 — Monolith route files
|
||||||
|
Largest route components (LOC):
|
||||||
|
|
||||||
|
| File | LOC |
|
||||||
|
| --- | --- |
|
||||||
|
| `routes/+page.svelte` (dashboard) | 2238 |
|
||||||
|
| `routes/product-costing/+page.svelte` | 1557 |
|
||||||
|
| `routes/throughput/+page.svelte` | 1232 |
|
||||||
|
| `routes/editor/+page.svelte` | 1163 |
|
||||||
|
| `routes/raw-materials/+page.svelte` | 1062 |
|
||||||
|
| `routes/client-access/+page.svelte` | 851 |
|
||||||
|
| `routes/reporting/+page.svelte` | 518 |
|
||||||
|
|
||||||
|
**Why it matters:** hard to test, hard to change safely, encourages more copy-paste.
|
||||||
|
|
||||||
|
**Fix:** decompose incrementally — extract components and `+page.ts` load logic. Dashboard and
|
||||||
|
product-costing first.
|
||||||
|
|
||||||
|
#### P1.3 — `migrations.py` conflates DDL + data backfill
|
||||||
|
Schema DDL (`ensure_*`) and data backfill (`sync_*`) live in one module, including ~250 lines of
|
||||||
|
near-identical `UPDATE` blocks in `sync_tenant_ids()`. Alembic will absorb most of this into
|
||||||
|
versioned schema steps + explicit data-migration steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2 — hygiene
|
||||||
|
|
||||||
|
- **P2.1** — `backend/tests/_repro_throughput_post.py` is a debug repro, not a real test. Remove.
|
||||||
|
- **P2.2** — ~20 `TODO`/`FIXME`/legacy markers across `backend/app` and `frontend/src`. Triage and burn down.
|
||||||
|
- **P2.3** — `backend/app/seed.py` is 1325 LOC. Split by module.
|
||||||
|
- **P2.4** — `CLAUDE.md` still says "PostgreSQL recommended / SQLite acceptable only for prototype",
|
||||||
|
stale vs the live Postgres migration. Refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0.4 — Three overlapping authentication / user-type systems
|
||||||
|
|
||||||
|
The app has accreted **three separate auth systems**, with **two cookies**, **three login
|
||||||
|
endpoints**, **three role namespaces**, and two parallel permission models. They overlap awkwardly
|
||||||
|
and one of them is already dead in the UI. This is the single largest piece of structural debt in
|
||||||
|
the codebase.
|
||||||
|
|
||||||
|
### The three systems
|
||||||
|
|
||||||
|
**1. Internal / "lean" system** — `users` / `roles` / `permissions` / `role_permissions`
|
||||||
|
- Code: `app/core/access.py`, `app/models/access.py`, `app/api/access.py` (`/api/access/*`),
|
||||||
|
`app/seed_access.py`.
|
||||||
|
- Per-user password hash (`User.password_hash`, PBKDF2). Role → permission keys
|
||||||
|
(`view_raw_materials`, `edit_products`, …). Fail-closed `require_permission(...)` dependencies.
|
||||||
|
- Token carries `sub=INTERNAL_USER_SUBJECT`; session `role="internal"`.
|
||||||
|
- Tenant is **hardcoded** to a constant: `INTERNAL_USER_TENANT_ID = "hunter-premium-produce"`
|
||||||
|
(`core/access.py:37`).
|
||||||
|
- Uses `CLIENT_AUTH_COOKIE`.
|
||||||
|
- **This is the actual primary login.** The root page (`routes/+page.svelte:57`) calls
|
||||||
|
`api.internalLogin()` → `/api/access/login`.
|
||||||
|
|
||||||
|
**2. Client-portal system** — `client_accounts` / `client_users` / `client_feature_access` /
|
||||||
|
`client_user_module_permissions` / `client_access_audit_events`
|
||||||
|
- Code: `app/models/client_access.py`, `app/services/client_access_service.py`,
|
||||||
|
`app/api/auth.py` (`/api/auth/client/*`), `app/api/client_access.py`.
|
||||||
|
- **Multi-tenant** (`tenant_id` on every table), per-account feature flags, per-user module
|
||||||
|
access levels (`none`/`view`/`edit`/`manage`), `client_role` in {superadmin, admin, viewer, …}.
|
||||||
|
- Session `role="client"`. Uses `CLIENT_AUTH_COOKIE`.
|
||||||
|
- **Authentication is broken-by-design: a single shared password.** `client_login` checks
|
||||||
|
`payload.password != settings.client_password` (`api/auth.py:79`) — *one* password for *all*
|
||||||
|
client users; the per-user record only supplies identity, not a credential.
|
||||||
|
- **The login UI is dead.** `api.clientLogin` is referenced only by `api.ts` (definition) and
|
||||||
|
`api.test.ts` — no component calls it. The endpoints, tables, tenant plumbing, and the
|
||||||
|
`require_client_session` / `module_access_map` path are all still live and still wired into the
|
||||||
|
shared route dependencies.
|
||||||
|
|
||||||
|
**3. Admin system** — environment-variable single credential
|
||||||
|
- Code: `app/api/auth.py` (`/api/auth/admin/*`), `require_admin_session` in `app/api/deps.py`.
|
||||||
|
- No DB row. Authenticates against `settings.admin_email` / `settings.admin_password`.
|
||||||
|
Session `role="admin"` → **blanket access** (`session.ts:105` `hasModuleAccess` returns `true`
|
||||||
|
for admin; `require_admin_session` gates the admin-only routes).
|
||||||
|
- Uses a **second cookie**, `ADMIN_AUTH_COOKIE`.
|
||||||
|
- Drives the separate `/admin` + `/admin/client-access` UI (`routes/admin/+page.svelte` calls
|
||||||
|
`api.adminLogin()`), which exists to manage client users / feature flags / Power BI preview —
|
||||||
|
i.e. the "management behind the scenes" layer we no longer want.
|
||||||
|
|
||||||
|
### How they tangle
|
||||||
|
|
||||||
|
- **Frontend** routes by URL: `/admin*` → `AdminShell`, everything else → `ClientShell`
|
||||||
|
(`routes/+layout.svelte:15,42-49`). Two `localStorage` session stores
|
||||||
|
(`data-entry-app-client-session`, `data-entry-app-admin-session`) in `session.ts`.
|
||||||
|
- **Shared route deps bend to accept two token shapes.** `require_client_session` and
|
||||||
|
`require_client_module_access` (`api/deps.py:97-184`) special-case `role=="internal"` to skip the
|
||||||
|
`ClientUser` DB lookup and read permissions from a role-derived map, while still supporting
|
||||||
|
`role=="client"`. `core/access.py:_PERMISSION_TO_MODULE_LEVEL` exists purely to translate the
|
||||||
|
internal permission keys into the legacy client module/level shape so the same routes accept both.
|
||||||
|
- **Two permission models run in parallel**: role→permission-keys (internal) vs
|
||||||
|
per-user module→access-level rows + per-account feature flags (client). `permissions_to_module_map`
|
||||||
|
bridges them.
|
||||||
|
- **`tenant_id` is smeared across ~25 tables and ~25 backend files** (heaviest:
|
||||||
|
`db/migrations.py` 70 refs, `seed.py` 52, `api/product_costing.py` 36, plus every service/model),
|
||||||
|
for multi-tenancy we no longer want.
|
||||||
|
|
||||||
|
### Target architecture (per product direction)
|
||||||
|
|
||||||
|
> One login for everyone. User type `lean` = full access. `client` = its own permissions.
|
||||||
|
> No multi-tenant. No separate behind-the-scenes management app, except `lean` may have a few
|
||||||
|
> extra settings (e.g. change logo).
|
||||||
|
|
||||||
|
- **One login endpoint + one login page** for all users.
|
||||||
|
- **One user store**: keep `users` / `roles` / `permissions` / `role_permissions`. Everyone is a
|
||||||
|
`User` with a role. Add a **`lean`** role = all permissions (full access, including the extra
|
||||||
|
settings like logo). Define a **`client`** role with its own permission set. Operations etc. stay
|
||||||
|
as additional roles.
|
||||||
|
- **One cookie**, one session shape, one frontend session store.
|
||||||
|
- **Remove multi-tenancy**: drop `tenant_id` from models/queries/migrations/seed; collapse to a
|
||||||
|
single implicit tenant.
|
||||||
|
- **Retire the env-var admin login** and the **dead client-portal login** + its tables/service,
|
||||||
|
folding any still-needed capability (e.g. managing users) into permission-gated routes inside the
|
||||||
|
single app. `lean`-only settings (logo, etc.) become permission-gated, not a separate shell.
|
||||||
|
|
||||||
|
### Decoupling / migration approach (proposed)
|
||||||
|
|
||||||
|
1. **Confirm the dead path is dead** (done: `clientLogin` has no UI caller) and snapshot any client
|
||||||
|
data worth keeping (`client_users`, module permissions) so it can be re-expressed as `users` +
|
||||||
|
`roles` if needed.
|
||||||
|
2. **Unify on the `users`/`roles`/`permissions` model.** Introduce `lean` and `client` roles in
|
||||||
|
`seed_access.py` with the right permission sets. Migrate any real client users into `users`.
|
||||||
|
3. **Single login**: make `/api/access/login` the only login; one cookie; one session store; one
|
||||||
|
login page. Remove `/api/auth/admin/*`, `/api/auth/client/*`, `ADMIN_AUTH_COOKIE`, the
|
||||||
|
admin/client localStorage split, and the `/admin*` shell routing (fold any surviving admin
|
||||||
|
screens into permission-gated routes in the main app).
|
||||||
|
4. **Collapse the dual permission model**: drop `_PERMISSION_TO_MODULE_LEVEL` bridging and the
|
||||||
|
`role=="internal"` / `role=="client"` special-casing in `deps.py`; every route depends on
|
||||||
|
`require_permission(...)` (or a thin module-level wrapper) only.
|
||||||
|
5. **Drop multi-tenancy**: Alembic migration removing `tenant_id` columns (or leaving them nullable
|
||||||
|
and unused first, then dropping), plus removing `tenant_id` filters from services/queries and the
|
||||||
|
`sync_tenant_ids` backfill. **Sequence this on top of Phase 1 (Alembic)** so the column drops are
|
||||||
|
versioned and run identically on SQLite and Postgres.
|
||||||
|
6. **Delete the client-portal subsystem** once nothing references it: `models/client_access.py`,
|
||||||
|
`client_access_service.py`, `api/auth.py`, `api/client_access.py`, related schemas, and the
|
||||||
|
`ClientShell`/`AdminShell` split.
|
||||||
|
|
||||||
|
### Risk notes
|
||||||
|
|
||||||
|
- This touches **authentication** — stage it carefully behind tests; do not delete the old endpoints
|
||||||
|
until the unified login is proven in dev against both SQLite and (a Postgres copy of) prod.
|
||||||
|
- The shared password (`P0.4`/system 2) and the env-admin credential should be considered a
|
||||||
|
**security cleanup**, not just structure: per-user hashed passwords for everyone is the target.
|
||||||
|
- Dropping `tenant_id` is irreversible data-wise — do it as a dedicated, reviewed Alembic step with
|
||||||
|
a backup, after the login unification has settled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation plan (phased)
|
||||||
|
|
||||||
|
### Phase 0 — Safety net (no behavior change)
|
||||||
|
- Add a schema-parity smoke test: fresh-SQLite `create_all` vs `Base.metadata` so later phases
|
||||||
|
cannot silently drift.
|
||||||
|
- Remove `backend/tests/_repro_throughput_post.py`.
|
||||||
|
|
||||||
|
### Phase 1 — Adopt Alembic *(foundation; most moving parts)*
|
||||||
|
- Add `alembic` to `backend/pyproject.toml`; `alembic init`.
|
||||||
|
- Wire `env.py` to read `DATABASE_URL` (via `app.core.config.settings`) and `Base.metadata`.
|
||||||
|
- **Critical for this setup:** enable `render_as_batch=True` so `ALTER` works on **SQLite (Windows dev)**;
|
||||||
|
Postgres handles `ALTER` natively.
|
||||||
|
- Autogenerate a **`0001_baseline`** migration from current models.
|
||||||
|
- `alembic stamp 0001_baseline` on existing dev **and** prod DBs so they are recognized without rebuilding.
|
||||||
|
- Fold `_LEGACY_COLUMN_PATCHES`, `sync_tenant_ids`, and `sync_product_visibility` into versioned
|
||||||
|
migrations (schema steps + explicit data-migration steps).
|
||||||
|
- Replace the startup `bootstrap_schema(...)` call (`main.py:105`) with `alembic upgrade head`
|
||||||
|
(or an explicit deploy step).
|
||||||
|
- Update `deploy/migrate-to-postgres.sh` **Phase 5** to run `alembic upgrade head` instead of
|
||||||
|
calling `bootstrap_schema`.
|
||||||
|
|
||||||
|
### Phase 2 — Money correctness
|
||||||
|
- `Float → Numeric(12, 4)` (tune per field) across the money/quantity columns listed in P0.1.
|
||||||
|
- Use `Decimal` in `services/costing_engine.py` and `services/product_costing_service.py`.
|
||||||
|
- Dedicated Alembic migration; guard with `tests/test_costing_engine.py` formula-parity tests.
|
||||||
|
|
||||||
|
### Phase 3 — Frontend shared utils
|
||||||
|
- New `frontend/src/lib/format.ts`: `formatDate`, `formatNumber`, `formatCurrency`, `toNum`.
|
||||||
|
- Replace the 9 / 5 / 2 duplicate implementations. Eliminates the `toNum`-style bug class.
|
||||||
|
|
||||||
|
### Phase 4 — Remove mock-on-error fallback *(quick, high-value correctness fix)*
|
||||||
|
- Remove the `fallback` return path in `api.ts` `fetchJson`.
|
||||||
|
- Surface real API errors / empty states in the UI.
|
||||||
|
- Keep `mock.ts` for tests only.
|
||||||
|
|
||||||
|
### Phase 5 — Unify authentication & user types *(addresses P0.4; large, cross-cutting)*
|
||||||
|
Sits on top of Phase 1 (Alembic) because the column drops must be versioned. Order within the phase:
|
||||||
|
1. Snapshot/migrate any real client users into `users` + `roles`; add `lean` and `client` roles in
|
||||||
|
`seed_access.py`.
|
||||||
|
2. Single login: make `/api/access/login` the only login; one cookie; one session store; one login
|
||||||
|
page. Remove `/api/auth/admin/*`, `/api/auth/client/*`, `ADMIN_AUTH_COOKIE`, and the `/admin*`
|
||||||
|
shell split.
|
||||||
|
3. Collapse the dual permission model — every route on `require_permission(...)`; delete the
|
||||||
|
`internal`/`client` special-casing and `_PERMISSION_TO_MODULE_LEVEL` bridge in `deps.py`/`access.py`.
|
||||||
|
4. Drop multi-tenancy (`tenant_id`) via a dedicated Alembic migration + query cleanup; remove
|
||||||
|
`sync_tenant_ids`.
|
||||||
|
5. Delete the dead client-portal subsystem (`models/client_access.py`, `client_access_service.py`,
|
||||||
|
`api/auth.py`, `api/client_access.py`, `AdminShell`).
|
||||||
|
6. `lean`-only extras (logo change, etc.) become permission-gated settings in the single app.
|
||||||
|
|
||||||
|
### Phase 6 — Decompose monolith routes
|
||||||
|
- Incrementally extract components + `+page.ts` load logic. Start with dashboard (`+page.svelte`)
|
||||||
|
and product-costing.
|
||||||
|
|
||||||
|
### Phase 7 — Hygiene
|
||||||
|
- Burn down `TODO`/legacy markers.
|
||||||
|
- Split `seed.py` by module.
|
||||||
|
- Refresh `CLAUDE.md` DB guidance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested sequencing note
|
||||||
|
Phase 1 (Alembic) is the foundation the SQLite-dev / Postgres-prod split most depends on. However,
|
||||||
|
**Phase 4 (mock-on-error)** is the scariest correctness bug and a ~20-minute fix — a reasonable
|
||||||
|
quick win to do first, before Phase 1.
|
||||||
|
|
||||||
|
## Progress log
|
||||||
|
- 2026-06-03 — Audit completed; plan written. `toNum` bug fixed in
|
||||||
|
`routes/throughput/+page.svelte` and `routes/throughput/add/+page.svelte` (precursor to Phase 3).
|
||||||
|
- 2026-06-03 — Auth/user-type investigation added (P0.4 + Phase 5). Found three overlapping auth
|
||||||
|
systems; the client-portal login (`/api/auth/client/login`, shared password) is already dead in
|
||||||
|
the UI (`clientLogin` has no component caller). Target: single login, `lean`/`client` roles, no
|
||||||
|
multi-tenant, no separate admin shell.
|
||||||
|
- 2026-06-04 — Phase 4 quick win started: removed production `api.ts` mock-on-error fallback so
|
||||||
|
failed reads throw normalized API errors instead of returning fabricated mock pricing/costing data.
|
||||||
|
Removed `backend/tests/_repro_throughput_post.py` debug repro file.
|
||||||
|
- 2026-06-04 — Phase 0 safety net started: added a fresh SQLite schema smoke test that checks
|
||||||
|
model metadata tables and columns are created as declared.
|
||||||
|
- 2026-06-04 — Phase 3 shared utils started: added `frontend/src/lib/format.ts`, covered it with
|
||||||
|
unit tests, and replaced the duplicated `toNum` helper plus the mix-calculator/throughput number
|
||||||
|
and date formatters touched in recent work.
|
||||||
@@ -9,6 +9,7 @@ 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_data/1.xlsx", "/app/input_data/1.xlsx"]
|
||||||
|
COPY ["input_data/Operations Throughput.xlsx", "/app/input_data/Operations Throughput.xlsx"]
|
||||||
COPY ["Input Cost Spreadsheet(1).xlsx", "/app/Input Cost Spreadsheet(1).xlsx"]
|
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 && \
|
||||||
|
|||||||
+547
-19
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, 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 func, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.core.access import (
|
from app.core.access import (
|
||||||
@@ -65,6 +65,9 @@ class RoleRead(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
permissions: list[str]
|
permissions: list[str]
|
||||||
|
module_permissions: dict[str, str]
|
||||||
|
is_protected: bool = False
|
||||||
|
user_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class UserRead(BaseModel):
|
class UserRead(BaseModel):
|
||||||
@@ -73,6 +76,273 @@ class UserRead(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
role: str | None
|
role: str | None
|
||||||
|
role_id: int | None
|
||||||
|
# True when this user can never be deleted (lean owner accounts). The UI
|
||||||
|
# uses this to disable the delete control rather than re-deriving the rule.
|
||||||
|
is_protected: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AssignableRole(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleModuleDefinition(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
levels: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
role_id: int | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUpdateUserRequest(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
role_id: int | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSetPasswordRequest(BaseModel):
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRoleRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
module_permissions: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateRoleRequest(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
module_permissions: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Lean owner accounts are permanent: they may be edited but never deleted, so a
|
||||||
|
# tenant can't accidentally lock itself out of the highest level of access.
|
||||||
|
LEAN_ROLE_NAME = "lean"
|
||||||
|
ADMIN_ROLE_NAME = "admin"
|
||||||
|
ROLE_MANAGEMENT_ALLOWED_ROLES = {LEAN_ROLE_NAME, ADMIN_ROLE_NAME}
|
||||||
|
PROTECTED_ROLE_NAMES = ROLE_MANAGEMENT_ALLOWED_ROLES
|
||||||
|
|
||||||
|
ROLE_MODULE_DEFINITIONS: tuple[dict[str, object], ...] = (
|
||||||
|
{
|
||||||
|
"key": "dashboard",
|
||||||
|
"label": "Dashboard",
|
||||||
|
"description": "Home dashboard visibility.",
|
||||||
|
"levels": {"none": (), "view": ("view_dashboard",)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mix_calculator",
|
||||||
|
"label": "Mix Calculator",
|
||||||
|
"description": "Open the calculator and save sessions.",
|
||||||
|
"levels": {
|
||||||
|
"none": (),
|
||||||
|
"view": ("view_mix_calculator",),
|
||||||
|
"edit": ("view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "raw_materials",
|
||||||
|
"label": "Raw Materials",
|
||||||
|
"description": "View or edit raw materials.",
|
||||||
|
"levels": {"none": (), "view": ("view_raw_materials",), "edit": ("view_raw_materials", "edit_raw_materials")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "products",
|
||||||
|
"label": "Products",
|
||||||
|
"description": "View or edit finished products.",
|
||||||
|
"levels": {"none": (), "view": ("view_products",), "edit": ("view_products", "edit_products")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mix_master",
|
||||||
|
"label": "Mix Master",
|
||||||
|
"description": "View or edit mix recipes.",
|
||||||
|
"levels": {"none": (), "view": ("view_mixes",), "edit": ("view_mixes", "edit_mixes")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "operations_throughput",
|
||||||
|
"label": "Throughput",
|
||||||
|
"description": "View or edit throughput entries.",
|
||||||
|
"levels": {"none": (), "view": ("view_throughput",), "edit": ("view_throughput", "edit_throughput")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ordering",
|
||||||
|
"label": "Ordering",
|
||||||
|
"description": "Access customer ordering and ordering administration.",
|
||||||
|
"levels": {
|
||||||
|
"none": (),
|
||||||
|
"view": ("view_ordering",),
|
||||||
|
"edit": ("view_ordering", "edit_ordering"),
|
||||||
|
"manage": ("view_ordering", "edit_ordering", "manage_ordering"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scenarios",
|
||||||
|
"label": "Scenarios",
|
||||||
|
"description": "View or run scenarios.",
|
||||||
|
"levels": {"none": (), "view": ("view_scenarios",), "edit": ("view_scenarios", "edit_scenarios")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "client_access",
|
||||||
|
"label": "Client Access",
|
||||||
|
"description": "Manage customer portal accounts and access.",
|
||||||
|
"levels": {"none": (), "manage": ("manage_client_access",)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "users",
|
||||||
|
"label": "Users",
|
||||||
|
"description": "View or manage internal users.",
|
||||||
|
"levels": {"none": (), "view": ("view_users",), "manage": ("view_users", "manage_users")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "roles",
|
||||||
|
"label": "Roles",
|
||||||
|
"description": "Manage roles and permission assignments.",
|
||||||
|
"levels": {"none": (), "manage": ("manage_permissions",)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "settings",
|
||||||
|
"label": "Settings",
|
||||||
|
"description": "Open settings and edit system configuration.",
|
||||||
|
"levels": {"none": (), "view": ("view_settings",), "edit": ("view_settings", "edit_settings")},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_user_read(user: User) -> UserRead:
|
||||||
|
role_name = user.role.name if user.role else None
|
||||||
|
return UserRead(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
is_active=user.is_active,
|
||||||
|
role=role_name,
|
||||||
|
role_id=user.role_id,
|
||||||
|
is_protected=(role_name or "").lower() == LEAN_ROLE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _role_name_lower(role: Role | None) -> str:
|
||||||
|
return (role.name if role else "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_protected_role_name(role_name: str | None) -> bool:
|
||||||
|
return (role_name or "").strip().lower() in PROTECTED_ROLE_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def _module_definitions_response() -> list[RoleModuleDefinition]:
|
||||||
|
return [
|
||||||
|
RoleModuleDefinition(
|
||||||
|
key=definition["key"],
|
||||||
|
label=definition["label"],
|
||||||
|
description=definition["description"],
|
||||||
|
levels=list(definition["levels"].keys()),
|
||||||
|
)
|
||||||
|
for definition in ROLE_MODULE_DEFINITIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _permissions_to_role_module_map(permission_keys: set[str]) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for definition in ROLE_MODULE_DEFINITIONS:
|
||||||
|
selected = "none"
|
||||||
|
levels = definition["levels"]
|
||||||
|
for level, required in levels.items():
|
||||||
|
required_keys = set(required)
|
||||||
|
if not required_keys or required_keys.issubset(permission_keys):
|
||||||
|
selected = level
|
||||||
|
result[definition["key"]] = selected
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _role_payload_to_permission_keys(module_permissions: dict[str, str]) -> set[str]:
|
||||||
|
known_modules = {definition["key"] for definition in ROLE_MODULE_DEFINITIONS}
|
||||||
|
unknown_modules = sorted(set(module_permissions) - known_modules)
|
||||||
|
if unknown_modules:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Unknown modules: {unknown_modules}",
|
||||||
|
)
|
||||||
|
|
||||||
|
granted: set[str] = set()
|
||||||
|
for definition in ROLE_MODULE_DEFINITIONS:
|
||||||
|
key = definition["key"]
|
||||||
|
level = module_permissions.get(key, "none")
|
||||||
|
available_levels: dict[str, tuple[str, ...]] = definition["levels"] # type: ignore[assignment]
|
||||||
|
if level not in available_levels:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid access level '{level}' for module '{key}'",
|
||||||
|
)
|
||||||
|
granted.update(available_levels[level])
|
||||||
|
return granted
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_role_read(role: Role, *, user_count: int = 0) -> RoleRead:
|
||||||
|
permission_keys = {permission.key for permission in role.permissions}
|
||||||
|
return RoleRead(
|
||||||
|
id=role.id,
|
||||||
|
name=role.name,
|
||||||
|
description=role.description,
|
||||||
|
permissions=sorted(permission_keys),
|
||||||
|
module_permissions=_permissions_to_role_module_map(permission_keys),
|
||||||
|
is_protected=_is_protected_role_name(role.name),
|
||||||
|
user_count=user_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_role_management_actor(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if _role_name_lower(user.role) not in ROLE_MANAGEMENT_ALLOWED_ROLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only lean and admin accounts can manage roles",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _load_role(db: Session, role_id: int) -> Role:
|
||||||
|
role = db.scalar(
|
||||||
|
select(Role).where(Role.id == role_id).options(selectinload(Role.permissions))
|
||||||
|
)
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_role_updates(
|
||||||
|
role: Role,
|
||||||
|
*,
|
||||||
|
name: str | None,
|
||||||
|
description: str | None,
|
||||||
|
module_permissions: dict[str, str] | None,
|
||||||
|
permissions_by_key: dict[str, Permission],
|
||||||
|
) -> None:
|
||||||
|
if name is not None:
|
||||||
|
trimmed_name = name.strip()
|
||||||
|
if not trimmed_name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
|
||||||
|
role.name = trimmed_name
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
role.description = description.strip() or None
|
||||||
|
|
||||||
|
if module_permissions is not None:
|
||||||
|
desired_permission_keys = _role_payload_to_permission_keys(module_permissions)
|
||||||
|
desired = {permissions_by_key[key] for key in desired_permission_keys}
|
||||||
|
current = set(role.permissions)
|
||||||
|
for permission in desired - current:
|
||||||
|
role.permissions.append(permission)
|
||||||
|
for permission in current - desired:
|
||||||
|
role.permissions.remove(permission)
|
||||||
|
|
||||||
|
|
||||||
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
|
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
|
||||||
@@ -216,36 +486,294 @@ def list_users(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_permission("view_users")), # gated by permission key
|
_: User = Depends(require_permission("view_users")), # gated by permission key
|
||||||
):
|
):
|
||||||
users = db.scalars(select(User).options(selectinload(User.role))).all()
|
users = db.scalars(
|
||||||
|
select(User).options(selectinload(User.role)).order_by(User.name)
|
||||||
|
).all()
|
||||||
|
return [_serialize_user_read(user) for user in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assignable-roles", response_model=list[AssignableRole])
|
||||||
|
def list_assignable_roles(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||||
|
):
|
||||||
|
"""Roles that a user-manager can assign — used to populate the role picker.
|
||||||
|
|
||||||
|
Separate from ``/roles`` (which exposes full permission sets and is gated by
|
||||||
|
``manage_permissions``); managing users only needs the role list itself.
|
||||||
|
"""
|
||||||
|
roles = db.scalars(select(Role).order_by(Role.name)).all()
|
||||||
return [
|
return [
|
||||||
UserRead(
|
AssignableRole(id=role.id, name=role.name, description=role.description)
|
||||||
id=user.id,
|
for role in roles
|
||||||
email=user.email,
|
|
||||||
name=user.name,
|
|
||||||
is_active=user.is_active,
|
|
||||||
role=user.role.name if user.role else None,
|
|
||||||
)
|
|
||||||
for user in users
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_managed_user(db: Session, user_id: int) -> User:
|
||||||
|
user = db.scalar(
|
||||||
|
select(User).where(User.id == user_id).options(selectinload(User.role))
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_role(db: Session, role_id: int | None) -> Role | None:
|
||||||
|
if role_id is None:
|
||||||
|
return None
|
||||||
|
role = db.scalar(select(Role).where(Role.id == role_id))
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role")
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(
|
||||||
|
payload: CreateUserRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||||
|
):
|
||||||
|
"""Create a new internal user.
|
||||||
|
|
||||||
|
A user with no password can still sign in with the shared internal password
|
||||||
|
until they set a personal one in their own settings.
|
||||||
|
"""
|
||||||
|
email = payload.email.strip().lower()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
|
||||||
|
name = payload.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
|
||||||
|
if db.scalar(select(User).where(User.email == email)):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
|
||||||
|
|
||||||
|
role = _resolve_role(db, payload.role_id)
|
||||||
|
|
||||||
|
password_hash = None
|
||||||
|
if payload.password:
|
||||||
|
if len(payload.password) < 8:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Password must be at least 8 characters",
|
||||||
|
)
|
||||||
|
password_hash = hash_password(payload.password)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
name=name,
|
||||||
|
role_id=role.id if role else None,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
password_hash=password_hash,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
log_security_event("users.created", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||||
|
return _serialize_user_read(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}", response_model=UserRead)
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
payload: AdminUpdateUserRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||||
|
):
|
||||||
|
"""Update another user's name, email, role, or active status."""
|
||||||
|
user = _load_managed_user(db, user_id)
|
||||||
|
|
||||||
|
if payload.is_active is not None:
|
||||||
|
# Guard against locking yourself out of your own management session.
|
||||||
|
if user.id == actor.id and not payload.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="You cannot deactivate your own account",
|
||||||
|
)
|
||||||
|
user.is_active = payload.is_active
|
||||||
|
|
||||||
|
if payload.name is not None:
|
||||||
|
name = payload.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
|
||||||
|
user.name = name
|
||||||
|
|
||||||
|
if payload.email is not None:
|
||||||
|
email = payload.email.strip().lower()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
|
||||||
|
existing = db.scalar(select(User).where(User.email == email, User.id != user.id))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
|
||||||
|
user.email = email
|
||||||
|
|
||||||
|
if payload.role_id is not None:
|
||||||
|
role = _resolve_role(db, payload.role_id)
|
||||||
|
user.role_id = role.id if role else None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
log_security_event("users.updated", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||||
|
return _serialize_user_read(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/password", response_model=UserRead)
|
||||||
|
def set_user_password(
|
||||||
|
user_id: int,
|
||||||
|
payload: AdminSetPasswordRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||||
|
):
|
||||||
|
"""Set (reset) another user's password without their current password."""
|
||||||
|
user = _load_managed_user(db, user_id)
|
||||||
|
if len(payload.new_password) < 8:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="New password must be at least 8 characters",
|
||||||
|
)
|
||||||
|
user.password_hash = hash_password(payload.new_password)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
log_security_event("users.password_reset", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||||
|
return _serialize_user_read(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||||
|
):
|
||||||
|
"""Delete a user. Lean owner accounts and your own account are protected."""
|
||||||
|
user = _load_managed_user(db, user_id)
|
||||||
|
if user.id == actor.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="You cannot delete your own account",
|
||||||
|
)
|
||||||
|
if (user.role.name if user.role else "").lower() == LEAN_ROLE_NAME:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Lean accounts cannot be deleted",
|
||||||
|
)
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
log_security_event("users.deleted", audience="internal", actor_user_id=actor.id, user_id=user_id)
|
||||||
|
response.status_code = status.HTTP_204_NO_CONTENT
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roles", response_model=list[RoleRead])
|
@router.get("/roles", response_model=list[RoleRead])
|
||||||
def list_roles(
|
def list_roles(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
|
_: User = Depends(_require_role_management_actor),
|
||||||
):
|
):
|
||||||
|
user_counts = dict(
|
||||||
|
db.execute(select(User.role_id, func.count(User.id)).group_by(User.role_id)).all()
|
||||||
|
)
|
||||||
roles = db.scalars(
|
roles = db.scalars(
|
||||||
select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
|
select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
|
||||||
).all()
|
).all()
|
||||||
return [
|
return [_serialize_role_read(role, user_count=user_counts.get(role.id, 0)) for role in roles]
|
||||||
RoleRead(
|
|
||||||
id=role.id,
|
|
||||||
name=role.name,
|
@router.get("/role-modules", response_model=list[RoleModuleDefinition])
|
||||||
description=role.description,
|
def list_role_modules(_: User = Depends(_require_role_management_actor)):
|
||||||
permissions=sorted(p.key for p in role.permissions),
|
return _module_definitions_response()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/roles", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_role(
|
||||||
|
payload: CreateRoleRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: User = Depends(_require_role_management_actor),
|
||||||
|
):
|
||||||
|
name = payload.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
|
||||||
|
existing = db.scalar(select(Role).where(func.lower(Role.name) == name.lower()))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
|
||||||
|
|
||||||
|
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
|
||||||
|
role = Role(name=name, description=None)
|
||||||
|
db.add(role)
|
||||||
|
db.flush()
|
||||||
|
_apply_role_updates(
|
||||||
|
role,
|
||||||
|
name=name,
|
||||||
|
description=payload.description,
|
||||||
|
module_permissions=payload.module_permissions,
|
||||||
|
permissions_by_key=permissions_by_key,
|
||||||
)
|
)
|
||||||
for role in roles
|
db.commit()
|
||||||
]
|
db.refresh(role)
|
||||||
|
log_security_event("roles.created", audience="internal", actor_user_id=actor.id, role_id=role.id)
|
||||||
|
return _serialize_role_read(role, user_count=0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/roles/{role_id}", response_model=RoleRead)
|
||||||
|
def update_role(
|
||||||
|
role_id: int,
|
||||||
|
payload: UpdateRoleRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: User = Depends(_require_role_management_actor),
|
||||||
|
):
|
||||||
|
role = _load_role(db, role_id)
|
||||||
|
original_name = role.name
|
||||||
|
protected = _is_protected_role_name(original_name)
|
||||||
|
requested_name = payload.name.strip() if payload.name is not None else role.name
|
||||||
|
if protected and requested_name.lower() != original_name.lower():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Lean and admin roles cannot be renamed",
|
||||||
|
)
|
||||||
|
if payload.name is not None:
|
||||||
|
duplicate = db.scalar(
|
||||||
|
select(Role).where(func.lower(Role.name) == requested_name.lower(), Role.id != role.id)
|
||||||
|
)
|
||||||
|
if duplicate:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
|
||||||
|
|
||||||
|
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
|
||||||
|
_apply_role_updates(
|
||||||
|
role,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
module_permissions=payload.module_permissions,
|
||||||
|
permissions_by_key=permissions_by_key,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(role)
|
||||||
|
user_count = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
|
||||||
|
log_security_event("roles.updated", audience="internal", actor_user_id=actor.id, role_id=role.id)
|
||||||
|
return _serialize_role_read(role, user_count=user_count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_role(
|
||||||
|
role_id: int,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: User = Depends(_require_role_management_actor),
|
||||||
|
):
|
||||||
|
role = _load_role(db, role_id)
|
||||||
|
if _is_protected_role_name(role.name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Lean and admin roles cannot be deleted",
|
||||||
|
)
|
||||||
|
assigned_users = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
|
||||||
|
if assigned_users > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Reassign users before deleting this role",
|
||||||
|
)
|
||||||
|
db.delete(role)
|
||||||
|
db.commit()
|
||||||
|
log_security_event("roles.deleted", audience="internal", actor_user_id=actor.id, role_id=role_id)
|
||||||
|
response.status_code = status.HTTP_204_NO_CONTENT
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions", response_model=list[str])
|
@router.get("/permissions", response_model=list[str])
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/client-access", tags=["client-access"])
|
|||||||
|
|
||||||
def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
|
def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
|
||||||
clients = list_client_accounts(db)
|
clients = list_client_accounts(db)
|
||||||
if session.role == "admin":
|
if session.role in {"admin", "internal"}:
|
||||||
return clients
|
return clients
|
||||||
return [client for client in clients if client.id == session.client_account_id]
|
return [client for client in clients if client.id == session.client_account_id]
|
||||||
|
|
||||||
@@ -61,12 +61,12 @@ def _read_client_account(db: Session, client_id: int, session: AuthSession) -> d
|
|||||||
|
|
||||||
|
|
||||||
def _actor_metadata(session: AuthSession) -> dict[str, str]:
|
def _actor_metadata(session: AuthSession) -> dict[str, str]:
|
||||||
if session.role == "admin":
|
if session.role in {"admin", "internal"}:
|
||||||
return {
|
return {
|
||||||
"actor_type": "lean_admin",
|
"actor_type": "lean_admin",
|
||||||
"actor_name": session.name,
|
"actor_name": session.name,
|
||||||
"actor_email": session.email,
|
"actor_email": session.email,
|
||||||
"actor_role": "admin",
|
"actor_role": session.client_role or "admin",
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"actor_type": "client_superadmin",
|
"actor_type": "client_superadmin",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ breakdowns, scenarios, data-quality) and only used summaries from each.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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
|
||||||
@@ -15,7 +18,9 @@ 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
|
||||||
|
from app.models.product_costing import ProductCostItem
|
||||||
from app.models.raw_material import RawMaterial
|
from app.models.raw_material import RawMaterial
|
||||||
|
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||||
from app.services.client_access_service import has_access_level
|
from app.services.client_access_service import has_access_level
|
||||||
from app.services.costing_engine import (
|
from app.services.costing_engine import (
|
||||||
calculate_mix_cost,
|
calculate_mix_cost,
|
||||||
@@ -33,6 +38,166 @@ def _can(session: AuthSession, module_key: str) -> bool:
|
|||||||
return has_access_level(permissions.get(module_key), "view")
|
return has_access_level(permissions.get(module_key), "view")
|
||||||
|
|
||||||
|
|
||||||
|
def _month_start(today: date) -> date:
|
||||||
|
return today.replace(day=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _warnings(item: ProductCostItem) -> list[str]:
|
||||||
|
if not item.warnings:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(item.warnings)
|
||||||
|
return parsed if isinstance(parsed, list) else [str(parsed)]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return [item.warnings]
|
||||||
|
|
||||||
|
|
||||||
|
def _pricing_key(value: str | None) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pricing_item(
|
||||||
|
entry: ProductionThroughput,
|
||||||
|
product: ThroughputProduct | None,
|
||||||
|
by_item_id: dict[str, ProductCostItem],
|
||||||
|
by_name: dict[str, ProductCostItem],
|
||||||
|
) -> ProductCostItem | None:
|
||||||
|
if product and product.item_id and product.item_id in by_item_id:
|
||||||
|
return by_item_id[product.item_id]
|
||||||
|
return by_name.get(_pricing_key(entry.product_name_snapshot)) or by_name.get(_pricing_key(product.name if product else None))
|
||||||
|
|
||||||
|
|
||||||
|
def _operations_summary(session: AuthSession, db: Session) -> dict | None:
|
||||||
|
if not (_can(session, "operations_throughput") or _can(session, "products") or _can(session, "dashboard")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
start = _month_start(today)
|
||||||
|
entries = db.scalars(
|
||||||
|
select(ProductionThroughput)
|
||||||
|
.where(
|
||||||
|
ProductionThroughput.tenant_id == session.tenant_id,
|
||||||
|
ProductionThroughput.production_date >= start,
|
||||||
|
ProductionThroughput.production_date <= today,
|
||||||
|
)
|
||||||
|
.options(selectinload(ProductionThroughput.product))
|
||||||
|
.order_by(ProductionThroughput.production_date.desc())
|
||||||
|
).all()
|
||||||
|
pricing_items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)).all()
|
||||||
|
by_item_id = {item.item_id: item for item in pricing_items if item.item_id}
|
||||||
|
by_name: dict[str, ProductCostItem] = {}
|
||||||
|
for item in pricing_items:
|
||||||
|
by_name.setdefault(_pricing_key(item.product_name), item)
|
||||||
|
by_name.setdefault(_pricing_key(item.mix_product_name), item)
|
||||||
|
|
||||||
|
product_totals: dict[str, dict] = {}
|
||||||
|
client_totals: dict[str, float] = {}
|
||||||
|
produced_not_priced: dict[str, dict] = {}
|
||||||
|
total_kg = 0.0
|
||||||
|
total_bags = 0.0
|
||||||
|
estimated_wholesale_value = 0.0
|
||||||
|
wholesale_rows = 0
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
kg = entry.calculated_kg or 0.0
|
||||||
|
bags = entry.quantity if entry.quantity_type == "bags" else 0.0
|
||||||
|
total_kg += kg
|
||||||
|
total_bags += bags
|
||||||
|
|
||||||
|
product = entry.product
|
||||||
|
name = entry.product_name_snapshot or product.name if product else entry.product_name_snapshot
|
||||||
|
bucket = product_totals.setdefault(
|
||||||
|
name,
|
||||||
|
{"product_name": name, "client_name": product.client_name if product else None, "kg": 0.0, "bags": 0.0, "entries": 0},
|
||||||
|
)
|
||||||
|
bucket["kg"] += kg
|
||||||
|
bucket["bags"] += bags
|
||||||
|
bucket["entries"] += 1
|
||||||
|
|
||||||
|
client = product.client_name if product and product.client_name else "Unassigned"
|
||||||
|
client_totals[client] = client_totals.get(client, 0.0) + kg
|
||||||
|
|
||||||
|
pricing = _find_pricing_item(entry, product, by_item_id, by_name)
|
||||||
|
pricing_warnings = _warnings(pricing) if pricing else ["Missing product pricing"]
|
||||||
|
wholesale_price = pricing.wholesale_price if pricing else None
|
||||||
|
unit_kg = pricing.unit_kg if pricing else None
|
||||||
|
if wholesale_price is not None:
|
||||||
|
units = kg / unit_kg if unit_kg and unit_kg > 0 else entry.quantity
|
||||||
|
estimated_wholesale_value += units * wholesale_price
|
||||||
|
wholesale_rows += 1
|
||||||
|
if pricing is None or pricing_warnings or wholesale_price is None:
|
||||||
|
missing = produced_not_priced.setdefault(
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
"product_name": name,
|
||||||
|
"kg": 0.0,
|
||||||
|
"status": "Missing pricing" if pricing is None else "Needs review",
|
||||||
|
"warnings": pricing_warnings[:2],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
missing["kg"] += kg
|
||||||
|
|
||||||
|
issue_counts = {
|
||||||
|
"missing_lookup": 0,
|
||||||
|
"missing_unit_kg": 0,
|
||||||
|
"missing_pallet_qty": 0,
|
||||||
|
"missing_price": 0,
|
||||||
|
"invalid_margin": 0,
|
||||||
|
}
|
||||||
|
for item in pricing_items:
|
||||||
|
warnings = " ".join(_warnings(item)).lower()
|
||||||
|
if "lookup" in warnings:
|
||||||
|
issue_counts["missing_lookup"] += 1
|
||||||
|
if "unit kg" in warnings:
|
||||||
|
issue_counts["missing_unit_kg"] += 1
|
||||||
|
if "pallet" in warnings:
|
||||||
|
issue_counts["missing_pallet_qty"] += 1
|
||||||
|
if item.distributor_price is None or item.wholesale_price is None:
|
||||||
|
issue_counts["missing_price"] += 1
|
||||||
|
if "margin" in warnings:
|
||||||
|
issue_counts["invalid_margin"] += 1
|
||||||
|
|
||||||
|
top_products = sorted(product_totals.values(), key=lambda row: row["kg"], reverse=True)[:5]
|
||||||
|
clients = [
|
||||||
|
{"client_name": client, "kg": round(kg, 2)}
|
||||||
|
for client, kg in sorted(client_totals.items(), key=lambda item: item[1], reverse=True)[:5]
|
||||||
|
]
|
||||||
|
produced_not_priced_rows = sorted(produced_not_priced.values(), key=lambda row: row["kg"], reverse=True)[:5]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period_label": "This month",
|
||||||
|
"total_kg": round(total_kg, 2),
|
||||||
|
"total_bags": round(total_bags, 2),
|
||||||
|
"entry_count": len(entries),
|
||||||
|
"estimated_wholesale_value": round(estimated_wholesale_value, 2),
|
||||||
|
"priced_entry_count": wholesale_rows,
|
||||||
|
"top_products": [
|
||||||
|
{
|
||||||
|
"product_name": row["product_name"],
|
||||||
|
"client_name": row["client_name"],
|
||||||
|
"kg": round(row["kg"], 2),
|
||||||
|
"bags": round(row["bags"], 2),
|
||||||
|
"entries": row["entries"],
|
||||||
|
}
|
||||||
|
for row in top_products
|
||||||
|
],
|
||||||
|
"client_totals": clients,
|
||||||
|
"pricing_issues": {
|
||||||
|
**issue_counts,
|
||||||
|
"total": sum(issue_counts.values()),
|
||||||
|
},
|
||||||
|
"produced_not_priced": [
|
||||||
|
{
|
||||||
|
"product_name": row["product_name"],
|
||||||
|
"kg": round(row["kg"], 2),
|
||||||
|
"status": row["status"],
|
||||||
|
"warnings": row["warnings"],
|
||||||
|
}
|
||||||
|
for row in produced_not_priced_rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
@router.get("/summary")
|
||||||
def dashboard_summary(
|
def dashboard_summary(
|
||||||
session: AuthSession = Depends(require_client_module_access("dashboard")),
|
session: AuthSession = Depends(require_client_module_access("dashboard")),
|
||||||
@@ -44,6 +209,7 @@ def dashboard_summary(
|
|||||||
raw_series: list[float] = []
|
raw_series: list[float] = []
|
||||||
mix_series: list[float] = []
|
mix_series: list[float] = []
|
||||||
product_series: list[float] = []
|
product_series: list[float] = []
|
||||||
|
operations_summary = _operations_summary(session, db)
|
||||||
|
|
||||||
if _can(session, "raw_materials") or _can(session, "dashboard"):
|
if _can(session, "raw_materials") or _can(session, "dashboard"):
|
||||||
materials = db.scalars(
|
materials = db.scalars(
|
||||||
@@ -147,4 +313,5 @@ def dashboard_summary(
|
|||||||
"mix_cost_per_kg": mix_series,
|
"mix_cost_per_kg": mix_series,
|
||||||
"product_finished_delivered": product_series,
|
"product_finished_delivered": product_series,
|
||||||
},
|
},
|
||||||
|
"operations": operations_summary,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,12 +184,41 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
|
|||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def require_ordering_admin_session(
|
||||||
|
session: AuthSession = Depends(get_auth_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AuthSession:
|
||||||
|
"""Internal-side authorization for managing the ordering portal.
|
||||||
|
|
||||||
|
Accepts the single Lean admin (``role == "admin"``) or an internal Hunter
|
||||||
|
user holding ``manage`` on the ordering module. Client/customer users are
|
||||||
|
rejected — they use the customer-facing ``require_client_module_access``
|
||||||
|
dependency instead.
|
||||||
|
"""
|
||||||
|
if session.role == "admin":
|
||||||
|
return session
|
||||||
|
if session.role == "internal":
|
||||||
|
permissions = session.module_permissions or {}
|
||||||
|
if not has_access_level(permissions.get("ordering"), "manage"):
|
||||||
|
log_security_event("authz.denied", role=session.role, module="ordering", access_level="manage")
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires manage access")
|
||||||
|
return session
|
||||||
|
log_security_event("authz.denied", role=session.role, module="ordering", required="admin")
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires internal admin access")
|
||||||
|
|
||||||
|
|
||||||
def require_client_access_manager_session(
|
def require_client_access_manager_session(
|
||||||
session: AuthSession = Depends(get_auth_session),
|
session: AuthSession = Depends(get_auth_session),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> AuthSession:
|
) -> AuthSession:
|
||||||
if session.role == "admin":
|
if session.role == "admin":
|
||||||
return session
|
return session
|
||||||
|
if session.role == "internal":
|
||||||
|
permissions = session.module_permissions or {}
|
||||||
|
if not has_access_level(permissions.get("client_access"), "manage"):
|
||||||
|
log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage")
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires Lean access")
|
||||||
|
return session
|
||||||
if session.role != "client":
|
if session.role != "client":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,982 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import case, func, or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession, get_auth_session
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.mix import Mix, MixIngredient
|
||||||
|
from app.models.product import Product, ProductIngredient
|
||||||
|
from app.models.raw_material import RawMaterial
|
||||||
|
from app.models.change_event import EditorChangeEvent
|
||||||
|
from app.schemas.editor import (
|
||||||
|
EditorChangeEventRead,
|
||||||
|
EditorIngredientCreate,
|
||||||
|
EditorIngredientRow,
|
||||||
|
EditorIngredientUpdate,
|
||||||
|
EditorMixFormulaRead,
|
||||||
|
EditorMixCreate,
|
||||||
|
EditorMixFormulaReplace,
|
||||||
|
EditorMixIngredientCreate,
|
||||||
|
EditorMixIngredientUpdate,
|
||||||
|
EditorMixRow,
|
||||||
|
EditorMixUpdate,
|
||||||
|
EditorProductFormulaRead,
|
||||||
|
EditorProductIngredientCreate,
|
||||||
|
EditorProductIngredientUpdate,
|
||||||
|
EditorProductRow,
|
||||||
|
EditorProductUpdate,
|
||||||
|
EditorResolvedMixFormula,
|
||||||
|
)
|
||||||
|
from app.services.change_log import (
|
||||||
|
ENTITY_INGREDIENT,
|
||||||
|
ENTITY_MIX,
|
||||||
|
diff_fields,
|
||||||
|
list_changes,
|
||||||
|
record_change,
|
||||||
|
)
|
||||||
|
from app.services.client_access_service import has_access_level
|
||||||
|
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
|
||||||
|
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/editor", tags=["editor"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(product: Product) -> dict:
|
||||||
|
return {
|
||||||
|
"id": product.id,
|
||||||
|
"tenant_id": product.tenant_id,
|
||||||
|
"client_name": product.client_name,
|
||||||
|
"item_id": product.item_id,
|
||||||
|
"name": product.name,
|
||||||
|
"mix_id": product.mix_id,
|
||||||
|
"mix_client_name": product.mix.client_name if product.mix else "",
|
||||||
|
"mix_name": product.mix.name if product.mix else "",
|
||||||
|
"sale_type": product.sale_type,
|
||||||
|
"unit_of_measure": product.unit_of_measure,
|
||||||
|
"visible": product.visible,
|
||||||
|
"product_notes": product.notes,
|
||||||
|
"mix_notes": product.mix.notes if product.mix else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_product_formula(product: Product) -> dict:
|
||||||
|
ingredients = [
|
||||||
|
{
|
||||||
|
"id": ingredient.id,
|
||||||
|
"raw_material_id": ingredient.raw_material_id,
|
||||||
|
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
|
||||||
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
|
"sort_order": ingredient.sort_order,
|
||||||
|
"notes": ingredient.notes,
|
||||||
|
}
|
||||||
|
for ingredient in sorted(product.ingredients, key=lambda item: (item.sort_order, item.raw_material.name if item.raw_material else ""))
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"id": product.id,
|
||||||
|
"tenant_id": product.tenant_id,
|
||||||
|
"client_name": product.client_name,
|
||||||
|
"name": product.name,
|
||||||
|
"mix_id": product.mix_id,
|
||||||
|
"mix_name": product.mix.name if product.mix else "",
|
||||||
|
"ingredients": ingredients,
|
||||||
|
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
|
||||||
|
# Status is product-driven once a mix has products (Active = at least one
|
||||||
|
# visible product). A mix with no products yet has nothing to fan out to, so
|
||||||
|
# it falls back to its own `status` column — that's what lets a brand-new
|
||||||
|
# mix read as Active instead of being stuck Inactive and hidden.
|
||||||
|
visible = visible_count > 0 if product_count > 0 else mix.status == "active"
|
||||||
|
return {
|
||||||
|
"id": mix.id,
|
||||||
|
"tenant_id": mix.tenant_id,
|
||||||
|
"client_name": mix.client_name,
|
||||||
|
"name": mix.name,
|
||||||
|
"visible": visible,
|
||||||
|
"product_count": product_count,
|
||||||
|
"visible_product_count": visible_count,
|
||||||
|
"notes": mix.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]:
|
||||||
|
"""Per-mix (total products, visible products) used to drive the Status column."""
|
||||||
|
rows = db.execute(
|
||||||
|
select(
|
||||||
|
Product.mix_id,
|
||||||
|
func.count(),
|
||||||
|
func.sum(case((Product.visible, 1), else_=0)),
|
||||||
|
)
|
||||||
|
.where(Product.tenant_id == tenant_id)
|
||||||
|
.group_by(Product.mix_id)
|
||||||
|
).all()
|
||||||
|
return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_mix_formula(mix: Mix) -> dict:
|
||||||
|
ingredients = [
|
||||||
|
{
|
||||||
|
"id": ingredient.id,
|
||||||
|
"raw_material_id": ingredient.raw_material_id,
|
||||||
|
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
|
||||||
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
|
"notes": ingredient.notes,
|
||||||
|
}
|
||||||
|
for ingredient in sorted(
|
||||||
|
mix.ingredients,
|
||||||
|
key=lambda item: item.raw_material.name if item.raw_material else "",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"id": mix.id,
|
||||||
|
"tenant_id": mix.tenant_id,
|
||||||
|
"client_name": mix.client_name,
|
||||||
|
"name": mix.name,
|
||||||
|
"ingredients": ingredients,
|
||||||
|
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_change_event(event: EditorChangeEvent) -> dict:
|
||||||
|
return {
|
||||||
|
"id": event.id,
|
||||||
|
"entity_type": event.entity_type,
|
||||||
|
"entity_id": event.entity_id,
|
||||||
|
"action": event.action,
|
||||||
|
"actor_name": event.actor_name,
|
||||||
|
"actor_email": event.actor_email,
|
||||||
|
"actor_role": event.actor_role,
|
||||||
|
"summary": event.summary,
|
||||||
|
"changes": event.changes or [],
|
||||||
|
"created_at": event.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_kg(value: float) -> str:
|
||||||
|
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
||||||
|
return f"{text or '0'} kg"
|
||||||
|
|
||||||
|
|
||||||
|
def _formula_deltas(
|
||||||
|
before: list[dict],
|
||||||
|
after: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Per-ingredient before/after deltas between two resolved formulas.
|
||||||
|
|
||||||
|
`resolve_editor_mix_formula` returns plain dicts (ingredients are dicts too),
|
||||||
|
so read the rows by key, not attribute.
|
||||||
|
"""
|
||||||
|
before_map = {row["raw_material_name"]: row["quantity_kg"] for row in before}
|
||||||
|
after_map = {row["raw_material_name"]: row["quantity_kg"] for row in after}
|
||||||
|
deltas: list[dict] = []
|
||||||
|
for name in sorted(set(before_map) | set(after_map)):
|
||||||
|
old = before_map.get(name)
|
||||||
|
new = after_map.get(name)
|
||||||
|
if old == new:
|
||||||
|
continue
|
||||||
|
deltas.append(
|
||||||
|
{
|
||||||
|
"field": name,
|
||||||
|
"label": name,
|
||||||
|
"before": _format_kg(old) if old is not None else None,
|
||||||
|
"after": _format_kg(new) if new is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return deltas
|
||||||
|
|
||||||
|
|
||||||
|
def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(Mix)
|
||||||
|
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
|
||||||
|
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(Product)
|
||||||
|
.where(Product.id == product_id, Product.tenant_id == tenant_id)
|
||||||
|
.options(
|
||||||
|
joinedload(Product.mix),
|
||||||
|
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_editor_session(
|
||||||
|
session: AuthSession = Depends(get_auth_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AuthSession:
|
||||||
|
if session.role == "internal":
|
||||||
|
permissions = session.module_permissions or {}
|
||||||
|
if not has_access_level(permissions.get("client_access"), "manage"):
|
||||||
|
raise HTTPException(status_code=403, detail="Lean access is required")
|
||||||
|
if not has_access_level(permissions.get("products"), "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="products edit access is required")
|
||||||
|
if not has_access_level(permissions.get("mix_master"), "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="mix_master edit access is required")
|
||||||
|
if not session.tenant_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Internal user context is missing")
|
||||||
|
return session
|
||||||
|
|
||||||
|
raise HTTPException(status_code=403, detail="Lean access is required")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products", response_model=list[EditorProductRow])
|
||||||
|
def list_editor_products(
|
||||||
|
q: str | None = Query(default=None, max_length=255),
|
||||||
|
client_name: str | None = Query(default=None, max_length=255),
|
||||||
|
limit: int = Query(default=500, ge=1, le=1000),
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
statement = (
|
||||||
|
select(Product)
|
||||||
|
.where(Product.tenant_id == session.tenant_id)
|
||||||
|
.options(joinedload(Product.mix))
|
||||||
|
.join(Product.mix)
|
||||||
|
.order_by(Product.client_name, Product.name, Product.id)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_name:
|
||||||
|
statement = statement.where(Product.client_name == client_name)
|
||||||
|
|
||||||
|
if q:
|
||||||
|
term = f"%{q.strip()}%"
|
||||||
|
statement = statement.where(
|
||||||
|
or_(
|
||||||
|
Product.client_name.ilike(term),
|
||||||
|
Product.name.ilike(term),
|
||||||
|
Product.item_id.ilike(term),
|
||||||
|
Product.unit_of_measure.ilike(term),
|
||||||
|
Mix.name.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [_serialize_row(product) for product in db.scalars(statement).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/products/{product_id}", response_model=EditorProductRow)
|
||||||
|
def update_editor_product(
|
||||||
|
product_id: int,
|
||||||
|
payload: EditorProductUpdate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
product = db.scalar(
|
||||||
|
select(Product)
|
||||||
|
.where(Product.id == product_id, Product.tenant_id == session.tenant_id)
|
||||||
|
.options(joinedload(Product.mix))
|
||||||
|
)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
if payload.mix_id is not None:
|
||||||
|
mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id))
|
||||||
|
if mix is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(product, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return _serialize_row(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mixes", response_model=list[EditorMixRow])
|
||||||
|
def list_editor_mixes(
|
||||||
|
q: str | None = Query(default=None, max_length=255),
|
||||||
|
client_name: str | None = Query(default=None, max_length=255),
|
||||||
|
limit: int = Query(default=500, ge=1, le=1000),
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
statement = select(Mix).where(Mix.tenant_id == session.tenant_id)
|
||||||
|
|
||||||
|
if client_name:
|
||||||
|
statement = statement.where(Mix.client_name == client_name)
|
||||||
|
|
||||||
|
if q:
|
||||||
|
term = f"%{q.strip()}%"
|
||||||
|
statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term)))
|
||||||
|
|
||||||
|
statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit)
|
||||||
|
|
||||||
|
counts = _mix_product_counts(db, session.tenant_id or "")
|
||||||
|
mixes = db.scalars(statement).all()
|
||||||
|
return [
|
||||||
|
_serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0])
|
||||||
|
for mix in mixes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mixes", response_model=EditorMixRow, status_code=201)
|
||||||
|
def create_editor_mix(
|
||||||
|
payload: EditorMixCreate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
mix = Mix(
|
||||||
|
tenant_id=session.tenant_id or "",
|
||||||
|
client_name=payload.client_name.strip(),
|
||||||
|
name=payload.name.strip(),
|
||||||
|
notes=payload.notes,
|
||||||
|
# Active by default so a freshly created mix shows under the default
|
||||||
|
# "Active" filter rather than being hidden until it has a visible product.
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_MIX,
|
||||||
|
entity_id=mix.id,
|
||||||
|
action="created",
|
||||||
|
summary=f"Created mix “{mix.name}” for {mix.client_name}",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(mix)
|
||||||
|
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
|
||||||
|
return _serialize_mix_row(mix, visible_count=0, product_count=0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
|
||||||
|
def update_editor_mix(
|
||||||
|
mix_id: int,
|
||||||
|
payload: EditorMixUpdate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
|
||||||
|
if mix is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
# `visible` is a virtual field: for a mix with products it fans out to the
|
||||||
|
# visibility of every product; for a product-less mix it maps to the mix's
|
||||||
|
# own `status` column so the toggle still persists.
|
||||||
|
visible = updates.pop("visible", None)
|
||||||
|
|
||||||
|
product_total = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Product)
|
||||||
|
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
before = {field: getattr(mix, field) for field in updates}
|
||||||
|
if visible is not None:
|
||||||
|
if product_total > 0:
|
||||||
|
visible_before = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Product)
|
||||||
|
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
|
||||||
|
)
|
||||||
|
before["visible"] = bool(visible_before)
|
||||||
|
else:
|
||||||
|
before["visible"] = mix.status == "active"
|
||||||
|
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(mix, field, value)
|
||||||
|
|
||||||
|
if visible is not None:
|
||||||
|
if product_total > 0:
|
||||||
|
for product in db.scalars(
|
||||||
|
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||||
|
).all():
|
||||||
|
product.visible = visible
|
||||||
|
else:
|
||||||
|
mix.status = "active" if visible else "inactive"
|
||||||
|
|
||||||
|
after = dict(updates)
|
||||||
|
if visible is not None:
|
||||||
|
after["visible"] = visible
|
||||||
|
deltas = diff_fields(
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
{"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"},
|
||||||
|
)
|
||||||
|
if deltas:
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_MIX,
|
||||||
|
entity_id=mix.id,
|
||||||
|
action="updated",
|
||||||
|
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
|
||||||
|
changes=deltas,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
counts = _mix_product_counts(db, session.tenant_id or "")
|
||||||
|
total, visible_count = counts.get(mix_id, (0, 0))
|
||||||
|
return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mixes/{mix_id}", status_code=204)
|
||||||
|
def delete_editor_mix(
|
||||||
|
mix_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a mix that no product depends on.
|
||||||
|
|
||||||
|
A product must reference a mix (`products.mix_id` is NOT NULL), so a mix that
|
||||||
|
still drives products can't be removed without orphaning them — those should
|
||||||
|
be marked inactive instead. The mix's own ingredient rows cascade away with
|
||||||
|
it via the `delete-orphan` relationship.
|
||||||
|
"""
|
||||||
|
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
|
||||||
|
if mix is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
|
||||||
|
product_total = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Product)
|
||||||
|
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if product_total > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"This mix has {product_total} linked product"
|
||||||
|
f"{'s' if product_total != 1 else ''}. Mark it inactive or remove its products first."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(mix)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
|
||||||
|
def get_editor_mix_ingredients(
|
||||||
|
mix_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||||
|
if mix is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
return _serialize_mix_formula(mix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201)
|
||||||
|
def add_editor_mix_ingredient(
|
||||||
|
mix_id: int,
|
||||||
|
payload: EditorMixIngredientCreate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||||
|
if mix is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
raw_material = db.scalar(
|
||||||
|
select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)
|
||||||
|
)
|
||||||
|
if raw_material is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
MixIngredient(
|
||||||
|
tenant_id=session.tenant_id or "",
|
||||||
|
mix_id=mix_id,
|
||||||
|
raw_material_id=payload.raw_material_id,
|
||||||
|
quantity_kg=payload.quantity_kg,
|
||||||
|
notes=payload.notes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_MIX,
|
||||||
|
entity_id=mix_id,
|
||||||
|
action="ingredient_added",
|
||||||
|
summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})",
|
||||||
|
changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc
|
||||||
|
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||||
|
return _serialize_mix_formula(mix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
|
||||||
|
def update_editor_mix_ingredient(
|
||||||
|
mix_id: int,
|
||||||
|
ingredient_id: int,
|
||||||
|
payload: EditorMixIngredientUpdate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
ingredient = db.scalar(
|
||||||
|
select(MixIngredient)
|
||||||
|
.join(Mix)
|
||||||
|
.where(
|
||||||
|
MixIngredient.id == ingredient_id,
|
||||||
|
MixIngredient.mix_id == mix_id,
|
||||||
|
Mix.tenant_id == session.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ingredient is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||||
|
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
before = {field: getattr(ingredient, field) for field in updates}
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(ingredient, field, value)
|
||||||
|
deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"})
|
||||||
|
if deltas:
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_MIX,
|
||||||
|
entity_id=mix_id,
|
||||||
|
action="ingredient_updated",
|
||||||
|
summary=f"Updated {raw_material_name}",
|
||||||
|
changes=deltas,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||||
|
return _serialize_mix_formula(mix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
|
||||||
|
def delete_editor_mix_ingredient(
|
||||||
|
mix_id: int,
|
||||||
|
ingredient_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
ingredient = db.scalar(
|
||||||
|
select(MixIngredient)
|
||||||
|
.join(Mix)
|
||||||
|
.where(
|
||||||
|
MixIngredient.id == ingredient_id,
|
||||||
|
MixIngredient.mix_id == mix_id,
|
||||||
|
Mix.tenant_id == session.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ingredient is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||||
|
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
|
||||||
|
removed_kg = ingredient.quantity_kg
|
||||||
|
db.delete(ingredient)
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_MIX,
|
||||||
|
entity_id=mix_id,
|
||||||
|
action="ingredient_removed",
|
||||||
|
summary=f"Removed {raw_material_name}",
|
||||||
|
changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}],
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||||
|
return _serialize_mix_formula(mix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
|
||||||
|
def get_editor_mix_resolved_formula(
|
||||||
|
mix_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""The mix formula as the Mix Calculator reads it (product-first resolution).
|
||||||
|
|
||||||
|
This is what the Mix Editor displays, so the two surfaces show identical
|
||||||
|
ingredients and quantities. See `resolve_editor_mix_formula`.
|
||||||
|
"""
|
||||||
|
tenant_id = session.tenant_id or ""
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||||
|
if mix is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
|
||||||
|
def replace_editor_mix_formula(
|
||||||
|
mix_id: int,
|
||||||
|
payload: EditorMixFormulaReplace,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Replace a mix's whole formula in one save.
|
||||||
|
|
||||||
|
Writes back to the *same source* the Mix Calculator reads: the representative
|
||||||
|
product's own formula (`ProductIngredient`) when it has one, otherwise the
|
||||||
|
shared mix master (`MixIngredient`). Either way the calculator immediately
|
||||||
|
reflects the edit.
|
||||||
|
"""
|
||||||
|
tenant_id = session.tenant_id or ""
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||||
|
if mix is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
|
||||||
|
# Snapshot the formula as it stands so we can diff it against the saved one.
|
||||||
|
before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||||
|
|
||||||
|
raw_ids = [row.raw_material_id for row in payload.rows]
|
||||||
|
if len(set(raw_ids)) != len(raw_ids):
|
||||||
|
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
|
||||||
|
existing_ids = set(
|
||||||
|
db.scalars(
|
||||||
|
select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||||
|
|
||||||
|
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
|
||||||
|
if product is not None and product.ingredients:
|
||||||
|
# Replace the representative product's own formula.
|
||||||
|
for ingredient in list(product.ingredients):
|
||||||
|
db.delete(ingredient)
|
||||||
|
db.flush()
|
||||||
|
for sort_order, row in enumerate(payload.rows, start=1):
|
||||||
|
db.add(
|
||||||
|
ProductIngredient(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
product_id=product.id,
|
||||||
|
raw_material_id=row.raw_material_id,
|
||||||
|
quantity_kg=row.quantity_kg,
|
||||||
|
sort_order=sort_order,
|
||||||
|
notes=row.notes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No product-specific formula in play: edit the shared mix master, which
|
||||||
|
# is what the calculator falls back to for this mix.
|
||||||
|
for ingredient in list(mix.ingredients):
|
||||||
|
db.delete(ingredient)
|
||||||
|
db.flush()
|
||||||
|
for row in payload.rows:
|
||||||
|
db.add(
|
||||||
|
MixIngredient(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
mix_id=mix.id,
|
||||||
|
raw_material_id=row.raw_material_id,
|
||||||
|
quantity_kg=row.quantity_kg,
|
||||||
|
notes=row.notes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
# Drop now-stale ORM state so the re-resolve reads the rows we just wrote
|
||||||
|
# rather than the formerly-loaded ingredient collections from the identity map.
|
||||||
|
db.expire_all()
|
||||||
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||||
|
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||||
|
deltas = _formula_deltas(before_formula["ingredients"], after_formula["ingredients"])
|
||||||
|
if deltas:
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_MIX,
|
||||||
|
entity_id=mix_id,
|
||||||
|
action="formula_updated",
|
||||||
|
summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})",
|
||||||
|
changes=deltas,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return after_formula
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead])
|
||||||
|
def get_editor_mix_history(
|
||||||
|
mix_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = session.tenant_id or ""
|
||||||
|
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Mix not found")
|
||||||
|
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id)
|
||||||
|
return [_serialize_change_event(event) for event in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
|
||||||
|
def get_editor_product_ingredients(
|
||||||
|
product_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
return _serialize_product_formula(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead, status_code=201)
|
||||||
|
def add_editor_product_ingredient(
|
||||||
|
product_id: int,
|
||||||
|
payload: EditorProductIngredientCreate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||||
|
|
||||||
|
next_sort_order = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(ProductIngredient.sort_order), 0)).where(ProductIngredient.product_id == product_id)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
) + 1
|
||||||
|
db.add(
|
||||||
|
ProductIngredient(
|
||||||
|
tenant_id=session.tenant_id or "",
|
||||||
|
product_id=product_id,
|
||||||
|
raw_material_id=payload.raw_material_id,
|
||||||
|
quantity_kg=payload.quantity_kg,
|
||||||
|
sort_order=next_sort_order,
|
||||||
|
notes=payload.notes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail="Raw material is already on this product") from exc
|
||||||
|
|
||||||
|
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
|
||||||
|
return _serialize_product_formula(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
|
||||||
|
def update_editor_product_ingredient(
|
||||||
|
product_id: int,
|
||||||
|
ingredient_id: int,
|
||||||
|
payload: EditorProductIngredientUpdate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
ingredient = db.scalar(
|
||||||
|
select(ProductIngredient)
|
||||||
|
.join(Product)
|
||||||
|
.where(
|
||||||
|
ProductIngredient.id == ingredient_id,
|
||||||
|
ProductIngredient.product_id == product_id,
|
||||||
|
Product.tenant_id == session.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ingredient is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(ingredient, field, value)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
|
||||||
|
return _serialize_product_formula(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
|
||||||
|
def delete_editor_product_ingredient(
|
||||||
|
product_id: int,
|
||||||
|
ingredient_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
ingredient = db.scalar(
|
||||||
|
select(ProductIngredient)
|
||||||
|
.join(Product)
|
||||||
|
.where(
|
||||||
|
ProductIngredient.id == ingredient_id,
|
||||||
|
ProductIngredient.product_id == product_id,
|
||||||
|
Product.tenant_id == session.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ingredient is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||||
|
db.delete(ingredient)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
|
||||||
|
return _serialize_product_formula(product)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ingredients (raw materials) catalogue -----------------------------------
|
||||||
|
#
|
||||||
|
# The mix editor consumes raw materials as the ingredients dropdown; this gives
|
||||||
|
# Lean admins a sibling editor to curate that catalogue — the ingredients that
|
||||||
|
# ultimately get used inside mixes. Same auth/tenant model as the mix editor.
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
|
||||||
|
active_price = get_active_price(material)
|
||||||
|
cost_per_kg = (
|
||||||
|
calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": material.id,
|
||||||
|
"name": material.name,
|
||||||
|
"supplier": material.supplier,
|
||||||
|
"unit_of_measure": material.unit_of_measure,
|
||||||
|
"kg_per_unit": material.kg_per_unit,
|
||||||
|
"status": material.status,
|
||||||
|
"rounding_decimals": material.rounding_decimals,
|
||||||
|
"category": material.category,
|
||||||
|
"notes": material.notes,
|
||||||
|
"cost_per_kg": cost_per_kg,
|
||||||
|
"usage_count": usage_count,
|
||||||
|
"created_at": material.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]:
|
||||||
|
"""How many product/mix formula rows reference each raw material."""
|
||||||
|
rows = db.execute(
|
||||||
|
select(ProductIngredient.raw_material_id, func.count())
|
||||||
|
.where(ProductIngredient.tenant_id == tenant_id)
|
||||||
|
.group_by(ProductIngredient.raw_material_id)
|
||||||
|
).all()
|
||||||
|
return {raw_material_id: count for raw_material_id, count in rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ingredients", response_model=list[EditorIngredientRow])
|
||||||
|
def list_editor_ingredients(
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = session.tenant_id or ""
|
||||||
|
materials = db.scalars(
|
||||||
|
select(RawMaterial)
|
||||||
|
.where(RawMaterial.tenant_id == tenant_id)
|
||||||
|
.options(selectinload(RawMaterial.price_versions))
|
||||||
|
.order_by(RawMaterial.name)
|
||||||
|
).all()
|
||||||
|
usage = _ingredient_usage_counts(db, tenant_id)
|
||||||
|
return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201)
|
||||||
|
def create_editor_ingredient(
|
||||||
|
payload: EditorIngredientCreate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
material = RawMaterial(
|
||||||
|
tenant_id=session.tenant_id or "",
|
||||||
|
name=payload.name.strip(),
|
||||||
|
supplier=(payload.supplier or "").strip() or None,
|
||||||
|
unit_of_measure=payload.unit_of_measure.strip(),
|
||||||
|
kg_per_unit=payload.kg_per_unit,
|
||||||
|
status=payload.status.strip() or "active",
|
||||||
|
rounding_decimals=payload.rounding_decimals,
|
||||||
|
category=(payload.category or "").strip() or None,
|
||||||
|
notes=payload.notes,
|
||||||
|
)
|
||||||
|
db.add(material)
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_INGREDIENT,
|
||||||
|
entity_id=material.id,
|
||||||
|
action="created",
|
||||||
|
summary=f"Created ingredient “{material.name}”",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
|
||||||
|
db.refresh(material)
|
||||||
|
return _serialize_ingredient(material, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow)
|
||||||
|
def update_editor_ingredient(
|
||||||
|
ingredient_id: int,
|
||||||
|
payload: EditorIngredientUpdate,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = session.tenant_id or ""
|
||||||
|
material = db.scalar(
|
||||||
|
select(RawMaterial)
|
||||||
|
.where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)
|
||||||
|
.options(selectinload(RawMaterial.price_versions))
|
||||||
|
)
|
||||||
|
if material is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
if "name" in updates and updates["name"] is not None:
|
||||||
|
updates["name"] = updates["name"].strip()
|
||||||
|
if "supplier" in updates:
|
||||||
|
updates["supplier"] = (updates["supplier"] or "").strip() or None
|
||||||
|
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
|
||||||
|
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
|
||||||
|
if "category" in updates:
|
||||||
|
updates["category"] = (updates["category"] or "").strip() or None
|
||||||
|
before = {field: getattr(material, field) for field in updates}
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(material, field, value)
|
||||||
|
deltas = diff_fields(
|
||||||
|
before,
|
||||||
|
updates,
|
||||||
|
{
|
||||||
|
"name": "Name",
|
||||||
|
"supplier": "Supplier",
|
||||||
|
"unit_of_measure": "Unit of measure",
|
||||||
|
"kg_per_unit": "Kg per unit",
|
||||||
|
"status": "Status",
|
||||||
|
"rounding_decimals": "Rounding (dp)",
|
||||||
|
"category": "Category",
|
||||||
|
"notes": "Notes",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if deltas:
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_INGREDIENT,
|
||||||
|
entity_id=material.id,
|
||||||
|
action="updated",
|
||||||
|
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
|
||||||
|
changes=deltas,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
|
||||||
|
db.refresh(material)
|
||||||
|
usage = _ingredient_usage_counts(db, tenant_id)
|
||||||
|
return _serialize_ingredient(material, usage.get(material.id, 0))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead])
|
||||||
|
def get_editor_ingredient_history(
|
||||||
|
ingredient_id: int,
|
||||||
|
session: AuthSession = Depends(_require_editor_session),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = session.tenant_id or ""
|
||||||
|
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||||
|
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id)
|
||||||
|
return [_serialize_change_event(event) for event in events]
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Customer-facing B2B ordering API.
|
||||||
|
|
||||||
|
Every endpoint is gated by the ``ordering`` module and scoped to the caller's
|
||||||
|
own company (``tenant_id`` + ``client_account_id``). Customers can never see
|
||||||
|
another company's catalogue visibility, prices, or orders.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession, require_client_module_access
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.ordering import CatalogueProduct, Order, OrderLine
|
||||||
|
from app.schemas.ordering import (
|
||||||
|
DraftOrderCreate,
|
||||||
|
DraftOrderUpdate,
|
||||||
|
OrderLineInput,
|
||||||
|
OrderSubmitRequest,
|
||||||
|
)
|
||||||
|
from app.services import ordering_service as svc
|
||||||
|
from app.services.order_notifications import send_order_submitted_notifications
|
||||||
|
from app.services.order_pdf import OrderPdfUnavailableError, build_order_confirmation_pdf
|
||||||
|
from app.services.ordering_pricing import resolve_price
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/ordering", tags=["ordering"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _require_active_customer(db: Session, session: AuthSession):
|
||||||
|
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
|
||||||
|
svc.ensure_customer_active(account)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def _get_visible_product(db: Session, session: AuthSession, product_id: int) -> CatalogueProduct:
|
||||||
|
hidden = svc.visible_product_ids_for_customer(
|
||||||
|
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
|
||||||
|
)
|
||||||
|
product = db.scalar(
|
||||||
|
select(CatalogueProduct).where(
|
||||||
|
CatalogueProduct.id == product_id,
|
||||||
|
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
|
||||||
|
CatalogueProduct.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if product is None or product.id in hidden:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not available")
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def _load_own_order(db: Session, session: AuthSession, order_id: int) -> Order:
|
||||||
|
order = db.scalar(
|
||||||
|
select(Order)
|
||||||
|
.where(
|
||||||
|
Order.id == order_id,
|
||||||
|
Order.tenant_id == svc.ORDERING_TENANT,
|
||||||
|
Order.client_account_id == session.client_account_id,
|
||||||
|
)
|
||||||
|
.options(selectinload(Order.lines))
|
||||||
|
)
|
||||||
|
if order is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_lines(db: Session, session: AuthSession, order: Order, lines: list[OrderLineInput]) -> None:
|
||||||
|
"""Replace an order's lines with freshly priced lines (server-side pricing).
|
||||||
|
|
||||||
|
Validates product availability and minimum order quantity. Raises 422 on
|
||||||
|
invalid input.
|
||||||
|
"""
|
||||||
|
order.lines.clear()
|
||||||
|
db.flush()
|
||||||
|
for index, line_input in enumerate(lines):
|
||||||
|
product = _get_visible_product(db, session, line_input.product_id)
|
||||||
|
if line_input.quantity < product.min_order_quantity:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
f"{product.name}: minimum order quantity is "
|
||||||
|
f"{product.min_order_quantity:g} {product.unit_of_measure}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
resolution = resolve_price(
|
||||||
|
db,
|
||||||
|
client_account_id=session.client_account_id,
|
||||||
|
product=product,
|
||||||
|
quantity=line_input.quantity,
|
||||||
|
)
|
||||||
|
order.lines.append(
|
||||||
|
OrderLine(
|
||||||
|
tenant_id=svc.ORDERING_TENANT,
|
||||||
|
product_id=product.id,
|
||||||
|
product_name=product.name,
|
||||||
|
product_sku=product.sku,
|
||||||
|
quantity=line_input.quantity,
|
||||||
|
unit_price=resolution.unit_price,
|
||||||
|
requires_quote=resolution.requires_quote,
|
||||||
|
price_source=resolution.price_source,
|
||||||
|
price_rule_id=resolution.price_rule_id,
|
||||||
|
discount_percent=resolution.discount_percent,
|
||||||
|
sort_order=index,
|
||||||
|
notes=line_input.notes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
svc.recompute_order_totals(order)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Catalogue ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogue")
|
||||||
|
def list_catalogue(
|
||||||
|
category: str | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None),
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_active_customer(db, session)
|
||||||
|
products = svc.list_visible_products(
|
||||||
|
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
|
||||||
|
)
|
||||||
|
if category:
|
||||||
|
products = [p for p in products if p.category == category]
|
||||||
|
if q:
|
||||||
|
needle = q.lower()
|
||||||
|
products = [
|
||||||
|
p
|
||||||
|
for p in products
|
||||||
|
if needle in p.name.lower()
|
||||||
|
or needle in p.sku.lower()
|
||||||
|
or (p.description and needle in p.description.lower())
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
svc.serialize_product(p, db=db, client_account_id=session.client_account_id)
|
||||||
|
for p in products
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogue/{product_id}")
|
||||||
|
def get_catalogue_product(
|
||||||
|
product_id: int,
|
||||||
|
quantity: float = Query(default=1.0, gt=0),
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_active_customer(db, session)
|
||||||
|
product = _get_visible_product(db, session, product_id)
|
||||||
|
return svc.serialize_product(
|
||||||
|
product, db=db, client_account_id=session.client_account_id, quantity=quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Orders ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orders")
|
||||||
|
def list_orders(
|
||||||
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = (
|
||||||
|
select(Order)
|
||||||
|
.where(
|
||||||
|
Order.tenant_id == svc.ORDERING_TENANT,
|
||||||
|
Order.client_account_id == session.client_account_id,
|
||||||
|
)
|
||||||
|
.options(selectinload(Order.lines))
|
||||||
|
.order_by(Order.created_at.desc())
|
||||||
|
)
|
||||||
|
orders = db.scalars(stmt).all()
|
||||||
|
if status_filter == "draft":
|
||||||
|
orders = [o for o in orders if o.status == "draft"]
|
||||||
|
elif status_filter == "submitted":
|
||||||
|
orders = [o for o in orders if o.status != "draft"]
|
||||||
|
return [svc.serialize_order(o, for_admin=False) for o in orders]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orders/{order_id}")
|
||||||
|
def get_order(
|
||||||
|
order_id: int,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
order = _load_own_order(db, session, order_id)
|
||||||
|
return svc.serialize_order(order, for_admin=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orders", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_draft_order(
|
||||||
|
payload: DraftOrderCreate,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_active_customer(db, session)
|
||||||
|
order = Order(
|
||||||
|
tenant_id=svc.ORDERING_TENANT,
|
||||||
|
client_account_id=session.client_account_id,
|
||||||
|
status="draft",
|
||||||
|
created_by_user_id=session.user_id,
|
||||||
|
created_by_name=session.name,
|
||||||
|
purchase_order_number=payload.purchase_order_number,
|
||||||
|
delivery_notes=payload.delivery_notes,
|
||||||
|
requested_delivery_date=payload.requested_delivery_date,
|
||||||
|
fulfilment_method=payload.fulfilment_method,
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
|
db.flush()
|
||||||
|
_rebuild_lines(db, session, order, payload.lines)
|
||||||
|
svc.record_status_change(
|
||||||
|
db, order, to_status="draft", actor_type="customer", actor_name=session.name, note="Draft created"
|
||||||
|
)
|
||||||
|
svc.audit_order_event(
|
||||||
|
db, session=session, order=order, action="order.created", summary="Draft order created."
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return svc.serialize_order(order, for_admin=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/orders/{order_id}")
|
||||||
|
def update_draft_order(
|
||||||
|
order_id: int,
|
||||||
|
payload: DraftOrderUpdate,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
order = _load_own_order(db, session, order_id)
|
||||||
|
if order.status not in svc.CUSTOMER_EDITABLE_STATUSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="This order has been submitted and can no longer be edited. Ask an admin to reopen it.",
|
||||||
|
)
|
||||||
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
|
||||||
|
if field_name in changes:
|
||||||
|
setattr(order, field_name, changes[field_name])
|
||||||
|
if payload.lines is not None:
|
||||||
|
_rebuild_lines(db, session, order, payload.lines)
|
||||||
|
svc.audit_order_event(
|
||||||
|
db, session=session, order=order, action="order.updated", summary="Draft order updated."
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return svc.serialize_order(order, for_admin=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_draft_order(
|
||||||
|
order_id: int,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
order = _load_own_order(db, session, order_id)
|
||||||
|
if order.status != "draft":
|
||||||
|
raise HTTPException(status_code=409, detail="Only draft orders can be deleted")
|
||||||
|
db.delete(order)
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orders/{order_id}/submit")
|
||||||
|
def submit_order(
|
||||||
|
order_id: int,
|
||||||
|
payload: OrderSubmitRequest,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
account = _require_active_customer(db, session)
|
||||||
|
order = _load_own_order(db, session, order_id)
|
||||||
|
if order.status != "draft":
|
||||||
|
raise HTTPException(status_code=409, detail="Only draft orders can be submitted")
|
||||||
|
if not order.lines:
|
||||||
|
raise HTTPException(status_code=422, detail="Cannot submit an empty order")
|
||||||
|
|
||||||
|
# Apply any last-minute header changes supplied on submit.
|
||||||
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
|
||||||
|
if field_name in changes and changes[field_name] is not None:
|
||||||
|
setattr(order, field_name, changes[field_name])
|
||||||
|
|
||||||
|
# Required PO number, if the tenant configured it.
|
||||||
|
from app.services.order_notifications import get_or_create_settings
|
||||||
|
|
||||||
|
settings = get_or_create_settings(db, order.tenant_id)
|
||||||
|
if settings.require_po_number and not (order.purchase_order_number or "").strip():
|
||||||
|
raise HTTPException(status_code=422, detail="A purchase order number is required to submit this order")
|
||||||
|
|
||||||
|
# Re-resolve and freeze the exact price used at submission time.
|
||||||
|
line_inputs = [
|
||||||
|
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
|
||||||
|
for line in order.lines
|
||||||
|
]
|
||||||
|
_rebuild_lines(db, session, order, line_inputs)
|
||||||
|
|
||||||
|
order.order_number = svc.next_order_number(db, order.tenant_id)
|
||||||
|
order.submitted_at = datetime.utcnow()
|
||||||
|
svc.record_status_change(
|
||||||
|
db, order, to_status="submitted", actor_type="customer", actor_name=session.name, note="Submitted by customer"
|
||||||
|
)
|
||||||
|
svc.audit_order_event(
|
||||||
|
db, session=session, order=order, action="order.submitted",
|
||||||
|
summary=f"Order {order.order_number} submitted (subtotal ex GST {order.subtotal_ex_gst:.2f}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications = send_order_submitted_notifications(db, order)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
result = svc.serialize_order(order, for_admin=False)
|
||||||
|
result["notifications"] = [
|
||||||
|
{"channel": n.channel, "recipients": n.recipients, "delivered": n.delivered, "detail": n.detail}
|
||||||
|
for n in notifications
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orders/{order_id}/reorder", status_code=status.HTTP_201_CREATED)
|
||||||
|
def reorder(
|
||||||
|
order_id: int,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_active_customer(db, session)
|
||||||
|
source = _load_own_order(db, session, order_id)
|
||||||
|
new_order = Order(
|
||||||
|
tenant_id=svc.ORDERING_TENANT,
|
||||||
|
client_account_id=session.client_account_id,
|
||||||
|
status="draft",
|
||||||
|
created_by_user_id=session.user_id,
|
||||||
|
created_by_name=session.name,
|
||||||
|
fulfilment_method=source.fulfilment_method,
|
||||||
|
)
|
||||||
|
db.add(new_order)
|
||||||
|
db.flush()
|
||||||
|
line_inputs = [
|
||||||
|
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
|
||||||
|
for line in source.lines
|
||||||
|
]
|
||||||
|
# Skip lines whose product is no longer available rather than failing.
|
||||||
|
available: list[OrderLineInput] = []
|
||||||
|
for line_input in line_inputs:
|
||||||
|
product = db.scalar(
|
||||||
|
select(CatalogueProduct).where(
|
||||||
|
CatalogueProduct.id == line_input.product_id,
|
||||||
|
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
|
||||||
|
CatalogueProduct.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if product is not None and line_input.quantity >= product.min_order_quantity:
|
||||||
|
available.append(line_input)
|
||||||
|
_rebuild_lines(db, session, new_order, available)
|
||||||
|
svc.record_status_change(
|
||||||
|
db, new_order, to_status="draft", actor_type="customer", actor_name=session.name,
|
||||||
|
note=f"Reordered from {source.order_number or source.id}",
|
||||||
|
)
|
||||||
|
svc.audit_order_event(
|
||||||
|
db, session=session, order=new_order, action="order.reordered",
|
||||||
|
summary=f"Draft created by reordering from {source.order_number or source.id}.",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_order)
|
||||||
|
return svc.serialize_order(new_order, for_admin=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orders/{order_id}/confirmation.pdf")
|
||||||
|
def order_confirmation_pdf(
|
||||||
|
order_id: int,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
order = _load_own_order(db, session, order_id)
|
||||||
|
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
|
||||||
|
try:
|
||||||
|
pdf_bytes = build_order_confirmation_pdf(order, account)
|
||||||
|
except OrderPdfUnavailableError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
filename = f"order-{order.order_number or order.id}.pdf"
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession, require_client_module_access
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.product_costing import (
|
||||||
|
ProductCostBagInput,
|
||||||
|
ProductCostBaseInput,
|
||||||
|
ProductCostClientInput,
|
||||||
|
ProductCostFreightInput,
|
||||||
|
ProductCostItem,
|
||||||
|
ProductCostProcessInput,
|
||||||
|
)
|
||||||
|
from app.schemas.product_costing import (
|
||||||
|
ProductCostInputsRead,
|
||||||
|
ProductCostInputsUpdate,
|
||||||
|
ProductCostItemCreate,
|
||||||
|
ProductCostItemRead,
|
||||||
|
ProductCostItemUpdate,
|
||||||
|
ProductCostRecalculateAllRead,
|
||||||
|
)
|
||||||
|
from app.services.product_costing_service import (
|
||||||
|
BAG_INPUTS,
|
||||||
|
FREIGHT_INPUTS,
|
||||||
|
PROCESS_NAMES,
|
||||||
|
recalculate_all_product_cost_items,
|
||||||
|
recalculate_product_cost_item,
|
||||||
|
serialize_product_cost_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/product-costing", tags=["product-costing"])
|
||||||
|
|
||||||
|
|
||||||
|
def _load_item(db: Session, tenant_id: str, item_id: int) -> ProductCostItem | None:
|
||||||
|
return db.scalar(select(ProductCostItem).where(ProductCostItem.id == item_id, ProductCostItem.tenant_id == tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_inputs(db: Session, tenant_id: str) -> ProductCostBaseInput:
|
||||||
|
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
|
||||||
|
if base is None:
|
||||||
|
base = ProductCostBaseInput(tenant_id=tenant_id)
|
||||||
|
db.add(base)
|
||||||
|
db.flush()
|
||||||
|
for process_name in PROCESS_NAMES:
|
||||||
|
if db.scalar(select(ProductCostProcessInput.id).where(ProductCostProcessInput.tenant_id == tenant_id, ProductCostProcessInput.process_name == process_name)) is None:
|
||||||
|
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=process_name, cost_per_kg=0.0))
|
||||||
|
for key, label in BAG_INPUTS.items():
|
||||||
|
if db.scalar(select(ProductCostBagInput.id).where(ProductCostBagInput.tenant_id == tenant_id, ProductCostBagInput.input_key == key)) is None:
|
||||||
|
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
|
||||||
|
for key, label in FREIGHT_INPUTS.items():
|
||||||
|
if db.scalar(select(ProductCostFreightInput.id).where(ProductCostFreightInput.tenant_id == tenant_id, ProductCostFreightInput.input_key == key)) is None:
|
||||||
|
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
|
||||||
|
db.flush()
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_inputs(db: Session, tenant_id: str) -> dict:
|
||||||
|
base = _ensure_inputs(db, tenant_id)
|
||||||
|
return {
|
||||||
|
"base": {
|
||||||
|
"grading_per_tonne": base.grading_per_tonne,
|
||||||
|
"grading_per_kg": base.grading_per_kg,
|
||||||
|
"cracking_per_tonne": base.cracking_per_tonne,
|
||||||
|
"cracking_per_kg": base.cracking_per_kg,
|
||||||
|
},
|
||||||
|
"processes": [
|
||||||
|
{"key": row.process_name, "label": row.process_name, "cost": row.cost_per_kg}
|
||||||
|
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id).order_by(ProductCostProcessInput.process_name)).all()
|
||||||
|
],
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"client_category": row.client_category,
|
||||||
|
"distributor_margin": row.distributor_margin,
|
||||||
|
"wholesale_margin": row.wholesale_margin,
|
||||||
|
}
|
||||||
|
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id).order_by(ProductCostClientInput.client_category)).all()
|
||||||
|
],
|
||||||
|
"bags": [
|
||||||
|
{"key": row.input_key, "label": row.label, "cost": row.cost}
|
||||||
|
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id).order_by(ProductCostBagInput.input_key)).all()
|
||||||
|
],
|
||||||
|
"freight": [
|
||||||
|
{"key": row.input_key, "label": row.label, "cost": row.cost}
|
||||||
|
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id).order_by(ProductCostFreightInput.input_key)).all()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[ProductCostItemRead])
|
||||||
|
def list_product_cost_items(
|
||||||
|
q: str | None = Query(default=None),
|
||||||
|
client_category: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=250, ge=1, le=1000),
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
statement = select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)
|
||||||
|
if client_category:
|
||||||
|
statement = statement.where(ProductCostItem.client_category == client_category)
|
||||||
|
if q:
|
||||||
|
term = f"%{q}%"
|
||||||
|
statement = statement.where(
|
||||||
|
or_(
|
||||||
|
ProductCostItem.client_category.ilike(term),
|
||||||
|
ProductCostItem.item_id.ilike(term),
|
||||||
|
ProductCostItem.product_name.ilike(term),
|
||||||
|
ProductCostItem.mix_product_name.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items = db.scalars(statement.order_by(ProductCostItem.client_category, ProductCostItem.product_name).limit(limit)).all()
|
||||||
|
return [serialize_product_cost_item(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items", response_model=ProductCostItemRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_product_cost_item(
|
||||||
|
payload: ProductCostItemCreate,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
item = ProductCostItem(tenant_id=session.tenant_id or "default", **payload.model_dump())
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
recalculate_product_cost_item(db, item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return serialize_product_cost_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}", response_model=ProductCostItemRead)
|
||||||
|
def get_product_cost_item(
|
||||||
|
item_id: int,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
item = _load_item(db, session.tenant_id or "default", item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product cost item not found")
|
||||||
|
return serialize_product_cost_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}", response_model=ProductCostItemRead)
|
||||||
|
def update_product_cost_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: ProductCostItemUpdate,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
item = _load_item(db, session.tenant_id or "default", item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product cost item not found")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(item, field, value)
|
||||||
|
recalculate_product_cost_item(db, item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return serialize_product_cost_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/recalculate", response_model=ProductCostItemRead)
|
||||||
|
def recalculate_one(
|
||||||
|
item_id: int,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
item = _load_item(db, session.tenant_id or "default", item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product cost item not found")
|
||||||
|
recalculate_product_cost_item(db, item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return serialize_product_cost_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recalculate-all", response_model=ProductCostRecalculateAllRead)
|
||||||
|
def recalculate_all(
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
count = recalculate_all_product_cost_items(db, session.tenant_id or "default")
|
||||||
|
db.commit()
|
||||||
|
return {"recalculated": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inputs", response_model=ProductCostInputsRead)
|
||||||
|
def get_product_cost_inputs(
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
return _serialize_inputs(db, session.tenant_id or "default")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/inputs", response_model=ProductCostInputsRead)
|
||||||
|
def update_product_cost_inputs(
|
||||||
|
payload: ProductCostInputsUpdate,
|
||||||
|
session: AuthSession = Depends(require_client_module_access("products", "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = session.tenant_id or "default"
|
||||||
|
base = _ensure_inputs(db, tenant_id)
|
||||||
|
if payload.base is not None:
|
||||||
|
for field, value in payload.base.model_dump().items():
|
||||||
|
setattr(base, field, value)
|
||||||
|
|
||||||
|
if payload.processes is not None:
|
||||||
|
existing = {row.process_name: row for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()}
|
||||||
|
for row in payload.processes:
|
||||||
|
target = existing.get(row.key)
|
||||||
|
if target is None:
|
||||||
|
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=row.key, cost_per_kg=row.cost))
|
||||||
|
else:
|
||||||
|
target.cost_per_kg = row.cost
|
||||||
|
|
||||||
|
if payload.clients is not None:
|
||||||
|
existing = {row.client_category: row for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()}
|
||||||
|
for row in payload.clients:
|
||||||
|
target = existing.get(row.client_category)
|
||||||
|
if target is None:
|
||||||
|
db.add(ProductCostClientInput(tenant_id=tenant_id, client_category=row.client_category, distributor_margin=row.distributor_margin, wholesale_margin=row.wholesale_margin))
|
||||||
|
else:
|
||||||
|
target.distributor_margin = row.distributor_margin
|
||||||
|
target.wholesale_margin = row.wholesale_margin
|
||||||
|
|
||||||
|
if payload.bags is not None:
|
||||||
|
existing = {row.input_key: row for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()}
|
||||||
|
for row in payload.bags:
|
||||||
|
target = existing.get(row.key)
|
||||||
|
if target is None:
|
||||||
|
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
|
||||||
|
else:
|
||||||
|
target.label = row.label
|
||||||
|
target.cost = row.cost
|
||||||
|
|
||||||
|
if payload.freight is not None:
|
||||||
|
existing = {row.input_key: row for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()}
|
||||||
|
for row in payload.freight:
|
||||||
|
target = existing.get(row.key)
|
||||||
|
if target is None:
|
||||||
|
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
|
||||||
|
else:
|
||||||
|
target.label = row.label
|
||||||
|
target.cost = row.cost
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
recalculate_all_product_cost_items(db, tenant_id)
|
||||||
|
db.commit()
|
||||||
|
return _serialize_inputs(db, tenant_id)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Read-only external data API (`/api/v1`).
|
||||||
|
|
||||||
|
A deliberately simple, API-key authenticated surface for Power BI (and any other
|
||||||
|
external reporting tool). It is intentionally separate from the cookie/JWT
|
||||||
|
session model used by the operator frontend: external tools cannot hold a
|
||||||
|
browser session, so they present a single static key instead.
|
||||||
|
|
||||||
|
Authentication: send the key either as an ``X-API-Key`` request header or an
|
||||||
|
``api_key`` query-string parameter (Power BI's Web connector supports both).
|
||||||
|
The key is configured via the ``POWERBI_API_KEY`` environment variable; when it
|
||||||
|
is blank the whole API is disabled and every request returns 503.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security_logging import log_security_event
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.throughput import ProductionThroughput
|
||||||
|
from app.services.throughput_service import serialize_entry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["public-v1"])
|
||||||
|
|
||||||
|
_API_KEY_HEADER = "X-API-Key"
|
||||||
|
|
||||||
|
|
||||||
|
def require_powerbi_api_key(request: Request) -> str:
|
||||||
|
"""Authorize an external request via the static Power BI API key.
|
||||||
|
|
||||||
|
Returns the tenant the caller may read. Raises 503 when the API is not
|
||||||
|
configured, or 401 when the key is missing/incorrect.
|
||||||
|
"""
|
||||||
|
configured = settings.powerbi_api_key
|
||||||
|
if not configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="The data API is not configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or ""
|
||||||
|
# Constant-time comparison so the endpoint does not leak key length/contents
|
||||||
|
# through response timing.
|
||||||
|
if not presented or not secrets.compare_digest(presented, configured):
|
||||||
|
log_security_event("authz.denied", role="powerbi", reason="invalid_api_key")
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
|
||||||
|
|
||||||
|
return settings.powerbi_tenant_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/throughput")
|
||||||
|
def list_throughput(
|
||||||
|
date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."),
|
||||||
|
date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."),
|
||||||
|
limit: int = Query(default=5000, ge=1, le=50000),
|
||||||
|
tenant_id: str = Depends(require_powerbi_api_key),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Flat list of production throughput entries for Power BI.
|
||||||
|
|
||||||
|
One row per packing run, oldest first so incremental refreshes append
|
||||||
|
naturally. Each row carries the same fields the operator UI shows
|
||||||
|
(date, product, bag size, quantity, calculated kg, QA flags, staff, notes).
|
||||||
|
"""
|
||||||
|
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id)
|
||||||
|
if date_from is not None:
|
||||||
|
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
|
||||||
|
stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit)
|
||||||
|
|
||||||
|
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -10,19 +10,25 @@ 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.throughput import ProductionThroughput, ThroughputProduct
|
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||||
from app.schemas.throughput import (
|
from app.schemas.throughput import (
|
||||||
|
ThroughputDeleteAllResult,
|
||||||
ThroughputEntryCreate,
|
ThroughputEntryCreate,
|
||||||
ThroughputEntryRead,
|
ThroughputEntryRead,
|
||||||
ThroughputEntryUpdate,
|
ThroughputEntryUpdate,
|
||||||
|
ThroughputImportResult,
|
||||||
ThroughputProductCreate,
|
ThroughputProductCreate,
|
||||||
ThroughputProductRead,
|
ThroughputProductRead,
|
||||||
ThroughputProductUpdate,
|
ThroughputProductUpdate,
|
||||||
)
|
)
|
||||||
from app.services.throughput_service import (
|
from app.services.throughput_service import (
|
||||||
calculate_kg,
|
calculate_kg,
|
||||||
|
import_entries_from_file,
|
||||||
normalise_staff_name,
|
normalise_staff_name,
|
||||||
serialize_entry,
|
serialize_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Uploaded files larger than this are rejected before we read them into memory.
|
||||||
|
_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
|
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
|
||||||
|
|
||||||
MODULE_KEY = "operations_throughput"
|
MODULE_KEY = "operations_throughput"
|
||||||
@@ -161,6 +167,10 @@ def create_entry(
|
|||||||
label_correct=payload.label_correct,
|
label_correct=payload.label_correct,
|
||||||
bag_sealed=payload.bag_sealed,
|
bag_sealed=payload.bag_sealed,
|
||||||
pallet_good_condition=payload.pallet_good_condition,
|
pallet_good_condition=payload.pallet_good_condition,
|
||||||
|
for_order=payload.for_order,
|
||||||
|
for_stock=payload.for_stock,
|
||||||
|
job_number=payload.job_number,
|
||||||
|
stock_quantity=payload.stock_quantity if payload.for_stock else None,
|
||||||
sample_box_no=payload.sample_box_no,
|
sample_box_no=payload.sample_box_no,
|
||||||
test_weight_1=payload.test_weight_1,
|
test_weight_1=payload.test_weight_1,
|
||||||
test_weight_2=payload.test_weight_2,
|
test_weight_2=payload.test_weight_2,
|
||||||
@@ -180,6 +190,48 @@ def create_entry(
|
|||||||
return serialize_entry(entry)
|
return serialize_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=ThroughputImportResult)
|
||||||
|
def import_entries(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
content = file.file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="The uploaded file is empty.")
|
||||||
|
if len(content) > _MAX_IMPORT_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="File is too large. Keep uploads under 10 MB.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = import_entries_from_file(
|
||||||
|
db,
|
||||||
|
filename=file.filename or "upload.csv",
|
||||||
|
content=content,
|
||||||
|
tenant_id=session.tenant_id,
|
||||||
|
created_by=session.email,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/entries", response_model=ThroughputDeleteAllResult)
|
||||||
|
def delete_all_entries(
|
||||||
|
# Clearing the log is part of correcting a bad import, so it sits at the same
|
||||||
|
# "edit" level as deleting a single entry. It is scoped to the caller's
|
||||||
|
# tenant, so one client can never wipe another's data.
|
||||||
|
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
deleted = db.query(ProductionThroughput).filter(
|
||||||
|
ProductionThroughput.tenant_id == session.tenant_id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return ThroughputDeleteAllResult(entries_deleted=deleted)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
|
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
|
||||||
def get_entry(
|
def get_entry(
|
||||||
entry_id: int,
|
entry_id: int,
|
||||||
@@ -229,7 +281,10 @@ def update_entry(
|
|||||||
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_entry(
|
def delete_entry(
|
||||||
entry_id: int,
|
entry_id: int,
|
||||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")),
|
# Correcting a mistaken run is part of day-to-day operating, so deleting an
|
||||||
|
# entry sits at the same "edit" level as adding/editing one. (No throughput
|
||||||
|
# role is granted "manage", so requiring it here would 403 everyone.)
|
||||||
|
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
entry = db.scalar(
|
entry = db.scalar(
|
||||||
|
|||||||
@@ -53,10 +53,15 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
|
|||||||
"edit_mixes": ("mix_master", "edit"),
|
"edit_mixes": ("mix_master", "edit"),
|
||||||
"view_throughput": ("operations_throughput", "view"),
|
"view_throughput": ("operations_throughput", "view"),
|
||||||
"edit_throughput": ("operations_throughput", "edit"),
|
"edit_throughput": ("operations_throughput", "edit"),
|
||||||
# Admin-only permissions (view_users, manage_users, manage_permissions,
|
"view_ordering": ("ordering", "view"),
|
||||||
# view_settings, edit_settings) are intentionally excluded — they don't
|
"edit_ordering": ("ordering", "edit"),
|
||||||
# correspond to any of the legacy module keys and remain accessible only
|
"manage_ordering": ("ordering", "manage"),
|
||||||
# via the explicit `require_permission(...)` dependency.
|
"view_scenarios": ("scenarios", "view"),
|
||||||
|
"edit_scenarios": ("scenarios", "edit"),
|
||||||
|
"manage_client_access": ("client_access", "manage"),
|
||||||
|
# User/role/settings permissions are intentionally excluded — they don't
|
||||||
|
# correspond to legacy module keys and remain accessible only via the
|
||||||
|
# explicit `require_permission(...)` dependency.
|
||||||
}
|
}
|
||||||
|
|
||||||
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
|
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
|
||||||
|
|||||||
@@ -58,11 +58,17 @@ class Settings:
|
|||||||
login_rate_limit_window_seconds: int
|
login_rate_limit_window_seconds: int
|
||||||
trusted_hosts: tuple[str, ...]
|
trusted_hosts: tuple[str, ...]
|
||||||
docs_enabled: bool
|
docs_enabled: bool
|
||||||
|
# Static API key for the read-only Power BI / external data API (`/api/v1`).
|
||||||
|
# Blank disables the API entirely (every request returns 503).
|
||||||
|
powerbi_api_key: str
|
||||||
|
# Tenant the Power BI API reads from. Defaults to the costing client tenant
|
||||||
|
# (where internal staff store throughput), so a single key serves Irwin.
|
||||||
|
powerbi_tenant_id: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
settings = cls(
|
settings = cls(
|
||||||
app_name=os.getenv("APP_NAME", "Data Entry App API"),
|
app_name=os.getenv("APP_NAME", "Hunter App"),
|
||||||
app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")),
|
app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")),
|
||||||
host=os.getenv("HOST", "0.0.0.0"),
|
host=os.getenv("HOST", "0.0.0.0"),
|
||||||
port=int(os.getenv("PORT", "8000")),
|
port=int(os.getenv("PORT", "8000")),
|
||||||
@@ -98,6 +104,8 @@ class Settings:
|
|||||||
login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")),
|
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")),
|
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"),
|
docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"),
|
||||||
|
powerbi_api_key=os.getenv("POWERBI_API_KEY", "").strip(),
|
||||||
|
powerbi_tenant_id=os.getenv("POWERBI_TENANT_ID", os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce")).strip(),
|
||||||
)
|
)
|
||||||
settings._validate()
|
settings._validate()
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -29,13 +29,35 @@ TENANT_TABLES = {
|
|||||||
"mix_calculator_sessions": None,
|
"mix_calculator_sessions": None,
|
||||||
"mix_calculator_session_lines": None,
|
"mix_calculator_session_lines": None,
|
||||||
"products": None,
|
"products": None,
|
||||||
|
"product_cost_items": None,
|
||||||
|
"product_cost_base_inputs": None,
|
||||||
|
"product_cost_process_inputs": None,
|
||||||
|
"product_cost_client_inputs": None,
|
||||||
|
"product_cost_bag_inputs": None,
|
||||||
|
"product_cost_freight_inputs": None,
|
||||||
"scenarios": None,
|
"scenarios": None,
|
||||||
"costing_results": None,
|
"costing_results": None,
|
||||||
|
"editor_change_events": None,
|
||||||
"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,
|
"throughput_products": None,
|
||||||
"production_throughput_entries": None,
|
"production_throughput_entries": None,
|
||||||
|
# B2B ordering portal
|
||||||
|
"product_categories": None,
|
||||||
|
"catalogue_products": None,
|
||||||
|
"customer_product_visibility": None,
|
||||||
|
"price_lists": None,
|
||||||
|
"price_list_items": None,
|
||||||
|
"customer_price_assignments": None,
|
||||||
|
"customer_product_prices": None,
|
||||||
|
"price_tiers": None,
|
||||||
|
"orders": None,
|
||||||
|
"order_lines": None,
|
||||||
|
"order_status_history": None,
|
||||||
|
"order_attachments": None,
|
||||||
|
"notification_settings": None,
|
||||||
|
"xero_sync_log": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,9 +66,15 @@ class MigrationReport:
|
|||||||
created_tables: tuple[str, ...] = ()
|
created_tables: tuple[str, ...] = ()
|
||||||
added_columns: tuple[str, ...] = ()
|
added_columns: tuple[str, ...] = ()
|
||||||
synced_tenant_rows: dict[str, int] = field(default_factory=dict)
|
synced_tenant_rows: dict[str, int] = field(default_factory=dict)
|
||||||
|
resynced_sequences: tuple[str, ...] = ()
|
||||||
|
|
||||||
def has_changes(self) -> bool:
|
def has_changes(self) -> bool:
|
||||||
return bool(self.created_tables or self.added_columns or self.synced_tenant_rows)
|
return bool(
|
||||||
|
self.created_tables
|
||||||
|
or self.added_columns
|
||||||
|
or self.synced_tenant_rows
|
||||||
|
or self.resynced_sequences
|
||||||
|
)
|
||||||
|
|
||||||
def summary(self) -> str:
|
def summary(self) -> str:
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
@@ -57,6 +85,8 @@ class MigrationReport:
|
|||||||
if self.synced_tenant_rows:
|
if self.synced_tenant_rows:
|
||||||
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
|
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
|
||||||
parts.append(f"synced tenant rows: {counts}")
|
parts.append(f"synced tenant rows: {counts}")
|
||||||
|
if self.resynced_sequences:
|
||||||
|
parts.append(f"resynced sequences: {', '.join(self.resynced_sequences)}")
|
||||||
return "; ".join(parts) if parts else "schema already up to date"
|
return "; ".join(parts) if parts else "schema already up to date"
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +133,14 @@ _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"),
|
("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
||||||
("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
||||||
|
("throughput_products", "client_name", "VARCHAR(255)"),
|
||||||
|
("production_throughput_entries", "for_order", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||||
|
("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||||
|
("production_throughput_entries", "job_number", "VARCHAR(64)"),
|
||||||
|
("production_throughput_entries", "stock_quantity", "FLOAT"),
|
||||||
|
("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
|
||||||
|
("raw_materials", "category", "VARCHAR(128)"),
|
||||||
|
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +444,58 @@ def sync_product_visibility(engine: Engine) -> int:
|
|||||||
return result.rowcount or 0
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def resync_identity_sequences(engine: Engine) -> tuple[str, ...]:
|
||||||
|
"""Realign Postgres identity sequences with each table's current MAX(id).
|
||||||
|
|
||||||
|
After a bulk import that carries original primary keys across (the SQLite →
|
||||||
|
Postgres migration inserts rows with their existing ids), every table's
|
||||||
|
sequence still points at its starting value. The next INSERT then reuses an
|
||||||
|
id that already exists and fails with ``duplicate key value violates unique
|
||||||
|
constraint`` — which is why creating a new mix/ingredient/product saved fine
|
||||||
|
on SQLite but not on production Postgres.
|
||||||
|
|
||||||
|
This advances each ``id`` sequence to MAX(id) so the next INSERT continues
|
||||||
|
cleanly. It is a no-op on SQLite and idempotent on Postgres, so it is safe to
|
||||||
|
run on every startup. A per-table failure is skipped rather than aborting the
|
||||||
|
whole boot.
|
||||||
|
"""
|
||||||
|
if engine.dialect.name != "postgresql":
|
||||||
|
return ()
|
||||||
|
|
||||||
|
resynced: list[str] = []
|
||||||
|
inspector = inspect(engine)
|
||||||
|
with engine.begin() as connection:
|
||||||
|
for table_name in inspector.get_table_names():
|
||||||
|
if not any(column["name"] == "id" for column in inspector.get_columns(table_name)):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sequence = connection.execute(
|
||||||
|
text("SELECT pg_get_serial_sequence(:table, 'id')"),
|
||||||
|
{"table": table_name},
|
||||||
|
).scalar()
|
||||||
|
if not sequence:
|
||||||
|
continue
|
||||||
|
max_id = connection.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
|
||||||
|
if max_id is None:
|
||||||
|
continue
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT setval(:sequence, :value, true)"),
|
||||||
|
{"sequence": sequence, "value": int(max_id)},
|
||||||
|
)
|
||||||
|
resynced.append(table_name)
|
||||||
|
except Exception:
|
||||||
|
# A single problematic table must not block startup; the others
|
||||||
|
# still get realigned.
|
||||||
|
continue
|
||||||
|
return tuple(resynced)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
|
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)
|
||||||
return MigrationReport(created_tables=created_tables, added_columns=added_columns)
|
resynced_sequences = resync_identity_sequences(engine)
|
||||||
|
return MigrationReport(
|
||||||
|
created_tables=created_tables,
|
||||||
|
added_columns=added_columns,
|
||||||
|
resynced_sequences=resynced_sequences,
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ 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
|
||||||
from app.api.dashboard import router as dashboard_router
|
from app.api.dashboard import router as dashboard_router
|
||||||
|
from app.api.editor import router as editor_router
|
||||||
from app.api.mix_calculator import router as mix_calculator_router
|
from app.api.mix_calculator import router as mix_calculator_router
|
||||||
from app.api.mixes import router as mixes_router
|
from app.api.mixes import router as mixes_router
|
||||||
|
from app.api.ordering import router as ordering_router
|
||||||
|
from app.api.ordering_admin import router as ordering_admin_router
|
||||||
from app.api.powerbi import router as powerbi_router
|
from app.api.powerbi import router as powerbi_router
|
||||||
|
from app.api.product_costing import router as product_costing_router
|
||||||
from app.api.products import router as products_router
|
from app.api.products import router as products_router
|
||||||
|
from app.api.public_v1 import router as public_v1_router
|
||||||
from app.api.raw_materials import router as raw_materials_router
|
from app.api.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.api.throughput import router as throughput_router
|
||||||
@@ -112,6 +117,7 @@ def ensure_database_ready() -> MigrationReport:
|
|||||||
**tenant_sync_report,
|
**tenant_sync_report,
|
||||||
**({"products_visibility": hidden_product_count} if hidden_product_count else {}),
|
**({"products_visibility": hidden_product_count} if hidden_product_count else {}),
|
||||||
},
|
},
|
||||||
|
resynced_sequences=schema_report.resynced_sequences,
|
||||||
)
|
)
|
||||||
logger.info("Database startup checks complete: %s", report.summary())
|
logger.info("Database startup checks complete: %s", report.summary())
|
||||||
_database_ready = True
|
_database_ready = True
|
||||||
@@ -194,13 +200,18 @@ app.include_router(auth_router)
|
|||||||
app.include_router(access_router)
|
app.include_router(access_router)
|
||||||
app.include_router(client_access_router)
|
app.include_router(client_access_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(editor_router)
|
||||||
app.include_router(raw_materials_router)
|
app.include_router(raw_materials_router)
|
||||||
app.include_router(mixes_router)
|
app.include_router(mixes_router)
|
||||||
app.include_router(mix_calculator_router)
|
app.include_router(mix_calculator_router)
|
||||||
|
app.include_router(product_costing_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(throughput_router)
|
||||||
|
app.include_router(ordering_router)
|
||||||
|
app.include_router(ordering_admin_router)
|
||||||
app.include_router(powerbi_router)
|
app.include_router(powerbi_router)
|
||||||
|
app.include_router(public_v1_router)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -262,6 +273,11 @@ async def enforce_request_limits_and_csrf(request: Request, call_next):
|
|||||||
"script-src 'self'; "
|
"script-src 'self'; "
|
||||||
"font-src 'self' data:; "
|
"font-src 'self' data:; "
|
||||||
"connect-src 'self'; "
|
"connect-src 'self'; "
|
||||||
|
# PDF previews/printing load a same-origin blob: URL into an iframe.
|
||||||
|
# Without an explicit frame-src/child-src these fall back to default-src
|
||||||
|
# ('self'), which blocks blob: and breaks the in-app print dialog.
|
||||||
|
"frame-src 'self' blob:; "
|
||||||
|
"child-src 'self' blob:; "
|
||||||
"frame-ancestors 'self'; "
|
"frame-ancestors 'self'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
"form-action 'self'"
|
"form-action 'self'"
|
||||||
@@ -307,6 +323,7 @@ def root():
|
|||||||
"scenarios": "/api/scenarios",
|
"scenarios": "/api/scenarios",
|
||||||
"operations_throughput": "/api/throughput",
|
"operations_throughput": "/api/throughput",
|
||||||
"client_access": "/api/client-access",
|
"client_access": "/api/client-access",
|
||||||
|
"powerbi_throughput": "/api/v1/throughput",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,62 @@
|
|||||||
from app.models.access import Permission, Role, User, role_permissions
|
from app.models.access import Permission, Role, User, role_permissions
|
||||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||||
|
from app.models.change_event import EditorChangeEvent
|
||||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
from app.models.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.ordering import (
|
||||||
|
CatalogueProduct,
|
||||||
|
CustomerPriceAssignment,
|
||||||
|
CustomerProductPrice,
|
||||||
|
CustomerProductVisibility,
|
||||||
|
NotificationSetting,
|
||||||
|
Order,
|
||||||
|
OrderAttachment,
|
||||||
|
OrderLine,
|
||||||
|
OrderStatusHistory,
|
||||||
|
PriceList,
|
||||||
|
PriceListItem,
|
||||||
|
PriceTier,
|
||||||
|
ProductCategory,
|
||||||
|
XeroSyncLog,
|
||||||
|
)
|
||||||
from app.models.product import Product, ProductIngredient
|
from app.models.product import Product, ProductIngredient
|
||||||
|
from app.models.product_costing import (
|
||||||
|
ProductCostBagInput,
|
||||||
|
ProductCostBaseInput,
|
||||||
|
ProductCostClientInput,
|
||||||
|
ProductCostFreightInput,
|
||||||
|
ProductCostItem,
|
||||||
|
ProductCostProcessInput,
|
||||||
|
)
|
||||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
from app.models.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
|
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CatalogueProduct",
|
||||||
"ClientAccount",
|
"ClientAccount",
|
||||||
"ClientAccessAuditEvent",
|
"ClientAccessAuditEvent",
|
||||||
"ClientFeatureAccess",
|
"ClientFeatureAccess",
|
||||||
"ClientUser",
|
"ClientUser",
|
||||||
"ClientUserModulePermission",
|
"ClientUserModulePermission",
|
||||||
"CostingResult",
|
"CostingResult",
|
||||||
|
"EditorChangeEvent",
|
||||||
|
"CustomerPriceAssignment",
|
||||||
|
"CustomerProductPrice",
|
||||||
|
"CustomerProductVisibility",
|
||||||
"FreightCostRule",
|
"FreightCostRule",
|
||||||
"Mix",
|
"Mix",
|
||||||
|
"NotificationSetting",
|
||||||
|
"Order",
|
||||||
|
"OrderAttachment",
|
||||||
|
"OrderLine",
|
||||||
|
"OrderStatusHistory",
|
||||||
|
"PriceList",
|
||||||
|
"PriceListItem",
|
||||||
|
"PriceTier",
|
||||||
|
"ProductCategory",
|
||||||
|
"XeroSyncLog",
|
||||||
"MixCalculatorSession",
|
"MixCalculatorSession",
|
||||||
"MixCalculatorSessionLine",
|
"MixCalculatorSessionLine",
|
||||||
"MixIngredient",
|
"MixIngredient",
|
||||||
@@ -24,6 +64,12 @@ __all__ = [
|
|||||||
"Permission",
|
"Permission",
|
||||||
"ProcessCostRule",
|
"ProcessCostRule",
|
||||||
"Product",
|
"Product",
|
||||||
|
"ProductCostBagInput",
|
||||||
|
"ProductCostBaseInput",
|
||||||
|
"ProductCostClientInput",
|
||||||
|
"ProductCostFreightInput",
|
||||||
|
"ProductCostItem",
|
||||||
|
"ProductCostProcessInput",
|
||||||
"ProductIngredient",
|
"ProductIngredient",
|
||||||
"ProductionThroughput",
|
"ProductionThroughput",
|
||||||
"ThroughputProduct",
|
"ThroughputProduct",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, JSON, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EditorChangeEvent(Base):
|
||||||
|
"""An audit row recording an edit to a mix or an ingredient.
|
||||||
|
|
||||||
|
Written by the editor API whenever a mix or raw material (ingredient) is
|
||||||
|
created or changed, and read back per-entity by the History buttons on the
|
||||||
|
Mix Editor and Ingredients Editor. `changes` holds a list of
|
||||||
|
``{"field", "label", "before", "after"}`` field deltas so the UI can show a
|
||||||
|
readable before/after for each edit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "editor_change_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
# "mix" or "ingredient" — the surface the History button lives on.
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||||
|
entity_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(48))
|
||||||
|
actor_name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
actor_email: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
actor_role: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
changes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
|
|||||||
required_kg: Mapped[float] = mapped_column(Float)
|
required_kg: Mapped[float] = mapped_column(Float)
|
||||||
mix_percentage: Mapped[float] = mapped_column(Float)
|
mix_percentage: Mapped[float] = mapped_column(Float)
|
||||||
unit: Mapped[str] = mapped_column(String(64))
|
unit: Mapped[str] = mapped_column(String(64))
|
||||||
|
# Snapshot of the ingredient's rounding setting at save time.
|
||||||
|
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
|
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""B2B ordering portal data model.
|
||||||
|
|
||||||
|
This module backs the private customer ordering portal. It deliberately reuses
|
||||||
|
the existing tenant/customer primitives rather than introducing parallel ones:
|
||||||
|
|
||||||
|
* A **customer/company** is an existing :class:`ClientAccount`.
|
||||||
|
* A **customer user** is an existing :class:`ClientUser` (owner/buyer/viewer/
|
||||||
|
accounts roles map onto the existing ``ordering`` module access levels).
|
||||||
|
|
||||||
|
Everything here is private: every row is tenant-scoped, and customer-facing
|
||||||
|
rows are additionally scoped to a ``client_account_id``. The catalogue itself is
|
||||||
|
global to the seller (one tenant), with per-customer visibility and pricing
|
||||||
|
layered on top.
|
||||||
|
|
||||||
|
Money is stored GST-exclusive (see ``CLAUDE.MD`` costing conventions) as floats,
|
||||||
|
matching the rest of the costing platform.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
# --- Controlled vocabularies -------------------------------------------------
|
||||||
|
# Plain string constants (not DB enums) keep SQLite migrations trivial and match
|
||||||
|
# the existing model style.
|
||||||
|
|
||||||
|
PRODUCT_CATEGORIES = (
|
||||||
|
"grains",
|
||||||
|
"premixed",
|
||||||
|
"bags",
|
||||||
|
"bulk_loads",
|
||||||
|
"custom_blends",
|
||||||
|
"services",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Full internal order lifecycle. Customers see a simplified subset (see
|
||||||
|
# ``CUSTOMER_VISIBLE_STATUS`` in the ordering service).
|
||||||
|
ORDER_STATUSES = (
|
||||||
|
"draft",
|
||||||
|
"submitted",
|
||||||
|
"under_review",
|
||||||
|
"confirmed",
|
||||||
|
"sent_to_xero",
|
||||||
|
"in_production",
|
||||||
|
"ready_for_pickup",
|
||||||
|
"dispatched",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
)
|
||||||
|
|
||||||
|
# How a resolved unit price was derived. Stored on each order line so future
|
||||||
|
# reporting can always explain which rule applied.
|
||||||
|
PRICE_SOURCES = (
|
||||||
|
"fixed", # CustomerProductPrice with rule_type fixed
|
||||||
|
"contract", # CustomerProductPrice with rule_type contract
|
||||||
|
"price_list", # PriceListItem via an assigned price list
|
||||||
|
"tiered", # a quantity tier won over the base rate
|
||||||
|
"base", # catalogue list price (optionally with customer discount)
|
||||||
|
"quote", # manual quote required; no automatic price
|
||||||
|
)
|
||||||
|
|
||||||
|
FULFILMENT_METHODS = ("delivery", "pickup")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategory(Base):
|
||||||
|
__tablename__ = "product_categories"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_product_category_slug"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogueProduct(Base):
|
||||||
|
"""A private catalogue product. Named ``CatalogueProduct`` to avoid clashing
|
||||||
|
with the existing costing ``Product`` (a separate concern)."""
|
||||||
|
|
||||||
|
__tablename__ = "catalogue_products"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "sku", name="uq_catalogue_product_sku"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
sku: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(64), default="grains", index=True)
|
||||||
|
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
unit_size: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
unit_of_measure: Mapped[str] = mapped_column(String(64), default="each")
|
||||||
|
min_order_quantity: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
|
# Base/list price, GST-exclusive. Used as the fallback price source and as
|
||||||
|
# the basis for a customer discount percentage.
|
||||||
|
base_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
stock_status: Mapped[str] = mapped_column(String(32), default="in_stock")
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerProductVisibility(Base):
|
||||||
|
"""Per-customer override of catalogue visibility.
|
||||||
|
|
||||||
|
Products are visible to every customer by default (the catalogue is
|
||||||
|
"standard globally"). A row here with ``visible=False`` hides a product from
|
||||||
|
one customer; ``visible=True`` is an explicit allow (no-op unless a future
|
||||||
|
default flips to opt-in).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "customer_product_visibility"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_visibility"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||||
|
visible: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceList(Base):
|
||||||
|
__tablename__ = "price_lists"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_price_list_code"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
items: Mapped[list["PriceListItem"]] = relationship(
|
||||||
|
back_populates="price_list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceListItem(Base):
|
||||||
|
__tablename__ = "price_list_items"
|
||||||
|
__table_args__ = (UniqueConstraint("price_list_id", "product_id", name="uq_price_list_item"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
price_list_id: Mapped[int] = mapped_column(ForeignKey("price_lists.id"), index=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||||
|
unit_price: Mapped[float] = mapped_column(Float)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
price_list: Mapped[PriceList] = relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerPriceAssignment(Base):
|
||||||
|
"""Links a customer to an assigned price list and a default discount."""
|
||||||
|
|
||||||
|
__tablename__ = "customer_price_assignments"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("client_account_id", name="uq_customer_price_assignment"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||||
|
price_list_id: Mapped[int | None] = mapped_column(ForeignKey("price_lists.id"), nullable=True, index=True)
|
||||||
|
# Default percentage discount applied to base prices for this customer when
|
||||||
|
# no more specific rule (fixed/contract/price-list) applies. 0..100.
|
||||||
|
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
price_list: Mapped[PriceList | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerProductPrice(Base):
|
||||||
|
"""A fixed or contract price for a specific customer + product. Highest
|
||||||
|
priority pricing rule."""
|
||||||
|
|
||||||
|
__tablename__ = "customer_product_prices"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_price"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||||
|
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
# "fixed" | "contract" | "quote" (quote forces a manual-quote workflow for
|
||||||
|
# this customer+product even if the product itself doesn't require one).
|
||||||
|
rule_type: Mapped[str] = mapped_column(String(32), default="fixed")
|
||||||
|
contract_reference: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceTier(Base):
|
||||||
|
"""Quantity break for a pricing source. Exactly one of
|
||||||
|
``customer_product_price_id`` / ``price_list_item_id`` is set."""
|
||||||
|
|
||||||
|
__tablename__ = "price_tiers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
customer_product_price_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("customer_product_prices.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
price_list_item_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("price_list_items.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
# Tier applies when ordered quantity >= min_quantity. The highest qualifying
|
||||||
|
# min_quantity wins.
|
||||||
|
min_quantity: Mapped[float] = mapped_column(Float)
|
||||||
|
unit_price: Mapped[float] = mapped_column(Float)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||||
|
# Human-friendly order reference, assigned on submit (e.g. ORD-000123).
|
||||||
|
order_number: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
|
||||||
|
|
||||||
|
# Who created / submitted it (ClientUser ids; nullable for admin-created).
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True)
|
||||||
|
created_by_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Customer-supplied order details.
|
||||||
|
purchase_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
delivery_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
requested_delivery_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
fulfilment_method: Mapped[str] = mapped_column(String(32), default="delivery")
|
||||||
|
|
||||||
|
# Admin/internal handling.
|
||||||
|
admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reopened: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Cached totals (authoritative figures live on lines; this is for listing).
|
||||||
|
subtotal_ex_gst: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Xero integration tracking.
|
||||||
|
xero_status: Mapped[str] = mapped_column(String(32), default="not_sent")
|
||||||
|
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
lines: Mapped[list["OrderLine"]] = relationship(
|
||||||
|
back_populates="order",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="OrderLine.sort_order",
|
||||||
|
)
|
||||||
|
status_history: Mapped[list["OrderStatusHistory"]] = relationship(
|
||||||
|
back_populates="order",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="OrderStatusHistory.created_at",
|
||||||
|
)
|
||||||
|
attachments: Mapped[list["OrderAttachment"]] = relationship(
|
||||||
|
back_populates="order",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderLine(Base):
|
||||||
|
__tablename__ = "order_lines"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||||
|
|
||||||
|
# Snapshot of product identity at order time (so later catalogue edits don't
|
||||||
|
# rewrite historical orders).
|
||||||
|
product_name: Mapped[str] = mapped_column(String(255))
|
||||||
|
product_sku: Mapped[str] = mapped_column(String(64))
|
||||||
|
|
||||||
|
quantity: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
|
|
||||||
|
# Authoritative unit price, GST-exclusive, computed by the backend pricing
|
||||||
|
# engine and frozen at submission. Null only while quote-only.
|
||||||
|
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
line_total: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Pricing provenance — enough to explain which rule applied in reporting.
|
||||||
|
price_source: Mapped[str] = mapped_column(String(32), default="base")
|
||||||
|
price_rule_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|
||||||
|
# Admin override of the resolved unit price before confirmation.
|
||||||
|
admin_override_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
admin_override_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
order: Mapped[Order] = relationship(back_populates="lines")
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatusHistory(Base):
|
||||||
|
__tablename__ = "order_status_history"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||||
|
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
to_status: Mapped[str] = mapped_column(String(32))
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(32), default="system")
|
||||||
|
actor_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
order: Mapped[Order] = relationship(back_populates="status_history")
|
||||||
|
|
||||||
|
|
||||||
|
class OrderAttachment(Base):
|
||||||
|
__tablename__ = "order_attachments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||||
|
# "purchase_order" | "confirmation_pdf" | "other"
|
||||||
|
kind: Mapped[str] = mapped_column(String(32), default="other")
|
||||||
|
filename: Mapped[str] = mapped_column(String(255))
|
||||||
|
content_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
order: Mapped[Order] = relationship(back_populates="attachments")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSetting(Base):
|
||||||
|
"""Per-tenant notification configuration for the ordering portal."""
|
||||||
|
|
||||||
|
__tablename__ = "notification_settings"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", name="uq_notification_settings_tenant"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
# Comma-separated internal recipients notified on new order submissions.
|
||||||
|
internal_recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
send_customer_confirmation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
require_po_number: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class XeroContactLink(Base):
|
||||||
|
"""Persistent link between a customer (:class:`ClientAccount`) and a Xero
|
||||||
|
contact.
|
||||||
|
|
||||||
|
Maintained from the Integrations console. Once a customer is linked, order
|
||||||
|
invoices reference the real Xero ``ContactID`` instead of falling back to the
|
||||||
|
client code — which is what lets Xero attach the invoice to the right
|
||||||
|
contact rather than creating a duplicate. One link per customer per tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "xero_contact_links"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "client_account_id", name="uq_xero_contact_link_customer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||||
|
xero_contact_id: Mapped[str] = mapped_column(String(128))
|
||||||
|
xero_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
xero_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# When the contact details were last reconciled with Xero (a future live
|
||||||
|
# sync can refresh name/email and stamp this).
|
||||||
|
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class XeroSyncLog(Base):
|
||||||
|
__tablename__ = "xero_sync_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||||
|
# "queued" | "success" | "failed"
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="queued")
|
||||||
|
request_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostItem(Base):
|
||||||
|
__tablename__ = "product_cost_items"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "item_id", name="uq_product_cost_item_tenant_item"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_category: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
item_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
product_name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
mix_product_name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
unit_type: Mapped[str] = mapped_column(String(32), default="Standard")
|
||||||
|
own_bag: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
unit_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
items_per_pallet: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
bagging_process: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
manual_distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
manual_wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
|
cleaned_product_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
grading_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
bagging_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
cracking_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
bag_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
freight_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
finished_product_delivered_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
distributor_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
wholesale_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
warnings: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostBaseInput(Base):
|
||||||
|
__tablename__ = "product_cost_base_inputs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", unique=True, index=True)
|
||||||
|
grading_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
grading_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
cracking_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
cracking_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostProcessInput(Base):
|
||||||
|
__tablename__ = "product_cost_process_inputs"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "process_name", name="uq_product_cost_process_tenant_name"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
process_name: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
cost_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostClientInput(Base):
|
||||||
|
__tablename__ = "product_cost_client_inputs"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "client_category", name="uq_product_cost_client_tenant_name"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_category: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostBagInput(Base):
|
||||||
|
__tablename__ = "product_cost_bag_inputs"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_bag_tenant_key"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
input_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(128))
|
||||||
|
cost: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostFreightInput(Base):
|
||||||
|
__tablename__ = "product_cost_freight_inputs"
|
||||||
|
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_freight_tenant_key"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
input_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(128))
|
||||||
|
cost: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
|
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@@ -18,6 +18,12 @@ class RawMaterial(Base):
|
|||||||
unit_of_measure: Mapped[str] = mapped_column(String(64))
|
unit_of_measure: Mapped[str] = mapped_column(String(64))
|
||||||
kg_per_unit: Mapped[float] = mapped_column(Float)
|
kg_per_unit: Mapped[float] = mapped_column(Float)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||||
|
# Manually-assigned grouping used to order ingredients in the Mix Calculator
|
||||||
|
# output (e.g. "Grains", "Additives"). Optional; uncategorised rows sort last.
|
||||||
|
category: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
# Decimal places this ingredient's required-kg is rounded to in the mix
|
||||||
|
# calculator output. Set per-ingredient from the Ingredients Editor.
|
||||||
|
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class ThroughputProduct(Base):
|
|||||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
default_bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
default_bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
@@ -48,6 +49,15 @@ class ProductionThroughput(Base):
|
|||||||
bag_sealed: 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)
|
pallet_good_condition: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Where the run is destined. A run can be for a client order, for stock, or
|
||||||
|
# split across both. job_number records the Order Circle job for the order
|
||||||
|
# portion; stock_quantity records how much of a split goes into stock (in the
|
||||||
|
# same unit as `quantity`).
|
||||||
|
for_order: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
for_stock: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
job_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
stock_quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
sample_box_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
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_1: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class EditorProductRow(BaseModel):
|
||||||
|
id: int
|
||||||
|
tenant_id: str
|
||||||
|
client_name: str
|
||||||
|
item_id: str | None
|
||||||
|
name: str
|
||||||
|
mix_id: int
|
||||||
|
mix_client_name: str
|
||||||
|
mix_name: str
|
||||||
|
sale_type: str
|
||||||
|
unit_of_measure: str
|
||||||
|
visible: bool
|
||||||
|
product_notes: str | None
|
||||||
|
mix_notes: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EditorProductUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
item_id: str | None = Field(default=None, max_length=128)
|
||||||
|
mix_id: int | None = None
|
||||||
|
sale_type: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
visible: bool | None = None
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
client_name: str = Field(min_length=1, max_length=255)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
# Toggling a mix's status fans out to the visibility of every product under it.
|
||||||
|
visible: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixRow(BaseModel):
|
||||||
|
id: int
|
||||||
|
tenant_id: str
|
||||||
|
client_name: str
|
||||||
|
name: str
|
||||||
|
# A mix reads as "Active" when at least one of its products is visible.
|
||||||
|
visible: bool
|
||||||
|
product_count: int
|
||||||
|
visible_product_count: int
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixIngredientRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
raw_material_id: int
|
||||||
|
raw_material_name: str
|
||||||
|
quantity_kg: float
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixFormulaRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
tenant_id: str
|
||||||
|
client_name: str
|
||||||
|
name: str
|
||||||
|
ingredients: list[EditorMixIngredientRead]
|
||||||
|
total_kg: float
|
||||||
|
|
||||||
|
|
||||||
|
class EditorResolvedMixIngredient(BaseModel):
|
||||||
|
raw_material_id: int
|
||||||
|
raw_material_name: str
|
||||||
|
quantity_kg: float
|
||||||
|
# This row's share of the mix total, matching the Mix Calculator.
|
||||||
|
mix_percentage: float
|
||||||
|
unit: str
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EditorResolvedMixFormula(BaseModel):
|
||||||
|
"""A mix formula resolved the way the Mix Calculator reads it.
|
||||||
|
|
||||||
|
`source` is `product` when the numbers come from a representative product's
|
||||||
|
own formula, or `mix` when they come from the shared mix master fallback.
|
||||||
|
The PUT endpoint writes back to whichever source produced these rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
tenant_id: str
|
||||||
|
client_name: str
|
||||||
|
name: str
|
||||||
|
source: str
|
||||||
|
product_id: int | None
|
||||||
|
ingredients: list[EditorResolvedMixIngredient]
|
||||||
|
total_kg: float
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixFormulaRowInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
raw_material_id: int
|
||||||
|
quantity_kg: float = Field(gt=0)
|
||||||
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixFormulaReplace(BaseModel):
|
||||||
|
"""Full replacement of a mix's formula in one save.
|
||||||
|
|
||||||
|
The frontend keeps kilograms as the canonical value (percentages are an
|
||||||
|
entry aid that resolve back to kg against the total), so the API only needs
|
||||||
|
the resolved kg per row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixIngredientCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
raw_material_id: int
|
||||||
|
quantity_kg: float = Field(gt=0)
|
||||||
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorMixIngredientUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
quantity_kg: float | None = Field(default=None, gt=0)
|
||||||
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorProductIngredientCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
raw_material_id: int
|
||||||
|
quantity_kg: float = Field(gt=0)
|
||||||
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorProductIngredientUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
quantity_kg: float | None = Field(default=None, gt=0)
|
||||||
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorProductIngredientRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
raw_material_id: int
|
||||||
|
raw_material_name: str
|
||||||
|
quantity_kg: float
|
||||||
|
sort_order: int
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EditorProductFormulaRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
tenant_id: str
|
||||||
|
client_name: str
|
||||||
|
name: str
|
||||||
|
mix_id: int
|
||||||
|
mix_name: str
|
||||||
|
ingredients: list[EditorProductIngredientRead]
|
||||||
|
total_kg: float
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ingredients (raw materials) catalogue -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class EditorIngredientRow(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
supplier: str | None
|
||||||
|
unit_of_measure: str
|
||||||
|
kg_per_unit: float
|
||||||
|
status: str
|
||||||
|
# Manual grouping used to order ingredients in the Mix Calculator output.
|
||||||
|
category: str | None
|
||||||
|
# Decimal places this ingredient is rounded to in the mix calculator output.
|
||||||
|
rounding_decimals: int
|
||||||
|
notes: str | None
|
||||||
|
cost_per_kg: float | None
|
||||||
|
# How many product/mix formulas currently reference this ingredient.
|
||||||
|
usage_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EditorIngredientCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
supplier: str | None = Field(default=None, max_length=255)
|
||||||
|
unit_of_measure: str = Field(min_length=1, max_length=64)
|
||||||
|
kg_per_unit: float = Field(gt=0)
|
||||||
|
status: str = Field(default="active", max_length=32)
|
||||||
|
rounding_decimals: int = Field(default=2, ge=0, le=6)
|
||||||
|
category: str | None = Field(default=None, max_length=128)
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorIngredientUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
supplier: str | None = Field(default=None, max_length=255)
|
||||||
|
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
kg_per_unit: float | None = Field(default=None, gt=0)
|
||||||
|
status: str | None = Field(default=None, max_length=32)
|
||||||
|
rounding_decimals: int | None = Field(default=None, ge=0, le=6)
|
||||||
|
category: str | None = Field(default=None, max_length=128)
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Change history ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class EditorChangeFieldDelta(BaseModel):
|
||||||
|
field: str
|
||||||
|
label: str
|
||||||
|
before: str | None = None
|
||||||
|
after: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EditorChangeEventRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
entity_type: str
|
||||||
|
entity_id: int
|
||||||
|
action: str
|
||||||
|
actor_name: str
|
||||||
|
actor_email: str
|
||||||
|
actor_role: str | None
|
||||||
|
summary: str
|
||||||
|
changes: list[EditorChangeFieldDelta]
|
||||||
|
created_at: datetime
|
||||||
@@ -26,6 +26,9 @@ class MixCalculatorSessionLineRead(BaseModel):
|
|||||||
required_kg: float
|
required_kg: float
|
||||||
mix_percentage: float
|
mix_percentage: float
|
||||||
unit: str
|
unit: str
|
||||||
|
rounding_decimals: int = 2
|
||||||
|
# Manual ingredient grouping used to order the calculator output.
|
||||||
|
category: str | None = None
|
||||||
sort_order: int
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""Request schemas for the B2B ordering portal.
|
||||||
|
|
||||||
|
Responses are returned as plain dicts (FastAPI encodes them); these models
|
||||||
|
constrain and validate *inputs* only.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from app.models.ordering import (
|
||||||
|
FULFILMENT_METHODS,
|
||||||
|
ORDER_STATUSES,
|
||||||
|
PRODUCT_CATEGORIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
_CATEGORY_SET = set(PRODUCT_CATEGORIES)
|
||||||
|
_STATUS_SET = set(ORDER_STATUSES)
|
||||||
|
_FULFILMENT_SET = set(FULFILMENT_METHODS)
|
||||||
|
_RULE_TYPES = {"fixed", "contract", "quote"}
|
||||||
|
_STOCK_STATUSES = {"in_stock", "low_stock", "out_of_stock", "made_to_order"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Customer ordering -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OrderLineInput(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
quantity: float = Field(gt=0)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DraftOrderCreate(BaseModel):
|
||||||
|
lines: list[OrderLineInput] = Field(default_factory=list)
|
||||||
|
purchase_order_number: str | None = None
|
||||||
|
delivery_notes: str | None = None
|
||||||
|
requested_delivery_date: datetime | None = None
|
||||||
|
fulfilment_method: str = "delivery"
|
||||||
|
|
||||||
|
@field_validator("fulfilment_method")
|
||||||
|
@classmethod
|
||||||
|
def _check_fulfilment(cls, value: str) -> str:
|
||||||
|
if value not in _FULFILMENT_SET:
|
||||||
|
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class DraftOrderUpdate(BaseModel):
|
||||||
|
lines: list[OrderLineInput] | None = None
|
||||||
|
purchase_order_number: str | None = None
|
||||||
|
delivery_notes: str | None = None
|
||||||
|
requested_delivery_date: datetime | None = None
|
||||||
|
fulfilment_method: str | None = None
|
||||||
|
|
||||||
|
@field_validator("fulfilment_method")
|
||||||
|
@classmethod
|
||||||
|
def _check_fulfilment(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and value not in _FULFILMENT_SET:
|
||||||
|
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class OrderSubmitRequest(BaseModel):
|
||||||
|
purchase_order_number: str | None = None
|
||||||
|
delivery_notes: str | None = None
|
||||||
|
requested_delivery_date: datetime | None = None
|
||||||
|
fulfilment_method: str | None = None
|
||||||
|
|
||||||
|
@field_validator("fulfilment_method")
|
||||||
|
@classmethod
|
||||||
|
def _check_fulfilment(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and value not in _FULFILMENT_SET:
|
||||||
|
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: catalogue --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(BaseModel):
|
||||||
|
slug: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
sort_order: int = 0
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
sort_order: int | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogueProductCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
sku: str = Field(min_length=1, max_length=64)
|
||||||
|
description: str | None = None
|
||||||
|
category: str = "grains"
|
||||||
|
image_url: str | None = None
|
||||||
|
unit_size: str | None = None
|
||||||
|
unit_of_measure: str = "each"
|
||||||
|
min_order_quantity: float = Field(default=1.0, ge=0)
|
||||||
|
base_price: float | None = Field(default=None, ge=0)
|
||||||
|
stock_status: str = "in_stock"
|
||||||
|
active: bool = True
|
||||||
|
requires_quote: bool = False
|
||||||
|
|
||||||
|
@field_validator("category")
|
||||||
|
@classmethod
|
||||||
|
def _check_category(cls, value: str) -> str:
|
||||||
|
if value not in _CATEGORY_SET:
|
||||||
|
raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("stock_status")
|
||||||
|
@classmethod
|
||||||
|
def _check_stock(cls, value: str) -> str:
|
||||||
|
if value not in _STOCK_STATUSES:
|
||||||
|
raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogueProductUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
sku: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
unit_size: str | None = None
|
||||||
|
unit_of_measure: str | None = None
|
||||||
|
min_order_quantity: float | None = Field(default=None, ge=0)
|
||||||
|
base_price: float | None = Field(default=None, ge=0)
|
||||||
|
stock_status: str | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
requires_quote: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("category")
|
||||||
|
@classmethod
|
||||||
|
def _check_category(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and value not in _CATEGORY_SET:
|
||||||
|
raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("stock_status")
|
||||||
|
@classmethod
|
||||||
|
def _check_stock(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and value not in _STOCK_STATUSES:
|
||||||
|
raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class VisibilityUpdate(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
visible: bool
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: pricing ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PriceTierInput(BaseModel):
|
||||||
|
min_quantity: float = Field(gt=0)
|
||||||
|
unit_price: float = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceListCreate(BaseModel):
|
||||||
|
code: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PriceListItemUpsert(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
unit_price: float = Field(ge=0)
|
||||||
|
tiers: list[PriceTierInput] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerPriceAssignmentUpsert(BaseModel):
|
||||||
|
price_list_id: int | None = None
|
||||||
|
discount_percent: float = Field(default=0.0, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerProductPriceUpsert(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
unit_price: float | None = Field(default=None, ge=0)
|
||||||
|
rule_type: str = "fixed"
|
||||||
|
contract_reference: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
active: bool = True
|
||||||
|
tiers: list[PriceTierInput] | None = None
|
||||||
|
|
||||||
|
@field_validator("rule_type")
|
||||||
|
@classmethod
|
||||||
|
def _check_rule(cls, value: str) -> str:
|
||||||
|
if value not in _RULE_TYPES:
|
||||||
|
raise ValueError(f"rule_type must be one of {sorted(_RULE_TYPES)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: orders -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatusUpdate(BaseModel):
|
||||||
|
to_status: str
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
@field_validator("to_status")
|
||||||
|
@classmethod
|
||||||
|
def _check_status(cls, value: str) -> str:
|
||||||
|
if value not in _STATUS_SET:
|
||||||
|
raise ValueError(f"to_status must be one of {sorted(_STATUS_SET)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class OrderLineOverride(BaseModel):
|
||||||
|
quantity: float | None = Field(default=None, gt=0)
|
||||||
|
unit_price: float | None = Field(default=None, ge=0)
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderAdminNotesUpdate(BaseModel):
|
||||||
|
admin_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReopenOrderRequest(BaseModel):
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: settings ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettingsUpdate(BaseModel):
|
||||||
|
internal_recipients: str | None = None
|
||||||
|
send_customer_confirmation: bool | None = None
|
||||||
|
require_po_number: bool | None = None
|
||||||
|
from_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: Xero integration -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class XeroContactLinkUpsert(BaseModel):
|
||||||
|
"""Link a customer to a Xero contact. ``xero_contact_id`` is the Xero
|
||||||
|
``ContactID`` (or, in stub mode, the deterministic stub id)."""
|
||||||
|
|
||||||
|
xero_contact_id: str = Field(min_length=1, max_length=128)
|
||||||
|
xero_contact_name: str | None = Field(default=None, max_length=255)
|
||||||
|
xero_contact_email: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: customers & users ------------------------------------------------
|
||||||
|
|
||||||
|
_CUSTOMER_STATUSES = {"active", "disabled"}
|
||||||
|
_ORDERING_USER_ROLES = {"owner", "buyer", "accounts", "viewer"}
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
client_code: str = Field(min_length=1, max_length=64)
|
||||||
|
tenant_id: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("status")
|
||||||
|
@classmethod
|
||||||
|
def _check_status(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and value not in _CUSTOMER_STATUSES:
|
||||||
|
raise ValueError(f"status must be one of {sorted(_CUSTOMER_STATUSES)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUserCreate(BaseModel):
|
||||||
|
full_name: str = Field(min_length=1, max_length=255)
|
||||||
|
email: str = Field(min_length=3, max_length=255)
|
||||||
|
role: str = "buyer"
|
||||||
|
|
||||||
|
@field_validator("role")
|
||||||
|
@classmethod
|
||||||
|
def _check_role(cls, value: str) -> str:
|
||||||
|
if value not in _ORDERING_USER_ROLES:
|
||||||
|
raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_email(cls, value: str) -> str:
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUserUpdate(BaseModel):
|
||||||
|
full_name: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
@field_validator("role")
|
||||||
|
@classmethod
|
||||||
|
def _check_role(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and value not in _ORDERING_USER_ROLES:
|
||||||
|
raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("status")
|
||||||
|
@classmethod
|
||||||
|
def _check_status(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and value not in {"active", "invited", "suspended"}:
|
||||||
|
raise ValueError("status must be one of ['active', 'invited', 'suspended']")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostItemBase(BaseModel):
|
||||||
|
client_category: str = Field(min_length=1, max_length=255)
|
||||||
|
item_id: str | None = Field(default=None, max_length=128)
|
||||||
|
product_name: str = Field(min_length=1, max_length=255)
|
||||||
|
mix_product_name: str = Field(min_length=1, max_length=255)
|
||||||
|
unit_type: str = "Standard"
|
||||||
|
own_bag: str | None = None
|
||||||
|
unit_kg: float | None = Field(default=None, gt=0)
|
||||||
|
items_per_pallet: int | None = Field(default=None, gt=0)
|
||||||
|
bagging_process: str | None = Field(default=None, max_length=128)
|
||||||
|
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
|
||||||
|
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostItemCreate(ProductCostItemBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostItemUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
client_category: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
item_id: str | None = Field(default=None, max_length=128)
|
||||||
|
product_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
mix_product_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
unit_type: str | None = None
|
||||||
|
own_bag: str | None = None
|
||||||
|
unit_kg: float | None = Field(default=None, gt=0)
|
||||||
|
items_per_pallet: int | None = Field(default=None, gt=0)
|
||||||
|
bagging_process: str | None = Field(default=None, max_length=128)
|
||||||
|
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
|
||||||
|
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostItemRead(ProductCostItemBase):
|
||||||
|
id: int
|
||||||
|
tenant_id: str
|
||||||
|
cleaned_product_cost_per_kg: float | None
|
||||||
|
grading_cost_per_kg: float | None
|
||||||
|
bagging_cost_per_kg: float | None
|
||||||
|
cracking_cost_per_kg: float | None
|
||||||
|
bag_cost_per_unit: float | None
|
||||||
|
freight_cost_per_unit: float | None
|
||||||
|
finished_product_delivered_cost: float | None
|
||||||
|
distributor_price: float | None
|
||||||
|
wholesale_price: float | None
|
||||||
|
warnings: list[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostBaseInputRead(BaseModel):
|
||||||
|
grading_per_tonne: float
|
||||||
|
grading_per_kg: float
|
||||||
|
cracking_per_tonne: float
|
||||||
|
cracking_per_kg: float
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostBaseInputUpdate(ProductCostBaseInputRead):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostNamedInputRead(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
cost: float
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostClientInputRead(BaseModel):
|
||||||
|
client_category: str
|
||||||
|
distributor_margin: float | None
|
||||||
|
wholesale_margin: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostInputsRead(BaseModel):
|
||||||
|
base: ProductCostBaseInputRead
|
||||||
|
processes: list[ProductCostNamedInputRead]
|
||||||
|
clients: list[ProductCostClientInputRead]
|
||||||
|
bags: list[ProductCostNamedInputRead]
|
||||||
|
freight: list[ProductCostNamedInputRead]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostInputsUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
base: ProductCostBaseInputUpdate | None = None
|
||||||
|
processes: list[ProductCostNamedInputRead] | None = None
|
||||||
|
clients: list[ProductCostClientInputRead] | None = None
|
||||||
|
bags: list[ProductCostNamedInputRead] | None = None
|
||||||
|
freight: list[ProductCostNamedInputRead] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCostRecalculateAllRead(BaseModel):
|
||||||
|
recalculated: int
|
||||||
@@ -13,6 +13,7 @@ class ThroughputProductBase(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
item_id: str | None = Field(default=None, max_length=64)
|
item_id: str | None = Field(default=None, max_length=64)
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
client_name: str | None = Field(default=None, max_length=255)
|
||||||
default_bag_size: float | None = Field(default=None, ge=0)
|
default_bag_size: float | None = Field(default=None, ge=0)
|
||||||
is_bulka_default: bool = False
|
is_bulka_default: bool = False
|
||||||
active: bool = True
|
active: bool = True
|
||||||
@@ -28,6 +29,7 @@ class ThroughputProductUpdate(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
item_id: str | None = Field(default=None, max_length=64)
|
item_id: str | None = Field(default=None, max_length=64)
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
client_name: str | None = Field(default=None, max_length=255)
|
||||||
default_bag_size: float | None = Field(default=None, ge=0)
|
default_bag_size: float | None = Field(default=None, ge=0)
|
||||||
is_bulka_default: bool | None = None
|
is_bulka_default: bool | None = None
|
||||||
active: bool | None = None
|
active: bool | None = None
|
||||||
@@ -53,6 +55,10 @@ class ThroughputEntryBase(BaseModel):
|
|||||||
label_correct: bool = True
|
label_correct: bool = True
|
||||||
bag_sealed: bool = True
|
bag_sealed: bool = True
|
||||||
pallet_good_condition: bool = True
|
pallet_good_condition: bool = True
|
||||||
|
for_order: bool = False
|
||||||
|
for_stock: bool = False
|
||||||
|
job_number: str | None = Field(default=None, max_length=64)
|
||||||
|
stock_quantity: float | None = Field(default=None, ge=0)
|
||||||
sample_box_no: str | None = Field(default=None, max_length=64)
|
sample_box_no: str | None = Field(default=None, max_length=64)
|
||||||
test_weight_1: float | None = Field(default=None, ge=0)
|
test_weight_1: float | None = Field(default=None, ge=0)
|
||||||
test_weight_2: float | None = Field(default=None, ge=0)
|
test_weight_2: float | None = Field(default=None, ge=0)
|
||||||
@@ -64,6 +70,14 @@ class ThroughputEntryBase(BaseModel):
|
|||||||
staff_name: str | None = Field(default=None, max_length=255)
|
staff_name: str | None = Field(default=None, max_length=255)
|
||||||
notes: str | None = Field(default=None, max_length=2000)
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
@field_validator("job_number")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_job_number(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
@field_validator("staff_name")
|
@field_validator("staff_name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_staff(cls, value: str | None) -> str | None:
|
def _normalize_staff(cls, value: str | None) -> str | None:
|
||||||
@@ -87,6 +101,10 @@ class ThroughputEntryUpdate(BaseModel):
|
|||||||
label_correct: bool | None = None
|
label_correct: bool | None = None
|
||||||
bag_sealed: bool | None = None
|
bag_sealed: bool | None = None
|
||||||
pallet_good_condition: bool | None = None
|
pallet_good_condition: bool | None = None
|
||||||
|
for_order: bool | None = None
|
||||||
|
for_stock: bool | None = None
|
||||||
|
job_number: str | None = Field(default=None, max_length=64)
|
||||||
|
stock_quantity: float | None = Field(default=None, ge=0)
|
||||||
sample_box_no: str | None = Field(default=None, max_length=64)
|
sample_box_no: str | None = Field(default=None, max_length=64)
|
||||||
test_weight_1: float | None = Field(default=None, ge=0)
|
test_weight_1: float | None = Field(default=None, ge=0)
|
||||||
test_weight_2: float | None = Field(default=None, ge=0)
|
test_weight_2: float | None = Field(default=None, ge=0)
|
||||||
@@ -99,6 +117,17 @@ class ThroughputEntryUpdate(BaseModel):
|
|||||||
notes: str | None = Field(default=None, max_length=2000)
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class ThroughputImportResult(BaseModel):
|
||||||
|
entries_imported: int
|
||||||
|
entries_skipped: int
|
||||||
|
products_created: int
|
||||||
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ThroughputDeleteAllResult(BaseModel):
|
||||||
|
entries_deleted: int
|
||||||
|
|
||||||
|
|
||||||
class ThroughputEntryRead(BaseModel):
|
class ThroughputEntryRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
@@ -110,6 +139,10 @@ class ThroughputEntryRead(BaseModel):
|
|||||||
label_correct: bool
|
label_correct: bool
|
||||||
bag_sealed: bool
|
bag_sealed: bool
|
||||||
pallet_good_condition: bool
|
pallet_good_condition: bool
|
||||||
|
for_order: bool
|
||||||
|
for_stock: bool
|
||||||
|
job_number: str | None
|
||||||
|
stock_quantity: float | None
|
||||||
sample_box_no: str | None
|
sample_box_no: str | None
|
||||||
test_weight_1: float | None
|
test_weight_1: float | None
|
||||||
test_weight_2: float | None
|
test_weight_2: float | None
|
||||||
|
|||||||
+285
-5
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.db.session import Base, SessionLocal, engine
|
from app.db.session import Base, SessionLocal, engine
|
||||||
@@ -16,12 +16,26 @@ 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 import Mix, MixIngredient
|
from app.models.mix import Mix, MixIngredient
|
||||||
from app.models.product import Product, ProductIngredient
|
from app.models.product import Product, ProductIngredient
|
||||||
|
from app.models.product_costing import (
|
||||||
|
ProductCostBagInput,
|
||||||
|
ProductCostBaseInput,
|
||||||
|
ProductCostClientInput,
|
||||||
|
ProductCostFreightInput,
|
||||||
|
ProductCostItem,
|
||||||
|
ProductCostProcessInput,
|
||||||
|
)
|
||||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||||
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
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 import_workbook as import_throughput_workbook
|
||||||
from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path
|
from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path
|
||||||
|
from app.services.product_costing_service import (
|
||||||
|
BAG_INPUTS,
|
||||||
|
FREIGHT_INPUTS,
|
||||||
|
PROCESS_NAMES,
|
||||||
|
recalculate_all_product_cost_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TENANT_ID = "hunter-premium-produce"
|
TENANT_ID = "hunter-premium-produce"
|
||||||
@@ -691,7 +705,36 @@ def _upsert_product_ingredients(
|
|||||||
for key, formula in product_ingredient_rows.items():
|
for key, formula in product_ingredient_rows.items():
|
||||||
matched_products = products_by_formula_key.get(key, [])
|
matched_products = products_by_formula_key.get(key, [])
|
||||||
if not matched_products:
|
if not matched_products:
|
||||||
continue
|
client_name, formula_name = key
|
||||||
|
mix_cache: dict[tuple[str, str], Mix] = {}
|
||||||
|
mix = _upsert_mix(
|
||||||
|
db,
|
||||||
|
client_name=client_name,
|
||||||
|
mix_name=formula_name,
|
||||||
|
ingredients=formula["ingredients"],
|
||||||
|
raw_material_map=raw_material_map,
|
||||||
|
mix_cache=mix_cache,
|
||||||
|
)
|
||||||
|
product = Product(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
client_name=client_name,
|
||||||
|
item_id=f"mix-calculator:{_slug(client_name, fallback='client')}:{_slug(formula_name, fallback='mix')}",
|
||||||
|
name=formula_name,
|
||||||
|
mix_id=mix.id,
|
||||||
|
sale_type="standard",
|
||||||
|
own_bag=True,
|
||||||
|
visible=True,
|
||||||
|
unit_of_measure="kg",
|
||||||
|
items_per_pallet=1,
|
||||||
|
bagging_process=None,
|
||||||
|
distributor_margin=None,
|
||||||
|
wholesale_margin=None,
|
||||||
|
notes="Seeded as a Mix Calculator source row from workbook formulas",
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
products_by_formula_key[key] = [product]
|
||||||
|
matched_products = [product]
|
||||||
|
|
||||||
for product in matched_products:
|
for product in matched_products:
|
||||||
existing_ingredients = {
|
existing_ingredients = {
|
||||||
@@ -726,6 +769,45 @@ def _upsert_product_ingredients(
|
|||||||
db.delete(ingredient)
|
db.delete(ingredient)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_product_ingredients_from_workbook(db) -> dict[str, int]:
|
||||||
|
"""Backfill row-specific product formulas for databases seeded before this table existed."""
|
||||||
|
try:
|
||||||
|
formula_workbook = _load_workbook("mix_quantites_per_client_per_pr")
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.info("Skipping product ingredient backfill because formula workbook is missing")
|
||||||
|
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
|
||||||
|
product_ingredient_rows = _read_product_ingredient_rows(formula_workbook)
|
||||||
|
if not product_ingredient_rows:
|
||||||
|
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
|
||||||
|
|
||||||
|
raw_material_map = {
|
||||||
|
material.name: material
|
||||||
|
for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == TENANT_ID)).all()
|
||||||
|
}
|
||||||
|
if not raw_material_map:
|
||||||
|
return {"formulas": len(product_ingredient_rows), "products_with_formulas": 0, "backfilled": 0}
|
||||||
|
|
||||||
|
had_product_ingredients = (
|
||||||
|
db.scalar(select(ProductIngredient.id).where(ProductIngredient.tenant_id == TENANT_ID).limit(1)) is not None
|
||||||
|
)
|
||||||
|
_upsert_product_ingredients(
|
||||||
|
db,
|
||||||
|
product_rows=[],
|
||||||
|
product_ingredient_rows=product_ingredient_rows,
|
||||||
|
raw_material_map=raw_material_map,
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
products_with_formulas = db.scalar(
|
||||||
|
select(func.count(func.distinct(ProductIngredient.product_id))).where(ProductIngredient.tenant_id == TENANT_ID)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"formulas": len(product_ingredient_rows),
|
||||||
|
"products_with_formulas": int(products_with_formulas or 0),
|
||||||
|
"backfilled": 0 if had_product_ingredients else int(products_with_formulas or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _infer_throughput_bag_size(product: Product) -> float | None:
|
def _infer_throughput_bag_size(product: Product) -> float | None:
|
||||||
if product.sale_type == "bulka":
|
if product.sale_type == "bulka":
|
||||||
return None
|
return None
|
||||||
@@ -794,15 +876,19 @@ def seed_throughput_products_from_costing(db) -> dict[str, int]:
|
|||||||
|
|
||||||
default_bag_size = _infer_throughput_bag_size(costing_product)
|
default_bag_size = _infer_throughput_bag_size(costing_product)
|
||||||
is_bulka_default = _infer_throughput_bulka_default(costing_product)
|
is_bulka_default = _infer_throughput_bulka_default(costing_product)
|
||||||
|
client_name = (costing_product.client_name or "").strip() or None
|
||||||
product = (by_item.get(item_id) if item_id else None) or by_name.get(name_key)
|
product = (by_item.get(item_id) if item_id else None) or by_name.get(name_key)
|
||||||
if product is None:
|
if product is None:
|
||||||
product = ThroughputProduct(
|
product = ThroughputProduct(
|
||||||
tenant_id=TENANT_ID,
|
tenant_id=TENANT_ID,
|
||||||
item_id=item_id,
|
item_id=item_id,
|
||||||
name=name,
|
name=name,
|
||||||
|
client_name=client_name,
|
||||||
default_bag_size=default_bag_size,
|
default_bag_size=default_bag_size,
|
||||||
is_bulka_default=is_bulka_default,
|
is_bulka_default=is_bulka_default,
|
||||||
active=costing_product.visible,
|
# Every costing SKU should be selectable in the throughput picker
|
||||||
|
# (the Client filter + search keep the long list manageable).
|
||||||
|
active=True,
|
||||||
is_stock_item=True,
|
is_stock_item=True,
|
||||||
notes="Seeded from costing products",
|
notes="Seeded from costing products",
|
||||||
)
|
)
|
||||||
@@ -830,8 +916,11 @@ def seed_throughput_products_from_costing(db) -> dict[str, int]:
|
|||||||
if product.is_bulka_default != is_bulka_default:
|
if product.is_bulka_default != is_bulka_default:
|
||||||
product.is_bulka_default = is_bulka_default
|
product.is_bulka_default = is_bulka_default
|
||||||
changed = True
|
changed = True
|
||||||
if product.active != costing_product.visible:
|
if product.client_name != client_name:
|
||||||
product.active = costing_product.visible
|
product.client_name = client_name
|
||||||
|
changed = True
|
||||||
|
if product.active is not True:
|
||||||
|
product.active = True
|
||||||
changed = True
|
changed = True
|
||||||
if product.is_stock_item is not True:
|
if product.is_stock_item is not True:
|
||||||
product.is_stock_item = True
|
product.is_stock_item = True
|
||||||
@@ -1014,12 +1103,203 @@ def seed_throughput_products(db):
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_type_from_product(product: Product) -> str:
|
||||||
|
sale_type = (product.sale_type or "").lower()
|
||||||
|
unit = (product.unit_of_measure or "").lower()
|
||||||
|
if sale_type == "bulka" or "bulka" in unit:
|
||||||
|
return "Bulka"
|
||||||
|
if "1.5kg" in unit or "1.5 kg" in unit:
|
||||||
|
return "1.5 kg"
|
||||||
|
if sale_type == "per_unit":
|
||||||
|
return "Per Unit"
|
||||||
|
return "Standard"
|
||||||
|
|
||||||
|
|
||||||
|
def _own_bag_label(product: Product) -> str | None:
|
||||||
|
if product.own_bag:
|
||||||
|
return "No Bag" if "no bag" in (product.unit_of_measure or "").lower() else "Yes"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def seed_product_costing_module(db) -> dict[str, int]:
|
||||||
|
tenant_id = TENANT_ID
|
||||||
|
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
|
||||||
|
if base is None:
|
||||||
|
process_rules = db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
|
||||||
|
grading_per_kg = max((rule.grading_cost for rule in process_rules), default=0.0)
|
||||||
|
cracking_per_kg = max((rule.cracking_cost for rule in process_rules), default=0.0)
|
||||||
|
base = ProductCostBaseInput(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
grading_per_tonne=round(grading_per_kg * 1000, 4),
|
||||||
|
grading_per_kg=round(grading_per_kg, 4),
|
||||||
|
cracking_per_tonne=round(cracking_per_kg * 1000, 4),
|
||||||
|
cracking_per_kg=round(cracking_per_kg, 4),
|
||||||
|
)
|
||||||
|
db.add(base)
|
||||||
|
|
||||||
|
existing_processes = {
|
||||||
|
row.process_name: row
|
||||||
|
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
process_rule_map = {
|
||||||
|
rule.process_name: rule
|
||||||
|
for rule in db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
for process_name in PROCESS_NAMES:
|
||||||
|
if process_name in existing_processes:
|
||||||
|
continue
|
||||||
|
normalized_key = _build_process_key(process_name, 0.0, 0.0, 0.0)
|
||||||
|
rule = process_rule_map.get(normalized_key or process_name) or process_rule_map.get(process_name)
|
||||||
|
db.add(
|
||||||
|
ProductCostProcessInput(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
process_name=process_name,
|
||||||
|
cost_per_kg=round(rule.bagging_cost, 4) if rule else 0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for process_name, rule in process_rule_map.items():
|
||||||
|
if process_name not in existing_processes:
|
||||||
|
db.add(
|
||||||
|
ProductCostProcessInput(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
process_name=process_name,
|
||||||
|
cost_per_kg=round(rule.bagging_cost, 4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bag_defaults = {
|
||||||
|
"20kg_bag": 0.0,
|
||||||
|
"bulka_bag": 0.0,
|
||||||
|
"own_bag_credit": 0.0,
|
||||||
|
"1_5kg_bagging": 0.0,
|
||||||
|
"peckish_bag": 0.0,
|
||||||
|
}
|
||||||
|
for rule in db.scalars(select(PackagingCostRule).where(PackagingCostRule.tenant_id == tenant_id)).all():
|
||||||
|
unit = (rule.unit_of_measure or "").lower()
|
||||||
|
if "1.5kg" in unit or "1.5 kg" in unit:
|
||||||
|
bag_defaults["1_5kg_bagging"] = max(bag_defaults["1_5kg_bagging"], rule.bag_cost)
|
||||||
|
elif "peckish" in unit:
|
||||||
|
bag_defaults["peckish_bag"] = max(bag_defaults["peckish_bag"], rule.bag_cost)
|
||||||
|
elif "bulka" in unit:
|
||||||
|
bag_defaults["bulka_bag"] = max(bag_defaults["bulka_bag"], rule.bag_cost)
|
||||||
|
elif "20kg" in unit:
|
||||||
|
bag_defaults["20kg_bag"] = max(bag_defaults["20kg_bag"], rule.bag_cost)
|
||||||
|
|
||||||
|
existing_bags = {
|
||||||
|
row.input_key
|
||||||
|
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
for key, label in BAG_INPUTS.items():
|
||||||
|
if key not in existing_bags:
|
||||||
|
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(bag_defaults.get(key, 0.0), 4)))
|
||||||
|
|
||||||
|
freight_defaults = {
|
||||||
|
"freight_per_pallet": 0.0,
|
||||||
|
"peckish_freight_per_pallet": 0.0,
|
||||||
|
"hay_straw_freight_per_pallet": 0.0,
|
||||||
|
}
|
||||||
|
for rule in db.scalars(select(FreightCostRule).where(FreightCostRule.tenant_id == tenant_id)).all():
|
||||||
|
unit = (rule.unit_of_measure or "").lower()
|
||||||
|
if "peckish" in unit:
|
||||||
|
freight_defaults["peckish_freight_per_pallet"] = max(freight_defaults["peckish_freight_per_pallet"], rule.cost_per_unit)
|
||||||
|
elif "hay" in unit or "straw" in unit:
|
||||||
|
freight_defaults["hay_straw_freight_per_pallet"] = max(freight_defaults["hay_straw_freight_per_pallet"], rule.cost_per_unit)
|
||||||
|
else:
|
||||||
|
freight_defaults["freight_per_pallet"] = max(freight_defaults["freight_per_pallet"], rule.cost_per_unit)
|
||||||
|
|
||||||
|
existing_freight = {
|
||||||
|
row.input_key
|
||||||
|
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
for key, label in FREIGHT_INPUTS.items():
|
||||||
|
if key not in existing_freight:
|
||||||
|
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(freight_defaults.get(key, 0.0), 4)))
|
||||||
|
|
||||||
|
existing_clients = {
|
||||||
|
row.client_category
|
||||||
|
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
products = db.scalars(select(Product).where(Product.tenant_id == tenant_id).options(selectinload(Product.mix))).all()
|
||||||
|
margins: dict[str, list[tuple[float | None, float | None]]] = {}
|
||||||
|
for product in products:
|
||||||
|
margins.setdefault(product.client_name, []).append((product.distributor_margin, product.wholesale_margin))
|
||||||
|
for client_name, rows in margins.items():
|
||||||
|
if client_name in existing_clients:
|
||||||
|
continue
|
||||||
|
distributor_values = [value for value, _ in rows if value is not None]
|
||||||
|
wholesale_values = [value for _, value in rows if value is not None]
|
||||||
|
db.add(
|
||||||
|
ProductCostClientInput(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
client_category=client_name,
|
||||||
|
distributor_margin=round(sum(distributor_values) / len(distributor_values), 6) if distributor_values else None,
|
||||||
|
wholesale_margin=round(sum(wholesale_values) / len(wholesale_values), 6) if wholesale_values else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_items = {
|
||||||
|
item.item_id: item
|
||||||
|
for item in db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
|
||||||
|
if item.item_id
|
||||||
|
}
|
||||||
|
created = 0
|
||||||
|
for product in products:
|
||||||
|
if not product.item_id:
|
||||||
|
continue
|
||||||
|
item = existing_items.get(product.item_id)
|
||||||
|
if item is not None:
|
||||||
|
continue
|
||||||
|
item = ProductCostItem(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
client_category=product.client_name,
|
||||||
|
item_id=product.item_id,
|
||||||
|
product_name=product.name,
|
||||||
|
mix_product_name=product.mix.name if product.mix else product.name,
|
||||||
|
unit_type=_unit_type_from_product(product),
|
||||||
|
own_bag=_own_bag_label(product),
|
||||||
|
unit_kg=_infer_throughput_bag_size(product) or 1.0,
|
||||||
|
items_per_pallet=product.items_per_pallet,
|
||||||
|
bagging_process=product.bagging_process,
|
||||||
|
manual_distributor_margin=product.distributor_margin,
|
||||||
|
manual_wholesale_margin=product.wholesale_margin,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
recalculated = recalculate_all_product_cost_items(db, tenant_id)
|
||||||
|
return {"created": created, "recalculated": recalculated}
|
||||||
|
|
||||||
|
|
||||||
def seed_startup_basics():
|
def seed_startup_basics():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
seed_client_access(db)
|
seed_client_access(db)
|
||||||
seed_access(db)
|
seed_access(db)
|
||||||
seed_throughput_workbook(db)
|
seed_throughput_workbook(db)
|
||||||
|
report = seed_product_ingredients_from_workbook(db)
|
||||||
|
if report["backfilled"]:
|
||||||
|
logger.info("Product ingredients backfilled from workbook: %s", report)
|
||||||
|
product_costing_report = seed_product_costing_module(db)
|
||||||
|
if any(product_costing_report.values()):
|
||||||
|
logger.info("Product costing module seeded: %s", product_costing_report)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# The ordering-portal seed (catalogue + the Riverside Stockfeeds test
|
||||||
|
# customer and its buyer user) runs in its own transaction so it always
|
||||||
|
# commits on a fresh Postgres deploy, independent of the workbook/costing
|
||||||
|
# seeds above. It is idempotent — safe on every boot.
|
||||||
|
seed_ordering_basics()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_ordering_basics():
|
||||||
|
"""Seed the B2B ordering portal (catalogue + test customer). Idempotent."""
|
||||||
|
from app.seed_ordering import seed_ordering
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
ordering_report = seed_ordering(db)
|
||||||
|
if any(ordering_report.values()):
|
||||||
|
logger.info("Ordering portal seeded: %s", ordering_report)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
|
|||||||
("edit_mixes", "Create and edit mix master recipes"),
|
("edit_mixes", "Create and edit mix master recipes"),
|
||||||
("view_throughput", "View operations throughput"),
|
("view_throughput", "View operations throughput"),
|
||||||
("edit_throughput", "Create and edit operations throughput entries"),
|
("edit_throughput", "Create and edit operations throughput entries"),
|
||||||
|
("view_ordering", "View the B2B customer ordering portal and orders"),
|
||||||
|
("edit_ordering", "Manage catalogue, pricing, and process customer orders"),
|
||||||
|
("manage_ordering", "Full ordering administration: customers, pricing, order lifecycle, Xero"),
|
||||||
|
("view_scenarios", "View scenario planning"),
|
||||||
|
("edit_scenarios", "Create, run, approve, and reject scenarios"),
|
||||||
|
("manage_client_access", "Manage client accounts, users, feature access, and exports"),
|
||||||
("view_users", "View internal users and roles"),
|
("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"),
|
||||||
@@ -50,9 +56,17 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
|||||||
"view_raw_materials",
|
"view_raw_materials",
|
||||||
"edit_raw_materials",
|
"edit_raw_materials",
|
||||||
"view_products",
|
"view_products",
|
||||||
|
"edit_products",
|
||||||
"view_mixes",
|
"view_mixes",
|
||||||
|
"edit_mixes",
|
||||||
"view_throughput",
|
"view_throughput",
|
||||||
"edit_throughput",
|
"edit_throughput",
|
||||||
|
"view_ordering",
|
||||||
|
"edit_ordering",
|
||||||
|
"manage_ordering",
|
||||||
|
"view_scenarios",
|
||||||
|
"edit_scenarios",
|
||||||
|
"manage_client_access",
|
||||||
"view_users",
|
"view_users",
|
||||||
"manage_users",
|
"manage_users",
|
||||||
"manage_permissions",
|
"manage_permissions",
|
||||||
@@ -85,6 +99,37 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
|||||||
"edit_mixes",
|
"edit_mixes",
|
||||||
"view_throughput",
|
"view_throughput",
|
||||||
"edit_throughput",
|
"edit_throughput",
|
||||||
|
"view_ordering",
|
||||||
|
"edit_ordering",
|
||||||
|
"manage_ordering",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"lean": {
|
||||||
|
"description": "Lean owner access with unrestricted view/edit access across every workspace module.",
|
||||||
|
"permissions": [
|
||||||
|
"view_dashboard",
|
||||||
|
"view_mix_calculator",
|
||||||
|
"use_mix_calculator",
|
||||||
|
"save_mix_calculator_session",
|
||||||
|
"view_raw_materials",
|
||||||
|
"edit_raw_materials",
|
||||||
|
"view_products",
|
||||||
|
"edit_products",
|
||||||
|
"view_mixes",
|
||||||
|
"edit_mixes",
|
||||||
|
"view_throughput",
|
||||||
|
"edit_throughput",
|
||||||
|
"view_ordering",
|
||||||
|
"edit_ordering",
|
||||||
|
"manage_ordering",
|
||||||
|
"view_scenarios",
|
||||||
|
"edit_scenarios",
|
||||||
|
"manage_client_access",
|
||||||
|
"view_users",
|
||||||
|
"manage_users",
|
||||||
|
"manage_permissions",
|
||||||
|
"view_settings",
|
||||||
|
"edit_settings",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Idempotent seed data for the B2B ordering portal.
|
||||||
|
|
||||||
|
Creates a small demo catalogue, a demo ordering customer + buyer user, a price
|
||||||
|
list, and a customer-specific price so the acceptance-criteria flow works
|
||||||
|
out of the box. Safe to run on every startup — it no-ops once seeded.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
|
||||||
|
from app.models.ordering import (
|
||||||
|
CatalogueProduct,
|
||||||
|
CustomerPriceAssignment,
|
||||||
|
CustomerProductPrice,
|
||||||
|
NotificationSetting,
|
||||||
|
PriceList,
|
||||||
|
PriceListItem,
|
||||||
|
ProductCategory,
|
||||||
|
)
|
||||||
|
from app.services.client_access_service import (
|
||||||
|
MODULE_INDEX,
|
||||||
|
ensure_user_module_permissions,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("data_entry_app.seed")
|
||||||
|
|
||||||
|
ORDERING_TENANT = settings.client_tenant_id
|
||||||
|
|
||||||
|
_CATEGORIES = [
|
||||||
|
("grains", "Grains", 10),
|
||||||
|
("premixed", "Premixed Products", 20),
|
||||||
|
("bags", "Bags", 30),
|
||||||
|
("bulk_loads", "Bulk Loads", 40),
|
||||||
|
("custom_blends", "Custom Blends", 50),
|
||||||
|
("services", "Services & Delivery", 60),
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRODUCTS = [
|
||||||
|
# (name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote)
|
||||||
|
("Cracked Maize", "GRN-MAIZE-20", "grains", "20kg bag", "20kg", 1, 24.50, "in_stock", False),
|
||||||
|
("Whole Barley", "GRN-BARLEY-20", "grains", "20kg bag", "20kg", 1, 21.00, "in_stock", False),
|
||||||
|
("Layer Premix", "PMX-LAYER-20", "premixed", "20kg bag", "20kg", 1, 32.75, "in_stock", False),
|
||||||
|
("Calf Starter Premix", "PMX-CALF-20", "premixed", "20kg bag", "20kg", 5, 38.40, "low_stock", False),
|
||||||
|
("Bulka Bag (1T)", "BAG-BULKA-1T", "bags", "each", "1 tonne", 1, 9.50, "in_stock", False),
|
||||||
|
("Bulk Maize Load", "BLK-MAIZE-T", "bulk_loads", "tonne", "per tonne", 1, 685.00, "made_to_order", False),
|
||||||
|
("Custom Horse Blend", "CST-HORSE-20", "custom_blends", "20kg bag", "20kg", 10, None, "made_to_order", True),
|
||||||
|
("Delivery (per pallet)", "SVC-DELIVERY", "services", "pallet", "1 pallet", 1, 45.00, "in_stock", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_ordering(db: Session) -> dict[str, int]:
|
||||||
|
created = {"categories": 0, "products": 0, "customer": 0, "pricing": 0}
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
existing_categories = {
|
||||||
|
c.slug for c in db.scalars(select(ProductCategory).where(ProductCategory.tenant_id == ORDERING_TENANT)).all()
|
||||||
|
}
|
||||||
|
for slug, name, sort_order in _CATEGORIES:
|
||||||
|
if slug in existing_categories:
|
||||||
|
continue
|
||||||
|
db.add(ProductCategory(tenant_id=ORDERING_TENANT, slug=slug, name=name, sort_order=sort_order))
|
||||||
|
created["categories"] += 1
|
||||||
|
|
||||||
|
# Catalogue products
|
||||||
|
existing_skus = {
|
||||||
|
p.sku for p in db.scalars(select(CatalogueProduct).where(CatalogueProduct.tenant_id == ORDERING_TENANT)).all()
|
||||||
|
}
|
||||||
|
product_by_sku: dict[str, CatalogueProduct] = {}
|
||||||
|
for name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote in _PRODUCTS:
|
||||||
|
if sku in existing_skus:
|
||||||
|
continue
|
||||||
|
product = CatalogueProduct(
|
||||||
|
tenant_id=ORDERING_TENANT,
|
||||||
|
name=name,
|
||||||
|
sku=sku,
|
||||||
|
category=category,
|
||||||
|
unit_of_measure=uom,
|
||||||
|
unit_size=unit_size,
|
||||||
|
min_order_quantity=moq,
|
||||||
|
base_price=base_price,
|
||||||
|
stock_status=stock,
|
||||||
|
requires_quote=requires_quote,
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
product_by_sku[sku] = product
|
||||||
|
created["products"] += 1
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Notification settings row
|
||||||
|
if db.scalar(select(NotificationSetting).where(NotificationSetting.tenant_id == ORDERING_TENANT)) is None:
|
||||||
|
db.add(
|
||||||
|
NotificationSetting(
|
||||||
|
tenant_id=ORDERING_TENANT,
|
||||||
|
internal_recipients=settings.admin_email,
|
||||||
|
send_customer_confirmation=True,
|
||||||
|
require_po_number=False,
|
||||||
|
from_email=settings.admin_email,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Demo ordering customer + buyer user
|
||||||
|
demo = db.scalar(select(ClientAccount).where(ClientAccount.client_code == "RIVERSIDE"))
|
||||||
|
if demo is None:
|
||||||
|
demo = ClientAccount(
|
||||||
|
tenant_id="riverside-stockfeeds",
|
||||||
|
name="Riverside Stockfeeds",
|
||||||
|
client_code="RIVERSIDE",
|
||||||
|
status="active",
|
||||||
|
notes="Demo B2B ordering customer",
|
||||||
|
)
|
||||||
|
db.add(demo)
|
||||||
|
db.flush()
|
||||||
|
created["customer"] += 1
|
||||||
|
|
||||||
|
info = MODULE_INDEX["ordering"]
|
||||||
|
db.add(
|
||||||
|
ClientFeatureAccess(
|
||||||
|
tenant_id=demo.tenant_id,
|
||||||
|
client_account_id=demo.id,
|
||||||
|
feature_key="ordering",
|
||||||
|
feature_name=info["module_name"],
|
||||||
|
feature_group=info["module_group"],
|
||||||
|
description=info["description"],
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
buyer = ClientUser(
|
||||||
|
tenant_id=demo.tenant_id,
|
||||||
|
client_account_id=demo.id,
|
||||||
|
full_name="Riverside Buyer",
|
||||||
|
email="buyer@riverside.example",
|
||||||
|
role="buyer",
|
||||||
|
status="active",
|
||||||
|
is_new_user=False,
|
||||||
|
)
|
||||||
|
db.add(buyer)
|
||||||
|
db.flush()
|
||||||
|
ensure_user_module_permissions(db, buyer)
|
||||||
|
|
||||||
|
# A customer-specific contract price + a small discount on the rest.
|
||||||
|
maize = db.scalar(
|
||||||
|
select(CatalogueProduct).where(
|
||||||
|
CatalogueProduct.tenant_id == ORDERING_TENANT, CatalogueProduct.sku == "GRN-MAIZE-20"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if maize is not None:
|
||||||
|
db.add(
|
||||||
|
CustomerProductPrice(
|
||||||
|
tenant_id=ORDERING_TENANT,
|
||||||
|
client_account_id=demo.id,
|
||||||
|
product_id=maize.id,
|
||||||
|
unit_price=22.00,
|
||||||
|
rule_type="contract",
|
||||||
|
contract_reference="2026 supply agreement",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
CustomerPriceAssignment(
|
||||||
|
tenant_id=ORDERING_TENANT,
|
||||||
|
client_account_id=demo.id,
|
||||||
|
price_list_id=None,
|
||||||
|
discount_percent=5.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created["pricing"] += 1
|
||||||
|
|
||||||
|
return created
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Recording and reading editor change history.
|
||||||
|
|
||||||
|
The Mix Editor and Ingredients Editor write a row here on every create/edit so
|
||||||
|
each mix and ingredient carries an auditable history (who changed what, when).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession
|
||||||
|
from app.models.change_event import EditorChangeEvent
|
||||||
|
|
||||||
|
# Entity types — these match the History buttons on the two editors.
|
||||||
|
ENTITY_MIX = "mix"
|
||||||
|
ENTITY_INGREDIENT = "ingredient"
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "Yes" if value else "No"
|
||||||
|
if isinstance(value, float):
|
||||||
|
# Trim trailing zeros so 12.50 reads as 12.5 and 12.0 as 12.
|
||||||
|
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
||||||
|
return text or "0"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_fields(before: dict[str, Any], after: dict[str, Any], labels: dict[str, str]) -> list[dict[str, Any]]:
|
||||||
|
"""Build a list of ``{field, label, before, after}`` deltas for changed fields.
|
||||||
|
|
||||||
|
Only keys present in ``labels`` are considered, and only those whose value
|
||||||
|
actually changed are emitted.
|
||||||
|
"""
|
||||||
|
deltas: list[dict[str, Any]] = []
|
||||||
|
for field, label in labels.items():
|
||||||
|
if field not in after:
|
||||||
|
continue
|
||||||
|
old = before.get(field)
|
||||||
|
new = after.get(field)
|
||||||
|
if old == new:
|
||||||
|
continue
|
||||||
|
deltas.append({"field": field, "label": label, "before": _stringify(old), "after": _stringify(new)})
|
||||||
|
return deltas
|
||||||
|
|
||||||
|
|
||||||
|
def record_change(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
session: AuthSession,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: int,
|
||||||
|
action: str,
|
||||||
|
summary: str,
|
||||||
|
changes: list[dict[str, Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Append a change event. Caller is responsible for committing the session."""
|
||||||
|
db.add(
|
||||||
|
EditorChangeEvent(
|
||||||
|
tenant_id=session.tenant_id or "",
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
action=action,
|
||||||
|
actor_name=session.name or session.email or "Unknown",
|
||||||
|
actor_email=session.email or "",
|
||||||
|
actor_role=session.client_role or session.role,
|
||||||
|
summary=summary,
|
||||||
|
changes=changes or [],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_changes(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: int,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[EditorChangeEvent]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(EditorChangeEvent)
|
||||||
|
.where(
|
||||||
|
EditorChangeEvent.tenant_id == tenant_id,
|
||||||
|
EditorChangeEvent.entity_type == entity_type,
|
||||||
|
EditorChangeEvent.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
.order_by(EditorChangeEvent.created_at.desc(), EditorChangeEvent.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
@@ -21,6 +21,7 @@ MODULE_CATALOG = (
|
|||||||
("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"),
|
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
|
||||||
|
("ordering", "Ordering Portal", "commerce", "Browse the private catalogue, build and submit B2B orders"),
|
||||||
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
|
("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"),
|
||||||
)
|
)
|
||||||
@@ -79,15 +80,25 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
|
|||||||
def default_access_level_for_role(role: str, module_key: str) -> str:
|
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", "operations_throughput"} else "edit"
|
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput", "ordering"} else "edit"
|
||||||
if normalized == "admin":
|
if normalized == "admin":
|
||||||
if module_key in {"mix_calculator", "operations_throughput"}:
|
if module_key in {"mix_calculator", "operations_throughput", "ordering"}:
|
||||||
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", "operations_throughput"} else "none"
|
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput", "ordering"} else "none"
|
||||||
if normalized == "viewer":
|
if normalized == "viewer":
|
||||||
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput"} else "none"
|
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput", "ordering"} else "none"
|
||||||
|
# --- B2B ordering-portal customer roles ---------------------------------
|
||||||
|
# Ordering customers are scoped to the ordering module (plus a dashboard
|
||||||
|
# view). They never see costing/admin modules.
|
||||||
|
if normalized == "owner":
|
||||||
|
return "manage" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
|
||||||
|
if normalized == "buyer":
|
||||||
|
return "edit" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
|
||||||
|
if normalized == "accounts":
|
||||||
|
# Accounts users review orders/invoicing but don't place orders.
|
||||||
|
return "view" if module_key in {"ordering", "dashboard"} else "none"
|
||||||
return "none"
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
|
|||||||
current_y,
|
current_y,
|
||||||
detail_width,
|
detail_width,
|
||||||
detail_height,
|
detail_height,
|
||||||
"Product",
|
"Mix",
|
||||||
session_record.product_name,
|
session_record.product_name,
|
||||||
value_font_size=12,
|
value_font_size=12,
|
||||||
)
|
)
|
||||||
@@ -251,7 +251,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
|
|||||||
current_y,
|
current_y,
|
||||||
detail_width,
|
detail_width,
|
||||||
detail_height,
|
detail_height,
|
||||||
"Mix source",
|
"Formula source",
|
||||||
session_record.mix_name,
|
session_record.mix_name,
|
||||||
value_font_size=11,
|
value_font_size=11,
|
||||||
)
|
)
|
||||||
@@ -287,12 +287,12 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
|
|||||||
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
|
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
|
||||||
row_count = max(len(session_record.lines), 1)
|
row_count = max(len(session_record.lines), 1)
|
||||||
row_height = clamp(available_table_height / row_count, 16, 32)
|
row_height = clamp(available_table_height / row_count, 16, 32)
|
||||||
table_font_size = clamp(row_height * 0.36, 7, 11)
|
table_font_size = clamp(row_height * 0.44, 8.5, 12.5)
|
||||||
table_height = table_header_height + (row_height * row_count)
|
table_height = table_header_height + (row_height * row_count)
|
||||||
table_bottom = table_top - table_height
|
table_bottom = table_top - table_height
|
||||||
|
|
||||||
pdf.setFillColor(palette["muted"])
|
pdf.setFillColor(palette["muted"])
|
||||||
pdf.setFont("Helvetica-Bold", 7.5)
|
pdf.setFont("Helvetica-Bold", 8.5)
|
||||||
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
|
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
|
||||||
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
|
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
|
||||||
|
|
||||||
@@ -319,7 +319,10 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
|
|||||||
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
|
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
|
||||||
)
|
)
|
||||||
pdf.setFont("Helvetica", table_font_size)
|
pdf.setFont("Helvetica", table_font_size)
|
||||||
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg")
|
# Each ingredient carries its own rounding (set in the Ingredients Editor)
|
||||||
|
# so the printed sheet matches the on-screen calculated output.
|
||||||
|
line_decimals = getattr(line, "rounding_decimals", 2)
|
||||||
|
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg, line_decimals)}kg")
|
||||||
|
|
||||||
strip_y = table_bottom - 6
|
strip_y = table_bottom - 6
|
||||||
if note_lines:
|
if note_lines:
|
||||||
|
|||||||
@@ -35,6 +35,33 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _category_sort_key(category: str | None) -> tuple[int, str]:
|
||||||
|
"""Order ingredients by their manual category; uncategorised rows sort last."""
|
||||||
|
cleaned = (category or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return (1, "")
|
||||||
|
return (0, cleaned.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _order_formula_rows(rows: list[dict]) -> list[dict]:
|
||||||
|
"""Sort rows by category (then their original order/name) and renumber.
|
||||||
|
|
||||||
|
Category is the primary key so the Mix Calculator groups ingredients by their
|
||||||
|
manually-assigned category. `sort_order` is reassigned sequentially after the
|
||||||
|
sort so every downstream consumer (lines, PDF) follows the same order.
|
||||||
|
"""
|
||||||
|
rows.sort(
|
||||||
|
key=lambda row: (
|
||||||
|
_category_sort_key(row.get("category")),
|
||||||
|
row.get("sort_order") or 0,
|
||||||
|
row["raw_material_name"].lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
row["sort_order"] = index
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||||
if product.ingredients:
|
if product.ingredients:
|
||||||
rows = [
|
rows = [
|
||||||
@@ -43,6 +70,8 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
|||||||
"raw_material_name": ingredient.raw_material.name,
|
"raw_material_name": ingredient.raw_material.name,
|
||||||
"quantity_kg": ingredient.quantity_kg,
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
"unit": ingredient.raw_material.unit_of_measure,
|
"unit": ingredient.raw_material.unit_of_measure,
|
||||||
|
"rounding_decimals": ingredient.raw_material.rounding_decimals,
|
||||||
|
"category": ingredient.raw_material.category,
|
||||||
"sort_order": ingredient.sort_order,
|
"sort_order": ingredient.sort_order,
|
||||||
}
|
}
|
||||||
for ingredient in product.ingredients
|
for ingredient in product.ingredients
|
||||||
@@ -55,6 +84,8 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
|||||||
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
"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,
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||||
|
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
|
||||||
|
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
|
||||||
"sort_order": index,
|
"sort_order": index,
|
||||||
}
|
}
|
||||||
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
||||||
@@ -62,7 +93,29 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
|||||||
else:
|
else:
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"]))
|
_order_formula_rows(rows)
|
||||||
|
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _mix_formula_rows(mix: Mix) -> tuple[list[dict], float]:
|
||||||
|
"""Resolve a mix's own (mix-master) formula rows, category-ordered.
|
||||||
|
|
||||||
|
Used by the Mix Calculator for mixes that have a formula but no representative
|
||||||
|
product yet — the formula lives directly on the mix.
|
||||||
|
"""
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"raw_material_id": ingredient.raw_material_id,
|
||||||
|
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
||||||
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
|
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||||
|
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
|
||||||
|
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
|
||||||
|
"sort_order": index,
|
||||||
|
}
|
||||||
|
for index, ingredient in enumerate(mix.ingredients, start=1)
|
||||||
|
]
|
||||||
|
_order_formula_rows(rows)
|
||||||
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
|
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,29 +129,137 @@ def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_mea
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def calculate_mix_calculator_preview(
|
def _mix_calculator_label(product: Product) -> str:
|
||||||
db: Session,
|
return product.mix.name if product.mix else product.name
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
|
|
||||||
):
|
|
||||||
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
|
|
||||||
product = _load_product_for_calculation(db, tenant_id, int(values["product_id"]))
|
|
||||||
if product is None:
|
|
||||||
raise ValueError("Product not found")
|
|
||||||
if product.client_name != values["client_name"]:
|
|
||||||
raise ValueError("Selected product does not belong to the chosen client")
|
|
||||||
formula_rows, source_total_kg = _resolved_formula_rows(product)
|
|
||||||
if source_total_kg <= 0:
|
|
||||||
raise ValueError("Product has no source kilograms to scale")
|
|
||||||
|
|
||||||
|
|
||||||
|
def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]:
|
||||||
|
unit_label = (product.unit_of_measure or "").lower()
|
||||||
|
unit_size = extract_unit_quantity_kg(product.unit_of_measure)
|
||||||
|
return (
|
||||||
|
0 if abs(unit_size - 20) < 1e-9 and "bag" in unit_label and "bulka" not in unit_label else 1,
|
||||||
|
0 if "bulka" not in unit_label else 1,
|
||||||
|
unit_size if unit_size > 0 else 999999,
|
||||||
|
product.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None:
|
||||||
|
"""The single product the Mix Calculator surfaces for a given mix.
|
||||||
|
|
||||||
|
The calculator lists one representative product per (client, mix) and reads
|
||||||
|
its formula. The Mix Editor reuses this so it edits exactly what the
|
||||||
|
calculator shows. Preference order mirrors `build_mix_calculator_options`:
|
||||||
|
visible products that already have a product-specific formula, ranked by
|
||||||
|
`_mix_calculator_option_rank`; then any visible product; then any product.
|
||||||
|
"""
|
||||||
|
products = db.scalars(
|
||||||
|
select(Product)
|
||||||
|
.where(Product.tenant_id == tenant_id, Product.mix_id == mix_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
|
||||||
|
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not products:
|
||||||
|
return None
|
||||||
|
with_formula = [product for product in products if product.visible and product.ingredients]
|
||||||
|
pool = with_formula or [product for product in products if product.visible] or list(products)
|
||||||
|
return min(pool, key=_mix_calculator_option_rank)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict:
|
||||||
|
"""Resolve a mix's formula the way the calculator does, for the editor.
|
||||||
|
|
||||||
|
Returns the resolved ingredient rows (with each row's share of the total as
|
||||||
|
`mix_percentage`), the total kg, and where the formula lives:
|
||||||
|
`source='product'` (a representative product's own formula) or `source='mix'`
|
||||||
|
(the shared mix master fallback). `product_id` names the product that owns the
|
||||||
|
formula when `source='product'`. The save path writes back to that same source.
|
||||||
|
"""
|
||||||
|
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id)
|
||||||
|
if product is not None and product.ingredients:
|
||||||
|
rows, total_kg = _resolved_formula_rows(product)
|
||||||
|
# Carry each ingredient's note through so saving doesn't wipe it.
|
||||||
|
notes_by_raw_material = {
|
||||||
|
ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients
|
||||||
|
}
|
||||||
|
for row in rows:
|
||||||
|
row["notes"] = notes_by_raw_material.get(row["raw_material_id"])
|
||||||
|
source = "product"
|
||||||
|
product_id = product.id
|
||||||
|
else:
|
||||||
|
# No product-specific formula: the calculator reads the shared mix master,
|
||||||
|
# so the editor shows and edits that.
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"raw_material_id": ingredient.raw_material_id,
|
||||||
|
"raw_material_name": ingredient.raw_material.name
|
||||||
|
if ingredient.raw_material is not None
|
||||||
|
else f"Raw material {ingredient.raw_material_id}",
|
||||||
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
|
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||||
|
"sort_order": index,
|
||||||
|
"notes": ingredient.notes,
|
||||||
|
}
|
||||||
|
for index, ingredient in enumerate(
|
||||||
|
sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""),
|
||||||
|
start=1,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
total_kg = round(sum(row["quantity_kg"] for row in rows), 4)
|
||||||
|
source = "mix"
|
||||||
|
product_id = product.id if product is not None else None
|
||||||
|
|
||||||
|
ingredients = [
|
||||||
|
{
|
||||||
|
"raw_material_id": row["raw_material_id"],
|
||||||
|
"raw_material_name": row["raw_material_name"],
|
||||||
|
"quantity_kg": round(row["quantity_kg"], 4),
|
||||||
|
"mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0,
|
||||||
|
"unit": row["unit"],
|
||||||
|
"notes": row.get("notes"),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"id": mix.id,
|
||||||
|
"tenant_id": mix.tenant_id,
|
||||||
|
"client_name": mix.client_name,
|
||||||
|
"name": mix.name,
|
||||||
|
"source": source,
|
||||||
|
"product_id": product_id,
|
||||||
|
"ingredients": ingredients,
|
||||||
|
"total_kg": total_kg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scale_preview(
|
||||||
|
*,
|
||||||
|
values: dict,
|
||||||
|
formula_rows: list[dict],
|
||||||
|
source_total_kg: float,
|
||||||
|
client_name: str,
|
||||||
|
product_id: int,
|
||||||
|
mix_label: str,
|
||||||
|
mix_id: int,
|
||||||
|
unit_of_measure: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Scale a resolved formula to the requested batch size and shape the preview.
|
||||||
|
|
||||||
|
Shared by the product-backed path and the formula-only mix path; only the
|
||||||
|
inputs (where the formula and unit come from) differ.
|
||||||
|
"""
|
||||||
batch_size_kg = float(values["batch_size_kg"])
|
batch_size_kg = float(values["batch_size_kg"])
|
||||||
scale_factor = batch_size_kg / source_total_kg
|
scale_factor = batch_size_kg / source_total_kg
|
||||||
unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure)
|
unit_size_kg = extract_unit_quantity_kg(unit_of_measure)
|
||||||
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
|
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
|
||||||
|
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure)
|
# A bag warning only makes sense when the unit resolves to a bag size; a
|
||||||
|
# formula-only mix sells in bulk kg, so there's nothing to round to whole bags.
|
||||||
|
if unit_size_kg > 0:
|
||||||
|
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure)
|
||||||
if bag_warning:
|
if bag_warning:
|
||||||
warnings.append(bag_warning)
|
warnings.append(bag_warning)
|
||||||
|
|
||||||
@@ -113,21 +274,25 @@ def calculate_mix_calculator_preview(
|
|||||||
"required_kg": required_kg,
|
"required_kg": required_kg,
|
||||||
"mix_percentage": mix_percentage,
|
"mix_percentage": mix_percentage,
|
||||||
"unit": ingredient["unit"],
|
"unit": ingredient["unit"],
|
||||||
|
"rounding_decimals": ingredient.get("rounding_decimals", 2),
|
||||||
|
"category": ingredient.get("category"),
|
||||||
"sort_order": ingredient["sort_order"] or index,
|
"sort_order": ingredient["sort_order"] or index,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"client_name": product.client_name,
|
"client_name": client_name,
|
||||||
"product_id": product.id,
|
"product_id": product_id,
|
||||||
"product_name": product.name,
|
# The source workbook labels this as Product, but for the calculator
|
||||||
"mix_id": product.mix_id,
|
# it is the mix/formula being produced.
|
||||||
"mix_name": product.mix.name if product.mix else product.name,
|
"product_name": mix_label,
|
||||||
|
"mix_id": mix_id,
|
||||||
|
"mix_name": mix_label,
|
||||||
"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,
|
||||||
"total_kg": round(batch_size_kg, 4),
|
"total_kg": round(batch_size_kg, 4),
|
||||||
"product_unit_of_measure": product.unit_of_measure,
|
"product_unit_of_measure": unit_of_measure,
|
||||||
"product_unit_size_kg": round(unit_size_kg, 4),
|
"product_unit_size_kg": round(unit_size_kg, 4),
|
||||||
"prepared_by_name": values["prepared_by_name"],
|
"prepared_by_name": values["prepared_by_name"],
|
||||||
"status": values.get("status") or "saved",
|
"status": values.get("status") or "saved",
|
||||||
@@ -137,6 +302,70 @@ def calculate_mix_calculator_preview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_mix_only_preview(db: Session, *, tenant_id: str, mix_id: int, values: dict) -> dict:
|
||||||
|
"""Preview for a mix that has a formula but no representative product.
|
||||||
|
|
||||||
|
The Mix Calculator surfaces these via a negative `product_id` sentinel
|
||||||
|
(`-mix_id`); the formula is read straight off the mix master and there's no
|
||||||
|
product unit, so output is bulk kg with no bag split.
|
||||||
|
"""
|
||||||
|
mix = db.scalar(
|
||||||
|
select(Mix)
|
||||||
|
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
|
||||||
|
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
|
||||||
|
)
|
||||||
|
if mix is None:
|
||||||
|
raise ValueError("Mix not found")
|
||||||
|
if mix.client_name != values["client_name"]:
|
||||||
|
raise ValueError("Selected mix does not belong to the chosen client")
|
||||||
|
formula_rows, source_total_kg = _mix_formula_rows(mix)
|
||||||
|
if source_total_kg <= 0:
|
||||||
|
raise ValueError("Mix has no formula to scale")
|
||||||
|
return _scale_preview(
|
||||||
|
values=values,
|
||||||
|
formula_rows=formula_rows,
|
||||||
|
source_total_kg=source_total_kg,
|
||||||
|
client_name=mix.client_name,
|
||||||
|
product_id=-mix.id,
|
||||||
|
mix_label=mix.name,
|
||||||
|
mix_id=mix.id,
|
||||||
|
unit_of_measure="kg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_mix_calculator_preview(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
|
||||||
|
):
|
||||||
|
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
|
||||||
|
product_id = int(values["product_id"])
|
||||||
|
# Negative ids are the sentinel for a formula-only mix (no product yet).
|
||||||
|
if product_id < 0:
|
||||||
|
return _calculate_mix_only_preview(db, tenant_id=tenant_id, mix_id=-product_id, values=values)
|
||||||
|
|
||||||
|
product = _load_product_for_calculation(db, tenant_id, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise ValueError("Product not found")
|
||||||
|
if product.client_name != values["client_name"]:
|
||||||
|
raise ValueError("Selected product does not belong to the chosen client")
|
||||||
|
formula_rows, source_total_kg = _resolved_formula_rows(product)
|
||||||
|
if source_total_kg <= 0:
|
||||||
|
raise ValueError("Product has no source kilograms to scale")
|
||||||
|
|
||||||
|
return _scale_preview(
|
||||||
|
values=values,
|
||||||
|
formula_rows=formula_rows,
|
||||||
|
source_total_kg=source_total_kg,
|
||||||
|
client_name=product.client_name,
|
||||||
|
product_id=product.id,
|
||||||
|
mix_label=_mix_calculator_label(product),
|
||||||
|
mix_id=product.mix_id,
|
||||||
|
unit_of_measure=product.unit_of_measure,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||||
# Prefer product-specific formulas where present; fall back to the shared
|
# Prefer product-specific formulas where present; fall back to the shared
|
||||||
# mix master for legacy rows that have not been migrated yet.
|
# mix master for legacy rows that have not been migrated yet.
|
||||||
@@ -156,21 +385,42 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
|||||||
).all()
|
).all()
|
||||||
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
|
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
|
||||||
|
|
||||||
|
product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id)
|
||||||
products = db.scalars(
|
products = db.scalars(
|
||||||
select(Product)
|
select(Product)
|
||||||
.where(Product.tenant_id == tenant_id, Product.visible.is_(True))
|
.where(
|
||||||
|
Product.tenant_id == tenant_id,
|
||||||
|
Product.visible.is_(True),
|
||||||
|
Product.id.in_(product_ids_with_formulas),
|
||||||
|
)
|
||||||
.options(joinedload(Product.mix))
|
.options(joinedload(Product.mix))
|
||||||
.order_by(Product.client_name, Product.name)
|
.order_by(Product.client_name, Product.name)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
clients = sorted({product.client_name for product in products})
|
representative_products: dict[tuple[str, str], Product] = {}
|
||||||
|
for product in products:
|
||||||
|
mix_label = _mix_calculator_label(product)
|
||||||
|
key = (product.client_name, mix_label)
|
||||||
|
current = representative_products.get(key)
|
||||||
|
if current is None:
|
||||||
|
representative_products[key] = product
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _mix_calculator_option_rank(product) < _mix_calculator_option_rank(current):
|
||||||
|
representative_products[key] = product
|
||||||
|
|
||||||
|
products = sorted(
|
||||||
|
representative_products.values(),
|
||||||
|
key=lambda product: (product.client_name, _mix_calculator_label(product), product.id),
|
||||||
|
)
|
||||||
|
|
||||||
product_rows = [
|
product_rows = [
|
||||||
{
|
{
|
||||||
"product_id": product.id,
|
"product_id": product.id,
|
||||||
"client_name": product.client_name,
|
"client_name": product.client_name,
|
||||||
"product_name": product.name,
|
"product_name": _mix_calculator_label(product),
|
||||||
"mix_id": product.mix_id,
|
"mix_id": product.mix_id,
|
||||||
"mix_name": product.mix.name if product.mix else "",
|
"mix_name": _mix_calculator_label(product),
|
||||||
"unit_of_measure": product.unit_of_measure,
|
"unit_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": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
|
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
|
||||||
@@ -178,6 +428,44 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
|||||||
for product in products
|
for product in products
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Surface mixes that have a formula but no product at all yet (e.g. a freshly
|
||||||
|
# created mix). They're selected via a negative `product_id` sentinel (-mix_id)
|
||||||
|
# and calculated straight off the mix master — bulk kg, no bag split. A mix
|
||||||
|
# whose only product is hidden is intentionally excluded (it HAS a product),
|
||||||
|
# so check every product, not just the visible representatives.
|
||||||
|
covered_mix_ids = set(
|
||||||
|
db.scalars(
|
||||||
|
select(Product.mix_id).where(Product.tenant_id == tenant_id).distinct()
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
formula_only_mix_ids = [
|
||||||
|
mix_id for mix_id, total in mix_totals.items() if total > 0 and mix_id not in covered_mix_ids
|
||||||
|
]
|
||||||
|
if formula_only_mix_ids:
|
||||||
|
formula_only_mixes = db.scalars(
|
||||||
|
select(Mix).where(
|
||||||
|
Mix.tenant_id == tenant_id,
|
||||||
|
Mix.id.in_(formula_only_mix_ids),
|
||||||
|
Mix.status == "active",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
product_rows.extend(
|
||||||
|
{
|
||||||
|
"product_id": -mix.id,
|
||||||
|
"client_name": mix.client_name,
|
||||||
|
"product_name": mix.name,
|
||||||
|
"mix_id": mix.id,
|
||||||
|
"mix_name": mix.name,
|
||||||
|
"unit_of_measure": "kg",
|
||||||
|
"unit_size_kg": 0.0,
|
||||||
|
"mix_total_kg": mix_totals.get(mix.id, 0.0),
|
||||||
|
}
|
||||||
|
for mix in formula_only_mixes
|
||||||
|
)
|
||||||
|
|
||||||
|
product_rows.sort(key=lambda row: (row["client_name"], row["product_name"], row["product_id"]))
|
||||||
|
clients = sorted({row["client_name"] for row in product_rows})
|
||||||
|
|
||||||
return {"clients": clients, "products": product_rows}
|
return {"clients": clients, "products": product_rows}
|
||||||
|
|
||||||
|
|
||||||
@@ -220,6 +508,7 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_
|
|||||||
"required_kg": round(line.required_kg, 4),
|
"required_kg": round(line.required_kg, 4),
|
||||||
"mix_percentage": round(line.mix_percentage, 4),
|
"mix_percentage": round(line.mix_percentage, 4),
|
||||||
"unit": line.unit,
|
"unit": line.unit,
|
||||||
|
"rounding_decimals": line.rounding_decimals,
|
||||||
"sort_order": line.sort_order,
|
"sort_order": line.sort_order,
|
||||||
}
|
}
|
||||||
for line in session_record.lines
|
for line in session_record.lines
|
||||||
@@ -262,6 +551,10 @@ def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
|
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
|
||||||
|
if payload.product_id < 0:
|
||||||
|
# Sessions reference a real product (FK). A formula-only mix has none yet —
|
||||||
|
# it can still be previewed and printed, just not saved as a session.
|
||||||
|
raise ValueError("Add a product to this mix before saving a calculator session.")
|
||||||
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
|
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
|
||||||
session_record = MixCalculatorSession(
|
session_record = MixCalculatorSession(
|
||||||
tenant_id=auth_session.tenant_id or "default",
|
tenant_id=auth_session.tenant_id or "default",
|
||||||
@@ -291,6 +584,7 @@ def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, pay
|
|||||||
required_kg=line["required_kg"],
|
required_kg=line["required_kg"],
|
||||||
mix_percentage=line["mix_percentage"],
|
mix_percentage=line["mix_percentage"],
|
||||||
unit=line["unit"],
|
unit=line["unit"],
|
||||||
|
rounding_decimals=line.get("rounding_decimals", 2),
|
||||||
sort_order=line["sort_order"],
|
sort_order=line["sort_order"],
|
||||||
)
|
)
|
||||||
for line in preview["lines"]
|
for line in preview["lines"]
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Order notification service (stub interface).
|
||||||
|
|
||||||
|
Emits the two notifications the ordering spec requires when an order is
|
||||||
|
submitted:
|
||||||
|
|
||||||
|
* a confirmation to the customer, and
|
||||||
|
* an internal notification to configured recipients.
|
||||||
|
|
||||||
|
Real email delivery is intentionally **stubbed** behind this interface: no SMTP
|
||||||
|
provider is configured in dev/alpha, so notifications are logged and returned as
|
||||||
|
structured results. To go live, implement :func:`_deliver_email` (TODO) — no
|
||||||
|
caller changes needed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.client_access import ClientAccount, ClientUser
|
||||||
|
from app.models.ordering import NotificationSetting, Order
|
||||||
|
|
||||||
|
logger = logging.getLogger("data_entry_app.ordering")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationResult:
|
||||||
|
channel: str # "customer" | "internal"
|
||||||
|
recipients: list[str]
|
||||||
|
subject: str
|
||||||
|
delivered: bool
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_settings(db: Session, tenant_id: str) -> NotificationSetting:
|
||||||
|
settings = db.scalar(
|
||||||
|
select(NotificationSetting).where(NotificationSetting.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
if settings is None:
|
||||||
|
settings = NotificationSetting(tenant_id=tenant_id)
|
||||||
|
db.add(settings)
|
||||||
|
db.flush()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_recipients(settings: NotificationSetting) -> list[str]:
|
||||||
|
if not settings.internal_recipients:
|
||||||
|
return []
|
||||||
|
return [part.strip() for part in settings.internal_recipients.split(",") if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_recipients(db: Session, order: Order) -> list[str]:
|
||||||
|
users = db.scalars(
|
||||||
|
select(ClientUser).where(
|
||||||
|
ClientUser.client_account_id == order.client_account_id,
|
||||||
|
ClientUser.status == "active",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [user.email for user in users if user.email]
|
||||||
|
|
||||||
|
|
||||||
|
def _deliver_email(*, to: list[str], subject: str, body: str, from_email: str | None) -> bool:
|
||||||
|
"""Deliver an email. Stubbed — logs instead of sending.
|
||||||
|
|
||||||
|
TODO (go-live): integrate the chosen provider (SMTP / SendGrid / SES) using
|
||||||
|
credentials from environment variables. Return True only on confirmed
|
||||||
|
delivery.
|
||||||
|
"""
|
||||||
|
logger.info("ordering.email.stub to=%s subject=%s", to, subject)
|
||||||
|
return False # stub: nothing actually sent
|
||||||
|
|
||||||
|
|
||||||
|
def send_order_submitted_notifications(db: Session, order: Order) -> list[NotificationResult]:
|
||||||
|
"""Send customer confirmation + internal notification for a submitted order."""
|
||||||
|
settings = get_or_create_settings(db, order.tenant_id)
|
||||||
|
customer = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
|
||||||
|
customer_name = customer.name if customer else "Customer"
|
||||||
|
order_ref = order.order_number or f"#{order.id}"
|
||||||
|
results: list[NotificationResult] = []
|
||||||
|
|
||||||
|
if settings.send_customer_confirmation:
|
||||||
|
to = _customer_recipients(db, order)
|
||||||
|
subject = f"Order {order_ref} received"
|
||||||
|
body = (
|
||||||
|
f"Thank you — we have received order {order_ref} for {customer_name}.\n"
|
||||||
|
f"Lines: {len(order.lines)}. Subtotal (ex GST): {order.subtotal_ex_gst:.2f}.\n"
|
||||||
|
"Our team will review and confirm shortly."
|
||||||
|
)
|
||||||
|
delivered = _deliver_email(to=to, subject=subject, body=body, from_email=settings.from_email) if to else False
|
||||||
|
results.append(
|
||||||
|
NotificationResult(
|
||||||
|
channel="customer",
|
||||||
|
recipients=to,
|
||||||
|
subject=subject,
|
||||||
|
delivered=delivered,
|
||||||
|
detail="stubbed" if not delivered else "sent",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
internal = _internal_recipients(settings)
|
||||||
|
subject = f"New order {order_ref} from {customer_name}"
|
||||||
|
body = (
|
||||||
|
f"New order {order_ref} submitted by {order.created_by_name or 'a customer user'}.\n"
|
||||||
|
f"Customer: {customer_name}. Lines: {len(order.lines)}. "
|
||||||
|
f"Subtotal (ex GST): {order.subtotal_ex_gst:.2f}."
|
||||||
|
)
|
||||||
|
delivered = _deliver_email(to=internal, subject=subject, body=body, from_email=settings.from_email) if internal else False
|
||||||
|
results.append(
|
||||||
|
NotificationResult(
|
||||||
|
channel="internal",
|
||||||
|
recipients=internal,
|
||||||
|
subject=subject,
|
||||||
|
delivered=delivered,
|
||||||
|
detail="no recipients configured" if not internal else ("stubbed" if not delivered else "sent"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Order confirmation PDF generation (reportlab)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from app.models.client_access import ClientAccount
|
||||||
|
from app.models.ordering import Order
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPdfUnavailableError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_money(value: float | None) -> str:
|
||||||
|
return "Quote" if value is None else f"${value:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_order_confirmation_pdf(order: Order, customer: ClientAccount | None) -> bytes:
|
||||||
|
try:
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
except ModuleNotFoundError as exc: # pragma: no cover
|
||||||
|
raise OrderPdfUnavailableError(
|
||||||
|
"PDF generation is unavailable because 'reportlab' is not installed."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
page_width, page_height = A4
|
||||||
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||||
|
margin = 40
|
||||||
|
y = page_height - margin
|
||||||
|
|
||||||
|
pdf.setFont("Helvetica-Bold", 18)
|
||||||
|
pdf.drawString(margin, y, "Order Confirmation")
|
||||||
|
y -= 24
|
||||||
|
|
||||||
|
pdf.setFont("Helvetica", 10)
|
||||||
|
order_ref = order.order_number or f"#{order.id}"
|
||||||
|
pdf.drawString(margin, y, f"Order: {order_ref}")
|
||||||
|
pdf.drawRightString(page_width - margin, y, datetime.utcnow().strftime("%d %b %Y %H:%M UTC"))
|
||||||
|
y -= 14
|
||||||
|
pdf.drawString(margin, y, f"Customer: {customer.name if customer else order.client_account_id}")
|
||||||
|
y -= 14
|
||||||
|
pdf.drawString(margin, y, f"Status: {order.status.replace('_', ' ').title()}")
|
||||||
|
y -= 14
|
||||||
|
if order.purchase_order_number:
|
||||||
|
pdf.drawString(margin, y, f"PO Number: {order.purchase_order_number}")
|
||||||
|
y -= 14
|
||||||
|
pdf.drawString(margin, y, f"Fulfilment: {order.fulfilment_method.title()}")
|
||||||
|
y -= 14
|
||||||
|
if order.requested_delivery_date:
|
||||||
|
pdf.drawString(margin, y, f"Requested date: {order.requested_delivery_date.strftime('%d %b %Y')}")
|
||||||
|
y -= 14
|
||||||
|
y -= 6
|
||||||
|
|
||||||
|
# Table header
|
||||||
|
pdf.setFont("Helvetica-Bold", 9)
|
||||||
|
pdf.setFillColor(colors.HexColor("#22362d"))
|
||||||
|
pdf.drawString(margin, y, "Product")
|
||||||
|
pdf.drawString(margin + 220, y, "SKU")
|
||||||
|
pdf.drawRightString(margin + 330, y, "Qty")
|
||||||
|
pdf.drawRightString(margin + 420, y, "Unit (ex GST)")
|
||||||
|
pdf.drawRightString(page_width - margin, y, "Line total")
|
||||||
|
y -= 6
|
||||||
|
pdf.setStrokeColor(colors.HexColor("#cccccc"))
|
||||||
|
pdf.line(margin, y, page_width - margin, y)
|
||||||
|
y -= 14
|
||||||
|
|
||||||
|
pdf.setFont("Helvetica", 9)
|
||||||
|
pdf.setFillColor(colors.black)
|
||||||
|
for line in order.lines:
|
||||||
|
if y < margin + 60:
|
||||||
|
pdf.showPage()
|
||||||
|
y = page_height - margin
|
||||||
|
pdf.setFont("Helvetica", 9)
|
||||||
|
unit = line.admin_override_price if line.admin_override_price is not None else line.unit_price
|
||||||
|
line_total = None if unit is None else round(unit * line.quantity, 2)
|
||||||
|
pdf.drawString(margin, y, (line.product_name or "")[:38])
|
||||||
|
pdf.drawString(margin + 220, y, (line.product_sku or "")[:16])
|
||||||
|
pdf.drawRightString(margin + 330, y, f"{line.quantity:g}")
|
||||||
|
pdf.drawRightString(margin + 420, y, _fmt_money(unit))
|
||||||
|
pdf.drawRightString(page_width - margin, y, _fmt_money(line_total))
|
||||||
|
y -= 14
|
||||||
|
|
||||||
|
y -= 4
|
||||||
|
pdf.line(margin, y, page_width - margin, y)
|
||||||
|
y -= 16
|
||||||
|
pdf.setFont("Helvetica-Bold", 10)
|
||||||
|
pdf.drawRightString(margin + 420, y, "Subtotal (ex GST)")
|
||||||
|
pdf.drawRightString(page_width - margin, y, _fmt_money(order.subtotal_ex_gst))
|
||||||
|
y -= 20
|
||||||
|
|
||||||
|
if order.requires_quote:
|
||||||
|
pdf.setFont("Helvetica-Oblique", 9)
|
||||||
|
pdf.setFillColor(colors.HexColor("#8a5a00"))
|
||||||
|
pdf.drawString(margin, y, "This order contains quote-only items. Final pricing will be confirmed by our team.")
|
||||||
|
y -= 14
|
||||||
|
|
||||||
|
if order.delivery_notes:
|
||||||
|
pdf.setFont("Helvetica", 9)
|
||||||
|
pdf.setFillColor(colors.black)
|
||||||
|
pdf.drawString(margin, y, f"Notes: {order.delivery_notes[:90]}")
|
||||||
|
|
||||||
|
pdf.setFont("Helvetica-Oblique", 8)
|
||||||
|
pdf.setFillColor(colors.HexColor("#888888"))
|
||||||
|
pdf.drawString(margin, margin - 12, "Prices exclude GST. This is an order confirmation, not a tax invoice.")
|
||||||
|
|
||||||
|
pdf.showPage()
|
||||||
|
pdf.save()
|
||||||
|
return buffer.getvalue()
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Customer-specific pricing engine for the B2B ordering portal.
|
||||||
|
|
||||||
|
The backend is the single source of truth for prices — the frontend never
|
||||||
|
computes a final price. :func:`resolve_price` returns a fully-attributed
|
||||||
|
:class:`PriceResolution` so every order line, and any future report, can explain
|
||||||
|
exactly which rule produced the number.
|
||||||
|
|
||||||
|
Resolution priority (highest wins):
|
||||||
|
|
||||||
|
1. **Quote-only** — the product requires a manual quote, or the customer has a
|
||||||
|
``quote`` price rule for it. No automatic price.
|
||||||
|
2. **Fixed / contract** — a :class:`CustomerProductPrice` for this customer +
|
||||||
|
product (quantity tiers may refine it).
|
||||||
|
3. **Price list** — a :class:`PriceListItem` from the customer's assigned price
|
||||||
|
list (quantity tiers may refine it).
|
||||||
|
4. **Base + discount** — the catalogue list price, optionally reduced by the
|
||||||
|
customer's default discount percentage.
|
||||||
|
5. **Quote fallback** — no resolvable price ⇒ treated as quote-only.
|
||||||
|
|
||||||
|
All prices are GST-exclusive.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.ordering import (
|
||||||
|
CatalogueProduct,
|
||||||
|
CustomerPriceAssignment,
|
||||||
|
CustomerProductPrice,
|
||||||
|
PriceListItem,
|
||||||
|
PriceTier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PriceResolution:
|
||||||
|
unit_price: float | None
|
||||||
|
price_source: str # one of ordering.PRICE_SOURCES
|
||||||
|
price_rule_id: int | None
|
||||||
|
discount_percent: float
|
||||||
|
requires_quote: bool
|
||||||
|
label: str
|
||||||
|
|
||||||
|
def line_total(self, quantity: float) -> float | None:
|
||||||
|
if self.unit_price is None:
|
||||||
|
return None
|
||||||
|
return round(self.unit_price * quantity, 4)
|
||||||
|
|
||||||
|
|
||||||
|
_SOURCE_LABELS = {
|
||||||
|
"fixed": "Fixed price",
|
||||||
|
"contract": "Contract price",
|
||||||
|
"price_list": "Price list",
|
||||||
|
"tiered": "Tiered price",
|
||||||
|
"base": "List price",
|
||||||
|
"quote": "Quote required",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _best_tier_price(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
customer_product_price_id: int | None = None,
|
||||||
|
price_list_item_id: int | None = None,
|
||||||
|
quantity: float,
|
||||||
|
) -> float | None:
|
||||||
|
"""Return the unit price of the highest qualifying quantity tier, if any."""
|
||||||
|
stmt = select(PriceTier).where(PriceTier.min_quantity <= quantity)
|
||||||
|
if customer_product_price_id is not None:
|
||||||
|
stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
|
||||||
|
elif price_list_item_id is not None:
|
||||||
|
stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
tiers = db.scalars(stmt.order_by(PriceTier.min_quantity.desc())).all()
|
||||||
|
return tiers[0].unit_price if tiers else None
|
||||||
|
|
||||||
|
|
||||||
|
def _quote(label: str = "Quote required") -> PriceResolution:
|
||||||
|
return PriceResolution(
|
||||||
|
unit_price=None,
|
||||||
|
price_source="quote",
|
||||||
|
price_rule_id=None,
|
||||||
|
discount_percent=0.0,
|
||||||
|
requires_quote=True,
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_assignment(
|
||||||
|
db: Session, *, client_account_id: int
|
||||||
|
) -> CustomerPriceAssignment | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(CustomerPriceAssignment).where(
|
||||||
|
CustomerPriceAssignment.client_account_id == client_account_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_price(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
client_account_id: int,
|
||||||
|
product: CatalogueProduct,
|
||||||
|
quantity: float,
|
||||||
|
) -> PriceResolution:
|
||||||
|
"""Resolve the GST-exclusive unit price for a customer + product + quantity."""
|
||||||
|
quantity = max(quantity, 0.0)
|
||||||
|
|
||||||
|
# 1. Product-level manual-quote flag.
|
||||||
|
if product.requires_quote:
|
||||||
|
return _quote()
|
||||||
|
|
||||||
|
# 2. Customer-specific fixed/contract/quote rule.
|
||||||
|
cpp = db.scalar(
|
||||||
|
select(CustomerProductPrice).where(
|
||||||
|
CustomerProductPrice.client_account_id == client_account_id,
|
||||||
|
CustomerProductPrice.product_id == product.id,
|
||||||
|
CustomerProductPrice.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if cpp is not None:
|
||||||
|
if cpp.rule_type == "quote":
|
||||||
|
return _quote()
|
||||||
|
tier_price = _best_tier_price(
|
||||||
|
db, customer_product_price_id=cpp.id, quantity=quantity
|
||||||
|
)
|
||||||
|
if tier_price is not None:
|
||||||
|
return PriceResolution(
|
||||||
|
unit_price=round(tier_price, 4),
|
||||||
|
price_source="tiered",
|
||||||
|
price_rule_id=cpp.id,
|
||||||
|
discount_percent=0.0,
|
||||||
|
requires_quote=False,
|
||||||
|
label=_SOURCE_LABELS["tiered"],
|
||||||
|
)
|
||||||
|
if cpp.unit_price is not None:
|
||||||
|
source = "contract" if cpp.rule_type == "contract" else "fixed"
|
||||||
|
return PriceResolution(
|
||||||
|
unit_price=round(cpp.unit_price, 4),
|
||||||
|
price_source=source,
|
||||||
|
price_rule_id=cpp.id,
|
||||||
|
discount_percent=0.0,
|
||||||
|
requires_quote=False,
|
||||||
|
label=_SOURCE_LABELS[source],
|
||||||
|
)
|
||||||
|
|
||||||
|
assignment = get_customer_assignment(db, client_account_id=client_account_id)
|
||||||
|
|
||||||
|
# 3. Assigned price list.
|
||||||
|
if assignment is not None and assignment.price_list_id is not None:
|
||||||
|
pli = db.scalar(
|
||||||
|
select(PriceListItem).where(
|
||||||
|
PriceListItem.price_list_id == assignment.price_list_id,
|
||||||
|
PriceListItem.product_id == product.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pli is not None:
|
||||||
|
tier_price = _best_tier_price(
|
||||||
|
db, price_list_item_id=pli.id, quantity=quantity
|
||||||
|
)
|
||||||
|
if tier_price is not None:
|
||||||
|
return PriceResolution(
|
||||||
|
unit_price=round(tier_price, 4),
|
||||||
|
price_source="tiered",
|
||||||
|
price_rule_id=pli.id,
|
||||||
|
discount_percent=0.0,
|
||||||
|
requires_quote=False,
|
||||||
|
label=_SOURCE_LABELS["tiered"],
|
||||||
|
)
|
||||||
|
return PriceResolution(
|
||||||
|
unit_price=round(pli.unit_price, 4),
|
||||||
|
price_source="price_list",
|
||||||
|
price_rule_id=pli.id,
|
||||||
|
discount_percent=0.0,
|
||||||
|
requires_quote=False,
|
||||||
|
label=_SOURCE_LABELS["price_list"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Base price (+ optional customer discount).
|
||||||
|
if product.base_price is not None:
|
||||||
|
discount = assignment.discount_percent if assignment else 0.0
|
||||||
|
discount = min(max(discount, 0.0), 100.0)
|
||||||
|
unit_price = round(product.base_price * (1.0 - discount / 100.0), 4)
|
||||||
|
label = _SOURCE_LABELS["base"]
|
||||||
|
if discount:
|
||||||
|
label = f"List price less {discount:g}%"
|
||||||
|
return PriceResolution(
|
||||||
|
unit_price=unit_price,
|
||||||
|
price_source="base",
|
||||||
|
price_rule_id=product.id,
|
||||||
|
discount_percent=discount,
|
||||||
|
requires_quote=False,
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. No resolvable price ⇒ quote.
|
||||||
|
return _quote("No price set — quote required")
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""Shared ordering-portal business logic: lifecycle, visibility, totals,
|
||||||
|
serialization, and audit.
|
||||||
|
|
||||||
|
Customer isolation is enforced by callers passing ``tenant_id`` /
|
||||||
|
``client_account_id`` into every query; this module never widens that scope.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.client_access import ClientAccount
|
||||||
|
from app.models.ordering import (
|
||||||
|
CatalogueProduct,
|
||||||
|
CustomerProductVisibility,
|
||||||
|
Order,
|
||||||
|
OrderLine,
|
||||||
|
OrderStatusHistory,
|
||||||
|
)
|
||||||
|
from app.services.client_access_service import record_audit_event
|
||||||
|
from app.services.ordering_pricing import resolve_price
|
||||||
|
|
||||||
|
# The ordering catalogue is "standard globally" — it lives under a single
|
||||||
|
# seller/ordering tenant, while individual customers (ClientAccounts) may have
|
||||||
|
# their own tenant_id. Customer isolation therefore keys on the globally-unique
|
||||||
|
# ``client_account_id``, NOT on the customer's session tenant. Admin and
|
||||||
|
# customer code both store/read ordering data under ORDERING_TENANT.
|
||||||
|
ORDERING_TENANT = settings.client_tenant_id
|
||||||
|
|
||||||
|
# --- Lifecycle ---------------------------------------------------------------
|
||||||
|
|
||||||
|
# Full internal lifecycle → the simplified status a customer sees.
|
||||||
|
CUSTOMER_VISIBLE_STATUS = {
|
||||||
|
"draft": "draft",
|
||||||
|
"submitted": "submitted",
|
||||||
|
"under_review": "processing",
|
||||||
|
"confirmed": "confirmed",
|
||||||
|
"sent_to_xero": "confirmed",
|
||||||
|
"in_production": "processing",
|
||||||
|
"ready_for_pickup": "ready",
|
||||||
|
"dispatched": "dispatched",
|
||||||
|
"completed": "completed",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statuses an admin may move an order into from a given status. Cancellation is
|
||||||
|
# allowed from any non-terminal status (handled separately).
|
||||||
|
ALLOWED_ADMIN_TRANSITIONS: dict[str, set[str]] = {
|
||||||
|
"draft": {"submitted", "cancelled"},
|
||||||
|
"submitted": {"under_review", "confirmed", "cancelled"},
|
||||||
|
"under_review": {"confirmed", "submitted", "cancelled"},
|
||||||
|
"confirmed": {"sent_to_xero", "in_production", "cancelled"},
|
||||||
|
"sent_to_xero": {"in_production", "cancelled"},
|
||||||
|
"in_production": {"ready_for_pickup", "dispatched", "cancelled"},
|
||||||
|
"ready_for_pickup": {"dispatched", "completed", "cancelled"},
|
||||||
|
"dispatched": {"completed", "cancelled"},
|
||||||
|
"completed": set(),
|
||||||
|
"cancelled": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Customers may only edit an order while it is a draft.
|
||||||
|
CUSTOMER_EDITABLE_STATUSES = {"draft"}
|
||||||
|
|
||||||
|
|
||||||
|
def can_admin_transition(from_status: str, to_status: str) -> bool:
|
||||||
|
if to_status == "cancelled" and from_status not in {"completed", "cancelled"}:
|
||||||
|
return True
|
||||||
|
return to_status in ALLOWED_ADMIN_TRANSITIONS.get(from_status, set())
|
||||||
|
|
||||||
|
|
||||||
|
def record_status_change(
|
||||||
|
db: Session,
|
||||||
|
order: Order,
|
||||||
|
*,
|
||||||
|
to_status: str,
|
||||||
|
actor_type: str,
|
||||||
|
actor_name: str | None,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
db.add(
|
||||||
|
OrderStatusHistory(
|
||||||
|
tenant_id=order.tenant_id,
|
||||||
|
order_id=order.id,
|
||||||
|
from_status=order.status,
|
||||||
|
to_status=to_status,
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_name=actor_name,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order.status = to_status
|
||||||
|
|
||||||
|
|
||||||
|
def next_order_number(db: Session, tenant_id: str) -> str:
|
||||||
|
# Cheap monotonic-ish numbering based on max id; good enough for v1.
|
||||||
|
last_id = db.scalar(select(Order.id).order_by(Order.id.desc()).limit(1)) or 0
|
||||||
|
return f"ORD-{last_id + 1:06d}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Product visibility ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def visible_product_ids_for_customer(db: Session, *, tenant_id: str, client_account_id: int) -> set[int]:
|
||||||
|
"""Ids the customer is explicitly *hidden* from (opt-out model)."""
|
||||||
|
rows = db.scalars(
|
||||||
|
select(CustomerProductVisibility.product_id).where(
|
||||||
|
CustomerProductVisibility.client_account_id == client_account_id,
|
||||||
|
CustomerProductVisibility.tenant_id == tenant_id,
|
||||||
|
CustomerProductVisibility.visible.is_(False),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def list_visible_products(
|
||||||
|
db: Session, *, tenant_id: str, client_account_id: int
|
||||||
|
) -> list[CatalogueProduct]:
|
||||||
|
hidden = visible_product_ids_for_customer(
|
||||||
|
db, tenant_id=tenant_id, client_account_id=client_account_id
|
||||||
|
)
|
||||||
|
products = db.scalars(
|
||||||
|
select(CatalogueProduct)
|
||||||
|
.where(CatalogueProduct.tenant_id == tenant_id, CatalogueProduct.active.is_(True))
|
||||||
|
.order_by(CatalogueProduct.category, CatalogueProduct.name)
|
||||||
|
).all()
|
||||||
|
return [p for p in products if p.id not in hidden]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Totals ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def effective_unit_price(line: OrderLine) -> float | None:
|
||||||
|
if line.admin_override_price is not None:
|
||||||
|
return line.admin_override_price
|
||||||
|
return line.unit_price
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_order_totals(order: Order) -> None:
|
||||||
|
subtotal = 0.0
|
||||||
|
requires_quote = False
|
||||||
|
for line in order.lines:
|
||||||
|
unit = effective_unit_price(line)
|
||||||
|
if line.requires_quote or unit is None:
|
||||||
|
requires_quote = True
|
||||||
|
line.line_total = None
|
||||||
|
continue
|
||||||
|
line.line_total = round(unit * line.quantity, 4)
|
||||||
|
subtotal += line.line_total
|
||||||
|
order.subtotal_ex_gst = round(subtotal, 4)
|
||||||
|
order.requires_quote = requires_quote
|
||||||
|
|
||||||
|
|
||||||
|
# --- Serialization -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_product(
|
||||||
|
product: CatalogueProduct,
|
||||||
|
*,
|
||||||
|
db: Session | None = None,
|
||||||
|
client_account_id: int | None = None,
|
||||||
|
quantity: float = 1.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Serialize a catalogue product. When ``db`` + ``client_account_id`` are
|
||||||
|
given, attach the resolved customer-specific price + provenance."""
|
||||||
|
data = {
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
"sku": product.sku,
|
||||||
|
"description": product.description,
|
||||||
|
"category": product.category,
|
||||||
|
"image_url": product.image_url,
|
||||||
|
"unit_size": product.unit_size,
|
||||||
|
"unit_of_measure": product.unit_of_measure,
|
||||||
|
"min_order_quantity": product.min_order_quantity,
|
||||||
|
"stock_status": product.stock_status,
|
||||||
|
"active": product.active,
|
||||||
|
"requires_quote": product.requires_quote,
|
||||||
|
"base_price": product.base_price,
|
||||||
|
"created_at": product.created_at,
|
||||||
|
}
|
||||||
|
if db is not None and client_account_id is not None:
|
||||||
|
resolution = resolve_price(
|
||||||
|
db, client_account_id=client_account_id, product=product, quantity=quantity
|
||||||
|
)
|
||||||
|
data["price"] = {
|
||||||
|
"unit_price": resolution.unit_price,
|
||||||
|
"price_source": resolution.price_source,
|
||||||
|
"price_rule_id": resolution.price_rule_id,
|
||||||
|
"discount_percent": resolution.discount_percent,
|
||||||
|
"requires_quote": resolution.requires_quote,
|
||||||
|
"label": resolution.label,
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_order_line(line: OrderLine, *, for_admin: bool) -> dict:
|
||||||
|
data = {
|
||||||
|
"id": line.id,
|
||||||
|
"product_id": line.product_id,
|
||||||
|
"product_name": line.product_name,
|
||||||
|
"product_sku": line.product_sku,
|
||||||
|
"quantity": line.quantity,
|
||||||
|
"unit_price": effective_unit_price(line),
|
||||||
|
"line_total": line.line_total,
|
||||||
|
"requires_quote": line.requires_quote,
|
||||||
|
"price_source": line.price_source,
|
||||||
|
"discount_percent": line.discount_percent,
|
||||||
|
"notes": line.notes,
|
||||||
|
}
|
||||||
|
if for_admin:
|
||||||
|
data.update(
|
||||||
|
{
|
||||||
|
"resolved_unit_price": line.unit_price,
|
||||||
|
"admin_override_price": line.admin_override_price,
|
||||||
|
"admin_override_reason": line.admin_override_reason,
|
||||||
|
"price_rule_id": line.price_rule_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_order(order: Order, *, for_admin: bool) -> dict:
|
||||||
|
status = order.status if for_admin else CUSTOMER_VISIBLE_STATUS.get(order.status, order.status)
|
||||||
|
data = {
|
||||||
|
"id": order.id,
|
||||||
|
"order_number": order.order_number,
|
||||||
|
"status": status,
|
||||||
|
"client_account_id": order.client_account_id,
|
||||||
|
"created_by_name": order.created_by_name,
|
||||||
|
"purchase_order_number": order.purchase_order_number,
|
||||||
|
"delivery_notes": order.delivery_notes,
|
||||||
|
"requested_delivery_date": order.requested_delivery_date,
|
||||||
|
"fulfilment_method": order.fulfilment_method,
|
||||||
|
"subtotal_ex_gst": order.subtotal_ex_gst,
|
||||||
|
"requires_quote": order.requires_quote,
|
||||||
|
"submitted_at": order.submitted_at,
|
||||||
|
"created_at": order.created_at,
|
||||||
|
"updated_at": order.updated_at,
|
||||||
|
"editable": order.status in CUSTOMER_EDITABLE_STATUSES,
|
||||||
|
"lines": [serialize_order_line(line, for_admin=for_admin) for line in order.lines],
|
||||||
|
}
|
||||||
|
if for_admin:
|
||||||
|
data.update(
|
||||||
|
{
|
||||||
|
"raw_status": order.status,
|
||||||
|
"admin_notes": order.admin_notes,
|
||||||
|
"reopened": order.reopened,
|
||||||
|
"xero_status": order.xero_status,
|
||||||
|
"xero_invoice_id": order.xero_invoice_id,
|
||||||
|
"status_history": [
|
||||||
|
{
|
||||||
|
"id": h.id,
|
||||||
|
"from_status": h.from_status,
|
||||||
|
"to_status": h.to_status,
|
||||||
|
"actor_type": h.actor_type,
|
||||||
|
"actor_name": h.actor_name,
|
||||||
|
"note": h.note,
|
||||||
|
"created_at": h.created_at,
|
||||||
|
}
|
||||||
|
for h in order.status_history
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# --- Audit -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def audit_order_event(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
session: AuthSession,
|
||||||
|
order: Order,
|
||||||
|
action: str,
|
||||||
|
summary: str,
|
||||||
|
target_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record an ordering audit event onto the shared audit trail."""
|
||||||
|
actor_type = "lean_admin" if session.role in {"admin", "internal"} else "customer"
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
tenant_id=order.tenant_id,
|
||||||
|
client_account_id=order.client_account_id,
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_name=session.name or "",
|
||||||
|
actor_email=session.email or "",
|
||||||
|
actor_role=session.client_role or session.role,
|
||||||
|
action=action,
|
||||||
|
target_type="order",
|
||||||
|
target_id=target_id if target_id is not None else order.id,
|
||||||
|
module_key="ordering",
|
||||||
|
summary=summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_account(db: Session, *, client_account_id: int) -> ClientAccount | None:
|
||||||
|
# client_account_id is a global primary key; a customer's own tenant_id may
|
||||||
|
# differ from ORDERING_TENANT, so we look up by id alone.
|
||||||
|
return db.scalar(select(ClientAccount).where(ClientAccount.id == client_account_id))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_customer_active(account: ClientAccount | None) -> None:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
if account is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer account not found")
|
||||||
|
if account.status != "active":
|
||||||
|
raise HTTPException(status_code=403, detail="This customer account is disabled")
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.product_costing import (
|
||||||
|
ProductCostBagInput,
|
||||||
|
ProductCostBaseInput,
|
||||||
|
ProductCostClientInput,
|
||||||
|
ProductCostFreightInput,
|
||||||
|
ProductCostItem,
|
||||||
|
ProductCostProcessInput,
|
||||||
|
)
|
||||||
|
from app.services.costing_engine import calculate_product_cost
|
||||||
|
|
||||||
|
|
||||||
|
UNIT_TYPES = ("Standard", "Bulka", "1.5 kg", "Per Unit")
|
||||||
|
OWN_BAG_VALUES = ("Yes", "No Bag")
|
||||||
|
ZERO_GRADING_CLIENTS = {"PHF Horse Mixes", "Peckish", "Hay & Straw"}
|
||||||
|
PROCESS_NAMES = ("Bagging + Grading", "Standard Bagging", "PHF Horse Mixes", "Peckish", "Hay & Straw")
|
||||||
|
BAG_INPUTS = {
|
||||||
|
"20kg_bag": "20kg bag",
|
||||||
|
"bulka_bag": "Bulka bag",
|
||||||
|
"own_bag_credit": "Own bag credit",
|
||||||
|
"1_5kg_bagging": "1.5kg bagging",
|
||||||
|
"peckish_bag": "Peckish bag",
|
||||||
|
}
|
||||||
|
FREIGHT_INPUTS = {
|
||||||
|
"freight_per_pallet": "Freight per pallet",
|
||||||
|
"peckish_freight_per_pallet": "Peckish freight per pallet",
|
||||||
|
"hay_straw_freight_per_pallet": "Hay & Straw freight per pallet",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProductCostInputItem:
|
||||||
|
client_category: str
|
||||||
|
product_name: str
|
||||||
|
mix_product_name: str
|
||||||
|
unit_type: str
|
||||||
|
own_bag: str | None
|
||||||
|
unit_kg: float | None
|
||||||
|
items_per_pallet: int | None
|
||||||
|
bagging_process: str | None
|
||||||
|
manual_distributor_margin: float | None
|
||||||
|
manual_wholesale_margin: float | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProductCostAssumptions:
|
||||||
|
grading_per_kg: float
|
||||||
|
cracking_per_kg: float
|
||||||
|
process_costs: dict[str, float]
|
||||||
|
client_margins: dict[str, dict[str, float | None]]
|
||||||
|
bag_costs: dict[str, float]
|
||||||
|
freight_costs: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProductCostCalculation:
|
||||||
|
cleaned_product_cost_per_kg: float | None
|
||||||
|
grading_cost_per_kg: float | None
|
||||||
|
bagging_cost_per_kg: float | None
|
||||||
|
cracking_cost_per_kg: float | None
|
||||||
|
bag_cost_per_unit: float | None
|
||||||
|
freight_cost_per_unit: float | None
|
||||||
|
finished_product_delivered_cost: float | None
|
||||||
|
distributor_price: float | None
|
||||||
|
wholesale_price: float | None
|
||||||
|
warnings: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _round4(value: float | None) -> float | None:
|
||||||
|
return None if value is None else round(value, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _ceil_to(value: float, digits: int) -> float:
|
||||||
|
factor = 10**digits
|
||||||
|
return math.ceil((value * factor) - 1e-9) / factor
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_margin(value: float | None, label: str, warnings: list[str]) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value < 0 or value >= 1:
|
||||||
|
warnings.append(f"Invalid {label} margin")
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_product_cost_item(
|
||||||
|
item: ProductCostInputItem,
|
||||||
|
assumptions: ProductCostAssumptions,
|
||||||
|
cleaned_product_cost_per_kg: float | None,
|
||||||
|
) -> ProductCostCalculation:
|
||||||
|
warnings: list[str] = []
|
||||||
|
unit_type = item.unit_type or "Standard"
|
||||||
|
unit_kg = item.unit_kg
|
||||||
|
items_per_pallet = item.items_per_pallet
|
||||||
|
|
||||||
|
if unit_type not in UNIT_TYPES:
|
||||||
|
warnings.append("Invalid unit type")
|
||||||
|
|
||||||
|
if cleaned_product_cost_per_kg is None:
|
||||||
|
warnings.append("Missing mix/product cost lookup")
|
||||||
|
|
||||||
|
if unit_kg is None or unit_kg <= 0:
|
||||||
|
warnings.append("Missing unit kg")
|
||||||
|
|
||||||
|
if items_per_pallet is None or items_per_pallet <= 0:
|
||||||
|
warnings.append("Missing pallet quantity")
|
||||||
|
|
||||||
|
grading_cost_per_kg = 0.0
|
||||||
|
if item.client_category not in ZERO_GRADING_CLIENTS and item.bagging_process:
|
||||||
|
grading_cost_per_kg = assumptions.grading_per_kg
|
||||||
|
|
||||||
|
bagging_cost_per_kg = assumptions.process_costs.get(item.bagging_process or "", 0.0)
|
||||||
|
if item.bagging_process and item.bagging_process not in assumptions.process_costs:
|
||||||
|
warnings.append("Missing bagging process cost")
|
||||||
|
|
||||||
|
cracking_cost_per_kg = assumptions.cracking_per_kg if "cracked" in item.product_name.lower() else 0.0
|
||||||
|
|
||||||
|
bag_cost_per_unit = 0.0
|
||||||
|
if item.client_category == "Peckish":
|
||||||
|
bag_cost_per_unit = assumptions.bag_costs.get("peckish_bag", 0.0)
|
||||||
|
elif unit_type == "1.5 kg":
|
||||||
|
bag_cost_per_unit = assumptions.bag_costs.get("1_5kg_bagging", 0.0)
|
||||||
|
elif item.own_bag == "No Bag":
|
||||||
|
bag_cost_per_unit = 0.0
|
||||||
|
elif unit_type == "Standard":
|
||||||
|
bag_cost_per_unit = assumptions.bag_costs.get("20kg_bag", 0.0)
|
||||||
|
elif unit_type == "Bulka":
|
||||||
|
bag_cost_per_unit = assumptions.bag_costs.get("bulka_bag", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
|
||||||
|
if bag_cost_per_unit is not None and item.own_bag == "Yes":
|
||||||
|
bag_cost_per_unit -= assumptions.bag_costs.get("own_bag_credit", 0.0)
|
||||||
|
|
||||||
|
freight_cost_per_unit: float | None
|
||||||
|
if item.client_category == "Peckish":
|
||||||
|
freight_cost_per_unit = assumptions.freight_costs.get("peckish_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
|
||||||
|
elif item.client_category == "Hay & Straw":
|
||||||
|
freight_cost_per_unit = assumptions.freight_costs.get("hay_straw_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
|
||||||
|
elif unit_type in {"Standard", "Per Unit"}:
|
||||||
|
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
|
||||||
|
elif unit_type == "Bulka":
|
||||||
|
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
|
||||||
|
else:
|
||||||
|
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / 1000 * unit_kg if unit_kg and unit_kg > 0 else None
|
||||||
|
|
||||||
|
finished_cost = None
|
||||||
|
components = [cleaned_product_cost_per_kg, grading_cost_per_kg, bagging_cost_per_kg, cracking_cost_per_kg, bag_cost_per_unit, freight_cost_per_unit]
|
||||||
|
if all(value is not None for value in components) and unit_kg and unit_kg > 0:
|
||||||
|
per_kg_cost = cleaned_product_cost_per_kg + grading_cost_per_kg + bagging_cost_per_kg + cracking_cost_per_kg # type: ignore[operator]
|
||||||
|
if unit_type == "Standard":
|
||||||
|
finished_cost = per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
|
||||||
|
elif unit_type in {"Bulka", "Per Unit"}:
|
||||||
|
finished_cost = per_kg_cost + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
|
||||||
|
else:
|
||||||
|
finished_cost = (per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit) * 8 # type: ignore[operator]
|
||||||
|
|
||||||
|
client_margin = assumptions.client_margins.get(item.client_category, {})
|
||||||
|
distributor_margin = _valid_margin(
|
||||||
|
item.manual_distributor_margin if item.manual_distributor_margin is not None else client_margin.get("distributor_margin"),
|
||||||
|
"distributor",
|
||||||
|
warnings,
|
||||||
|
)
|
||||||
|
wholesale_margin = _valid_margin(
|
||||||
|
item.manual_wholesale_margin if item.manual_wholesale_margin is not None else client_margin.get("wholesale_margin"),
|
||||||
|
"wholesale",
|
||||||
|
warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
distributor_price = finished_cost / (1 - distributor_margin) if finished_cost is not None and distributor_margin is not None else None
|
||||||
|
wholesale_price = finished_cost / (1 - wholesale_margin) if finished_cost is not None and wholesale_margin is not None else None
|
||||||
|
if wholesale_price is not None:
|
||||||
|
wholesale_price = _ceil_to(wholesale_price, 2 if item.client_category == "Straight Grain" and unit_type == "Bulka" else 1)
|
||||||
|
|
||||||
|
return ProductCostCalculation(
|
||||||
|
cleaned_product_cost_per_kg=_round4(cleaned_product_cost_per_kg),
|
||||||
|
grading_cost_per_kg=_round4(grading_cost_per_kg),
|
||||||
|
bagging_cost_per_kg=_round4(bagging_cost_per_kg),
|
||||||
|
cracking_cost_per_kg=_round4(cracking_cost_per_kg),
|
||||||
|
bag_cost_per_unit=_round4(bag_cost_per_unit),
|
||||||
|
freight_cost_per_unit=_round4(freight_cost_per_unit),
|
||||||
|
finished_product_delivered_cost=_round4(finished_cost),
|
||||||
|
distributor_price=_round4(distributor_price),
|
||||||
|
wholesale_price=_round4(wholesale_price),
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _item_input(item: ProductCostItem) -> ProductCostInputItem:
|
||||||
|
return ProductCostInputItem(
|
||||||
|
client_category=item.client_category,
|
||||||
|
product_name=item.product_name,
|
||||||
|
mix_product_name=item.mix_product_name,
|
||||||
|
unit_type=item.unit_type,
|
||||||
|
own_bag=item.own_bag,
|
||||||
|
unit_kg=item.unit_kg,
|
||||||
|
items_per_pallet=item.items_per_pallet,
|
||||||
|
bagging_process=item.bagging_process,
|
||||||
|
manual_distributor_margin=item.manual_distributor_margin,
|
||||||
|
manual_wholesale_margin=item.manual_wholesale_margin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_product_costing_assumptions(db: Session, tenant_id: str) -> ProductCostAssumptions:
|
||||||
|
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
|
||||||
|
if base is None:
|
||||||
|
base = ProductCostBaseInput(tenant_id=tenant_id)
|
||||||
|
db.add(base)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
process_costs = {
|
||||||
|
row.process_name: row.cost_per_kg
|
||||||
|
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
client_margins = {
|
||||||
|
row.client_category: {
|
||||||
|
"distributor_margin": row.distributor_margin,
|
||||||
|
"wholesale_margin": row.wholesale_margin,
|
||||||
|
}
|
||||||
|
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
bag_costs = {
|
||||||
|
row.input_key: row.cost
|
||||||
|
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
freight_costs = {
|
||||||
|
row.input_key: row.cost
|
||||||
|
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
|
||||||
|
}
|
||||||
|
return ProductCostAssumptions(
|
||||||
|
grading_per_kg=base.grading_per_kg or ((base.grading_per_tonne or 0.0) / 1000),
|
||||||
|
cracking_per_kg=base.cracking_per_kg or ((base.cracking_per_tonne or 0.0) / 1000),
|
||||||
|
process_costs=process_costs,
|
||||||
|
client_margins=client_margins,
|
||||||
|
bag_costs=bag_costs,
|
||||||
|
freight_costs=freight_costs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_cleaned_product_cost_per_kg(db: Session, item: ProductCostItem) -> float | None:
|
||||||
|
product = db.scalar(
|
||||||
|
select(Product)
|
||||||
|
.where(
|
||||||
|
Product.tenant_id == item.tenant_id,
|
||||||
|
Product.client_name == item.client_category,
|
||||||
|
Product.name == item.mix_product_name,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if product is None:
|
||||||
|
product = db.scalar(
|
||||||
|
select(Product)
|
||||||
|
.where(
|
||||||
|
Product.tenant_id == item.tenant_id,
|
||||||
|
Product.client_name == item.client_category,
|
||||||
|
Product.name == item.product_name,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if product is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = calculate_product_cost(db, product.id)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
mix = (result.get("inputs") or {}).get("mix") or {}
|
||||||
|
return mix.get("mix_cost_per_kg")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_calculation(item: ProductCostItem, calculation: ProductCostCalculation) -> ProductCostItem:
|
||||||
|
item.cleaned_product_cost_per_kg = calculation.cleaned_product_cost_per_kg
|
||||||
|
item.grading_cost_per_kg = calculation.grading_cost_per_kg
|
||||||
|
item.bagging_cost_per_kg = calculation.bagging_cost_per_kg
|
||||||
|
item.cracking_cost_per_kg = calculation.cracking_cost_per_kg
|
||||||
|
item.bag_cost_per_unit = calculation.bag_cost_per_unit
|
||||||
|
item.freight_cost_per_unit = calculation.freight_cost_per_unit
|
||||||
|
item.finished_product_delivered_cost = calculation.finished_product_delivered_cost
|
||||||
|
item.distributor_price = calculation.distributor_price
|
||||||
|
item.wholesale_price = calculation.wholesale_price
|
||||||
|
item.warnings = json.dumps(calculation.warnings)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def recalculate_product_cost_item(db: Session, item: ProductCostItem) -> ProductCostItem:
|
||||||
|
assumptions = get_product_costing_assumptions(db, item.tenant_id)
|
||||||
|
cleaned_cost = lookup_cleaned_product_cost_per_kg(db, item)
|
||||||
|
calculation = calculate_product_cost_item(_item_input(item), assumptions, cleaned_cost)
|
||||||
|
return apply_calculation(item, calculation)
|
||||||
|
|
||||||
|
|
||||||
|
def recalculate_all_product_cost_items(db: Session, tenant_id: str) -> int:
|
||||||
|
items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
|
||||||
|
for item in items:
|
||||||
|
recalculate_product_cost_item(db, item)
|
||||||
|
return len(items)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_product_cost_item(item: ProductCostItem) -> dict:
|
||||||
|
warnings = []
|
||||||
|
if item.warnings:
|
||||||
|
try:
|
||||||
|
warnings = json.loads(item.warnings)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
warnings = [item.warnings]
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"tenant_id": item.tenant_id,
|
||||||
|
"client_category": item.client_category,
|
||||||
|
"item_id": item.item_id,
|
||||||
|
"product_name": item.product_name,
|
||||||
|
"mix_product_name": item.mix_product_name,
|
||||||
|
"unit_type": item.unit_type,
|
||||||
|
"own_bag": item.own_bag,
|
||||||
|
"unit_kg": item.unit_kg,
|
||||||
|
"items_per_pallet": item.items_per_pallet,
|
||||||
|
"bagging_process": item.bagging_process,
|
||||||
|
"manual_distributor_margin": item.manual_distributor_margin,
|
||||||
|
"manual_wholesale_margin": item.manual_wholesale_margin,
|
||||||
|
"cleaned_product_cost_per_kg": item.cleaned_product_cost_per_kg,
|
||||||
|
"grading_cost_per_kg": item.grading_cost_per_kg,
|
||||||
|
"bagging_cost_per_kg": item.bagging_cost_per_kg,
|
||||||
|
"cracking_cost_per_kg": item.cracking_cost_per_kg,
|
||||||
|
"bag_cost_per_unit": item.bag_cost_per_unit,
|
||||||
|
"freight_cost_per_unit": item.freight_cost_per_unit,
|
||||||
|
"finished_product_delivered_cost": item.finished_product_delivered_cost,
|
||||||
|
"distributor_price": item.distributor_price,
|
||||||
|
"wholesale_price": item.wholesale_price,
|
||||||
|
"warnings": warnings,
|
||||||
|
"created_at": item.created_at,
|
||||||
|
"updated_at": item.updated_at,
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
@@ -16,6 +20,10 @@ logger = logging.getLogger("data_entry_app.throughput")
|
|||||||
PRODUCTION_SHEET = "Production"
|
PRODUCTION_SHEET = "Production"
|
||||||
NAMES_SHEET = "Names"
|
NAMES_SHEET = "Names"
|
||||||
|
|
||||||
|
# The historical throughput export. Bundled into the image under input_data/ so
|
||||||
|
# the seed can import it on a fresh deployment (e.g. a new Postgres volume).
|
||||||
|
WORKBOOK_FILENAME = "Operations Throughput.xlsx"
|
||||||
|
|
||||||
# Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count.
|
# Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count.
|
||||||
_BULKA_BAG_SIZE_THRESHOLD = 100.0
|
_BULKA_BAG_SIZE_THRESHOLD = 100.0
|
||||||
|
|
||||||
@@ -57,6 +65,10 @@ def serialize_entry(entry: ProductionThroughput) -> dict:
|
|||||||
"label_correct": entry.label_correct,
|
"label_correct": entry.label_correct,
|
||||||
"bag_sealed": entry.bag_sealed,
|
"bag_sealed": entry.bag_sealed,
|
||||||
"pallet_good_condition": entry.pallet_good_condition,
|
"pallet_good_condition": entry.pallet_good_condition,
|
||||||
|
"for_order": entry.for_order,
|
||||||
|
"for_stock": entry.for_stock,
|
||||||
|
"job_number": entry.job_number,
|
||||||
|
"stock_quantity": entry.stock_quantity,
|
||||||
"sample_box_no": entry.sample_box_no,
|
"sample_box_no": entry.sample_box_no,
|
||||||
"test_weight_1": entry.test_weight_1,
|
"test_weight_1": entry.test_weight_1,
|
||||||
"test_weight_2": entry.test_weight_2,
|
"test_weight_2": entry.test_weight_2,
|
||||||
@@ -90,6 +102,29 @@ def _coerce_bool(value: object) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_import_bool(value: object, *, default: bool = False) -> bool:
|
||||||
|
"""Conservative boolean parsing for ad-hoc imports.
|
||||||
|
|
||||||
|
Uploaded CSV/XLSX rows often leave destination columns blank, or use text
|
||||||
|
like "stock" / "order" elsewhere in the row. Those should not silently
|
||||||
|
become True. Only explicit truthy markers opt in.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return bool(value)
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if not text:
|
||||||
|
return default
|
||||||
|
if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}:
|
||||||
|
return True
|
||||||
|
if text in {"no", "n", "false", "0", "fail"}:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _coerce_float(value: object) -> float | None:
|
def _coerce_float(value: object) -> float | None:
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return None
|
return None
|
||||||
@@ -115,7 +150,17 @@ def _coerce_text(value: object) -> str | None:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _coerce_date(value: object) -> date | None:
|
# Default slash-date preference. The app is Australian, so an ambiguous
|
||||||
|
# "x/y/z" is read day-first unless a column is detected as month-first.
|
||||||
|
_DAY_FIRST_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y")
|
||||||
|
_MONTH_FIRST_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y")
|
||||||
|
|
||||||
|
_SLASH_DATE_RE = re.compile(r"^\s*(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(
|
||||||
|
value: object, formats: tuple[str, ...] = _DAY_FIRST_FORMATS
|
||||||
|
) -> date | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
@@ -125,7 +170,7 @@ def _coerce_date(value: object) -> date | None:
|
|||||||
text = str(value).strip()
|
text = str(value).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
|
for fmt in formats:
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(text, fmt).date()
|
return datetime.strptime(text, fmt).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -133,6 +178,32 @@ def _coerce_date(value: object) -> date | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_slash_date_formats(values: Iterable[object]) -> tuple[str, ...]:
|
||||||
|
"""Inspect every slash/dash date in a column and decide whether the file is
|
||||||
|
day-first (D/M/Y) or month-first (M/D/Y), so all rows parse consistently.
|
||||||
|
|
||||||
|
A first component > 12 proves day-first; a second component > 12 proves
|
||||||
|
month-first. If only month-first evidence exists we switch to M/D/Y;
|
||||||
|
otherwise we keep the Australian day-first default.
|
||||||
|
"""
|
||||||
|
day_first = False
|
||||||
|
month_first = False
|
||||||
|
for value in values:
|
||||||
|
if value is None or isinstance(value, (datetime, date)):
|
||||||
|
continue
|
||||||
|
match = _SLASH_DATE_RE.match(str(value))
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
first, second = int(match.group(1)), int(match.group(2))
|
||||||
|
if first > 12:
|
||||||
|
day_first = True
|
||||||
|
elif second > 12:
|
||||||
|
month_first = True
|
||||||
|
if month_first and not day_first:
|
||||||
|
return _MONTH_FIRST_FORMATS
|
||||||
|
return _DAY_FIRST_FORMATS
|
||||||
|
|
||||||
|
|
||||||
def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
|
def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
|
||||||
lowered = name.lower()
|
lowered = name.lower()
|
||||||
if "bulka" in lowered:
|
if "bulka" in lowered:
|
||||||
@@ -323,16 +394,30 @@ def import_workbook(db: Session, workbook_path: Path, tenant_id: str) -> dict:
|
|||||||
|
|
||||||
def workbook_candidates() -> Iterable[Path]:
|
def workbook_candidates() -> Iterable[Path]:
|
||||||
repo_root = Path(__file__).resolve().parents[3]
|
repo_root = Path(__file__).resolve().parents[3]
|
||||||
|
cwd = Path.cwd()
|
||||||
|
|
||||||
|
env_value = os.getenv("THROUGHPUT_WORKBOOK_PATH")
|
||||||
|
env_path = Path(env_value.strip()) if isinstance(env_value, str) and env_value.strip() else None
|
||||||
|
|
||||||
|
# input_data/ is where the workbook is bundled in the image; in the
|
||||||
|
# container the working directory is /app, so cwd/input_data resolves it.
|
||||||
candidates = [
|
candidates = [
|
||||||
repo_root / "Operations Throughput.xlsx",
|
env_path,
|
||||||
repo_root.parent / "Operations Throughput.xlsx",
|
repo_root / "input_data" / WORKBOOK_FILENAME,
|
||||||
Path.cwd() / "Operations Throughput.xlsx",
|
cwd / "input_data" / WORKBOOK_FILENAME,
|
||||||
Path("/srv/lean101-clients") / "Operations Throughput.xlsx",
|
Path("/app") / "input_data" / WORKBOOK_FILENAME,
|
||||||
Path("/app") / "Operations Throughput.xlsx",
|
Path("/srv/lean101-clients") / "input_data" / WORKBOOK_FILENAME,
|
||||||
|
repo_root / WORKBOOK_FILENAME,
|
||||||
|
repo_root.parent / WORKBOOK_FILENAME,
|
||||||
|
cwd / WORKBOOK_FILENAME,
|
||||||
|
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
|
||||||
|
Path("/app") / WORKBOOK_FILENAME,
|
||||||
]
|
]
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
ordered: list[Path] = []
|
ordered: list[Path] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
key = str(candidate)
|
key = str(candidate)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
@@ -346,3 +431,314 @@ def resolve_workbook_path() -> Path | None:
|
|||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ad-hoc CSV / spreadsheet upload import ──────────────────────────────────
|
||||||
|
# Lets an operator upload their own CSV or .xlsx of packing runs (from Settings
|
||||||
|
# → Import) and have every row saved as a throughput entry. Unlike the bundled
|
||||||
|
# workbook seed above, this is column-header driven so the file can be a simple
|
||||||
|
# hand-built sheet rather than the exact "Operations Throughput.xlsx" layout.
|
||||||
|
|
||||||
|
# Maps the column headers we accept (normalised: lower-cased, spaces/dashes →
|
||||||
|
# single spaces) onto the canonical field used internally. Several aliases per
|
||||||
|
# field so a human-built sheet "just works".
|
||||||
|
_HEADER_ALIASES: dict[str, str] = {
|
||||||
|
"date": "date",
|
||||||
|
"production date": "date",
|
||||||
|
"production_date": "date",
|
||||||
|
"product": "product",
|
||||||
|
"product name": "product",
|
||||||
|
"product_name": "product",
|
||||||
|
"product name snapshot": "product",
|
||||||
|
"name": "product",
|
||||||
|
"item id": "item_id",
|
||||||
|
"item_id": "item_id",
|
||||||
|
"itemid": "item_id",
|
||||||
|
"sku": "item_id",
|
||||||
|
"quantity": "quantity",
|
||||||
|
"qty": "quantity",
|
||||||
|
"packed": "quantity",
|
||||||
|
"quantity packed": "quantity",
|
||||||
|
"amount": "quantity",
|
||||||
|
"quantity type": "quantity_type",
|
||||||
|
"type": "quantity_type",
|
||||||
|
"unit": "quantity_type",
|
||||||
|
"packed as": "quantity_type",
|
||||||
|
"bag size": "bag_size",
|
||||||
|
"bag_size": "bag_size",
|
||||||
|
"kg per bag": "bag_size",
|
||||||
|
"kg/bag": "bag_size",
|
||||||
|
"bagsize": "bag_size",
|
||||||
|
"staff": "staff_name",
|
||||||
|
"staff name": "staff_name",
|
||||||
|
"packed by": "staff_name",
|
||||||
|
"operator": "staff_name",
|
||||||
|
"for order": "for_order",
|
||||||
|
"order": "for_order",
|
||||||
|
"for stock": "for_stock",
|
||||||
|
"stock": "for_stock",
|
||||||
|
"job number": "job_number",
|
||||||
|
"job": "job_number",
|
||||||
|
"job no": "job_number",
|
||||||
|
"order number": "job_number",
|
||||||
|
"stock quantity": "stock_quantity",
|
||||||
|
"stock qty": "stock_quantity",
|
||||||
|
"sample box no": "sample_box_no",
|
||||||
|
"sample box": "sample_box_no",
|
||||||
|
"scales checked": "scales_checked",
|
||||||
|
"scales": "scales_checked",
|
||||||
|
"label correct": "label_correct",
|
||||||
|
"label": "label_correct",
|
||||||
|
"bag sealed": "bag_sealed",
|
||||||
|
"sealed": "bag_sealed",
|
||||||
|
"pallet good condition": "pallet_good_condition",
|
||||||
|
"pallet": "pallet_good_condition",
|
||||||
|
"notes": "notes",
|
||||||
|
"note": "notes",
|
||||||
|
"comment": "notes",
|
||||||
|
"comments": "notes",
|
||||||
|
}
|
||||||
|
|
||||||
|
# How many row-level errors we collect before truncating, to keep the response
|
||||||
|
# (and the toast) sane on a badly-formed file.
|
||||||
|
_MAX_REPORTED_ERRORS = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_header(raw: object) -> str | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
key = " ".join(str(raw).strip().lower().replace("-", " ").replace("_", " ").split())
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
if key in _HEADER_ALIASES:
|
||||||
|
return _HEADER_ALIASES[key]
|
||||||
|
# Test weights: "test weight 1".."test weight 5" (and "tw1" style).
|
||||||
|
for n in range(1, 6):
|
||||||
|
if key in {f"test weight {n}", f"tw{n}", f"test {n}"}:
|
||||||
|
return f"test_weight_{n}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_quantity_type(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text in {"bag", "bags", "b"}:
|
||||||
|
return "bags"
|
||||||
|
if text in {"kg", "kgs", "kilogram", "kilograms", "bulka", "bulk"}:
|
||||||
|
return "kg"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_tabular_file(filename: str, content: bytes) -> tuple[list[str | None], list[tuple]]:
|
||||||
|
"""Return (headers, data_rows). Detects CSV vs .xlsx by extension/content."""
|
||||||
|
lowered = (filename or "").lower()
|
||||||
|
is_excel = lowered.endswith((".xlsx", ".xlsm", ".xls"))
|
||||||
|
|
||||||
|
if is_excel:
|
||||||
|
workbook = load_workbook(io.BytesIO(content), data_only=True, read_only=True)
|
||||||
|
ws = workbook.active
|
||||||
|
rows = [tuple(r) for r in ws.iter_rows(values_only=True)]
|
||||||
|
workbook.close()
|
||||||
|
else:
|
||||||
|
text = None
|
||||||
|
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
|
||||||
|
try:
|
||||||
|
text = content.decode(encoding)
|
||||||
|
break
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
if text is None:
|
||||||
|
raise ValueError("Could not decode the file as text. Save it as UTF-8 CSV or .xlsx.")
|
||||||
|
# Sniff the delimiter (comma/semicolon/tab) but fall back to comma.
|
||||||
|
sample = text[:4096]
|
||||||
|
try:
|
||||||
|
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
|
||||||
|
except csv.Error:
|
||||||
|
dialect = csv.excel
|
||||||
|
rows = [tuple(r) for r in csv.reader(io.StringIO(text), dialect)]
|
||||||
|
|
||||||
|
# Find the first row that has at least one recognised header; treat it as
|
||||||
|
# the header row and everything after as data.
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
if any(_normalise_header(cell) is not None for cell in row):
|
||||||
|
return list(row), rows[index + 1 :]
|
||||||
|
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
|
||||||
|
def import_entries_from_file(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
content: bytes,
|
||||||
|
tenant_id: str,
|
||||||
|
created_by: str | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Parse an uploaded CSV/spreadsheet and persist each row as a throughput
|
||||||
|
entry. Products are matched by item_id then name, and auto-created when not
|
||||||
|
found so every entry stays linked. Returns a summary with row-level errors.
|
||||||
|
"""
|
||||||
|
headers, data_rows = _read_tabular_file(filename, content)
|
||||||
|
if not headers:
|
||||||
|
raise ValueError(
|
||||||
|
"No recognised columns found. The file needs a header row with at "
|
||||||
|
"least Date, Product and Quantity columns."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map canonical field name → column index. First occurrence wins.
|
||||||
|
field_index: dict[str, int] = {}
|
||||||
|
for col, raw in enumerate(headers):
|
||||||
|
field = _normalise_header(raw)
|
||||||
|
if field and field not in field_index:
|
||||||
|
field_index[field] = col
|
||||||
|
|
||||||
|
for required in ("date", "product", "quantity"):
|
||||||
|
if required not in field_index:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing required '{required}' column. Required columns are "
|
||||||
|
"Date, Product and Quantity."
|
||||||
|
)
|
||||||
|
|
||||||
|
def cell(row: tuple, field: str) -> object:
|
||||||
|
idx = field_index.get(field)
|
||||||
|
if idx is None or idx >= len(row):
|
||||||
|
return None
|
||||||
|
return row[idx]
|
||||||
|
|
||||||
|
# Decide the slash-date order once for the whole file so ambiguous values
|
||||||
|
# like "12/9/2025" follow the same convention as the unambiguous ones.
|
||||||
|
date_formats = _detect_slash_date_formats(cell(row, "date") for row in data_rows)
|
||||||
|
|
||||||
|
# Index existing products for matching (by item_id and by lower-cased name).
|
||||||
|
by_item: dict[str, ThroughputProduct] = {}
|
||||||
|
by_name: dict[str, ThroughputProduct] = {}
|
||||||
|
for product in db.scalars(
|
||||||
|
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
|
||||||
|
).all():
|
||||||
|
if product.item_id:
|
||||||
|
by_item[str(product.item_id)] = product
|
||||||
|
by_name[product.name.lower()] = product
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
products_created = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def note_error(message: str) -> None:
|
||||||
|
if len(errors) < _MAX_REPORTED_ERRORS:
|
||||||
|
errors.append(message)
|
||||||
|
|
||||||
|
for offset, row in enumerate(data_rows):
|
||||||
|
# Sheet/file row number for human-friendly error messages (header = 1).
|
||||||
|
line_no = offset + 2
|
||||||
|
if not row or all(value is None or str(value).strip() == "" for value in row):
|
||||||
|
continue
|
||||||
|
|
||||||
|
production_date = _coerce_date(cell(row, "date"), date_formats)
|
||||||
|
product_name = _coerce_text(cell(row, "product"))
|
||||||
|
quantity = _coerce_float(cell(row, "quantity"))
|
||||||
|
|
||||||
|
if production_date is None:
|
||||||
|
skipped += 1
|
||||||
|
note_error(f"Row {line_no}: missing or invalid date.")
|
||||||
|
continue
|
||||||
|
if not product_name:
|
||||||
|
skipped += 1
|
||||||
|
note_error(f"Row {line_no}: missing product name.")
|
||||||
|
continue
|
||||||
|
if quantity is None or quantity < 0:
|
||||||
|
skipped += 1
|
||||||
|
note_error(f"Row {line_no}: missing or invalid quantity.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
bag_size = _coerce_float(cell(row, "bag_size"))
|
||||||
|
|
||||||
|
quantity_type = _coerce_quantity_type(cell(row, "quantity_type"))
|
||||||
|
if quantity_type is None:
|
||||||
|
# Infer: bulka-style rows have a blank or very large bag size.
|
||||||
|
if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower():
|
||||||
|
quantity_type = "kg"
|
||||||
|
else:
|
||||||
|
quantity_type = "bags"
|
||||||
|
|
||||||
|
if quantity_type == "bags" and (bag_size is None or bag_size <= 0):
|
||||||
|
skipped += 1
|
||||||
|
note_error(f"Row {line_no}: bag size is required when packed as bags.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_id_raw = cell(row, "item_id")
|
||||||
|
item_id = None
|
||||||
|
if item_id_raw is not None:
|
||||||
|
if isinstance(item_id_raw, float) and item_id_raw.is_integer():
|
||||||
|
item_id = str(int(item_id_raw))
|
||||||
|
else:
|
||||||
|
item_id = _coerce_text(item_id_raw)
|
||||||
|
|
||||||
|
product = (by_item.get(item_id) if item_id else None) or by_name.get(product_name.lower())
|
||||||
|
if product is None:
|
||||||
|
product = ThroughputProduct(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
item_id=item_id,
|
||||||
|
name=product_name,
|
||||||
|
default_bag_size=bag_size,
|
||||||
|
is_bulka_default=_infer_bulka_default(product_name, bag_size),
|
||||||
|
active=True,
|
||||||
|
notes="Auto-created during throughput import",
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
products_created += 1
|
||||||
|
if item_id:
|
||||||
|
by_item[item_id] = product
|
||||||
|
by_name[product_name.lower()] = product
|
||||||
|
|
||||||
|
for_order = _coerce_import_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False
|
||||||
|
for_stock = _coerce_import_bool(cell(row, "for_stock")) if field_index.get("for_stock") is not None else False
|
||||||
|
stock_quantity = _coerce_float(cell(row, "stock_quantity")) if for_stock else None
|
||||||
|
|
||||||
|
calculated = calculate_kg(quantity, quantity_type, bag_size)
|
||||||
|
entry = ProductionThroughput(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
production_date=production_date,
|
||||||
|
product_id=product.id,
|
||||||
|
product_name_snapshot=product_name,
|
||||||
|
bag_size=bag_size,
|
||||||
|
scales_checked=_coerce_bool(cell(row, "scales_checked")),
|
||||||
|
label_correct=_coerce_bool(cell(row, "label_correct")),
|
||||||
|
bag_sealed=_coerce_bool(cell(row, "bag_sealed")),
|
||||||
|
pallet_good_condition=_coerce_bool(cell(row, "pallet_good_condition")),
|
||||||
|
for_order=for_order,
|
||||||
|
for_stock=for_stock,
|
||||||
|
job_number=_coerce_text(cell(row, "job_number")) if for_order else None,
|
||||||
|
stock_quantity=stock_quantity,
|
||||||
|
sample_box_no=_coerce_text(cell(row, "sample_box_no")),
|
||||||
|
test_weight_1=_coerce_float(cell(row, "test_weight_1")),
|
||||||
|
test_weight_2=_coerce_float(cell(row, "test_weight_2")),
|
||||||
|
test_weight_3=_coerce_float(cell(row, "test_weight_3")),
|
||||||
|
test_weight_4=_coerce_float(cell(row, "test_weight_4")),
|
||||||
|
test_weight_5=_coerce_float(cell(row, "test_weight_5")),
|
||||||
|
quantity=quantity,
|
||||||
|
quantity_type=quantity_type,
|
||||||
|
calculated_kg=calculated,
|
||||||
|
staff_name=normalise_staff_name(cell(row, "staff_name")),
|
||||||
|
notes=_coerce_text(cell(row, "notes")),
|
||||||
|
created_by=created_by or "csv-import",
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
if imported == 0 and products_created == 0:
|
||||||
|
# Nothing landed — don't leave a half-open transaction.
|
||||||
|
db.rollback()
|
||||||
|
else:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries_imported": imported,
|
||||||
|
"entries_skipped": skipped,
|
||||||
|
"products_created": products_created,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Xero integration layer for the ordering portal.
|
||||||
|
|
||||||
|
This is a **clean, stubbed interface**. No Xero credentials are hard-coded — all
|
||||||
|
configuration comes from environment variables (``XERO_*``). When credentials
|
||||||
|
are absent (the default in dev/alpha), the service runs in "stub" mode: it
|
||||||
|
records what *would* be sent and returns a deterministic fake invoice id so the
|
||||||
|
rest of the order lifecycle can be exercised end-to-end.
|
||||||
|
|
||||||
|
To go live, implement the real HTTP calls inside :func:`_submit_to_xero_api`
|
||||||
|
(see the TODOs) — no caller needs to change.
|
||||||
|
|
||||||
|
Service responsibilities (per spec):
|
||||||
|
* Map a customer (ClientAccount) to a Xero contact.
|
||||||
|
* Map catalogue products to Xero item codes.
|
||||||
|
* Create a draft invoice equivalent for a confirmed order.
|
||||||
|
* Record Xero response ids/status (via XeroSyncLog, written by the caller).
|
||||||
|
* Handle failures gracefully (never raise into the request path).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.models.client_access import ClientAccount
|
||||||
|
from app.models.ordering import Order, XeroContactLink
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class XeroConfig:
|
||||||
|
client_id: str | None = None
|
||||||
|
client_secret: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
base_url: str = "https://api.xero.com/api.xro/2.0"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.client_id and self.client_secret and self.tenant_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "XeroConfig":
|
||||||
|
return cls(
|
||||||
|
client_id=os.getenv("XERO_CLIENT_ID") or None,
|
||||||
|
client_secret=os.getenv("XERO_CLIENT_SECRET") or None,
|
||||||
|
tenant_id=os.getenv("XERO_TENANT_ID") or None,
|
||||||
|
base_url=os.getenv("XERO_API_BASE_URL", "https://api.xero.com/api.xro/2.0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class XeroSubmissionResult:
|
||||||
|
status: str # "success" | "failed"
|
||||||
|
xero_invoice_id: str | None
|
||||||
|
message: str
|
||||||
|
request_summary: str
|
||||||
|
stubbed: bool = False
|
||||||
|
line_items: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class XeroContact:
|
||||||
|
"""A Xero contact available to link a customer against."""
|
||||||
|
|
||||||
|
contact_id: str
|
||||||
|
name: str
|
||||||
|
email: str | None = None
|
||||||
|
status: str = "ACTIVE"
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"contact_id": self.contact_id,
|
||||||
|
"name": self.name,
|
||||||
|
"email": self.email,
|
||||||
|
"status": self.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Deterministic sample contacts used while running in stub mode (no Xero
|
||||||
|
# credentials). They stand in for "what's in Xero" so the customer→contact
|
||||||
|
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
|
||||||
|
_STUB_CONTACTS: tuple[XeroContact, ...] = (
|
||||||
|
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
|
||||||
|
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
|
||||||
|
|
||||||
|
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
|
||||||
|
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
|
||||||
|
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
|
||||||
|
|
||||||
|
|
||||||
|
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
|
||||||
|
"""Return the Xero contacts available for linking and whether they're stubbed.
|
||||||
|
|
||||||
|
Never raises — on a live-mode error it returns an empty list so the mapping
|
||||||
|
console still renders.
|
||||||
|
"""
|
||||||
|
config = config or XeroConfig.from_env()
|
||||||
|
if not config.configured:
|
||||||
|
return list(_STUB_CONTACTS), True
|
||||||
|
try:
|
||||||
|
return _fetch_contacts_from_api(config), False
|
||||||
|
except Exception: # pragma: no cover - defensive: never break the request path
|
||||||
|
return [], False
|
||||||
|
|
||||||
|
|
||||||
|
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
|
||||||
|
"""Map a customer account onto a Xero contact payload.
|
||||||
|
|
||||||
|
When the customer has been linked to a Xero contact we send the real
|
||||||
|
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
|
||||||
|
link we fall back to keying on the client code (Xero will match-or-create).
|
||||||
|
"""
|
||||||
|
if link is not None and link.xero_contact_id:
|
||||||
|
return {
|
||||||
|
"ContactID": link.xero_contact_id,
|
||||||
|
"Name": link.xero_contact_name or customer.name,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ContactNumber": customer.client_code,
|
||||||
|
"Name": customer.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def map_product_to_item_code(product_sku: str) -> str:
|
||||||
|
"""Map a catalogue SKU onto a Xero item code.
|
||||||
|
|
||||||
|
TODO: support an explicit SKU→Xero item-code mapping table if the codes
|
||||||
|
diverge. Today the SKU is used directly.
|
||||||
|
"""
|
||||||
|
return product_sku
|
||||||
|
|
||||||
|
|
||||||
|
def build_invoice_payload(
|
||||||
|
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Build the Xero draft-invoice payload for a confirmed order."""
|
||||||
|
line_items = []
|
||||||
|
for line in order.lines:
|
||||||
|
if line.requires_quote or line.unit_price is None:
|
||||||
|
# Quote-only lines can't carry a price; skip until quoted.
|
||||||
|
continue
|
||||||
|
unit_price = line.admin_override_price if line.admin_override_price is not None else line.unit_price
|
||||||
|
line_items.append(
|
||||||
|
{
|
||||||
|
"ItemCode": map_product_to_item_code(line.product_sku),
|
||||||
|
"Description": line.product_name,
|
||||||
|
"Quantity": line.quantity,
|
||||||
|
"UnitAmount": unit_price,
|
||||||
|
"AccountCode": os.getenv("XERO_SALES_ACCOUNT_CODE", "200"),
|
||||||
|
# Prices are GST-exclusive throughout the platform.
|
||||||
|
"TaxType": os.getenv("XERO_TAX_TYPE", "OUTPUT"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"Type": "ACCREC",
|
||||||
|
"Status": "DRAFT",
|
||||||
|
"Contact": map_customer_to_contact(customer, link),
|
||||||
|
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
||||||
|
"LineAmountTypes": "Exclusive",
|
||||||
|
"LineItems": line_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResult:
|
||||||
|
"""Real Xero submission. Stubbed until credentials/endpoints are wired.
|
||||||
|
|
||||||
|
TODO (go-live):
|
||||||
|
1. Obtain an OAuth2 token (client-credentials or stored refresh token).
|
||||||
|
2. POST ``payload`` to ``{config.base_url}/Invoices`` with the
|
||||||
|
``Xero-tenant-id`` header set to ``config.tenant_id``.
|
||||||
|
3. Parse ``Invoices[0].InvoiceID`` from the response.
|
||||||
|
4. Map non-2xx responses onto ``status="failed"`` with the error body.
|
||||||
|
"""
|
||||||
|
summary = f"{len(payload.get('LineItems', []))} line(s) for {payload['Contact']['Name']}"
|
||||||
|
# Real call would go here. Intentionally not implemented yet.
|
||||||
|
return XeroSubmissionResult(
|
||||||
|
status="failed",
|
||||||
|
xero_invoice_id=None,
|
||||||
|
message="Xero live submission is not implemented yet (stub interface).",
|
||||||
|
request_summary=summary,
|
||||||
|
stubbed=False,
|
||||||
|
line_items=payload.get("LineItems", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_order_to_xero(
|
||||||
|
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||||
|
) -> XeroSubmissionResult:
|
||||||
|
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
||||||
|
|
||||||
|
Pass ``link`` to invoice against the customer's mapped Xero contact. Never
|
||||||
|
raises — failures are returned as ``status="failed"`` results so the order
|
||||||
|
lifecycle can record the attempt and continue.
|
||||||
|
"""
|
||||||
|
config = XeroConfig.from_env()
|
||||||
|
payload = build_invoice_payload(order, customer, link)
|
||||||
|
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
||||||
|
|
||||||
|
if not config.configured:
|
||||||
|
# Stub mode: deterministic fake invoice id so downstream flows work.
|
||||||
|
fake_id = f"STUB-INV-{order.id:06d}"
|
||||||
|
return XeroSubmissionResult(
|
||||||
|
status="success",
|
||||||
|
xero_invoice_id=fake_id,
|
||||||
|
message="Xero not configured — order recorded in stub mode.",
|
||||||
|
request_summary=summary,
|
||||||
|
stubbed=True,
|
||||||
|
line_items=payload["LineItems"],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _submit_to_xero_api(config, payload)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive: never break the request path
|
||||||
|
return XeroSubmissionResult(
|
||||||
|
status="failed",
|
||||||
|
xero_invoice_id=None,
|
||||||
|
message=f"Xero submission error: {exc}",
|
||||||
|
request_summary=summary,
|
||||||
|
stubbed=False,
|
||||||
|
line_items=payload["LineItems"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def xero_status_snapshot() -> dict:
|
||||||
|
config = XeroConfig.from_env()
|
||||||
|
return {
|
||||||
|
"configured": config.configured,
|
||||||
|
"mode": "live" if config.configured else "stub",
|
||||||
|
"base_url": config.base_url,
|
||||||
|
"checked_at": datetime.utcnow().isoformat(),
|
||||||
|
"missing_env": [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("XERO_CLIENT_ID", config.client_id),
|
||||||
|
("XERO_CLIENT_SECRET", config.client_secret),
|
||||||
|
("XERO_TENANT_ID", config.tenant_id),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
Metadata-Version: 2.4
|
Metadata-Version: 2.4
|
||||||
Name: data-entry-app-backend
|
Name: data-entry-app-backend
|
||||||
Version: 0.1.5
|
Version: 0.1.12
|
||||||
Summary: Costing platform MVP backend
|
Summary: Costing platform MVP backend
|
||||||
Requires-Python: >=3.11
|
Requires-Python: >=3.11
|
||||||
Requires-Dist: fastapi<1.0,>=0.115
|
Requires-Dist: fastapi<1.0,>=0.115
|
||||||
Requires-Dist: openpyxl<4.0,>=3.1
|
Requires-Dist: openpyxl<4.0,>=3.1
|
||||||
|
Requires-Dist: rich<15.0,>=13.9
|
||||||
Requires-Dist: uvicorn[standard]<1.0,>=0.30
|
Requires-Dist: uvicorn[standard]<1.0,>=0.30
|
||||||
Requires-Dist: sqlalchemy<3.0,>=2.0
|
Requires-Dist: sqlalchemy<3.0,>=2.0
|
||||||
Requires-Dist: pydantic<3.0,>=2.8
|
Requires-Dist: pydantic<3.0,>=2.8
|
||||||
|
|||||||
@@ -9,16 +9,23 @@ pyproject.toml
|
|||||||
./app/api/client_access.py
|
./app/api/client_access.py
|
||||||
./app/api/dashboard.py
|
./app/api/dashboard.py
|
||||||
./app/api/deps.py
|
./app/api/deps.py
|
||||||
|
./app/api/editor.py
|
||||||
./app/api/mix_calculator.py
|
./app/api/mix_calculator.py
|
||||||
./app/api/mixes.py
|
./app/api/mixes.py
|
||||||
./app/api/powerbi.py
|
./app/api/powerbi.py
|
||||||
|
./app/api/product_costing.py
|
||||||
./app/api/products.py
|
./app/api/products.py
|
||||||
./app/api/raw_materials.py
|
./app/api/raw_materials.py
|
||||||
./app/api/scenarios.py
|
./app/api/scenarios.py
|
||||||
|
./app/api/throughput.py
|
||||||
./app/core/__init__.py
|
./app/core/__init__.py
|
||||||
./app/core/access.py
|
./app/core/access.py
|
||||||
./app/core/config.py
|
./app/core/config.py
|
||||||
|
./app/core/http.py
|
||||||
|
./app/core/logging.py
|
||||||
|
./app/core/rate_limit.py
|
||||||
./app/core/security.py
|
./app/core/security.py
|
||||||
|
./app/core/security_logging.py
|
||||||
./app/db/__init__.py
|
./app/db/__init__.py
|
||||||
./app/db/migrations.py
|
./app/db/migrations.py
|
||||||
./app/db/session.py
|
./app/db/session.py
|
||||||
@@ -29,49 +36,89 @@ pyproject.toml
|
|||||||
./app/models/mix.py
|
./app/models/mix.py
|
||||||
./app/models/mix_calculator.py
|
./app/models/mix_calculator.py
|
||||||
./app/models/product.py
|
./app/models/product.py
|
||||||
|
./app/models/product_costing.py
|
||||||
./app/models/raw_material.py
|
./app/models/raw_material.py
|
||||||
./app/models/scenario.py
|
./app/models/scenario.py
|
||||||
|
./app/models/throughput.py
|
||||||
./app/schemas/__init__.py
|
./app/schemas/__init__.py
|
||||||
./app/schemas/client_access.py
|
./app/schemas/client_access.py
|
||||||
|
./app/schemas/editor.py
|
||||||
./app/schemas/mix.py
|
./app/schemas/mix.py
|
||||||
./app/schemas/mix_calculator.py
|
./app/schemas/mix_calculator.py
|
||||||
./app/schemas/product.py
|
./app/schemas/product.py
|
||||||
|
./app/schemas/product_costing.py
|
||||||
./app/schemas/raw_material.py
|
./app/schemas/raw_material.py
|
||||||
./app/schemas/scenario.py
|
./app/schemas/scenario.py
|
||||||
|
./app/schemas/throughput.py
|
||||||
./app/services/__init__.py
|
./app/services/__init__.py
|
||||||
./app/services/client_access_service.py
|
./app/services/client_access_service.py
|
||||||
./app/services/costing_engine.py
|
./app/services/costing_engine.py
|
||||||
./app/services/mix_calculator_filenames.py
|
./app/services/mix_calculator_filenames.py
|
||||||
./app/services/mix_calculator_pdf.py
|
./app/services/mix_calculator_pdf.py
|
||||||
./app/services/mix_calculator_service.py
|
./app/services/mix_calculator_service.py
|
||||||
|
./app/services/product_costing_service.py
|
||||||
./app/services/scenario_engine.py
|
./app/services/scenario_engine.py
|
||||||
|
./app/services/throughput_service.py
|
||||||
app/__init__.py
|
app/__init__.py
|
||||||
app/main.py
|
app/main.py
|
||||||
app/seed.py
|
app/seed.py
|
||||||
|
app/seed_access.py
|
||||||
app/api/__init__.py
|
app/api/__init__.py
|
||||||
|
app/api/access.py
|
||||||
|
app/api/auth.py
|
||||||
|
app/api/client_access.py
|
||||||
|
app/api/dashboard.py
|
||||||
|
app/api/deps.py
|
||||||
|
app/api/editor.py
|
||||||
|
app/api/mix_calculator.py
|
||||||
app/api/mixes.py
|
app/api/mixes.py
|
||||||
app/api/powerbi.py
|
app/api/powerbi.py
|
||||||
|
app/api/product_costing.py
|
||||||
app/api/products.py
|
app/api/products.py
|
||||||
app/api/raw_materials.py
|
app/api/raw_materials.py
|
||||||
app/api/scenarios.py
|
app/api/scenarios.py
|
||||||
|
app/api/throughput.py
|
||||||
app/core/__init__.py
|
app/core/__init__.py
|
||||||
|
app/core/access.py
|
||||||
app/core/config.py
|
app/core/config.py
|
||||||
|
app/core/http.py
|
||||||
|
app/core/logging.py
|
||||||
|
app/core/rate_limit.py
|
||||||
|
app/core/security.py
|
||||||
|
app/core/security_logging.py
|
||||||
app/db/__init__.py
|
app/db/__init__.py
|
||||||
|
app/db/migrations.py
|
||||||
app/db/session.py
|
app/db/session.py
|
||||||
app/models/__init__.py
|
app/models/__init__.py
|
||||||
|
app/models/access.py
|
||||||
app/models/assumption.py
|
app/models/assumption.py
|
||||||
|
app/models/client_access.py
|
||||||
app/models/mix.py
|
app/models/mix.py
|
||||||
|
app/models/mix_calculator.py
|
||||||
app/models/product.py
|
app/models/product.py
|
||||||
|
app/models/product_costing.py
|
||||||
app/models/raw_material.py
|
app/models/raw_material.py
|
||||||
app/models/scenario.py
|
app/models/scenario.py
|
||||||
|
app/models/throughput.py
|
||||||
app/schemas/__init__.py
|
app/schemas/__init__.py
|
||||||
|
app/schemas/client_access.py
|
||||||
|
app/schemas/editor.py
|
||||||
app/schemas/mix.py
|
app/schemas/mix.py
|
||||||
|
app/schemas/mix_calculator.py
|
||||||
app/schemas/product.py
|
app/schemas/product.py
|
||||||
|
app/schemas/product_costing.py
|
||||||
app/schemas/raw_material.py
|
app/schemas/raw_material.py
|
||||||
app/schemas/scenario.py
|
app/schemas/scenario.py
|
||||||
|
app/schemas/throughput.py
|
||||||
app/services/__init__.py
|
app/services/__init__.py
|
||||||
|
app/services/client_access_service.py
|
||||||
app/services/costing_engine.py
|
app/services/costing_engine.py
|
||||||
|
app/services/mix_calculator_filenames.py
|
||||||
|
app/services/mix_calculator_pdf.py
|
||||||
|
app/services/mix_calculator_service.py
|
||||||
|
app/services/product_costing_service.py
|
||||||
app/services/scenario_engine.py
|
app/services/scenario_engine.py
|
||||||
|
app/services/throughput_service.py
|
||||||
data_entry_app_backend.egg-info/PKG-INFO
|
data_entry_app_backend.egg-info/PKG-INFO
|
||||||
data_entry_app_backend.egg-info/SOURCES.txt
|
data_entry_app_backend.egg-info/SOURCES.txt
|
||||||
data_entry_app_backend.egg-info/dependency_links.txt
|
data_entry_app_backend.egg-info/dependency_links.txt
|
||||||
@@ -79,3 +126,6 @@ data_entry_app_backend.egg-info/requires.txt
|
|||||||
data_entry_app_backend.egg-info/top_level.txt
|
data_entry_app_backend.egg-info/top_level.txt
|
||||||
tests/test_access.py
|
tests/test_access.py
|
||||||
tests/test_costing_engine.py
|
tests/test_costing_engine.py
|
||||||
|
tests/test_product_costing.py
|
||||||
|
tests/test_schema.py
|
||||||
|
tests/test_throughput.py
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
fastapi<1.0,>=0.115
|
fastapi<1.0,>=0.115
|
||||||
openpyxl<4.0,>=3.1
|
openpyxl<4.0,>=3.1
|
||||||
|
rich<15.0,>=13.9
|
||||||
uvicorn[standard]<1.0,>=0.30
|
uvicorn[standard]<1.0,>=0.30
|
||||||
sqlalchemy<3.0,>=2.0
|
sqlalchemy<3.0,>=2.0
|
||||||
pydantic<3.0,>=2.8
|
pydantic<3.0,>=2.8
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ requires = ["setuptools>=68", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "data-entry-app-backend"
|
name = "hunter-backend"
|
||||||
version = "0.1.8"
|
version = "0.1.36"
|
||||||
description = "Costing platform MVP backend"
|
description = "Costing platform MVP backend (API for Hunter)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1.0",
|
"fastapi>=0.115,<1.0",
|
||||||
|
"python-multipart>=0.0.9,<1.0",
|
||||||
"openpyxl>=3.1,<4.0",
|
"openpyxl>=3.1,<4.0",
|
||||||
"rich>=13.9,<15.0",
|
"rich>=13.9,<15.0",
|
||||||
"uvicorn[standard]>=0.30,<1.0",
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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,
|
||||||
|
permissions_to_module_map,
|
||||||
require_all_permissions,
|
require_all_permissions,
|
||||||
require_any_permission,
|
require_any_permission,
|
||||||
require_permission,
|
require_permission,
|
||||||
@@ -72,9 +73,17 @@ def test_admin_role_permissions_match_spec():
|
|||||||
assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"])
|
assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"])
|
||||||
assert "manage_users" in granted
|
assert "manage_users" in granted
|
||||||
assert "manage_permissions" in granted
|
assert "manage_permissions" in granted
|
||||||
# Admin spec deliberately excludes edit_products / edit_mixes.
|
assert "edit_products" in granted
|
||||||
assert "edit_products" not in granted
|
assert "edit_mixes" in granted
|
||||||
assert "edit_mixes" not in granted
|
assert "view_scenarios" in granted
|
||||||
|
assert "edit_scenarios" in granted
|
||||||
|
assert "manage_client_access" in granted
|
||||||
|
|
||||||
|
modules = permissions_to_module_map(granted)
|
||||||
|
assert modules["products"] == "edit"
|
||||||
|
assert modules["mix_master"] == "edit"
|
||||||
|
assert modules["scenarios"] == "edit"
|
||||||
|
assert modules["client_access"] == "manage"
|
||||||
|
|
||||||
|
|
||||||
def test_operations_role_is_mix_calculator_and_throughput_only():
|
def test_operations_role_is_mix_calculator_and_throughput_only():
|
||||||
@@ -108,6 +117,30 @@ def test_full_access_role_can_edit_operational_data_but_not_users():
|
|||||||
assert "manage_permissions" not in granted
|
assert "manage_permissions" not in granted
|
||||||
|
|
||||||
|
|
||||||
|
def test_lean_role_has_unrestricted_workspace_permissions():
|
||||||
|
db = _build_session()
|
||||||
|
seed_access(db)
|
||||||
|
|
||||||
|
lean_role = db.query(Role).filter_by(name="lean").one()
|
||||||
|
user = User(email="lean@example.com", name="Lean User", role_id=lean_role.id, is_active=True)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
granted = get_user_permissions(user)
|
||||||
|
assert granted == set(ROLE_DEFINITIONS["lean"]["permissions"])
|
||||||
|
assert {key for key, _ in PERMISSION_DEFINITIONS} == granted
|
||||||
|
|
||||||
|
modules = permissions_to_module_map(granted)
|
||||||
|
assert modules["dashboard"] == "view"
|
||||||
|
assert modules["raw_materials"] == "edit"
|
||||||
|
assert modules["mix_master"] == "edit"
|
||||||
|
assert modules["mix_calculator"] == "edit"
|
||||||
|
assert modules["products"] == "edit"
|
||||||
|
assert modules["operations_throughput"] == "edit"
|
||||||
|
assert modules["scenarios"] == "edit"
|
||||||
|
assert modules["client_access"] == "manage"
|
||||||
|
|
||||||
|
|
||||||
def test_inactive_user_has_no_permissions():
|
def test_inactive_user_has_no_permissions():
|
||||||
db = _build_session()
|
db = _build_session()
|
||||||
seed_access(db)
|
seed_access(db)
|
||||||
@@ -314,3 +347,207 @@ def test_internal_user_can_change_own_password(access_app_and_db):
|
|||||||
json={"email": admin.email, "password": "new-personal-password"},
|
json={"email": admin.email, "password": "new-personal-password"},
|
||||||
)
|
)
|
||||||
assert new_login.status_code == 200
|
assert new_login.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin user management --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_headers(db: Session) -> dict[str, str]:
|
||||||
|
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||||
|
return {"Authorization": f"Bearer {_token_for(admin)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_users_create_update_password_delete(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
headers = _admin_headers(db)
|
||||||
|
full_access_role = db.query(Role).filter_by(name="Full Access").one()
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/api/access/users",
|
||||||
|
json={"email": "new.user@hunterstockfeeds.com", "name": "New User", "role_id": full_access_role.id},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
body = created.json()
|
||||||
|
assert body["email"] == "new.user@hunterstockfeeds.com"
|
||||||
|
assert body["role"] == "Full Access"
|
||||||
|
assert body["is_protected"] is False
|
||||||
|
user_id = body["id"]
|
||||||
|
|
||||||
|
operations_role = db.query(Role).filter_by(name="Operations").one()
|
||||||
|
updated = client.patch(
|
||||||
|
f"/api/access/users/{user_id}",
|
||||||
|
json={"name": "Renamed", "role_id": operations_role.id, "is_active": False},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["name"] == "Renamed"
|
||||||
|
assert updated.json()["role"] == "Operations"
|
||||||
|
assert updated.json()["is_active"] is False
|
||||||
|
|
||||||
|
pw = client.post(
|
||||||
|
f"/api/access/users/{user_id}/password",
|
||||||
|
json={"new_password": "brand-new-pass"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert pw.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
target = db.query(User).filter_by(id=user_id).one()
|
||||||
|
assert verify_password("brand-new-pass", target.password_hash)
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/access/users/{user_id}", headers=headers)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
assert db.query(User).filter_by(id=user_id).one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_duplicate_email(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
headers = _admin_headers(db)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/access/users",
|
||||||
|
json={"email": "admin@hunterstockfeeds.com", "name": "Dup"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_users_requires_permission(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||||
|
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/access/users",
|
||||||
|
json={"email": "x@hunterstockfeeds.com", "name": "X"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_deactivate_or_delete_self(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||||
|
headers = {"Authorization": f"Bearer {_token_for(admin)}"}
|
||||||
|
|
||||||
|
deactivate = client.patch(
|
||||||
|
f"/api/access/users/{admin.id}", json={"is_active": False}, headers=headers
|
||||||
|
)
|
||||||
|
assert deactivate.status_code == 400
|
||||||
|
|
||||||
|
delete = client.delete(f"/api/access/users/{admin.id}", headers=headers)
|
||||||
|
assert delete.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_lean_users_cannot_be_deleted(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
headers = _admin_headers(db)
|
||||||
|
lean_role = db.query(Role).filter_by(name="lean").one()
|
||||||
|
lean_user = User(email="owner@hunterstockfeeds.com", name="Owner", role_id=lean_role.id, is_active=True)
|
||||||
|
db.add(lean_user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
listed = client.get("/api/access/users", headers=headers)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
owner_row = next(row for row in listed.json() if row["id"] == lean_user.id)
|
||||||
|
assert owner_row["is_protected"] is True
|
||||||
|
|
||||||
|
response = client.delete(f"/api/access/users/{lean_user.id}", headers=headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert db.query(User).filter_by(id=lean_user.id).one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignable_roles_lists_all_roles(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
headers = _admin_headers(db)
|
||||||
|
|
||||||
|
response = client.get("/api/access/assignable-roles", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
names = {row["name"] for row in response.json()}
|
||||||
|
assert names == set(ROLE_DEFINITIONS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_management_lists_modules_and_roles_for_admin(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
headers = _admin_headers(db)
|
||||||
|
|
||||||
|
modules = client.get("/api/access/role-modules", headers=headers)
|
||||||
|
assert modules.status_code == 200
|
||||||
|
module_keys = {row["key"] for row in modules.json()}
|
||||||
|
assert {"dashboard", "mix_calculator", "roles", "settings"} <= module_keys
|
||||||
|
|
||||||
|
roles = client.get("/api/access/roles", headers=headers)
|
||||||
|
assert roles.status_code == 200
|
||||||
|
admin_role = next(row for row in roles.json() if row["name"] == "Admin")
|
||||||
|
assert admin_role["is_protected"] is True
|
||||||
|
assert admin_role["module_permissions"]["ordering"] == "manage"
|
||||||
|
assert admin_role["module_permissions"]["roles"] == "manage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_management_is_blocked_for_non_admin_non_lean_roles(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||||
|
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
|
||||||
|
|
||||||
|
response = client.get("/api/access/roles", headers=headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "lean and admin" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_management_create_update_delete_custom_role(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
headers = _admin_headers(db)
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/api/access/roles",
|
||||||
|
json={
|
||||||
|
"name": "Reporting Viewer",
|
||||||
|
"description": "Can review dashboards and reporting inputs",
|
||||||
|
"module_permissions": {
|
||||||
|
"dashboard": "view",
|
||||||
|
"products": "view",
|
||||||
|
"settings": "view",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
created_body = created.json()
|
||||||
|
assert created_body["module_permissions"]["dashboard"] == "view"
|
||||||
|
assert created_body["module_permissions"]["products"] == "view"
|
||||||
|
assert created_body["module_permissions"]["settings"] == "view"
|
||||||
|
role_id = created_body["id"]
|
||||||
|
|
||||||
|
updated = client.patch(
|
||||||
|
f"/api/access/roles/{role_id}",
|
||||||
|
json={
|
||||||
|
"description": "Can review and edit product data",
|
||||||
|
"module_permissions": {
|
||||||
|
"dashboard": "view",
|
||||||
|
"products": "edit",
|
||||||
|
"settings": "view",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
updated_body = updated.json()
|
||||||
|
assert updated_body["module_permissions"]["products"] == "edit"
|
||||||
|
assert "edit_products" in updated_body["permissions"]
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/access/roles/{role_id}", headers=headers)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
assert db.query(Role).filter_by(id=role_id).one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_protected_or_assigned_roles_cannot_be_deleted(access_app_and_db):
|
||||||
|
client, db = access_app_and_db
|
||||||
|
headers = _admin_headers(db)
|
||||||
|
admin_role = db.query(Role).filter_by(name="Admin").one()
|
||||||
|
|
||||||
|
protected = client.delete(f"/api/access/roles/{admin_role.id}", headers=headers)
|
||||||
|
assert protected.status_code == 403
|
||||||
|
|
||||||
|
full_access_role = db.query(Role).filter_by(name="Full Access").one()
|
||||||
|
assigned = client.delete(f"/api/access/roles/{full_access_role.id}", headers=headers)
|
||||||
|
assert assigned.status_code == 400
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""The change log records who edited a mix/ingredient and what changed.
|
||||||
|
|
||||||
|
Covers `record_change` / `diff_fields` / `list_changes`: edits are stored with a
|
||||||
|
field-level before/after diff and read back newest-first per entity.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.services.change_log import (
|
||||||
|
ENTITY_INGREDIENT,
|
||||||
|
ENTITY_MIX,
|
||||||
|
diff_fields,
|
||||||
|
list_changes,
|
||||||
|
record_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
TENANT = "hunter-premium-produce"
|
||||||
|
|
||||||
|
LABELS = {"name": "Name", "kg_per_unit": "Kg per unit", "status": "Status"}
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> Session:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
|
||||||
|
|
||||||
|
def _actor() -> AuthSession:
|
||||||
|
return AuthSession(role="internal", email="lara@hunter.test", name="Lara", tenant_id=TENANT, client_role="admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_fields_only_emits_changed_keys():
|
||||||
|
deltas = diff_fields({"name": "Maize", "kg_per_unit": 25.0}, {"name": "Maize", "kg_per_unit": 30.0}, LABELS)
|
||||||
|
assert len(deltas) == 1
|
||||||
|
assert deltas[0]["field"] == "kg_per_unit"
|
||||||
|
assert deltas[0]["before"] == "25"
|
||||||
|
assert deltas[0]["after"] == "30"
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_and_lists_changes_newest_first():
|
||||||
|
db = _session()
|
||||||
|
session = _actor()
|
||||||
|
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_INGREDIENT,
|
||||||
|
entity_id=7,
|
||||||
|
action="created",
|
||||||
|
summary="Created ingredient “Maize”",
|
||||||
|
)
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
entity_type=ENTITY_INGREDIENT,
|
||||||
|
entity_id=7,
|
||||||
|
action="updated",
|
||||||
|
summary="Updated Kg per unit",
|
||||||
|
changes=diff_fields({"kg_per_unit": 25.0}, {"kg_per_unit": 30.0}, LABELS),
|
||||||
|
)
|
||||||
|
# A different entity must not leak into entity 7's history.
|
||||||
|
record_change(db, session=session, entity_type=ENTITY_MIX, entity_id=7, action="created", summary="Created mix")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
events = list_changes(db, tenant_id=TENANT, entity_type=ENTITY_INGREDIENT, entity_id=7)
|
||||||
|
assert [event.action for event in events] == ["updated", "created"]
|
||||||
|
assert events[0].actor_name == "Lara"
|
||||||
|
assert events[0].actor_role == "admin"
|
||||||
|
assert events[0].changes[0]["label"] == "Kg per unit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_changes_are_tenant_scoped():
|
||||||
|
db = _session()
|
||||||
|
record_change(
|
||||||
|
db,
|
||||||
|
session=AuthSession(role="internal", email="x@y.test", name="X", tenant_id="other-tenant"),
|
||||||
|
entity_type=ENTITY_MIX,
|
||||||
|
entity_id=1,
|
||||||
|
action="created",
|
||||||
|
summary="Created mix",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert list_changes(db, tenant_id=TENANT, entity_type=ENTITY_MIX, entity_id=1) == []
|
||||||
@@ -246,7 +246,7 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
|
|||||||
options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
|
options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
|
||||||
|
|
||||||
assert options["clients"] == ["Peckish"]
|
assert options["clients"] == ["Peckish"]
|
||||||
assert [product["product_name"] for product in options["products"]] == ["Visible Product"]
|
assert [product["product_name"] for product in options["products"]] == ["Visible Mix"]
|
||||||
assert options["products"][0]["mix_total_kg"] == 20
|
assert options["products"][0]["mix_total_kg"] == 20
|
||||||
|
|
||||||
|
|
||||||
@@ -482,8 +482,18 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
|||||||
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
|
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
|
# 83 product-backed mixes + 1 formula-only mix ("Hi Carb Popcorn", which
|
||||||
seeded_product = next(product for product in options_payload["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg")
|
# has a mix-master formula but no product yet, surfaced via a negative
|
||||||
|
# product_id sentinel so a new mix is usable before a product is linked).
|
||||||
|
assert len(options_payload["products"]) == 84 + 1
|
||||||
|
formula_only = [product for product in options_payload["products"] if product["product_id"] < 0]
|
||||||
|
assert len(formula_only) == 1
|
||||||
|
assert formula_only[0]["unit_size_kg"] == 0
|
||||||
|
seeded_product = next(
|
||||||
|
product
|
||||||
|
for product in options_payload["products"]
|
||||||
|
if product["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix"
|
||||||
|
)
|
||||||
assert seeded_product["unit_size_kg"] == 20
|
assert seeded_product["unit_size_kg"] == 20
|
||||||
|
|
||||||
create_response = client.post(
|
create_response = client.post(
|
||||||
@@ -540,7 +550,9 @@ def test_mix_calculator_pdf_endpoint_returns_pdf():
|
|||||||
|
|
||||||
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
|
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["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix"
|
||||||
)
|
)
|
||||||
|
|
||||||
create_response = client.post(
|
create_response = client.post(
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
|
||||||
|
|
||||||
|
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
|
||||||
|
whose product carries its own formula resolves to that product (not the shared
|
||||||
|
mix master), percentages are computed against the total, and the representative
|
||||||
|
product is chosen the way the calculator chooses it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession
|
||||||
|
from app.api.editor import delete_editor_mix, replace_editor_mix_formula
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.mix import Mix, MixIngredient
|
||||||
|
from app.models.product import Product, ProductIngredient
|
||||||
|
from app.models.raw_material import RawMaterial
|
||||||
|
from app.schemas.editor import EditorMixFormulaReplace, EditorMixFormulaRowInput
|
||||||
|
from app.services.mix_calculator_service import (
|
||||||
|
resolve_editor_mix_formula,
|
||||||
|
resolve_representative_product,
|
||||||
|
)
|
||||||
|
|
||||||
|
TENANT = "hunter-premium-produce"
|
||||||
|
|
||||||
|
|
||||||
|
def _editor_session() -> AuthSession:
|
||||||
|
return AuthSession(
|
||||||
|
role="internal",
|
||||||
|
email="editor@hunter.test",
|
||||||
|
name="Editor",
|
||||||
|
tenant_id=TENANT,
|
||||||
|
client_role="admin",
|
||||||
|
user_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> Session:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
|
||||||
|
|
||||||
|
def _raw(db: Session, name: str) -> RawMaterial:
|
||||||
|
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
|
||||||
|
db.add(material)
|
||||||
|
db.flush()
|
||||||
|
return material
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolves_product_formula_not_mix_master():
|
||||||
|
db = _session()
|
||||||
|
bayley = _raw(db, "Bayley")
|
||||||
|
filler = _raw(db, "Filler")
|
||||||
|
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Shared mix master says something different from the product formula.
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
|
||||||
|
|
||||||
|
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||||
|
|
||||||
|
assert formula["source"] == "product"
|
||||||
|
assert formula["product_id"] == product.id
|
||||||
|
assert formula["total_kg"] == 1320.41
|
||||||
|
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
|
||||||
|
assert by_name["Bayley"]["quantity_kg"] == 787.5
|
||||||
|
# Percentage matches the worked example (787.5 / 1320.41 * 100).
|
||||||
|
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_mix_master_when_no_product_formula():
|
||||||
|
db = _session()
|
||||||
|
maize = _raw(db, "Maize")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||||
|
assert formula["source"] == "mix"
|
||||||
|
assert formula["total_kg"] == 50
|
||||||
|
assert formula["ingredients"][0]["mix_percentage"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_mix_master_formula_returns_fresh_rows():
|
||||||
|
"""PUT formula on a mix without a product writes the mix master and the
|
||||||
|
response reflects the just-saved rows (not the stale pre-save collection).
|
||||||
|
|
||||||
|
Regression: the diff path read the resolved formula by attribute, but the
|
||||||
|
resolver returns dicts, which raised AttributeError -> HTTP 500 on save.
|
||||||
|
"""
|
||||||
|
db = _session()
|
||||||
|
maize = _raw(db, "Maize")
|
||||||
|
barley = _raw(db, "Barley")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=100))
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Percentages need not total 100% — kg is canonical.
|
||||||
|
payload = EditorMixFormulaReplace(
|
||||||
|
rows=[
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=maize.id, quantity_kg=330.0, notes=None),
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=barley.id, quantity_kg=140.0, notes="confirmed"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
|
||||||
|
|
||||||
|
assert result["source"] == "mix"
|
||||||
|
assert result["total_kg"] == 470.0
|
||||||
|
by_name = {row["raw_material_name"]: row for row in result["ingredients"]}
|
||||||
|
assert by_name["Maize"]["quantity_kg"] == 330.0
|
||||||
|
assert by_name["Barley"]["quantity_kg"] == 140.0
|
||||||
|
|
||||||
|
persisted = db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all()
|
||||||
|
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
|
||||||
|
(maize.id, 330.0),
|
||||||
|
(barley.id, 140.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_product_formula_writes_product_ingredients():
|
||||||
|
"""When a representative product owns the formula, PUT replaces the product's
|
||||||
|
ingredients (the source the calculator reads) and returns the fresh rows."""
|
||||||
|
db = _session()
|
||||||
|
bayley = _raw(db, "Bayley")
|
||||||
|
filler = _raw(db, "Filler")
|
||||||
|
canola = _raw(db, "Canola")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
product = Product(
|
||||||
|
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
|
||||||
|
unit_of_measure="20kg bag", visible=True,
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=10, sort_order=1))
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=10, sort_order=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
payload = EditorMixFormulaReplace(
|
||||||
|
rows=[
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=bayley.id, quantity_kg=600.0, notes=None),
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=canola.id, quantity_kg=200.0, notes=None),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
|
||||||
|
|
||||||
|
assert result["source"] == "product"
|
||||||
|
assert result["product_id"] == product.id
|
||||||
|
assert result["total_kg"] == 800.0
|
||||||
|
|
||||||
|
persisted = db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
|
||||||
|
# Filler dropped, Canola added; mix master is untouched.
|
||||||
|
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
|
||||||
|
(bayley.id, 600.0),
|
||||||
|
(canola.id, 200.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_mix_without_products_removes_mix_and_ingredients():
|
||||||
|
"""A product-less mix can be deleted; its ingredient rows cascade away."""
|
||||||
|
db = _session()
|
||||||
|
maize = _raw(db, "Maize")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
|
||||||
|
db.commit()
|
||||||
|
mix_id = mix.id
|
||||||
|
|
||||||
|
delete_editor_mix(mix_id, session=_editor_session(), db=db)
|
||||||
|
|
||||||
|
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is None
|
||||||
|
assert db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix_id)).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_mix_with_products_is_refused():
|
||||||
|
"""A mix that still drives products can't be deleted (409) — products must
|
||||||
|
keep a mix, so the user marks it inactive instead."""
|
||||||
|
db = _session()
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Product(
|
||||||
|
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
|
||||||
|
unit_of_measure="20kg bag", visible=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
mix_id = mix.id
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
delete_editor_mix(mix_id, session=_editor_session(), db=db)
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 409
|
||||||
|
assert "linked product" in excinfo.value.detail
|
||||||
|
# The mix is left intact.
|
||||||
|
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_representative_product_prefers_20kg_bag():
|
||||||
|
db = _session()
|
||||||
|
maize = _raw(db, "Maize")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
|
||||||
|
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||||
|
db.add_all([bulka, bag])
|
||||||
|
db.flush()
|
||||||
|
for product in (bulka, bag):
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
|
||||||
|
assert representative is not None
|
||||||
|
assert representative.unit_of_measure == "20kg bag"
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""End-to-end ordering flow test exercising the acceptance criteria.
|
||||||
|
|
||||||
|
Drives the real FastAPI app via TestClient against an in-memory database, using
|
||||||
|
directly-issued auth tokens (bypassing the password-based login endpoints).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.core.access import INTERNAL_USER_SUBJECT
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import issue_token
|
||||||
|
from app.db.session import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.access import User
|
||||||
|
from app.models.client_access import ClientUser
|
||||||
|
from app.seed_access import seed_access
|
||||||
|
from app.services import ordering_service as svc
|
||||||
|
|
||||||
|
ORDERING_TENANT = svc.ORDERING_TENANT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = TestingSession()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
test_client._session_factory = TestingSession # type: ignore[attr-defined]
|
||||||
|
yield test_client
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_headers() -> dict[str, str]:
|
||||||
|
token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_headers(db_session_factory, *, account_id: int) -> dict[str, str]:
|
||||||
|
db = db_session_factory()
|
||||||
|
user = db.scalar(select(ClientUser).where(ClientUser.client_account_id == account_id))
|
||||||
|
payload = {
|
||||||
|
"name": user.full_name,
|
||||||
|
"email": user.email,
|
||||||
|
"role": "client",
|
||||||
|
"tenant_id": user.tenant_id,
|
||||||
|
"client_role": user.role,
|
||||||
|
"user_id": user.id,
|
||||||
|
"client_account_id": user.client_account_id,
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
token = issue_token(payload)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_b2b_ordering_flow(client):
|
||||||
|
admin = _admin_headers()
|
||||||
|
|
||||||
|
# 1. Admin creates a customer.
|
||||||
|
r = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Acme Farms", "client_code": "ACME"})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
customer = r.json()
|
||||||
|
customer_id = customer["id"]
|
||||||
|
|
||||||
|
# 2. Admin creates a user for that customer.
|
||||||
|
r = client.post(
|
||||||
|
f"/api/ordering-admin/customers/{customer_id}/users",
|
||||||
|
headers=admin,
|
||||||
|
json={"full_name": "Buyer Bob", "email": "bob@acme.test", "role": "buyer"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
# 3. Admin creates products.
|
||||||
|
r = client.post(
|
||||||
|
"/api/ordering-admin/products",
|
||||||
|
headers=admin,
|
||||||
|
json={"name": "Maize 1T", "sku": "MAIZE-1T", "category": "grains", "base_price": 100.0, "min_order_quantity": 1},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
product = r.json()
|
||||||
|
product_id = product["id"]
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/ordering-admin/products",
|
||||||
|
headers=admin,
|
||||||
|
json={"name": "Hidden Blend", "sku": "HIDDEN-1", "category": "custom_blends", "base_price": 50.0},
|
||||||
|
)
|
||||||
|
hidden_product_id = r.json()["id"]
|
||||||
|
|
||||||
|
# 4. Admin assigns a customer-specific price (contract @ 92.5).
|
||||||
|
r = client.put(
|
||||||
|
f"/api/ordering-admin/customers/{customer_id}/product-prices",
|
||||||
|
headers=admin,
|
||||||
|
json={"product_id": product_id, "unit_price": 92.5, "rule_type": "contract"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# 4b. Admin hides the second product from this customer.
|
||||||
|
r = client.put(
|
||||||
|
f"/api/ordering-admin/customers/{customer_id}/visibility",
|
||||||
|
headers=admin,
|
||||||
|
json={"product_id": hidden_product_id, "visible": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# 5. Customer logs in (token) and sees only their available product + price.
|
||||||
|
cust = _customer_headers(client._session_factory, account_id=customer_id)
|
||||||
|
r = client.get("/api/ordering/catalogue", headers=cust)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
catalogue = r.json()
|
||||||
|
skus = {p["sku"] for p in catalogue}
|
||||||
|
assert skus == {"MAIZE-1T"} # hidden product is not visible
|
||||||
|
maize = catalogue[0]
|
||||||
|
assert maize["price"]["unit_price"] == 92.5
|
||||||
|
assert maize["price"]["price_source"] == "contract"
|
||||||
|
|
||||||
|
# 6. Customer creates a draft order.
|
||||||
|
r = client.post(
|
||||||
|
"/api/ordering/orders",
|
||||||
|
headers=cust,
|
||||||
|
json={"lines": [{"product_id": product_id, "quantity": 3}], "fulfilment_method": "delivery"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
draft = r.json()
|
||||||
|
assert draft["status"] == "draft"
|
||||||
|
assert draft["subtotal_ex_gst"] == 277.5 # 3 * 92.5
|
||||||
|
order_id = draft["id"]
|
||||||
|
|
||||||
|
# Minimum-order-quantity validation.
|
||||||
|
r = client.post(
|
||||||
|
"/api/ordering/orders",
|
||||||
|
headers=cust,
|
||||||
|
json={"lines": [{"product_id": product_id, "quantity": 0.5}]},
|
||||||
|
)
|
||||||
|
# quantity below MOQ of 1 → caught by schema (gt=0 ok) then MOQ check 422.
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
# 7. Customer submits the order — price is frozen on the line.
|
||||||
|
r = client.post(f"/api/ordering/orders/{order_id}/submit", headers=cust, json={"purchase_order_number": "PO-77"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
submitted = r.json()
|
||||||
|
assert submitted["status"] == "submitted"
|
||||||
|
assert submitted["order_number"].startswith("ORD-")
|
||||||
|
assert submitted["lines"][0]["unit_price"] == 92.5
|
||||||
|
assert submitted["purchase_order_number"] == "PO-77"
|
||||||
|
|
||||||
|
# Customer can no longer edit a submitted order.
|
||||||
|
r = client.patch(f"/api/ordering/orders/{order_id}", headers=cust, json={"delivery_notes": "late edit"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
# 8. Admin sees the submitted order and manages it.
|
||||||
|
r = client.get("/api/ordering-admin/orders", headers=admin)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
admin_orders = r.json()
|
||||||
|
assert any(o["id"] == order_id for o in admin_orders)
|
||||||
|
assert admin_orders[0]["customer_name"] == "Acme Farms"
|
||||||
|
|
||||||
|
# Status progression with lifecycle enforcement.
|
||||||
|
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "confirmed"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# Illegal jump is rejected.
|
||||||
|
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "completed"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
# 9. Xero submission (stub) succeeds and records an invoice id.
|
||||||
|
r = client.post(f"/api/ordering-admin/orders/{order_id}/send-to-xero", headers=admin)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
xero = r.json()
|
||||||
|
assert xero["xero_result"]["status"] == "success"
|
||||||
|
assert xero["xero_result"]["stubbed"] is True
|
||||||
|
assert xero["xero_invoice_id"].startswith("STUB-INV-")
|
||||||
|
assert xero["raw_status"] == "sent_to_xero"
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_isolation_between_companies(client):
|
||||||
|
admin = _admin_headers()
|
||||||
|
# Two customers, each with a user.
|
||||||
|
a = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Alpha", "client_code": "ALPHA"}).json()
|
||||||
|
b = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Beta", "client_code": "BETA"}).json()
|
||||||
|
client.post(f"/api/ordering-admin/customers/{a['id']}/users", headers=admin, json={"full_name": "A1", "email": "a1@alpha.test", "role": "owner"})
|
||||||
|
client.post(f"/api/ordering-admin/customers/{b['id']}/users", headers=admin, json={"full_name": "B1", "email": "b1@beta.test", "role": "owner"})
|
||||||
|
product = client.post("/api/ordering-admin/products", headers=admin, json={"name": "Barley 1T", "sku": "BAR-1T", "category": "grains", "base_price": 80.0}).json()
|
||||||
|
|
||||||
|
headers_a = _customer_headers(client._session_factory, account_id=a["id"])
|
||||||
|
headers_b = _customer_headers(client._session_factory, account_id=b["id"])
|
||||||
|
|
||||||
|
# Alpha creates and submits an order.
|
||||||
|
order = client.post("/api/ordering/orders", headers=headers_a, json={"lines": [{"product_id": product["id"], "quantity": 2}]}).json()
|
||||||
|
# Beta must not be able to read Alpha's order.
|
||||||
|
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_b)
|
||||||
|
assert r.status_code == 404
|
||||||
|
# Alpha can.
|
||||||
|
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_a)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_staff_can_manage_orders(client):
|
||||||
|
# Internal Hunter Stock Feeds staff (not the legacy /admin login) manage the
|
||||||
|
# ordering portal via the client/internal session + manage_ordering perm.
|
||||||
|
db = client._session_factory()
|
||||||
|
seed_access(db)
|
||||||
|
db.commit()
|
||||||
|
admin_user = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||||
|
user_id = admin_user.id
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user_id})
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# Can list customers and create catalogue products through the admin API.
|
||||||
|
assert client.get("/api/ordering-admin/customers", headers=headers).status_code == 200
|
||||||
|
r = client.post(
|
||||||
|
"/api/ordering-admin/products",
|
||||||
|
headers=headers,
|
||||||
|
json={"name": "Staff Wheat", "sku": "WHEAT-STAFF", "category": "grains", "base_price": 30.0},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_customer_cannot_order(client):
|
||||||
|
admin = _admin_headers()
|
||||||
|
cust = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Gamma", "client_code": "GAMMA"}).json()
|
||||||
|
client.post(f"/api/ordering-admin/customers/{cust['id']}/users", headers=admin, json={"full_name": "G1", "email": "g1@gamma.test", "role": "buyer"})
|
||||||
|
client.post("/api/ordering-admin/products", headers=admin, json={"name": "Oats 1T", "sku": "OATS-1T", "category": "grains", "base_price": 70.0})
|
||||||
|
|
||||||
|
# Disable the customer account.
|
||||||
|
r = client.patch(f"/api/ordering-admin/customers/{cust['id']}", headers=admin, json={"status": "disabled"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
headers = _customer_headers(client._session_factory, account_id=cust["id"])
|
||||||
|
r = client.get("/api/ordering/catalogue", headers=headers)
|
||||||
|
assert r.status_code == 403
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Unit tests for the backend-owned ordering pricing engine."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.ordering import (
|
||||||
|
CatalogueProduct,
|
||||||
|
CustomerPriceAssignment,
|
||||||
|
CustomerProductPrice,
|
||||||
|
PriceList,
|
||||||
|
PriceListItem,
|
||||||
|
PriceTier,
|
||||||
|
)
|
||||||
|
from app.services.ordering_pricing import resolve_price
|
||||||
|
|
||||||
|
CUSTOMER_ID = 1
|
||||||
|
TENANT = "test-tenant"
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> Session:
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
|
||||||
|
|
||||||
|
def _product(db: Session, **overrides) -> CatalogueProduct:
|
||||||
|
values = {
|
||||||
|
"tenant_id": TENANT,
|
||||||
|
"name": "Maize 1T",
|
||||||
|
"sku": "MAIZE-1T",
|
||||||
|
"category": "grains",
|
||||||
|
"base_price": 100.0,
|
||||||
|
"min_order_quantity": 1.0,
|
||||||
|
"requires_quote": False,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
product = CatalogueProduct(**values)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_price_with_no_rules():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db)
|
||||||
|
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=2)
|
||||||
|
assert res.unit_price == 100.0
|
||||||
|
assert res.price_source == "base"
|
||||||
|
assert res.requires_quote is False
|
||||||
|
assert res.line_total(2) == 200.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_discount_applies_to_base():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db)
|
||||||
|
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, discount_percent=10.0))
|
||||||
|
db.flush()
|
||||||
|
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||||
|
assert res.unit_price == 90.0
|
||||||
|
assert res.price_source == "base"
|
||||||
|
assert res.discount_percent == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_list_overrides_base():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db)
|
||||||
|
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
|
||||||
|
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id, discount_percent=10.0))
|
||||||
|
db.flush()
|
||||||
|
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||||
|
# Price list wins over base+discount.
|
||||||
|
assert res.unit_price == 80.0
|
||||||
|
assert res.price_source == "price_list"
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_fixed_price_wins_over_price_list():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db)
|
||||||
|
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
|
||||||
|
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id))
|
||||||
|
db.add(
|
||||||
|
CustomerProductPrice(
|
||||||
|
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=72.5, rule_type="contract"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||||
|
assert res.unit_price == 72.5
|
||||||
|
assert res.price_source == "contract"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quantity_tier_wins_when_threshold_met():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db)
|
||||||
|
cpp = CustomerProductPrice(
|
||||||
|
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=90.0, rule_type="fixed"
|
||||||
|
)
|
||||||
|
db.add(cpp)
|
||||||
|
db.flush()
|
||||||
|
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=10, unit_price=85.0))
|
||||||
|
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=50, unit_price=80.0))
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Below first tier → flat fixed price.
|
||||||
|
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=5).unit_price == 90.0
|
||||||
|
# Meets first tier.
|
||||||
|
mid = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=10)
|
||||||
|
assert mid.unit_price == 85.0
|
||||||
|
assert mid.price_source == "tiered"
|
||||||
|
# Meets highest tier.
|
||||||
|
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=60).unit_price == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_requires_quote():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db, requires_quote=True)
|
||||||
|
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||||
|
assert res.requires_quote is True
|
||||||
|
assert res.unit_price is None
|
||||||
|
assert res.price_source == "quote"
|
||||||
|
assert res.line_total(5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_quote_rule_forces_quote():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db)
|
||||||
|
db.add(
|
||||||
|
CustomerProductPrice(
|
||||||
|
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=None, rule_type="quote"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||||
|
assert res.requires_quote is True
|
||||||
|
assert res.price_source == "quote"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_price_falls_back_to_quote():
|
||||||
|
db = _session()
|
||||||
|
product = _product(db, base_price=None)
|
||||||
|
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||||
|
assert res.requires_quote is True
|
||||||
|
assert res.price_source == "quote"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
|
||||||
|
|
||||||
|
Drives the real FastAPI app via TestClient against an in-memory database, and
|
||||||
|
swaps the configured API key in by replacing the module-level `settings` object
|
||||||
|
(the real one is a frozen dataclass).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api import public_v1
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.throughput import ProductionThroughput
|
||||||
|
|
||||||
|
API_KEY = "test-powerbi-key"
|
||||||
|
TENANT = "hunter-premium-produce"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
|
||||||
|
db.add(
|
||||||
|
ProductionThroughput(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
production_date=when,
|
||||||
|
product_name_snapshot=product,
|
||||||
|
bag_size=20,
|
||||||
|
quantity=quantity,
|
||||||
|
quantity_type="bags",
|
||||||
|
calculated_kg=quantity * 20,
|
||||||
|
created_by="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_factory():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = TestingSession()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
yield TestingSession
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
|
||||||
|
# Replace the whole settings object the router reads (frozen dataclass).
|
||||||
|
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_when_key_unset(monkeypatch, db_factory):
|
||||||
|
client = _make_client(monkeypatch, api_key="")
|
||||||
|
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
|
||||||
|
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||||
|
assert client.get("/api/v1/throughput").status_code == 401
|
||||||
|
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_entries_for_tenant(monkeypatch, db_factory):
|
||||||
|
db = db_factory()
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
|
||||||
|
# An entry in another tenant must never leak through.
|
||||||
|
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||||
|
|
||||||
|
# Header auth.
|
||||||
|
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = response.json()
|
||||||
|
names = [row["product_name_snapshot"] for row in rows]
|
||||||
|
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
|
||||||
|
assert rows[0]["calculated_kg"] == 200.0
|
||||||
|
|
||||||
|
# Query-string auth works too (Power BI Web connector convenience).
|
||||||
|
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_filter(monkeypatch, db_factory):
|
||||||
|
db = db_factory()
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||||
|
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
|
||||||
|
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from app.services.product_costing_service import (
|
||||||
|
ProductCostAssumptions,
|
||||||
|
ProductCostInputItem,
|
||||||
|
calculate_product_cost_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assumptions() -> ProductCostAssumptions:
|
||||||
|
return ProductCostAssumptions(
|
||||||
|
grading_per_kg=0.05,
|
||||||
|
cracking_per_kg=0.03,
|
||||||
|
process_costs={
|
||||||
|
"Bagging + Grading": 0.04,
|
||||||
|
"Standard Bagging": 0.02,
|
||||||
|
"PHF Horse Mixes": 0.01,
|
||||||
|
"Peckish": 0.08,
|
||||||
|
"Hay & Straw": 0.09,
|
||||||
|
},
|
||||||
|
client_margins={
|
||||||
|
"Specialty": {"distributor_margin": 0.2, "wholesale_margin": 0.1},
|
||||||
|
"Peckish": {"distributor_margin": 0.25, "wholesale_margin": 0.15},
|
||||||
|
"Hay & Straw": {"distributor_margin": 0.3, "wholesale_margin": 0.2},
|
||||||
|
"Straight Grain": {"distributor_margin": 0.1, "wholesale_margin": 0.08},
|
||||||
|
"PHF Horse Mixes": {"distributor_margin": 0.18, "wholesale_margin": 0.12},
|
||||||
|
},
|
||||||
|
bag_costs={
|
||||||
|
"20kg_bag": 0.6,
|
||||||
|
"bulka_bag": 22.0,
|
||||||
|
"own_bag_credit": 0.2,
|
||||||
|
"1_5kg_bagging": 0.35,
|
||||||
|
"peckish_bag": 0.4,
|
||||||
|
},
|
||||||
|
freight_costs={
|
||||||
|
"freight_per_pallet": 80.0,
|
||||||
|
"peckish_freight_per_pallet": 96.0,
|
||||||
|
"hay_straw_freight_per_pallet": 120.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def item(**overrides) -> ProductCostInputItem:
|
||||||
|
values = {
|
||||||
|
"client_category": "Specialty",
|
||||||
|
"product_name": "Pigeon Mix 20kg",
|
||||||
|
"mix_product_name": "Pigeon Mix",
|
||||||
|
"unit_type": "Standard",
|
||||||
|
"own_bag": None,
|
||||||
|
"unit_kg": 20.0,
|
||||||
|
"items_per_pallet": 40,
|
||||||
|
"bagging_process": "Bagging + Grading",
|
||||||
|
"manual_distributor_margin": None,
|
||||||
|
"manual_wholesale_margin": None,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return ProductCostInputItem(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_product_uses_per_kg_components_unit_bag_freight_and_default_margins():
|
||||||
|
result = calculate_product_cost_item(item(), assumptions(), 0.5)
|
||||||
|
|
||||||
|
assert result.grading_cost_per_kg == 0.05
|
||||||
|
assert result.bagging_cost_per_kg == 0.04
|
||||||
|
assert result.bag_cost_per_unit == 0.6
|
||||||
|
assert result.freight_cost_per_unit == 2.0
|
||||||
|
assert result.finished_product_delivered_cost == 14.4
|
||||||
|
assert result.distributor_price == 18.0
|
||||||
|
assert result.wholesale_price == 16.0
|
||||||
|
assert result.warnings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulka_uses_per_kg_delivered_formula_and_bulka_bag_and_freight_divided_by_unit_kg():
|
||||||
|
result = calculate_product_cost_item(item(unit_type="Bulka", unit_kg=1000, items_per_pallet=1), assumptions(), 0.5)
|
||||||
|
|
||||||
|
assert result.bag_cost_per_unit == 0.022
|
||||||
|
assert result.freight_cost_per_unit == 0.08
|
||||||
|
assert result.finished_product_delivered_cost == 0.692
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_unit_uses_per_kg_delivered_formula_but_standard_pallet_freight():
|
||||||
|
result = calculate_product_cost_item(item(unit_type="Per Unit", unit_kg=1, items_per_pallet=10), assumptions(), 0.5)
|
||||||
|
|
||||||
|
assert result.freight_cost_per_unit == 8.0
|
||||||
|
assert result.finished_product_delivered_cost == 8.59
|
||||||
|
|
||||||
|
|
||||||
|
def test_peckish_uses_peckish_bag_freight_and_zero_grading():
|
||||||
|
result = calculate_product_cost_item(
|
||||||
|
item(client_category="Peckish", bagging_process="Peckish", items_per_pallet=24),
|
||||||
|
assumptions(),
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.grading_cost_per_kg == 0
|
||||||
|
assert result.bagging_cost_per_kg == 0.08
|
||||||
|
assert result.bag_cost_per_unit == 0.4
|
||||||
|
assert result.freight_cost_per_unit == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_hay_and_straw_uses_hay_freight_and_zero_grading():
|
||||||
|
result = calculate_product_cost_item(
|
||||||
|
item(client_category="Hay & Straw", bagging_process="Hay & Straw", items_per_pallet=30),
|
||||||
|
assumptions(),
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.grading_cost_per_kg == 0
|
||||||
|
assert result.freight_cost_per_unit == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_phf_horse_mixes_have_zero_grading():
|
||||||
|
result = calculate_product_cost_item(
|
||||||
|
item(client_category="PHF Horse Mixes", bagging_process="PHF Horse Mixes"),
|
||||||
|
assumptions(),
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.grading_cost_per_kg == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_own_bag_subtracts_credit_and_no_bag_sets_bag_cost_to_zero():
|
||||||
|
own_bag = calculate_product_cost_item(item(own_bag="Yes"), assumptions(), 0.5)
|
||||||
|
no_bag = calculate_product_cost_item(item(own_bag="No Bag"), assumptions(), 0.5)
|
||||||
|
|
||||||
|
assert own_bag.bag_cost_per_unit == 0.4
|
||||||
|
assert no_bag.bag_cost_per_unit == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_point_five_kg_branch_multiplies_pack_formula_by_eight():
|
||||||
|
result = calculate_product_cost_item(item(unit_type="1.5 kg", unit_kg=1.5), assumptions(), 0.5)
|
||||||
|
|
||||||
|
assert result.bag_cost_per_unit == 0.35
|
||||||
|
assert result.finished_product_delivered_cost == 10.84
|
||||||
|
|
||||||
|
|
||||||
|
def test_cracked_product_adds_cracking_cost_and_manual_margins_override_defaults():
|
||||||
|
result = calculate_product_cost_item(
|
||||||
|
item(product_name="Cracked Maize 20kg", manual_distributor_margin=0.1, manual_wholesale_margin=0.05),
|
||||||
|
assumptions(),
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.cracking_cost_per_kg == 0.03
|
||||||
|
assert result.distributor_price == 16.6667
|
||||||
|
assert result.wholesale_price == 15.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_straight_grain_bulka_wholesale_rounds_up_to_two_decimals():
|
||||||
|
result = calculate_product_cost_item(
|
||||||
|
item(client_category="Straight Grain", unit_type="Bulka", unit_kg=1000, items_per_pallet=1),
|
||||||
|
assumptions(),
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.wholesale_price == 0.76
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_lookup_and_invalid_inputs_generate_warnings_without_prices():
|
||||||
|
result = calculate_product_cost_item(
|
||||||
|
item(unit_kg=None, items_per_pallet=0, manual_distributor_margin=1.2),
|
||||||
|
assumptions(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Missing mix/product cost lookup" in result.warnings
|
||||||
|
assert "Missing unit kg" in result.warnings
|
||||||
|
assert "Missing pallet quantity" in result.warnings
|
||||||
|
assert "Invalid distributor margin" in result.warnings
|
||||||
|
assert result.finished_product_delivered_cost is None
|
||||||
|
assert result.distributor_price is None
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
import app.models # noqa: F401 - import all model modules before reading metadata
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_sqlite_schema_matches_model_metadata():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
actual_tables = set(inspector.get_table_names())
|
||||||
|
expected_tables = set(Base.metadata.tables)
|
||||||
|
assert actual_tables == expected_tables
|
||||||
|
|
||||||
|
for table_name, table in Base.metadata.tables.items():
|
||||||
|
actual_columns = {column["name"] for column in inspector.get_columns(table_name)}
|
||||||
|
expected_columns = {column.name for column in table.columns}
|
||||||
|
assert actual_columns == expected_columns
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Regression guards for the print/PDF Content-Security-Policy.
|
||||||
|
|
||||||
|
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
|
||||||
|
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
|
||||||
|
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
|
||||||
|
which silently blocks the frame and breaks printing for every user (regardless of
|
||||||
|
role). These tests pin the policy on both layers that emit it:
|
||||||
|
|
||||||
|
* the FastAPI security middleware (covers every API response), and
|
||||||
|
* the production nginx config (the source of the *document* CSP that actually
|
||||||
|
governs ``frame-src`` in the browser).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import issue_token
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
|
||||||
|
|
||||||
|
# Directives the print flow depends on. blob: must be framable, and that must not
|
||||||
|
# come at the cost of dropping the same-origin baseline.
|
||||||
|
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csp(header: str) -> dict[str, set[str]]:
|
||||||
|
"""Parse a CSP header string into ``{directive: {sources}}``."""
|
||||||
|
directives: dict[str, set[str]] = {}
|
||||||
|
for part in header.split(";"):
|
||||||
|
tokens = part.split()
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
directives[tokens[0].lower()] = set(tokens[1:])
|
||||||
|
return directives
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_blob_framing(header: str) -> None:
|
||||||
|
csp = _parse_csp(header)
|
||||||
|
# frame-src must exist and allow self + blob (no falling back to default-src).
|
||||||
|
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
|
||||||
|
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
|
||||||
|
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
|
||||||
|
)
|
||||||
|
# child-src is the Safari fallback for frame-src; keep it aligned.
|
||||||
|
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
|
||||||
|
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
|
||||||
|
# We only widened framing: the same-origin default must stay intact.
|
||||||
|
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Backend middleware policy ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client() -> TestClient:
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
|
||||||
|
response = client.get("/health")
|
||||||
|
assert "content-security-policy" in response.headers
|
||||||
|
_assert_blob_framing(response.headers["content-security-policy"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
|
||||||
|
"""The policy is identical for anonymous, authenticated, and rejected (401)
|
||||||
|
requests, so printing can never depend on who is signed in."""
|
||||||
|
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
client.get("/health"), # anonymous
|
||||||
|
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
|
||||||
|
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
policies = set()
|
||||||
|
for response in responses:
|
||||||
|
header = response.headers.get("content-security-policy")
|
||||||
|
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
|
||||||
|
_assert_blob_framing(header)
|
||||||
|
policies.add(header)
|
||||||
|
|
||||||
|
assert len(policies) == 1, "CSP must not vary by authentication state"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Production document policy (nginx) ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_nginx_csp_allows_blob_frames() -> None:
|
||||||
|
"""Every CSP the production nginx emits must allow blob framing. This guards
|
||||||
|
the *document* policy, which is what the browser enforces for the print iframe."""
|
||||||
|
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
|
||||||
|
|
||||||
|
for config_path in NGINX_CONFIGS:
|
||||||
|
assert config_path.exists(), f"missing nginx config: {config_path}"
|
||||||
|
text = config_path.read_text(encoding="utf-8")
|
||||||
|
policies = csp_line.findall(text)
|
||||||
|
assert policies, f"no Content-Security-Policy header found in {config_path}"
|
||||||
|
for policy in policies:
|
||||||
|
_assert_blob_framing(policy)
|
||||||
@@ -13,6 +13,7 @@ from app.models.throughput import ProductionThroughput, ThroughputProduct
|
|||||||
from app.seed import seed_throughput_products_from_costing
|
from app.seed import seed_throughput_products_from_costing
|
||||||
from app.services.throughput_service import (
|
from app.services.throughput_service import (
|
||||||
calculate_kg,
|
calculate_kg,
|
||||||
|
import_entries_from_file,
|
||||||
import_names_sheet,
|
import_names_sheet,
|
||||||
import_production_sheet,
|
import_production_sheet,
|
||||||
normalise_staff_name,
|
normalise_staff_name,
|
||||||
@@ -197,9 +198,12 @@ def test_seed_throughput_products_from_costing_products():
|
|||||||
assert products[0].default_bag_size == 20
|
assert products[0].default_bag_size == 20
|
||||||
assert products[0].is_bulka_default is False
|
assert products[0].is_bulka_default is False
|
||||||
assert products[0].active is True
|
assert products[0].active is True
|
||||||
|
assert products[0].client_name == "Hunter"
|
||||||
assert products[1].default_bag_size is None
|
assert products[1].default_bag_size is None
|
||||||
assert products[1].is_bulka_default is True
|
assert products[1].is_bulka_default is True
|
||||||
assert products[1].active is False
|
# Every costing SKU is selectable in the throughput picker, even hidden ones.
|
||||||
|
assert products[1].active is True
|
||||||
|
assert products[1].client_name == "Hunter"
|
||||||
|
|
||||||
|
|
||||||
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
|
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
|
||||||
@@ -237,3 +241,102 @@ def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
|
|||||||
assert products[0].name == "Updated Wheat 25kg"
|
assert products[0].name == "Updated Wheat 25kg"
|
||||||
assert products[0].default_bag_size == 25
|
assert products[0].default_bag_size == 25
|
||||||
assert products[0].active is True
|
assert products[0].active is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_import_keeps_blank_destination_flags_false():
|
||||||
|
db = _session()
|
||||||
|
csv_bytes = (
|
||||||
|
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock,Job Number\n"
|
||||||
|
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,,,\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
result = import_entries_from_file(
|
||||||
|
db,
|
||||||
|
filename="throughput-import.csv",
|
||||||
|
content=csv_bytes,
|
||||||
|
tenant_id="test-tenant",
|
||||||
|
created_by="tester@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["entries_imported"] == 1
|
||||||
|
entry = db.scalar(select(ProductionThroughput))
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.for_order is False
|
||||||
|
assert entry.for_stock is False
|
||||||
|
assert entry.job_number is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_import_detects_month_first_dates_consistently():
|
||||||
|
# The pasted sheet is US month-first (M/D/Y). "9/23/2025" is unambiguous, so
|
||||||
|
# the ambiguous "12/9/2025" must follow the same convention: 9 December, not
|
||||||
|
# 12 September (which the old day-first-by-default parser produced).
|
||||||
|
db = _session()
|
||||||
|
csv_bytes = (
|
||||||
|
"Date,Product,Item ID,Quantity,Type,Bag Size,Packed By\n"
|
||||||
|
"12/9/2025,Whole Wheat Cleaned & Graded 20kg,373022,156,bags,20,Jake\n"
|
||||||
|
"9/23/2025,Steam Rolled Barley 20kg,568240,34,bags,20,jake\n"
|
||||||
|
"9/24/2025,Stock Mix 20kg,540725,153,bags,20,jake\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
result = import_entries_from_file(
|
||||||
|
db,
|
||||||
|
filename="throughput-import.csv",
|
||||||
|
content=csv_bytes,
|
||||||
|
tenant_id="test-tenant",
|
||||||
|
created_by="tester@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["entries_imported"] == 3
|
||||||
|
dates = {
|
||||||
|
e.product_name_snapshot: e.production_date
|
||||||
|
for e in db.scalars(select(ProductionThroughput)).all()
|
||||||
|
}
|
||||||
|
assert dates["Whole Wheat Cleaned & Graded 20kg"] == date(2025, 12, 9)
|
||||||
|
assert dates["Steam Rolled Barley 20kg"] == date(2025, 9, 23)
|
||||||
|
assert dates["Stock Mix 20kg"] == date(2025, 9, 24)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_import_keeps_day_first_dates_for_australian_sheets():
|
||||||
|
# A genuinely day-first file (23/9/2025 proves D/M/Y) must stay day-first, so
|
||||||
|
# 12/9/2025 reads as 12 September.
|
||||||
|
db = _session()
|
||||||
|
csv_bytes = (
|
||||||
|
"Date,Product,Quantity,Type,Bag Size\n"
|
||||||
|
"23/9/2025,Stock Mix 20kg,10,bags,20\n"
|
||||||
|
"12/9/2025,Stock Mix 20kg,10,bags,20\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
import_entries_from_file(
|
||||||
|
db,
|
||||||
|
filename="throughput-import.csv",
|
||||||
|
content=csv_bytes,
|
||||||
|
tenant_id="test-tenant",
|
||||||
|
created_by="tester@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
produced = sorted(
|
||||||
|
e.production_date for e in db.scalars(select(ProductionThroughput)).all()
|
||||||
|
)
|
||||||
|
assert produced == [date(2025, 9, 12), date(2025, 9, 23)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_import_does_not_treat_unknown_destination_text_as_true():
|
||||||
|
db = _session()
|
||||||
|
csv_bytes = (
|
||||||
|
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock\n"
|
||||||
|
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,stock,\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
result = import_entries_from_file(
|
||||||
|
db,
|
||||||
|
filename="throughput-import.csv",
|
||||||
|
content=csv_bytes,
|
||||||
|
tenant_id="test-tenant",
|
||||||
|
created_by="tester@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["entries_imported"] == 1
|
||||||
|
entry = db.scalar(select(ProductionThroughput))
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.for_order is False
|
||||||
|
assert entry.for_stock is False
|
||||||
|
|||||||
@@ -458,6 +458,21 @@ fi
|
|||||||
throw
|
throw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Reload nginx config ───────────────────────────────────────────────────
|
||||||
|
# The nginx config is a bind-mounted file. `docker compose up` only recreates
|
||||||
|
# a service when its definition changes, not when a mounted file's contents
|
||||||
|
# change, so a running nginx keeps serving the config it loaded at start. Force
|
||||||
|
# a reload so edits to clients.lean-101.conf (routing, security headers/CSP)
|
||||||
|
# actually take effect on every deploy. Non-fatal: stacks without an nginx
|
||||||
|
# service simply skip this.
|
||||||
|
Write-Step "Reloading nginx to apply config changes"
|
||||||
|
$nginxReload = "cd '$RemotePath' && docker compose $ComposeArgs exec -T nginx nginx -t && docker compose $ComposeArgs exec -T nginx nginx -s reload"
|
||||||
|
if ((Try-Ssh $nginxReload) -eq 0) {
|
||||||
|
Write-Ok "nginx reloaded"
|
||||||
|
} else {
|
||||||
|
Write-Warn "Skipped nginx reload (no nginx service, or config test failed)"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Health check ────────────────────────────────────────────────────────────
|
# ── Health check ────────────────────────────────────────────────────────────
|
||||||
Write-Step "Waiting for backend health check ($BackendContainer)"
|
Write-Step "Waiting for backend health check ($BackendContainer)"
|
||||||
$healthScript = @"
|
$healthScript = @"
|
||||||
|
|||||||
@@ -405,6 +405,8 @@ TABLE_ORDER = [
|
|||||||
"costing_results",
|
"costing_results",
|
||||||
"mix_calculator_sessions",
|
"mix_calculator_sessions",
|
||||||
"mix_calculator_session_lines",
|
"mix_calculator_session_lines",
|
||||||
|
"throughput_products",
|
||||||
|
"production_throughput_entries",
|
||||||
]
|
]
|
||||||
|
|
||||||
def migrate():
|
def migrate():
|
||||||
@@ -455,19 +457,27 @@ def migrate():
|
|||||||
# Re-enable FK checks
|
# Re-enable FK checks
|
||||||
dst_conn.execute(text("SET session_replication_role = 'origin'"))
|
dst_conn.execute(text("SET session_replication_role = 'origin'"))
|
||||||
|
|
||||||
# Reset auto-increment sequences
|
# Reset auto-increment sequences for EVERY table with an id sequence — not
|
||||||
|
# just the ones we copied above — so later inserts (e.g. editor_change_events)
|
||||||
|
# don't collide with pre-existing ids. Leaving a sequence behind MAX(id) is
|
||||||
|
# what makes "create new mix" fail with a duplicate-key error on Postgres.
|
||||||
print("\n Resetting sequences...")
|
print("\n Resetting sequences...")
|
||||||
with dst.begin() as conn:
|
with dst.begin() as conn:
|
||||||
for table_name in TABLE_ORDER:
|
all_tables = inspect(dst).get_table_names()
|
||||||
try:
|
for table_name in all_tables:
|
||||||
conn.execute(text(
|
sequence = conn.execute(text(
|
||||||
f"SELECT setval("
|
"SELECT pg_get_serial_sequence(:table, 'id')"
|
||||||
f" pg_get_serial_sequence('{table_name}', 'id'),"
|
), {"table": table_name}).scalar()
|
||||||
f" COALESCE((SELECT MAX(id) FROM {table_name}), 1)"
|
if not sequence:
|
||||||
f")"
|
continue
|
||||||
))
|
max_id = conn.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
|
||||||
except Exception:
|
if max_id is None:
|
||||||
pass
|
continue
|
||||||
|
conn.execute(text("SELECT setval(:sequence, :value, true)"), {
|
||||||
|
"sequence": sequence,
|
||||||
|
"value": int(max_id),
|
||||||
|
})
|
||||||
|
print(f" SEQ {table_name:<45} -> {max_id}")
|
||||||
|
|
||||||
print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.")
|
print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.")
|
||||||
return totals
|
return totals
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ server {
|
|||||||
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 Permissions-Policy "camera=(), microphone=(), geolocation=()" 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;
|
# frame-src/child-src allow same-origin blob: URLs so the in-app PDF print
|
||||||
|
# dialog (an iframe pointed at a blob:) is not blocked by the default-src fallback.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
|
||||||
location /_app/immutable/ {
|
location /_app/immutable/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -90,6 +92,14 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" always;
|
add_header Pragma "no-cache" always;
|
||||||
|
# nginx drops inherited add_header directives once a location defines its own,
|
||||||
|
# so the security headers (incl. the blob:-aware CSP) are repeated here to
|
||||||
|
# guarantee the HTML document carries them.
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires -1;
|
expires -1;
|
||||||
proxy_pass http://lean101_clients_frontend;
|
proxy_pass http://lean101_clients_frontend;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
|
APP_NAME: ${APP_NAME:-Hunter App}
|
||||||
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
|
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}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
|
APP_NAME: ${APP_NAME:-Hunter App}
|
||||||
APP_ENV: ${APP_ENV:-production}
|
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}
|
||||||
@@ -45,6 +45,9 @@ services:
|
|||||||
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
|
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
|
||||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
|
||||||
DOCS_ENABLED: ${DOCS_ENABLED:-false}
|
DOCS_ENABLED: ${DOCS_ENABLED:-false}
|
||||||
|
# Read-only Power BI data API (/api/v1). Blank disables it.
|
||||||
|
POWERBI_API_KEY: ${POWERBI_API_KEY:-}
|
||||||
|
POWERBI_TENANT_ID: ${POWERBI_TENANT_ID:-${CLIENT_TENANT_ID:-hunter-premium-produce}}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ services:
|
|||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
|
APP_NAME: ${APP_NAME:-Hunter App}
|
||||||
APP_ENV: ${APP_ENV:-development}
|
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}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.8",
|
"version": "0.1.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.8",
|
"version": "0.1.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.8",
|
"version": "0.1.36",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.png" />
|
<link rel="icon" href="/favicon.png" />
|
||||||
|
<script>
|
||||||
|
// Resolve the theme before first paint so there is no dark-mode flash.
|
||||||
|
// Dark mode is strictly opt-in: it loads only when the user has explicitly
|
||||||
|
// chosen it. Absent preference (or the legacy 'system' value) stays light,
|
||||||
|
// so the OS scheme never pulls the app into dark on its own.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var pref = localStorage.getItem('theme');
|
||||||
|
document.documentElement.dataset.theme = pref === 'dark' ? 'dark' : 'light';
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.dataset.theme = 'light';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Modern hover/focus tooltip action.
|
||||||
|
*
|
||||||
|
* Renders a styled bubble appended to <body> with fixed positioning, so it is
|
||||||
|
* never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the
|
||||||
|
* shared design tokens — see `.app-tooltip` in styles/theme.css.
|
||||||
|
*
|
||||||
|
* Usage: <button use:tooltip={'Switch to dark mode'}>…</button>
|
||||||
|
* <button use:tooltip={{ label: 'What’s new', placement: 'bottom' }}>…</button>
|
||||||
|
*/
|
||||||
|
type Placement = 'top' | 'bottom';
|
||||||
|
|
||||||
|
type TooltipOptions = string | { label: string; placement?: Placement; delay?: number };
|
||||||
|
|
||||||
|
const GAP = 8;
|
||||||
|
const DEFAULT_DELAY = 300;
|
||||||
|
|
||||||
|
function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } {
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
return { label: options, placement: 'bottom', delay: DEFAULT_DELAY };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: options.label,
|
||||||
|
placement: options.placement ?? 'bottom',
|
||||||
|
delay: options.delay ?? DEFAULT_DELAY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tooltip(node: HTMLElement, options: TooltipOptions) {
|
||||||
|
let current = normalize(options);
|
||||||
|
let bubble: HTMLDivElement | null = null;
|
||||||
|
let showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function position() {
|
||||||
|
if (!bubble) return;
|
||||||
|
const anchor = node.getBoundingClientRect();
|
||||||
|
const tip = bubble.getBoundingClientRect();
|
||||||
|
|
||||||
|
let left = anchor.left + anchor.width / 2 - tip.width / 2;
|
||||||
|
left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP));
|
||||||
|
|
||||||
|
const top =
|
||||||
|
current.placement === 'top'
|
||||||
|
? anchor.top - tip.height - GAP
|
||||||
|
: anchor.bottom + GAP;
|
||||||
|
|
||||||
|
bubble.style.left = `${Math.round(left)}px`;
|
||||||
|
bubble.style.top = `${Math.round(top)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
if (bubble || !current.label) return;
|
||||||
|
bubble = document.createElement('div');
|
||||||
|
bubble.className = 'app-tooltip';
|
||||||
|
bubble.setAttribute('role', 'tooltip');
|
||||||
|
bubble.dataset.placement = current.placement;
|
||||||
|
bubble.textContent = current.label;
|
||||||
|
document.body.appendChild(bubble);
|
||||||
|
position();
|
||||||
|
// Trigger the fade/translate transition on the next frame.
|
||||||
|
requestAnimationFrame(() => bubble?.classList.add('is-visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleShow() {
|
||||||
|
clearTimeout(showTimer ?? undefined);
|
||||||
|
showTimer = setTimeout(show, current.delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
clearTimeout(showTimer ?? undefined);
|
||||||
|
showTimer = null;
|
||||||
|
bubble?.remove();
|
||||||
|
bubble = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('mouseenter', scheduleShow);
|
||||||
|
node.addEventListener('mouseleave', hide);
|
||||||
|
node.addEventListener('focus', show);
|
||||||
|
node.addEventListener('blur', hide);
|
||||||
|
node.addEventListener('click', hide);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: TooltipOptions) {
|
||||||
|
current = normalize(next);
|
||||||
|
if (bubble) {
|
||||||
|
bubble.textContent = current.label;
|
||||||
|
bubble.dataset.placement = current.placement;
|
||||||
|
position();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
hide();
|
||||||
|
node.removeEventListener('mouseenter', scheduleShow);
|
||||||
|
node.removeEventListener('mouseleave', hide);
|
||||||
|
node.removeEventListener('focus', show);
|
||||||
|
node.removeEventListener('blur', hide);
|
||||||
|
node.removeEventListener('click', hide);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+354
-46
@@ -1,16 +1,5 @@
|
|||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import {
|
|
||||||
mockClientAccess,
|
|
||||||
mockClientAccessExport,
|
|
||||||
mockCosts,
|
|
||||||
mockMixCalculatorOptions,
|
|
||||||
mockMixCalculatorSessions,
|
|
||||||
mockMixes,
|
|
||||||
mockProducts,
|
|
||||||
mockRawMaterials,
|
|
||||||
mockScenarios
|
|
||||||
} from '$lib/mock';
|
|
||||||
import type {
|
import type {
|
||||||
ClientAccessAccount,
|
ClientAccessAccount,
|
||||||
ClientAccessPowerBiExport,
|
ClientAccessPowerBiExport,
|
||||||
@@ -18,7 +7,28 @@ import type {
|
|||||||
ClientUserCreateInput,
|
ClientUserCreateInput,
|
||||||
ClientUserModulePermission,
|
ClientUserModulePermission,
|
||||||
ClientUserUpdateInput,
|
ClientUserUpdateInput,
|
||||||
|
InternalUser,
|
||||||
|
InternalRoleOption,
|
||||||
|
InternalRole,
|
||||||
|
InternalRoleCreateInput,
|
||||||
|
InternalRoleModuleDefinition,
|
||||||
|
InternalRoleUpdateInput,
|
||||||
|
InternalUserCreateInput,
|
||||||
|
InternalUserUpdateInput,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
EditorMixCreateInput,
|
||||||
|
EditorMixUpdateInput,
|
||||||
|
EditorMixRow,
|
||||||
|
EditorMixFormula,
|
||||||
|
EditorResolvedMixFormula,
|
||||||
|
EditorMixFormulaRowInput,
|
||||||
|
EditorIngredientRow,
|
||||||
|
EditorIngredientCreateInput,
|
||||||
|
EditorIngredientUpdateInput,
|
||||||
|
EditorChangeEvent,
|
||||||
|
EditorProductFormula,
|
||||||
|
EditorProductRow,
|
||||||
|
EditorProductUpdateInput,
|
||||||
MixCalculatorCreateInput,
|
MixCalculatorCreateInput,
|
||||||
MixCalculatorOptions,
|
MixCalculatorOptions,
|
||||||
MixCalculatorPreview,
|
MixCalculatorPreview,
|
||||||
@@ -30,13 +40,30 @@ import type {
|
|||||||
MixUpdateInput,
|
MixUpdateInput,
|
||||||
Product,
|
Product,
|
||||||
ProductCostBreakdown,
|
ProductCostBreakdown,
|
||||||
|
ProductCostingInputs,
|
||||||
|
ProductCostingItem,
|
||||||
|
ProductCostingItemUpdateInput,
|
||||||
RawMaterial,
|
RawMaterial,
|
||||||
RawMaterialCreateInput,
|
RawMaterialCreateInput,
|
||||||
RawMaterialPriceCreateInput,
|
RawMaterialPriceCreateInput,
|
||||||
|
CatalogueProduct,
|
||||||
|
CustomerPricing,
|
||||||
|
CustomerVisibilityRow,
|
||||||
|
DraftOrderInput,
|
||||||
|
Order,
|
||||||
|
OrderingCustomer,
|
||||||
|
OrderingCustomerUser,
|
||||||
|
OrderingNotificationSettings,
|
||||||
|
XeroStatus,
|
||||||
|
XeroContactList,
|
||||||
|
XeroContactLinkRow,
|
||||||
Scenario,
|
Scenario,
|
||||||
|
ThroughputDeleteAllResult,
|
||||||
ThroughputEntry,
|
ThroughputEntry,
|
||||||
ThroughputEntryCreateInput,
|
ThroughputEntryCreateInput,
|
||||||
|
ThroughputEntryUpdateInput,
|
||||||
ThroughputEntryListParams,
|
ThroughputEntryListParams,
|
||||||
|
ThroughputImportResult,
|
||||||
ThroughputProduct,
|
ThroughputProduct,
|
||||||
ThroughputProductCreateInput,
|
ThroughputProductCreateInput,
|
||||||
ThroughputProductUpdateInput
|
ThroughputProductUpdateInput
|
||||||
@@ -132,24 +159,18 @@ function normalizeRequestError(error: unknown) {
|
|||||||
return new Error('An unexpected error occurred while contacting the server.');
|
return new Error('An unexpected error occurred while contacting the server.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
|
async function fetchJson<T>(path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (auth !== 'none') {
|
throw new Error(response.statusText || 'Request failed');
|
||||||
throw new Error(response.statusText || 'Unauthorized');
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (auth !== 'none') {
|
|
||||||
throw normalizeRequestError(error);
|
throw normalizeRequestError(error);
|
||||||
}
|
}
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
|
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
|
||||||
@@ -168,13 +189,12 @@ function makeCacheKey(path: string, auth: AuthMode) {
|
|||||||
|
|
||||||
async function cachedFetchJson<T>(
|
async function cachedFetchJson<T>(
|
||||||
path: string,
|
path: string,
|
||||||
fallback: T,
|
|
||||||
auth: AuthMode = 'none',
|
auth: AuthMode = 'none',
|
||||||
fetcher: ApiFetch = fetch
|
fetcher: ApiFetch = fetch
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
// Bypass the cache during SSR (no localStorage, no shared session).
|
// Bypass the cache during SSR (no localStorage, no shared session).
|
||||||
if (!browser) {
|
if (!browser) {
|
||||||
return fetchJson<T>(path, fallback, auth, fetcher);
|
return fetchJson<T>(path, auth, fetcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = makeCacheKey(path, auth);
|
const key = makeCacheKey(path, auth);
|
||||||
@@ -190,7 +210,7 @@ async function cachedFetchJson<T>(
|
|||||||
return existing as Promise<T>;
|
return existing as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = fetchJson<T>(path, fallback, auth, fetcher)
|
const promise = fetchJson<T>(path, auth, fetcher)
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
|
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
|
||||||
return value;
|
return value;
|
||||||
@@ -252,6 +272,45 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multipart upload. Unlike `request`, we must NOT set Content-Type ourselves —
|
||||||
|
// the browser sets `multipart/form-data` with the correct boundary when given a
|
||||||
|
// FormData body. Mirrors `request`'s auth/cache/error handling otherwise.
|
||||||
|
async function uploadFile<T>(
|
||||||
|
path: string,
|
||||||
|
formData: FormData,
|
||||||
|
auth: AuthMode = 'none',
|
||||||
|
fetcher: ApiFetch = fetch
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Request failed';
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { detail?: string };
|
||||||
|
message = body.detail ?? message;
|
||||||
|
} catch {
|
||||||
|
message = response.statusText || message;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
clearApiCache();
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeRequestError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function requestBlob(
|
async function requestBlob(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
@@ -286,13 +345,13 @@ async function requestBlob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
|
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', 'client', fetcher),
|
||||||
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
|
mixes: (fetcher?: ApiFetch) => cachedFetchJson<Mix[]>('/api/mixes', 'client', fetcher),
|
||||||
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
|
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
|
||||||
mixCalculatorOptions: (fetcher?: ApiFetch) =>
|
mixCalculatorOptions: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
|
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', 'client', fetcher),
|
||||||
mixCalculatorSessions: (fetcher?: ApiFetch) =>
|
mixCalculatorSessions: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
|
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', 'client', fetcher),
|
||||||
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) =>
|
||||||
@@ -317,12 +376,124 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}, 'client'),
|
}, 'client'),
|
||||||
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
|
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', 'client', fetcher),
|
||||||
|
editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.q) search.set('q', params.q);
|
||||||
|
if (params?.client_name) search.set('client_name', params.client_name);
|
||||||
|
if (params?.limit) search.set('limit', String(params.limit));
|
||||||
|
const qs = search.toString();
|
||||||
|
const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products';
|
||||||
|
return cachedFetchJson<EditorProductRow[]>(path, 'client', fetcher);
|
||||||
|
},
|
||||||
|
updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
|
||||||
|
request<EditorProductRow>(`/api/editor/products/${productId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.q) search.set('q', params.q);
|
||||||
|
if (params?.client_name) search.set('client_name', params.client_name);
|
||||||
|
if (params?.limit) search.set('limit', String(params.limit));
|
||||||
|
const qs = search.toString();
|
||||||
|
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
|
||||||
|
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
|
||||||
|
},
|
||||||
|
createEditorMix: (payload: EditorMixCreateInput) =>
|
||||||
|
request<EditorMixRow>('/api/editor/mixes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
|
||||||
|
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
deleteEditorMix: (mixId: number) =>
|
||||||
|
request<void>(`/api/editor/mixes/${mixId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}, 'client'),
|
||||||
|
editorMixFormula: (mixId: number) =>
|
||||||
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
|
||||||
|
// The resolved formula matching the Mix Calculator (product-first), used by
|
||||||
|
// the Mix Editor ingredient panel.
|
||||||
|
editorMixResolvedFormula: (mixId: number) =>
|
||||||
|
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {}, 'client'),
|
||||||
|
replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) =>
|
||||||
|
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ rows })
|
||||||
|
}, 'client'),
|
||||||
|
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
||||||
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
|
||||||
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
deleteEditorMixIngredient: (mixId: number, ingredientId: number) =>
|
||||||
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}, 'client'),
|
||||||
|
editorProductFormula: (productId: number) =>
|
||||||
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
|
||||||
|
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
||||||
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
updateEditorProductIngredient: (productId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
|
||||||
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
deleteEditorProductIngredient: (productId: number, ingredientId: number) =>
|
||||||
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}, 'client'),
|
||||||
|
editorIngredients: (fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<EditorIngredientRow[]>('/api/editor/ingredients', 'client', fetcher),
|
||||||
|
createEditorIngredient: (payload: EditorIngredientCreateInput) =>
|
||||||
|
request<EditorIngredientRow>('/api/editor/ingredients', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) =>
|
||||||
|
request<EditorIngredientRow>(`/api/editor/ingredients/${ingredientId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
editorMixHistory: (mixId: number) =>
|
||||||
|
request<EditorChangeEvent[]>(`/api/editor/mixes/${mixId}/history`, {}, 'client'),
|
||||||
|
editorIngredientHistory: (ingredientId: number) =>
|
||||||
|
request<EditorChangeEvent[]>(`/api/editor/ingredients/${ingredientId}/history`, {}, 'client'),
|
||||||
productCosts: (fetcher?: ApiFetch) =>
|
productCosts: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
|
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
|
||||||
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
|
productCostingItems: (fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<ProductCostingItem[]>('/api/product-costing/items', 'client', fetcher),
|
||||||
|
productCostingItemsFresh: () =>
|
||||||
|
request<ProductCostingItem[]>(`/api/product-costing/items?_=${Date.now()}`, { method: 'GET' }, 'client'),
|
||||||
|
productCostingInputs: (fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<ProductCostingInputs>('/api/product-costing/inputs', 'client', fetcher),
|
||||||
|
updateProductCostingInputs: (payload: Partial<ProductCostingInputs>) =>
|
||||||
|
request<ProductCostingInputs>('/api/product-costing/inputs', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
updateProductCostingItem: (itemId: number, payload: ProductCostingItemUpdateInput) =>
|
||||||
|
request<ProductCostingItem>(`/api/product-costing/items/${itemId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
recalculateProductCosting: () =>
|
||||||
|
request<{ recalculated: number }>('/api/product-costing/recalculate-all', { method: 'POST' }, 'client'),
|
||||||
|
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', 'client', fetcher),
|
||||||
throughputProducts: (fetcher?: ApiFetch) =>
|
throughputProducts: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', [], 'client', fetcher),
|
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', 'client', fetcher),
|
||||||
throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => {
|
throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
if (params?.date_from) search.set('date_from', params.date_from);
|
if (params?.date_from) search.set('date_from', params.date_from);
|
||||||
@@ -333,13 +504,27 @@ export const api = {
|
|||||||
if (params?.limit) search.set('limit', String(params.limit));
|
if (params?.limit) search.set('limit', String(params.limit));
|
||||||
const qs = search.toString();
|
const qs = search.toString();
|
||||||
const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries';
|
const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries';
|
||||||
return cachedFetchJson<ThroughputEntry[]>(path, [], 'client', fetcher);
|
return cachedFetchJson<ThroughputEntry[]>(path, 'client', fetcher);
|
||||||
},
|
},
|
||||||
createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
|
createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
|
||||||
request<ThroughputEntry>('/api/throughput/entries', {
|
request<ThroughputEntry>('/api/throughput/entries', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}, 'client'),
|
}, 'client'),
|
||||||
|
updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) =>
|
||||||
|
request<ThroughputEntry>(`/api/throughput/entries/${entryId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
deleteThroughputEntry: (entryId: number) =>
|
||||||
|
request<void>(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'),
|
||||||
|
importThroughputEntries: (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
|
||||||
|
},
|
||||||
|
deleteAllThroughputEntries: () =>
|
||||||
|
request<ThroughputDeleteAllResult>('/api/throughput/entries', { method: 'DELETE' }, 'client'),
|
||||||
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
|
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
|
||||||
request<ThroughputProduct>('/api/throughput/products', {
|
request<ThroughputProduct>('/api/throughput/products', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -350,22 +535,12 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}, 'client'),
|
}, 'client'),
|
||||||
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
|
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', 'manager', fetcher),
|
||||||
clientAccessExport: (fetcher?: ApiFetch) =>
|
clientAccessExport: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
|
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', 'manager', fetcher),
|
||||||
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
|
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', 'client', fetcher),
|
||||||
dashboardSummary: (fetcher?: ApiFetch) =>
|
dashboardSummary: (fetcher?: ApiFetch) =>
|
||||||
cachedFetchJson<DashboardSummary>(
|
cachedFetchJson<DashboardSummary>('/api/dashboard/summary', 'client', fetcher),
|
||||||
'/api/dashboard/summary',
|
|
||||||
{
|
|
||||||
raw_materials: null,
|
|
||||||
mixes: null,
|
|
||||||
products: null,
|
|
||||||
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
|
|
||||||
},
|
|
||||||
'client',
|
|
||||||
fetcher
|
|
||||||
),
|
|
||||||
clientLogin: (email: string, password: string) =>
|
clientLogin: (email: string, password: string) =>
|
||||||
request<LoginResponse>('/api/auth/client/login', {
|
request<LoginResponse>('/api/auth/client/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -386,6 +561,44 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}, 'client'),
|
}, 'client'),
|
||||||
|
// --- Internal user management (lean/admin: manage_users) ------------------
|
||||||
|
accessUsers: (fetcher?: ApiFetch) =>
|
||||||
|
request<InternalUser[]>('/api/access/users', { method: 'GET' }, 'client', fetcher),
|
||||||
|
accessAssignableRoles: (fetcher?: ApiFetch) =>
|
||||||
|
request<InternalRoleOption[]>('/api/access/assignable-roles', { method: 'GET' }, 'client', fetcher),
|
||||||
|
accessRoles: (fetcher?: ApiFetch) =>
|
||||||
|
request<InternalRole[]>('/api/access/roles', { method: 'GET' }, 'client', fetcher),
|
||||||
|
accessRoleModules: (fetcher?: ApiFetch) =>
|
||||||
|
request<InternalRoleModuleDefinition[]>('/api/access/role-modules', { method: 'GET' }, 'client', fetcher),
|
||||||
|
createAccessRole: (payload: InternalRoleCreateInput) =>
|
||||||
|
request<InternalRole>('/api/access/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
updateAccessRole: (roleId: number, payload: InternalRoleUpdateInput) =>
|
||||||
|
request<InternalRole>(`/api/access/roles/${roleId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
deleteAccessRole: (roleId: number) =>
|
||||||
|
request<void>(`/api/access/roles/${roleId}`, { method: 'DELETE' }, 'client'),
|
||||||
|
createAccessUser: (payload: InternalUserCreateInput) =>
|
||||||
|
request<InternalUser>('/api/access/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
updateAccessUser: (userId: number, payload: InternalUserUpdateInput) =>
|
||||||
|
request<InternalUser>(`/api/access/users/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, 'client'),
|
||||||
|
setAccessUserPassword: (userId: number, newPassword: string) =>
|
||||||
|
request<InternalUser>(`/api/access/users/${userId}/password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_password: newPassword })
|
||||||
|
}, 'client'),
|
||||||
|
deleteAccessUser: (userId: number) =>
|
||||||
|
request<void>(`/api/access/users/${userId}`, { method: 'DELETE' }, 'client'),
|
||||||
adminLogin: (email: string, password: string) =>
|
adminLogin: (email: string, password: string) =>
|
||||||
request<LoginResponse>('/api/auth/admin/login', {
|
request<LoginResponse>('/api/auth/admin/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -454,5 +667,100 @@ export const api = {
|
|||||||
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
|
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}, 'manager')
|
}, 'manager'),
|
||||||
|
|
||||||
|
// --- B2B ordering portal (customer) ---------------------------------------
|
||||||
|
ordering: {
|
||||||
|
catalogue: (params?: { category?: string; q?: string }, fetcher?: ApiFetch) => {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.category) search.set('category', params.category);
|
||||||
|
if (params?.q) search.set('q', params.q);
|
||||||
|
const qs = search.toString();
|
||||||
|
return cachedFetchJson<CatalogueProduct[]>(`/api/ordering/catalogue${qs ? `?${qs}` : ''}`, 'client', fetcher);
|
||||||
|
},
|
||||||
|
product: (productId: number, quantity = 1, fetcher?: ApiFetch) =>
|
||||||
|
request<CatalogueProduct>(`/api/ordering/catalogue/${productId}?quantity=${quantity}`, { method: 'GET' }, 'client', fetcher),
|
||||||
|
orders: (statusFilter?: string, fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<Order[]>(`/api/ordering/orders${statusFilter ? `?status=${statusFilter}` : ''}`, 'client', fetcher),
|
||||||
|
order: (orderId: number, fetcher?: ApiFetch) =>
|
||||||
|
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
|
||||||
|
createDraft: (payload: DraftOrderInput) =>
|
||||||
|
request<Order>('/api/ordering/orders', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
updateDraft: (orderId: number, payload: Partial<DraftOrderInput>) =>
|
||||||
|
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
deleteDraft: (orderId: number) =>
|
||||||
|
request<void>(`/api/ordering/orders/${orderId}`, { method: 'DELETE' }, 'client'),
|
||||||
|
submit: (orderId: number, payload: Partial<DraftOrderInput> = {}) =>
|
||||||
|
request<Order>(`/api/ordering/orders/${orderId}/submit`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
reorder: (orderId: number) =>
|
||||||
|
request<Order>(`/api/ordering/orders/${orderId}/reorder`, { method: 'POST' }, 'client'),
|
||||||
|
confirmationPdf: (orderId: number) =>
|
||||||
|
requestBlob(`/api/ordering/orders/${orderId}/confirmation.pdf`, {}, 'client')
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- B2B ordering portal (admin) ------------------------------------------
|
||||||
|
orderingAdmin: {
|
||||||
|
customers: (fetcher?: ApiFetch) => cachedFetchJson<OrderingCustomer[]>('/api/ordering-admin/customers', 'client', fetcher),
|
||||||
|
createCustomer: (payload: { name: string; client_code: string; tenant_id?: string; notes?: string }) =>
|
||||||
|
request<OrderingCustomer>('/api/ordering-admin/customers', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
updateCustomer: (customerId: number, payload: { name?: string; status?: string; notes?: string }) =>
|
||||||
|
request<OrderingCustomer>(`/api/ordering-admin/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
customerUsers: (customerId: number, fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<OrderingCustomerUser[]>(`/api/ordering-admin/customers/${customerId}/users`, 'client', fetcher),
|
||||||
|
createCustomerUser: (customerId: number, payload: { full_name: string; email: string; role: string }) =>
|
||||||
|
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
updateCustomerUser: (customerId: number, userId: number, payload: { full_name?: string; role?: string; status?: string }) =>
|
||||||
|
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
products: (fetcher?: ApiFetch) => cachedFetchJson<CatalogueProduct[]>('/api/ordering-admin/products', 'client', fetcher),
|
||||||
|
createProduct: (payload: Partial<CatalogueProduct>) =>
|
||||||
|
request<CatalogueProduct>('/api/ordering-admin/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
updateProduct: (productId: number, payload: Partial<CatalogueProduct>) =>
|
||||||
|
request<CatalogueProduct>(`/api/ordering-admin/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
visibility: (customerId: number, fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<CustomerVisibilityRow[]>(`/api/ordering-admin/customers/${customerId}/visibility`, 'client', fetcher),
|
||||||
|
setVisibility: (customerId: number, payload: { product_id: number; visible: boolean }) =>
|
||||||
|
request(`/api/ordering-admin/customers/${customerId}/visibility`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
pricing: (customerId: number, fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/pricing`, 'client', fetcher),
|
||||||
|
setAssignment: (customerId: number, payload: { price_list_id: number | null; discount_percent: number }) =>
|
||||||
|
request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/assignment`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
setProductPrice: (
|
||||||
|
customerId: number,
|
||||||
|
payload: { product_id: number; unit_price: number | null; rule_type: string; contract_reference?: string | null; notes?: string | null; active?: boolean }
|
||||||
|
) => request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/product-prices`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
deleteProductPrice: (customerId: number, productId: number) =>
|
||||||
|
request<void>(`/api/ordering-admin/customers/${customerId}/product-prices/${productId}`, { method: 'DELETE' }, 'client'),
|
||||||
|
orders: (params?: { status?: string; customer_id?: number }, fetcher?: ApiFetch) => {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.status) search.set('status', params.status);
|
||||||
|
if (params?.customer_id != null) search.set('customer_id', String(params.customer_id));
|
||||||
|
const qs = search.toString();
|
||||||
|
return cachedFetchJson<Order[]>(`/api/ordering-admin/orders${qs ? `?${qs}` : ''}`, 'client', fetcher);
|
||||||
|
},
|
||||||
|
order: (orderId: number, fetcher?: ApiFetch) =>
|
||||||
|
request<Order>(`/api/ordering-admin/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
|
||||||
|
updateStatus: (orderId: number, payload: { to_status: string; note?: string }) =>
|
||||||
|
request<Order>(`/api/ordering-admin/orders/${orderId}/status`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
overrideLine: (orderId: number, lineId: number, payload: { quantity?: number; unit_price?: number; reason?: string }) =>
|
||||||
|
request<Order>(`/api/ordering-admin/orders/${orderId}/lines/${lineId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
reopen: (orderId: number, note?: string) =>
|
||||||
|
request<Order>(`/api/ordering-admin/orders/${orderId}/reopen`, { method: 'POST', body: JSON.stringify({ note }) }, 'client'),
|
||||||
|
sendToXero: (orderId: number) =>
|
||||||
|
request<Order>(`/api/ordering-admin/orders/${orderId}/send-to-xero`, { method: 'POST' }, 'client'),
|
||||||
|
notificationSettings: (fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
|
||||||
|
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
|
||||||
|
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher),
|
||||||
|
xeroContacts: (fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<XeroContactList>('/api/ordering-admin/xero/contacts', 'client', fetcher),
|
||||||
|
xeroContactLinks: (fetcher?: ApiFetch) =>
|
||||||
|
cachedFetchJson<XeroContactLinkRow[]>('/api/ordering-admin/xero/contact-links', 'client', fetcher),
|
||||||
|
linkCustomerToXero: (
|
||||||
|
customerId: number,
|
||||||
|
payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null }
|
||||||
|
) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||||
|
unlinkCustomerFromXero: (customerId: number) =>
|
||||||
|
request<void>(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client')
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import packageInfo from '../../package.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release notes shown in the "What's new" dialog. This is the single source of
|
||||||
|
* truth for the changelog: add a new entry at the top of `changelog` whenever
|
||||||
|
* the version in package.json is bumped, and the dialog will surface it once per
|
||||||
|
* user on their next login (see $lib/whats-new and WhatsNewDialog.svelte).
|
||||||
|
*/
|
||||||
|
export type ChangelogEntry = {
|
||||||
|
version: string;
|
||||||
|
/** ISO date (YYYY-MM-DD) the version shipped. */
|
||||||
|
date: string;
|
||||||
|
highlights: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The running app version, read straight from package.json at build time. */
|
||||||
|
export const APP_VERSION: string = packageInfo.version;
|
||||||
|
|
||||||
|
export const changelog: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: '0.1.36',
|
||||||
|
date: '2026-06-21',
|
||||||
|
highlights: [
|
||||||
|
'App: Bug fixes & improvements.',
|
||||||
|
'App: General improvements.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.35',
|
||||||
|
date: '2026-06-21',
|
||||||
|
highlights: [
|
||||||
|
'App: Bug fixes & improvements.',
|
||||||
|
'App: General improvements.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.34',
|
||||||
|
date: '2026-06-21',
|
||||||
|
highlights: [
|
||||||
|
'App: Bug fixes & improvements.',
|
||||||
|
'App: General improvements.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.33',
|
||||||
|
date: '2026-06-21',
|
||||||
|
highlights: [
|
||||||
|
'App: Mix Calculator & Ingredients improvements.',
|
||||||
|
'App: Bug fixes & improvements.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.32',
|
||||||
|
date: '2026-06-21',
|
||||||
|
highlights: [
|
||||||
|
'Mix Calculator: Added search.',
|
||||||
|
'Ingredients: Added ingredient categories.',
|
||||||
|
'Throughput: Tidy-up and refinements.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.31',
|
||||||
|
date: '2026-06-18',
|
||||||
|
highlights: [
|
||||||
|
'Mix Editor: Multi-row editing.',
|
||||||
|
'App: Bug fixes & improvements.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.30',
|
||||||
|
date: '2026-06-18',
|
||||||
|
highlights: [
|
||||||
|
'Throughput: Overview now shows today-only mix cards.',
|
||||||
|
'Mix Editor: Fixed an error when saving a mix formula.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.29',
|
||||||
|
date: '2026-06-18',
|
||||||
|
highlights: [
|
||||||
|
'Mix Editor: Editing a % no longer rebalances the other ingredients.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.28',
|
||||||
|
date: '2026-06-17',
|
||||||
|
highlights: [
|
||||||
|
'Editor & Throughput: Updates and improvements.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.27',
|
||||||
|
date: '2026-06-16',
|
||||||
|
highlights: [
|
||||||
|
'Editor: Edit a mix’s resolved formula directly, with % and kg entry on each row.',
|
||||||
|
'Editor: New mix and new ingredient buttons.',
|
||||||
|
'Throughput: Power BI / external API now live.',
|
||||||
|
'App: Security hardening on API responses.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.23',
|
||||||
|
date: '2026-06-15',
|
||||||
|
highlights: [
|
||||||
|
'App: Improvements & bug fixes.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.22',
|
||||||
|
date: '2026-06-15',
|
||||||
|
highlights: [
|
||||||
|
'Web App - Throughput module is now live.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.21',
|
||||||
|
date: '2026-06-14',
|
||||||
|
highlights: [
|
||||||
|
'Mix Calculator: Composer restyle.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.20',
|
||||||
|
date: '2026-06-13',
|
||||||
|
highlights: [
|
||||||
|
'App - Improvements',
|
||||||
|
'App - Bug fixes'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.19',
|
||||||
|
date: '2026-06-13',
|
||||||
|
highlights: [
|
||||||
|
'Throughput: Overview view added.',
|
||||||
|
'App: Responsive header.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.18',
|
||||||
|
date: '2026-06-12',
|
||||||
|
highlights: [
|
||||||
|
'App - Improvements',
|
||||||
|
'App - Bug fixes'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.17',
|
||||||
|
date: '2026-06-12',
|
||||||
|
highlights: [
|
||||||
|
'App - Improvements',
|
||||||
|
'App - Mix Calculator bug fixes'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.14',
|
||||||
|
date: '2026-06-11',
|
||||||
|
highlights: [
|
||||||
|
'Web App: Improved mix calculator',
|
||||||
|
'Web App: Improved design'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.12',
|
||||||
|
date: '2026-06-10',
|
||||||
|
highlights: [
|
||||||
|
'Mix Calculator: Changed from selecting Product to Mix.',
|
||||||
|
'Web app design improved',
|
||||||
|
'Throughput tab ready for testing',
|
||||||
|
'Costing Editor tab ready for testing'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.1.11',
|
||||||
|
date: '2026-06-03',
|
||||||
|
highlights: [
|
||||||
|
'Costing Editor: First release.'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The changelog entry matching a specific version, if one exists. */
|
||||||
|
export function changelogFor(version: string): ChangelogEntry | undefined {
|
||||||
|
return changelog.find((entry) => entry.version === version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most recent changelog entry, regardless of the running version. */
|
||||||
|
export const latestChangelog: ChangelogEntry | undefined = changelog[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry the "What's new" dialog shows. Prefer an exact match for the running
|
||||||
|
* version, but fall back to the latest documented entry so the manual button
|
||||||
|
* always opens something even when the current build's version (e.g. a hotfix
|
||||||
|
* suffix like `0.1.24b`) isn't itself listed in the changelog.
|
||||||
|
*/
|
||||||
|
export const currentChangelog: ChangelogEntry | undefined =
|
||||||
|
changelogFor(APP_VERSION) ?? latestChangelog;
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import { adminSession, sessionHydrated } from '$lib/session';
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
|
|
||||||
{ href: '/admin/client-access', label: 'Client Access', shortLabel: 'CA' }
|
|
||||||
];
|
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
let isRestoringSession = $state(false);
|
|
||||||
let restoredSessionKey = $state<string | null>(null);
|
|
||||||
|
|
||||||
function matchesRoute(href: string, pathname: string) {
|
|
||||||
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageTitle(pathname: string) {
|
|
||||||
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(name: string) {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((piece) => piece[0])
|
|
||||||
.join('')
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const hydrated = $sessionHydrated;
|
|
||||||
const sessionKey = $adminSession ? `${$adminSession.role}:${$adminSession.email}` : null;
|
|
||||||
|
|
||||||
if (!hydrated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionKey) {
|
|
||||||
isRestoringSession = false;
|
|
||||||
restoredSessionKey = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (restoredSessionKey === sessionKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
restoredSessionKey = sessionKey;
|
|
||||||
isRestoringSession = true;
|
|
||||||
|
|
||||||
invalidateAll().finally(() => {
|
|
||||||
if (restoredSessionKey === sessionKey) {
|
|
||||||
isRestoringSession = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{pageTitle(page.url.pathname)} | Lean 101 Admin Panel</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="admin-shell">
|
|
||||||
<aside class="admin-sidebar">
|
|
||||||
<a class="admin-brand" href="/admin">
|
|
||||||
<span class="brand-mark">L1</span>
|
|
||||||
<span>Lean 101 Admin Panel</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<p class="admin-copy">
|
|
||||||
Internal workspace for Lean 101 operators managing client access and controlled workspace changes.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<nav class="admin-nav" aria-label="Admin navigation">
|
|
||||||
{#each navigation as item}
|
|
||||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
|
||||||
<span class="nav-icon">{item.shortLabel}</span>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="admin-footer">
|
|
||||||
<a href="/">Open client workspace</a>
|
|
||||||
{#if $adminSession}
|
|
||||||
<button type="button" onclick={signOut}>Sign out</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="admin-main">
|
|
||||||
<header class="admin-topbar">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Admin Area</p>
|
|
||||||
<h1>{pageTitle(page.url.pathname)}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !$sessionHydrated}
|
|
||||||
<div class="profile-card guest">
|
|
||||||
<span class="profile-avatar">A</span>
|
|
||||||
<div>
|
|
||||||
<strong>Checking saved session</strong>
|
|
||||||
<span>Restoring admin access</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if $adminSession}
|
|
||||||
<div class="profile-card">
|
|
||||||
<span class="profile-avatar">{initials($adminSession.name)}</span>
|
|
||||||
<div>
|
|
||||||
<strong>{$adminSession.name}</strong>
|
|
||||||
<span>{$adminSession.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="profile-card guest">
|
|
||||||
<span class="profile-avatar">A</span>
|
|
||||||
<div>
|
|
||||||
<strong>Admin sign-in required</strong>
|
|
||||||
<span>Use `/admin` to authenticate</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="admin-content">
|
|
||||||
{#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)}
|
|
||||||
<section class="locked-card loading-card">
|
|
||||||
<p class="eyebrow">Checking Session</p>
|
|
||||||
<h2>Restoring the Lean 101 admin workspace.</h2>
|
|
||||||
<p>Refreshing the current route with the saved operator session before prompting for sign-in.</p>
|
|
||||||
</section>
|
|
||||||
{:else if isProtectedRoute && !$adminSession}
|
|
||||||
<section class="locked-card">
|
|
||||||
<p class="eyebrow">Restricted</p>
|
|
||||||
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
|
|
||||||
<p>Client access controls are only available inside the separate admin workspace.</p>
|
|
||||||
<a href="/admin">Go to admin sign-in</a>
|
|
||||||
</section>
|
|
||||||
{:else}
|
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.admin-shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(167, 217, 190, 0.22), transparent 34%),
|
|
||||||
linear-gradient(180deg, #f7f8f4 0%, #eef2ea 100%);
|
|
||||||
color: #203028;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.1rem;
|
|
||||||
border-right: 1px solid rgba(34, 54, 45, 0.12);
|
|
||||||
background: rgba(20, 29, 24, 0.96);
|
|
||||||
color: #f4f7f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-brand {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.8rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-mark,
|
|
||||||
.nav-icon,
|
|
||||||
.profile-avatar {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-mark {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-radius: 0.72rem;
|
|
||||||
color: #0f1713;
|
|
||||||
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-copy {
|
|
||||||
margin: 0;
|
|
||||||
color: rgba(244, 247, 241, 0.74);
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.72rem;
|
|
||||||
padding: 0.82rem 0.78rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
color: rgba(244, 247, 241, 0.88);
|
|
||||||
transition: background-color 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav a:hover,
|
|
||||||
.admin-nav a.active {
|
|
||||||
background: rgba(207, 228, 184, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav a.active {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
width: 1.65rem;
|
|
||||||
height: 1.65rem;
|
|
||||||
border-radius: 0.58rem;
|
|
||||||
color: #0f1713;
|
|
||||||
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-footer a,
|
|
||||||
.admin-footer button {
|
|
||||||
padding: 0.82rem 0.88rem;
|
|
||||||
border: 1px solid rgba(244, 247, 241, 0.14);
|
|
||||||
border-radius: 0.88rem;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-main {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 1.4rem;
|
|
||||||
border-bottom: 1px solid rgba(34, 54, 45, 0.1);
|
|
||||||
background: rgba(247, 248, 244, 0.85);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 0.18rem;
|
|
||||||
color: #66806e;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-topbar h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.72rem;
|
|
||||||
padding: 0.45rem 0.52rem;
|
|
||||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
|
||||||
border-radius: 0.95rem;
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar {
|
|
||||||
width: 2.2rem;
|
|
||||||
height: 2.2rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #ffffff;
|
|
||||||
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-card strong,
|
|
||||||
.profile-card span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-card span {
|
|
||||||
margin-top: 0.14rem;
|
|
||||||
color: #6b7f72;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guest .profile-avatar {
|
|
||||||
background: linear-gradient(135deg, #c4d0c8 0%, #7b8b80 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked-card {
|
|
||||||
max-width: 42rem;
|
|
||||||
padding: 1.35rem;
|
|
||||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
|
||||||
border-radius: 1.35rem;
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-card {
|
|
||||||
min-height: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked-card h2,
|
|
||||||
.locked-card p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked-card h2 {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
font-size: clamp(1.8rem, 3vw, 2.3rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked-card p:last-of-type {
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
color: #5d7166;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked-card a {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.82rem 0.95rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background: #203028;
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.admin-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid rgba(34, 54, 45, 0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.admin-topbar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import AuthGate from '$lib/components/AuthGate.svelte';
|
||||||
|
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
|
||||||
|
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
|
||||||
|
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
|
||||||
|
import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte';
|
||||||
|
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
|
||||||
|
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
|
||||||
|
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
|
||||||
|
import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
|
||||||
|
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
|
||||||
|
import { currentChangelog } from '$lib/changelog';
|
||||||
|
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
|
||||||
|
import { featureFlags } from '$lib/features';
|
||||||
|
import {
|
||||||
|
canCreateMixSession as sessionCanCreateMixSession,
|
||||||
|
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
|
||||||
|
canOpenClientAccess as sessionCanOpenClientAccess,
|
||||||
|
canOpenDashboard as sessionCanOpenDashboard,
|
||||||
|
canOpenEditor as sessionCanOpenEditor,
|
||||||
|
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||||
|
canOpenMixMaster as sessionCanOpenMixMaster,
|
||||||
|
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
|
||||||
|
canManageOrdering as sessionCanManageOrdering,
|
||||||
|
canOpenProductCosting as sessionCanOpenProductCosting,
|
||||||
|
canOpenReporting as sessionCanOpenReporting,
|
||||||
|
canOpenSettings as sessionCanOpenSettings,
|
||||||
|
canOpenThroughput as sessionCanOpenThroughput,
|
||||||
|
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
|
||||||
|
getWorkspaceRole,
|
||||||
|
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
|
||||||
|
isWorkspaceRouteAllowed
|
||||||
|
} from '$lib/workspace-access';
|
||||||
|
import {
|
||||||
|
accessControlItem,
|
||||||
|
baseSearchItems,
|
||||||
|
buildClientNavEntries,
|
||||||
|
dashboardItem,
|
||||||
|
editorItem,
|
||||||
|
ingredientsEditorItem,
|
||||||
|
footerLinks,
|
||||||
|
mixCalculatorItem,
|
||||||
|
orderingItem,
|
||||||
|
orderingManageChildren,
|
||||||
|
orderingManageGroup,
|
||||||
|
pageMeta,
|
||||||
|
productCostingItem,
|
||||||
|
reportingItem,
|
||||||
|
throughputItem,
|
||||||
|
type FooterLink,
|
||||||
|
type NavEntry,
|
||||||
|
type SearchItem,
|
||||||
|
type NavItem,
|
||||||
|
workingDocumentItems
|
||||||
|
} from '$lib/navigation/client-navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import packageInfo from '../../../package.json';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
const isRootRoute = $derived(page.url.pathname === '/');
|
||||||
|
|
||||||
|
let searchOpen = $state(false);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchFocusRequest = $state(0);
|
||||||
|
let appsFabOpen = $state(false);
|
||||||
|
let sidebarOpen = $state(true);
|
||||||
|
let userMenuOpen = $state(false);
|
||||||
|
let navOpen = $state(false);
|
||||||
|
let showBottomNav = $state(false);
|
||||||
|
let whatsNewOpen = $state(false);
|
||||||
|
// The user identity we've already run the "what's new" check for this mount,
|
||||||
|
// so the dialog is evaluated once per login rather than on every navigation.
|
||||||
|
let whatsNewCheckedFor = $state<string | null>(null);
|
||||||
|
let isRestoringSession = $state(false);
|
||||||
|
let restoredSessionKey = $state<string | null>(null);
|
||||||
|
let seededSearchItems = $state<SearchItem[]>([]);
|
||||||
|
let seededSearchKey = $state<string | null>(null);
|
||||||
|
let bootDelayDone = $state(false);
|
||||||
|
let sidebarStateReady = $state(false);
|
||||||
|
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
|
||||||
|
const appVersion = `v${packageInfo.version}`;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
|
||||||
|
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
|
||||||
|
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
|
||||||
|
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
|
||||||
|
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
|
||||||
|
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
|
||||||
|
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
|
||||||
|
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
|
||||||
|
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
|
||||||
|
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
|
||||||
|
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
|
||||||
|
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
|
||||||
|
const shellPageMeta = $derived(
|
||||||
|
routeGuardPending
|
||||||
|
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
|
||||||
|
: pageMeta(page.url.pathname)
|
||||||
|
);
|
||||||
|
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
|
||||||
|
const visibleWorkingDocumentItems = $derived(
|
||||||
|
!$clientSession
|
||||||
|
? workingDocumentItems
|
||||||
|
: workingDocumentItems.filter((item) => {
|
||||||
|
if (item.href === '/mixes') return canOpenMixMaster;
|
||||||
|
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
|
||||||
|
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
|
||||||
|
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
|
||||||
|
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
|
||||||
|
// Ordering serves two audiences: internal staff get the management console
|
||||||
|
// (/ordering/manage), customers get the catalogue (/ordering).
|
||||||
|
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
|
||||||
|
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
|
||||||
|
// Internal staff get the collapsible "Order Management" family (queue +
|
||||||
|
// products/customers/pricing/settings/integrations); customers get the single
|
||||||
|
// catalogue link. Either way it becomes one NavEntry the rail can render.
|
||||||
|
const visibleOrderingEntry = $derived<NavEntry | null>(
|
||||||
|
canManageOrdering
|
||||||
|
? { kind: 'group', group: orderingManageGroup }
|
||||||
|
: canOpenCustomerOrdering
|
||||||
|
? { kind: 'item', item: orderingItem }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||||
|
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
||||||
|
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
|
||||||
|
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
|
||||||
|
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix
|
||||||
|
// calculator plus throughput), a "Costing" family (product costing and the
|
||||||
|
// editors), then the standalone ordering/insights modules. Built from the same
|
||||||
|
// access-filtered items, so a role only ever sees the families it may open.
|
||||||
|
const navEntries = $derived(
|
||||||
|
buildClientNavEntries({
|
||||||
|
dashboard: visibleDashboardItem,
|
||||||
|
operations: [
|
||||||
|
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||||
|
...(visibleThroughputItem ? [visibleThroughputItem] : [])
|
||||||
|
],
|
||||||
|
costing: [
|
||||||
|
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
|
||||||
|
...(visibleEditorItem ? [visibleEditorItem] : []),
|
||||||
|
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
|
||||||
|
...visibleWorkingDocumentItems
|
||||||
|
],
|
||||||
|
ordering: visibleOrderingEntry,
|
||||||
|
reporting: visibleReportingItem
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
|
||||||
|
const workspaceRole = $derived(getWorkspaceRole($clientSession));
|
||||||
|
const visibleFooterLinks = $derived([
|
||||||
|
...(!isOperationsUser ? footerLinks : [])
|
||||||
|
] as FooterLink[]);
|
||||||
|
const fabItems = $derived.by(() => {
|
||||||
|
const items = [
|
||||||
|
visibleDashboardItem,
|
||||||
|
...visibleWorkingDocumentItems,
|
||||||
|
visibleMixCalculatorItem,
|
||||||
|
visibleProductCostingItem,
|
||||||
|
visibleThroughputItem,
|
||||||
|
visibleOrderingEntry?.kind === 'item'
|
||||||
|
? visibleOrderingEntry.item
|
||||||
|
: visibleOrderingEntry?.group
|
||||||
|
? {
|
||||||
|
href: visibleOrderingEntry.group.href ?? '/ordering/manage',
|
||||||
|
label: visibleOrderingEntry.group.label,
|
||||||
|
icon: visibleOrderingEntry.group.icon
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
visibleReportingItem,
|
||||||
|
visibleEditorItem,
|
||||||
|
visibleIngredientsEditorItem,
|
||||||
|
visibleAccessControlItem
|
||||||
|
].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item));
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items.flatMap((item) => {
|
||||||
|
if (seen.has(item.href)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
seen.add(item.href);
|
||||||
|
return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const primaryBottomNavigation = $derived(
|
||||||
|
[
|
||||||
|
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
||||||
|
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||||
|
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
|
||||||
|
...visibleWorkingDocumentItems.slice(0, 2)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const workingDocumentsActive = $derived(
|
||||||
|
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
|
||||||
|
);
|
||||||
|
const visibleBaseSearchItems = $derived(
|
||||||
|
baseSearchItems.filter((item) => {
|
||||||
|
if (item.href === '/') return canOpenDashboard;
|
||||||
|
if (item.href === '/mixes') return canOpenMixMaster;
|
||||||
|
if (item.href === '/mixes/new') return canCreateMixWorksheet;
|
||||||
|
if (item.href === '/mix-calculator') return canOpenMixCalculator;
|
||||||
|
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
|
||||||
|
if (item.href === '/editor') return canOpenEditor;
|
||||||
|
if (item.href === '/ingredients') return canOpenEditor;
|
||||||
|
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
|
||||||
|
if (item.href === '/settings') return canOpenSettings;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
|
||||||
|
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
|
||||||
|
const showDesktopSidebar = $derived(!showBottomNav);
|
||||||
|
|
||||||
|
function restoreSidebarState() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSidebarState() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen));
|
||||||
|
} catch {
|
||||||
|
// Storage failures should not block shell interactions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch(query = '') {
|
||||||
|
searchQuery = query;
|
||||||
|
searchOpen = true;
|
||||||
|
searchFocusRequest += 1;
|
||||||
|
appsFabOpen = false;
|
||||||
|
userMenuOpen = false;
|
||||||
|
navOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncViewport() {
|
||||||
|
showBottomNav = window.innerWidth <= 1180;
|
||||||
|
|
||||||
|
if (!showBottomNav) {
|
||||||
|
navOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearchItem(item: SearchItem) {
|
||||||
|
searchOpen = false;
|
||||||
|
searchQuery = '';
|
||||||
|
await goto(item.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings() {
|
||||||
|
appsFabOpen = false;
|
||||||
|
userMenuOpen = false;
|
||||||
|
navOpen = false;
|
||||||
|
await goto('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
try {
|
||||||
|
if ($clientSession?.role === 'internal') {
|
||||||
|
await api.internalLogout();
|
||||||
|
} else {
|
||||||
|
await api.clientLogout();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Clearing the local session remains the safe fallback.
|
||||||
|
} finally {
|
||||||
|
clientSession.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
page.url.pathname;
|
||||||
|
appsFabOpen = false;
|
||||||
|
userMenuOpen = false;
|
||||||
|
searchOpen = false;
|
||||||
|
searchQuery = '';
|
||||||
|
navOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!sidebarStateReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarOpen;
|
||||||
|
persistSidebarState();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const hydrated = $sessionHydrated;
|
||||||
|
const sessionKey = buildSessionKey($clientSession);
|
||||||
|
|
||||||
|
if (!hydrated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionKey) {
|
||||||
|
isRestoringSession = false;
|
||||||
|
restoredSessionKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restoredSessionKey === sessionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoredSessionKey = sessionKey;
|
||||||
|
isRestoringSession = true;
|
||||||
|
|
||||||
|
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
|
||||||
|
// legacy client-portal users keep using /api/auth/client/session.
|
||||||
|
const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession();
|
||||||
|
|
||||||
|
refresh
|
||||||
|
.then((session) => {
|
||||||
|
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
|
||||||
|
clientSession.set(session);
|
||||||
|
return invalidateAll();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
restoredSessionKey = null;
|
||||||
|
clientSession.clear();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRestoringSession = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search items are seeded lazily — three list endpoints worth of
|
||||||
|
// data only when the user actually opens the search, not on every login or
|
||||||
|
// navigation. Subsequent opens hit the api.ts cache.
|
||||||
|
$effect(() => {
|
||||||
|
const hydrated = $sessionHydrated;
|
||||||
|
const session = $clientSession;
|
||||||
|
const sessionKey = buildSessionKey(session);
|
||||||
|
const shouldSeed = searchOpen;
|
||||||
|
|
||||||
|
if (!hydrated || !session || !sessionKey) {
|
||||||
|
seededSearchItems = [];
|
||||||
|
seededSearchKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldSeed || seededSearchKey === sessionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seededSearchKey = sessionKey;
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
|
||||||
|
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
|
||||||
|
? api.mixCalculatorSessions()
|
||||||
|
: Promise.resolve([])
|
||||||
|
])
|
||||||
|
.then(([mixes, sessions]) => {
|
||||||
|
if (seededSearchKey !== sessionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seededSearchItems = [
|
||||||
|
...mixes.map((mix) => ({
|
||||||
|
href: `/mixes/${mix.id}`,
|
||||||
|
label: mix.name,
|
||||||
|
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
|
||||||
|
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
|
||||||
|
})),
|
||||||
|
...sessions.map((savedSession) => ({
|
||||||
|
href: `/mix-calculator/${savedSession.id}`,
|
||||||
|
label: `${savedSession.session_number} · ${savedSession.product_name}`,
|
||||||
|
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
|
||||||
|
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seededSearchKey === sessionKey) {
|
||||||
|
seededSearchItems = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($sessionHydrated && !$clientSession && !isRootRoute) {
|
||||||
|
goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!$sessionHydrated || !$clientSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
goto(workspaceHomeHref, { replaceState: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Surface the release notes once per version per user, right after login.
|
||||||
|
// hasSeenVersion keeps this to a single appearance: once dismissed (which
|
||||||
|
// records the version), it won't return until the next version ships.
|
||||||
|
$effect(() => {
|
||||||
|
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userKey = buildSessionKey($clientSession);
|
||||||
|
if (!userKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (whatsNewCheckedFor === userKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
whatsNewCheckedFor = userKey;
|
||||||
|
if (!hasSeenVersion(userKey, currentChangelog.version)) {
|
||||||
|
whatsNewOpen = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function dismissWhatsNew() {
|
||||||
|
const userKey = buildSessionKey($clientSession);
|
||||||
|
if (userKey && currentChangelog) {
|
||||||
|
markVersionSeen(userKey, currentChangelog.version);
|
||||||
|
}
|
||||||
|
whatsNewOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
sidebarOpen = restoreSidebarState();
|
||||||
|
sidebarStateReady = true;
|
||||||
|
|
||||||
|
const bootTimer = window.setTimeout(() => {
|
||||||
|
bootDelayDone = true;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
syncViewport();
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const isTypingField =
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
target?.isContentEditable;
|
||||||
|
|
||||||
|
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
searchOpen = false;
|
||||||
|
appsFabOpen = false;
|
||||||
|
userMenuOpen = false;
|
||||||
|
navOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
window.addEventListener('resize', syncViewport);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(bootTimer);
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
window.removeEventListener('resize', syncViewport);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInitials = $derived(
|
||||||
|
($clientSession?.name ?? '')
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w: string) => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase() || '?'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !$clientSession}
|
||||||
|
<div class="signed-out-shell">
|
||||||
|
{#if isRootRoute}
|
||||||
|
{@render children()}
|
||||||
|
{:else if showWorkspaceBoot}
|
||||||
|
<WorkspaceBootCard />
|
||||||
|
{:else}
|
||||||
|
<WorkspaceSignedOutCard />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
|
||||||
|
{#if showDesktopSidebar}
|
||||||
|
<ClientPrimaryRail
|
||||||
|
collapsed={!sidebarOpen}
|
||||||
|
currentPath={shellPathname}
|
||||||
|
entries={navEntries}
|
||||||
|
brandHref={workspaceHomeHref}
|
||||||
|
footerItems={visibleFooterLinks}
|
||||||
|
{appVersion}
|
||||||
|
{currentYear}
|
||||||
|
{canOpenSettings}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
onSignOut={signOut}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
|
||||||
|
<ClientTopbar
|
||||||
|
sessionHydrated={$sessionHydrated}
|
||||||
|
session={$clientSession}
|
||||||
|
showSidebarToggle={!showBottomNav}
|
||||||
|
{sidebarOpen}
|
||||||
|
{userInitials}
|
||||||
|
{userMenuOpen}
|
||||||
|
{canUseWorkspaceSearch}
|
||||||
|
bind:searchQuery={searchQuery}
|
||||||
|
bind:searchOpen={searchOpen}
|
||||||
|
searchFocusRequest={searchFocusRequest}
|
||||||
|
filteredSearchItems={searchState.filteredItems}
|
||||||
|
hiddenResultCount={searchState.hiddenResultCount}
|
||||||
|
{canOpenSettings}
|
||||||
|
onRunSearchItem={runSearchItem}
|
||||||
|
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
|
||||||
|
onToggleUserMenu={() => {
|
||||||
|
userMenuOpen = !userMenuOpen;
|
||||||
|
appsFabOpen = false;
|
||||||
|
}}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
onSignOut={signOut}
|
||||||
|
onShowWhatsNew={() => (whatsNewOpen = true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
{#if !routeGuardPending}
|
||||||
|
<WorkspacePageHeader
|
||||||
|
category={shellPageMeta.category}
|
||||||
|
title={shellPageMeta.title}
|
||||||
|
icon={shellPageMeta.icon}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<AuthGate
|
||||||
|
blocked={routeGuardPending}
|
||||||
|
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
|
||||||
|
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
|
||||||
|
detail={
|
||||||
|
isRestoringSession
|
||||||
|
? 'Refreshing the saved session before rendering workspace content.'
|
||||||
|
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</AuthGate>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WorkspaceTabletNav
|
||||||
|
bind:navOpen
|
||||||
|
{showBottomNav}
|
||||||
|
{primaryBottomNavigation}
|
||||||
|
{visibleDashboardItem}
|
||||||
|
{visibleMixCalculatorItem}
|
||||||
|
{visibleProductCostingItem}
|
||||||
|
{visibleThroughputItem}
|
||||||
|
{visibleEditorItem}
|
||||||
|
{visibleReportingItem}
|
||||||
|
{visibleWorkingDocumentItems}
|
||||||
|
{visibleFooterLinks}
|
||||||
|
{orderingManageGroup}
|
||||||
|
{orderingManageChildren}
|
||||||
|
{orderingItem}
|
||||||
|
{canManageOrdering}
|
||||||
|
{canOpenCustomerOrdering}
|
||||||
|
{canCreateMixWorksheet}
|
||||||
|
{canCreateMixSession}
|
||||||
|
{canOpenSettings}
|
||||||
|
pagePath={page.url.pathname}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
onSignOut={signOut}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $clientSession && whatsNewOpen && currentChangelog}
|
||||||
|
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 252px minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell.sidebar-collapsed {
|
||||||
|
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-out-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-shell {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
--content-padding: 1.34rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--content-padding);
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-shell.bottom-nav-layout .content {
|
||||||
|
padding-bottom: 7.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
--content-padding: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1181px) {
|
||||||
|
.bottom-nav-layout .content {
|
||||||
|
padding-bottom: 1.34rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.content {
|
||||||
|
--content-padding: 0.92rem;
|
||||||
|
padding: 0.92rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,43 +9,146 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if blocked}
|
{#if blocked}
|
||||||
<section class="auth-gate-card">
|
<div class="auth-gate-screen">
|
||||||
|
<section class="auth-gate-card" role="status" aria-live="polite">
|
||||||
|
<div class="auth-gate-loader" aria-hidden="true">
|
||||||
|
<span class="ring"></span>
|
||||||
|
<span class="ring ring-2"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
</div>
|
||||||
<p class="auth-gate-label">{label}</p>
|
<p class="auth-gate-label">{label}</p>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<p>{detail}</p>
|
<p class="auth-gate-detail">{detail}</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.auth-gate-screen {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-gate-card {
|
.auth-gate-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 1.35rem 1.4rem;
|
width: min(26rem, 100%);
|
||||||
|
padding: 2.1rem 2rem 2.25rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1rem;
|
border-radius: 1.25rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: auth-gate-in 380ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animated loader ───────────────────────────────────────── */
|
||||||
|
.auth-gate-loader {
|
||||||
|
position: relative;
|
||||||
|
width: 3.1rem;
|
||||||
|
height: 3.1rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-loader .ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2.5px solid transparent;
|
||||||
|
border-top-color: var(--color-brand, #2f9e6f);
|
||||||
|
border-right-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 45%, transparent);
|
||||||
|
animation: auth-gate-spin 0.85s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-loader .ring-2 {
|
||||||
|
inset: 0.42rem;
|
||||||
|
border-top-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 60%, transparent);
|
||||||
|
border-right-color: transparent;
|
||||||
|
animation-duration: 1.25s;
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-loader .dot {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand, #2f9e6f);
|
||||||
|
animation: auth-gate-pulse 1.1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-gate-label {
|
.auth-gate-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.76rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-gate-card h2 {
|
.auth-gate-card h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.18rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-gate-card p:last-child {
|
.auth-gate-detail {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
max-width: 22rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes auth-gate-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auth-gate-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auth-gate-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.7);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.auth-gate-card {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-loader .ring,
|
||||||
|
.auth-gate-loader .ring-2 {
|
||||||
|
animation-duration: 1.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate-loader .dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -206,9 +206,7 @@
|
|||||||
|
|
||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Client Access Control</p>
|
<p class="page-intro-copy">Lean 101 admins and tenant superadmins use the same control surface, and every change lands in the audit log immediately.</p>
|
||||||
<h2>Manage module permissions, feature flags, and audit history from one workspace.</h2>
|
|
||||||
<p>Lean 101 admins and tenant superadmins use the same control surface, and every change lands in the audit log immediately.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="status-pill positive">Signed in as {accessManagerLabel}</span>
|
<span class="status-pill positive">Signed in as {accessManagerLabel}</span>
|
||||||
</section>
|
</section>
|
||||||
@@ -587,7 +585,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h2,
|
|
||||||
h3,
|
h3,
|
||||||
h4,
|
h4,
|
||||||
p,
|
p,
|
||||||
@@ -617,14 +614,12 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro h2 {
|
.page-intro-copy {
|
||||||
margin: 0.35rem 0 0.45rem;
|
max-width: 62ch;
|
||||||
max-width: 22ch;
|
line-height: 1.55;
|
||||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro p:last-child,
|
.page-intro-copy,
|
||||||
.metric-card p,
|
.metric-card p,
|
||||||
.card-toolbar p,
|
.card-toolbar p,
|
||||||
.client-row span,
|
.client-row span,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { ShoppingCart, LogOut } from 'lucide-svelte';
|
||||||
|
import { clientSession, sessionHydrated } from '$lib/session';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const isRootRoute = $derived(page.url.pathname === '/');
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const userName = $derived($clientSession?.name ?? '');
|
||||||
|
const userInitials = $derived(
|
||||||
|
($clientSession?.name ?? '')
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word: string) => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase() || '?'
|
||||||
|
);
|
||||||
|
|
||||||
|
const navItems = [{ href: '/ordering', label: 'Order Catalogue', icon: ShoppingCart }];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
try {
|
||||||
|
await api.clientLogout();
|
||||||
|
} catch {
|
||||||
|
// Clearing the local session is the safe fallback.
|
||||||
|
} finally {
|
||||||
|
clientSession.clear();
|
||||||
|
await goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the saved session fresh on reload (mirrors the workspace shell).
|
||||||
|
let restoredSessionKey = $state<string | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
const hydrated = $sessionHydrated;
|
||||||
|
const sessionKey = $clientSession ? `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}` : null;
|
||||||
|
if (!hydrated || !sessionKey || restoredSessionKey === sessionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoredSessionKey = sessionKey;
|
||||||
|
api
|
||||||
|
.clientSession()
|
||||||
|
.then((session) => {
|
||||||
|
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
|
||||||
|
clientSession.set(session);
|
||||||
|
return invalidateAll();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
restoredSessionKey = null;
|
||||||
|
clientSession.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signed-out customers go back to the sign-in screen; the portal landing is
|
||||||
|
// always the catalogue, never the internal dashboard/login root.
|
||||||
|
$effect(() => {
|
||||||
|
if (!$sessionHydrated) return;
|
||||||
|
if (!$clientSession && !isRootRoute) {
|
||||||
|
goto('/', { replaceState: true });
|
||||||
|
} else if ($clientSession && isRootRoute) {
|
||||||
|
goto('/ordering', { replaceState: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Customer Ordering Portal | Hunter Premium Produce</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !$clientSession}
|
||||||
|
{#if isRootRoute}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div class="loading-screen">
|
||||||
|
<p>Returning you to sign in…</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="portal">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark"><ShoppingCart size={20} strokeWidth={2} /></span>
|
||||||
|
<div class="brand-text">
|
||||||
|
<span class="brand-name">Hunter Premium Produce</span>
|
||||||
|
<span class="brand-sub">Customer Ordering Portal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav" aria-label="Customer portal navigation">
|
||||||
|
{#each navItems as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a class="nav-row" class:active={isActive(item.href)} href={item.href}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-foot">
|
||||||
|
<div class="account">
|
||||||
|
<span class="avatar">{userInitials}</span>
|
||||||
|
<span class="account-name">{userName}</span>
|
||||||
|
</div>
|
||||||
|
<button class="signout" type="button" onclick={signOut}>
|
||||||
|
<LogOut size={16} strokeWidth={1.9} />
|
||||||
|
<span>Sign out</span>
|
||||||
|
</button>
|
||||||
|
<small class="copyright">© {currentYear} Hunter Premium Produce</small>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>Customer Ordering Portal</h1>
|
||||||
|
<div class="topbar-account">
|
||||||
|
<span class="avatar small">{userInitials}</span>
|
||||||
|
<span class="topbar-name">{userName}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.portal {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16rem minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f4f7f4;
|
||||||
|
color: #1f2a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-screen {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #5f7266;
|
||||||
|
background: #f4f7f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ─────────────────────────────────────────────── */
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1.4rem 1.05rem;
|
||||||
|
background: #1f3a2c;
|
||||||
|
color: #e7efe9;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding-bottom: 1.1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(231, 239, 233, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: rgba(231, 239, 233, 0.85);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #1f3a2c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.small {
|
||||||
|
width: 1.85rem;
|
||||||
|
height: 1.85rem;
|
||||||
|
background: #1f3a2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signout {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #e7efe9;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signout:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(231, 239, 233, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main ────────────────────────────────────────────────── */
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.05rem 1.6rem;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid rgba(31, 58, 44, 0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f3a2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.portal {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-foot {
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -62,8 +62,8 @@
|
|||||||
<div><span>Total bags</span><strong>{formatNumber(session.total_bags, 2)}</strong><small>{session.product_unit_of_measure}</small></div>
|
<div><span>Total bags</span><strong>{formatNumber(session.total_bags, 2)}</strong><small>{session.product_unit_of_measure}</small></div>
|
||||||
<div><span>Prepared by</span><strong>{session.prepared_by_name}</strong><small>{formatDate(session.mix_date)}</small></div>
|
<div><span>Prepared by</span><strong>{session.prepared_by_name}</strong><small>{formatDate(session.mix_date)}</small></div>
|
||||||
<div><span>Client</span><strong>{session.client_name}</strong></div>
|
<div><span>Client</span><strong>{session.client_name}</strong></div>
|
||||||
<div><span>Product</span><strong>{session.product_name}</strong></div>
|
<div><span>Mix</span><strong>{session.product_name}</strong></div>
|
||||||
<div><span>Mix source</span><strong>{session.mix_name}</strong></div>
|
<div><span>Formula source</span><strong>{session.mix_name}</strong></div>
|
||||||
<div><span>Unit size</span><strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong></div>
|
<div><span>Unit size</span><strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong></div>
|
||||||
<div><span>Batch size</span><strong>{formatNumber(session.batch_size_kg, 2)}kg</strong></div>
|
<div><span>Batch size</span><strong>{formatNumber(session.batch_size_kg, 2)}kg</strong></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong>{line.raw_material_name}</strong>
|
<strong>{line.raw_material_name}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
<td>{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -285,16 +285,16 @@
|
|||||||
|
|
||||||
th {
|
th {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 0.6rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
font-size: 0.7rem;
|
font-size: 0.82rem;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
td strong {
|
td strong {
|
||||||
font-size: 0.72rem;
|
font-size: 0.84rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,62 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
|
||||||
import type { MixCalculatorSession } from '$lib/types';
|
import type { MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
let { session }: { session: MixCalculatorSession } = $props();
|
let { session, autoPrint = true }: { session: MixCalculatorSession; autoPrint?: boolean } = $props();
|
||||||
|
let pdfUrl = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let printAfterLoad = $state(false);
|
||||||
|
let pdfFrame = $state<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
const printableTitle = $derived(
|
const printableTitle = $derived(
|
||||||
`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() {
|
function revokePdfUrl() {
|
||||||
|
if (pdfUrl) {
|
||||||
|
URL.revokeObjectURL(pdfUrl);
|
||||||
|
pdfUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPdf() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
revokePdfUrl();
|
||||||
|
|
||||||
|
try {
|
||||||
const blob = await api.mixCalculatorSessionPdf(session.id);
|
const blob = await api.mixCalculatorSessionPdf(session.id);
|
||||||
const url = URL.createObjectURL(blob);
|
pdfUrl = URL.createObjectURL(blob);
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
printAfterLoad = autoPrint;
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
} catch (loadError) {
|
||||||
|
error = loadError instanceof Error ? loadError.message : 'Unable to load the PDF preview.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printPage() {
|
||||||
|
if (!pdfUrl) {
|
||||||
|
printAfterLoad = true;
|
||||||
|
loadPdf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfFrame?.contentWindow?.focus();
|
||||||
|
pdfFrame?.contentWindow?.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewLoaded() {
|
||||||
|
if (printAfterLoad) {
|
||||||
|
printAfterLoad = false;
|
||||||
|
requestAnimationFrame(() => printPage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadPdf() {
|
async function downloadPdf() {
|
||||||
const blob = await api.mixCalculatorSessionPdf(session.id);
|
const blob = pdfUrl ? await fetch(pdfUrl).then((response) => response.blob()) : await api.mixCalculatorSessionPdf(session.id);
|
||||||
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;
|
||||||
@@ -27,6 +66,14 @@
|
|||||||
anchor.remove();
|
anchor.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadPdf();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
revokePdfUrl();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -36,11 +83,28 @@
|
|||||||
<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="primary-button" type="button" disabled={!pdfUrl && loading} onclick={printPage}>Print</button>
|
||||||
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
<button class="secondary-button" type="button" disabled={!pdfUrl && loading} onclick={downloadPdf}>Download PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
|
<div class="pdf-preview-shell">
|
||||||
|
{#if loading}
|
||||||
|
<div class="preview-state">Loading PDF preview...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="preview-state error">
|
||||||
|
<strong>PDF preview unavailable</strong>
|
||||||
|
<span>{error}</span>
|
||||||
|
<button class="secondary-button" type="button" onclick={loadPdf}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if pdfUrl}
|
||||||
|
<iframe
|
||||||
|
bind:this={pdfFrame}
|
||||||
|
src={pdfUrl}
|
||||||
|
title={`${printableTitle} PDF preview`}
|
||||||
|
onload={handlePreviewLoaded}
|
||||||
|
></iframe>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -57,6 +121,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
width: min(100%, 210mm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button,
|
.primary-button,
|
||||||
@@ -82,6 +147,52 @@
|
|||||||
color: #304038;
|
color: #304038;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-preview-shell {
|
||||||
|
width: min(100%, 210mm);
|
||||||
|
aspect-ratio: 210 / 297;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18px 50px rgba(25, 35, 30, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state.error {
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state strong,
|
||||||
|
.preview-state span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state strong {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.print-toolbar {
|
.print-toolbar {
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
@@ -93,16 +204,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
:global(body) {
|
.print-page,
|
||||||
background: #fff;
|
.print-toolbar,
|
||||||
}
|
.pdf-preview-shell {
|
||||||
|
|
||||||
.print-page {
|
|
||||||
padding: 0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-toolbar {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Moon, Sun } from 'lucide-svelte';
|
||||||
|
import { resolvedTheme, toggleTheme } from '$lib/theme';
|
||||||
|
import { tooltip } from '$lib/actions/tooltip';
|
||||||
|
|
||||||
|
const isDark = $derived($resolvedTheme === 'dark');
|
||||||
|
const label = $derived(
|
||||||
|
isDark ? 'Switch to light mode' : 'Switch to dark mode'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="theme-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={toggleTheme}
|
||||||
|
aria-label={label}
|
||||||
|
use:tooltip={label}
|
||||||
|
>
|
||||||
|
{#if isDark}
|
||||||
|
<Sun size={18} strokeWidth={1.75} />
|
||||||
|
{:else}
|
||||||
|
<Moon size={18} strokeWidth={1.75} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.82rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Sparkles } from 'lucide-svelte';
|
||||||
|
import { changelog, type ChangelogEntry } from '$lib/changelog';
|
||||||
|
|
||||||
|
let { entry, onClose }: { entry: ChangelogEntry; onClose: () => void } = $props();
|
||||||
|
|
||||||
|
function formatDate(date: string): string {
|
||||||
|
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseDate = $derived(formatDate(entry.date));
|
||||||
|
|
||||||
|
// Every release older than the one being shown, newest first, for the
|
||||||
|
// "Read previous changes" accordion.
|
||||||
|
const previousEntries = $derived(changelog.filter((item) => item.version !== entry.version));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="whats-new-backdrop" role="presentation" onclick={onClose}>
|
||||||
|
<div
|
||||||
|
class="whats-new"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="whats-new-title"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="whats-new-head">
|
||||||
|
<span class="whats-new-mark"><Sparkles size={20} strokeWidth={1.75} /></span>
|
||||||
|
<div>
|
||||||
|
<p class="whats-new-kicker">What's new · v{entry.version}</p>
|
||||||
|
<h2 id="whats-new-title">A few updates in this release</h2>
|
||||||
|
<p class="whats-new-date">{releaseDate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="whats-new-list">
|
||||||
|
{#each entry.highlights as highlight}
|
||||||
|
<li>{highlight}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if previousEntries.length}
|
||||||
|
<details class="whats-new-history">
|
||||||
|
<summary>Read previous changes</summary>
|
||||||
|
<ol class="history-list">
|
||||||
|
{#each previousEntries as item (item.version)}
|
||||||
|
<li class="history-entry">
|
||||||
|
<div class="history-head">
|
||||||
|
<span class="history-version">v{item.version}</span>
|
||||||
|
<span class="history-date">{formatDate(item.date)}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="history-highlights">
|
||||||
|
{#each item.highlights as highlight}
|
||||||
|
<li>{highlight}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="whats-new-actions">
|
||||||
|
<button class="whats-new-button" type="button" onclick={onClose}>Got it</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(11, 18, 14, 0.45);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new {
|
||||||
|
width: min(34rem, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 1.15rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
border-radius: 0.82rem;
|
||||||
|
color: var(--color-on-brand);
|
||||||
|
background: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-kicker {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-head h2 {
|
||||||
|
margin-top: 0.22rem;
|
||||||
|
font-size: 1.28rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-date {
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-list li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-list li::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.3rem;
|
||||||
|
width: 0.46rem;
|
||||||
|
height: 0.46rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-history {
|
||||||
|
border-top: 1px solid var(--color-divider);
|
||||||
|
padding-top: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-history > summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-history > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-history > summary::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.46rem;
|
||||||
|
height: 0.46rem;
|
||||||
|
margin-right: 0.55rem;
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-history[open] > summary::before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-history > summary:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.05rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-version {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-highlights {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-highlights li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-highlights li::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.15rem;
|
||||||
|
width: 0.34rem;
|
||||||
|
height: 0.34rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.6rem;
|
||||||
|
padding: 0.72rem 1.3rem;
|
||||||
|
border: 1px solid var(--color-brand);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: var(--color-on-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease, border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-button:hover {
|
||||||
|
background: var(--color-brand-hover);
|
||||||
|
border-color: var(--color-brand-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whats-new-button:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { LayoutGrid } from 'lucide-svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import { tooltip } from '$lib/actions/tooltip';
|
||||||
|
import type { ComponentType } from 'svelte';
|
||||||
|
|
||||||
|
export type WorkspaceFabItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
open = $bindable(false)
|
||||||
|
}: {
|
||||||
|
items: WorkspaceFabItem[];
|
||||||
|
open?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let root = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!open || !root || root.contains(event.target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if items.length}
|
||||||
|
<div class="apps-fab-wrap" bind:this={root}>
|
||||||
|
{#if open}
|
||||||
|
<div class="apps-panel" role="menu" aria-label="Quick access apps">
|
||||||
|
{#each items as item (item.href)}
|
||||||
|
<a href={item.href} class="apps-link" role="menuitem" onclick={close}>
|
||||||
|
<span class="apps-link-icon" aria-hidden="true">
|
||||||
|
<item.icon size={16} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="apps-fab"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="Open app launcher"
|
||||||
|
use:tooltip={{ label: 'Quickly access apps', placement: 'top' }}
|
||||||
|
onclick={toggle}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={20} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.apps-fab-wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: max(1rem, env(safe-area-inset-right));
|
||||||
|
bottom: max(1rem, env(safe-area-inset-bottom));
|
||||||
|
z-index: 46;
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-fab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: var(--color-on-brand);
|
||||||
|
box-shadow: 0 20px 36px -22px color-mix(in srgb, var(--color-brand) 75%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-fab:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: color-mix(in srgb, var(--color-brand) 92%, black);
|
||||||
|
box-shadow: 0 24px 44px -24px color-mix(in srgb, var(--color-brand) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-fab:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 24%, white);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-panel {
|
||||||
|
min-width: 15rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.24rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
box-shadow: 0 24px 48px -28px rgba(15, 23, 42, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.72rem;
|
||||||
|
padding: 0.8rem 0.82rem;
|
||||||
|
border-radius: 0.82rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-link:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-link-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
|
||||||
|
color: var(--color-brand);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.apps-fab-wrap {
|
||||||
|
bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { LoaderCircle } from 'lucide-svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="locked-card loading-card workspace-boot-card" aria-live="polite">
|
||||||
|
<div class="workspace-boot-orb" aria-hidden="true">
|
||||||
|
<LoaderCircle size={26} strokeWidth={2.1} />
|
||||||
|
</div>
|
||||||
|
<p class="workspace-label">Checking Workspace</p>
|
||||||
|
<h2>Restoring your client workspace.</h2>
|
||||||
|
<p>Hold on while we reload your saved session and bring the app back into place.</p>
|
||||||
|
<div class="workspace-boot-progress" aria-hidden="true">
|
||||||
|
<span class="workspace-boot-progress-bar"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.locked-card {
|
||||||
|
max-width: 42rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-boot-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: start;
|
||||||
|
min-height: 14rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.45rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-boot-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 18%, transparent), transparent 42%),
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 4%, transparent), transparent 45%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-boot-card > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-boot-orb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.35rem;
|
||||||
|
height: 3.35rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--color-brand) 12%, var(--color-bg-surface));
|
||||||
|
color: var(--color-brand);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-brand) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-boot-orb :global(svg) {
|
||||||
|
animation: workspace-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-boot-progress {
|
||||||
|
width: min(16rem, 100%);
|
||||||
|
height: 0.42rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-boot-progress-bar {
|
||||||
|
display: block;
|
||||||
|
width: 38%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--color-brand), color-mix(in srgb, var(--color-brand) 62%, white));
|
||||||
|
animation: workspace-progress 1.15s ease-in-out infinite;
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-of-type {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes workspace-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes workspace-progress {
|
||||||
|
0% { transform: translateX(-110%) scaleX(0.85); }
|
||||||
|
55% { transform: translateX(135%) scaleX(1.05); }
|
||||||
|
100% { transform: translateX(280%) scaleX(0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.workspace-boot-orb :global(svg),
|
||||||
|
.workspace-boot-progress-bar {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<section class="locked-card loading-card signed-out-card">
|
||||||
|
<p class="workspace-label">Checking Session</p>
|
||||||
|
<h2>Returning to the client login screen.</h2>
|
||||||
|
<p>Only authenticated client users can open workspace routes directly.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.locked-card {
|
||||||
|
max-width: 42rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-out-card {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-of-type {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calculator, LogOut, Menu, Plus, Settings } from 'lucide-svelte';
|
||||||
|
|
||||||
|
import type { FooterLink, NavGroup, NavItem } from '$lib/navigation/client-navigation';
|
||||||
|
import { matchesRoute } from '$lib/navigation/client-navigation';
|
||||||
|
|
||||||
|
let {
|
||||||
|
showBottomNav,
|
||||||
|
navOpen = $bindable(false),
|
||||||
|
primaryBottomNavigation,
|
||||||
|
visibleDashboardItem,
|
||||||
|
visibleMixCalculatorItem,
|
||||||
|
visibleProductCostingItem,
|
||||||
|
visibleThroughputItem,
|
||||||
|
visibleEditorItem,
|
||||||
|
visibleReportingItem,
|
||||||
|
visibleWorkingDocumentItems,
|
||||||
|
visibleFooterLinks,
|
||||||
|
orderingManageGroup,
|
||||||
|
orderingManageChildren,
|
||||||
|
orderingItem,
|
||||||
|
canManageOrdering,
|
||||||
|
canOpenCustomerOrdering,
|
||||||
|
canCreateMixWorksheet,
|
||||||
|
canCreateMixSession,
|
||||||
|
canOpenSettings,
|
||||||
|
pagePath,
|
||||||
|
onOpenSettings,
|
||||||
|
onSignOut
|
||||||
|
}: {
|
||||||
|
showBottomNav: boolean;
|
||||||
|
navOpen?: boolean;
|
||||||
|
primaryBottomNavigation: NavItem[];
|
||||||
|
visibleDashboardItem: NavItem | null;
|
||||||
|
visibleMixCalculatorItem: NavItem | null;
|
||||||
|
visibleProductCostingItem: NavItem | null;
|
||||||
|
visibleThroughputItem: NavItem | null;
|
||||||
|
visibleEditorItem: NavItem | null;
|
||||||
|
visibleReportingItem: NavItem | null;
|
||||||
|
visibleWorkingDocumentItems: NavItem[];
|
||||||
|
visibleFooterLinks: FooterLink[];
|
||||||
|
orderingManageGroup: NavGroup;
|
||||||
|
orderingManageChildren: NavItem[];
|
||||||
|
orderingItem: NavItem;
|
||||||
|
canManageOrdering: boolean;
|
||||||
|
canOpenCustomerOrdering: boolean;
|
||||||
|
canCreateMixWorksheet: boolean;
|
||||||
|
canCreateMixSession: boolean;
|
||||||
|
canOpenSettings: boolean;
|
||||||
|
pagePath: string;
|
||||||
|
onOpenSettings: () => void | Promise<void>;
|
||||||
|
onSignOut: () => void | Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
navOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showBottomNav}
|
||||||
|
{#if navOpen}
|
||||||
|
<button aria-label="Close navigation" class="nav-backdrop" type="button" onclick={closeDrawer}></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<nav class="bottom-nav" aria-label="Tablet navigation">
|
||||||
|
{#each primaryBottomNavigation as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a class:active={matchesRoute(item.href, pagePath)} href={item.href}>
|
||||||
|
<span class="bottom-nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
|
||||||
|
<span class="bottom-nav-icon"><Menu size={18} strokeWidth={1.85} /></span>
|
||||||
|
<span>More</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if navOpen}
|
||||||
|
<div aria-label="Tablet navigation drawer" class="bottom-drawer" role="dialog" aria-modal="true">
|
||||||
|
<div class="drawer-handle"></div>
|
||||||
|
|
||||||
|
<div class="drawer-header">
|
||||||
|
<div>
|
||||||
|
<p class="workspace-label">Workspace Drawer</p>
|
||||||
|
<strong>Hunter Premium Produce</strong>
|
||||||
|
</div>
|
||||||
|
<button aria-label="Close drawer" class="nav-toggle" type="button" onclick={closeDrawer}>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-grid">
|
||||||
|
<nav class="drawer-section" aria-label="All workspace pages">
|
||||||
|
{#if visibleDashboardItem}
|
||||||
|
{@const Icon = visibleDashboardItem.icon}
|
||||||
|
<a class:active={matchesRoute(visibleDashboardItem.href, pagePath)} href={visibleDashboardItem.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{visibleDashboardItem.label}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleMixCalculatorItem}
|
||||||
|
{@const Icon = visibleMixCalculatorItem.icon}
|
||||||
|
<a class:active={matchesRoute(visibleMixCalculatorItem.href, pagePath)} href={visibleMixCalculatorItem.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{visibleMixCalculatorItem.label}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleProductCostingItem}
|
||||||
|
{@const Icon = visibleProductCostingItem.icon}
|
||||||
|
<a class:active={matchesRoute(visibleProductCostingItem.href, pagePath)} href={visibleProductCostingItem.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{visibleProductCostingItem.label}</span>
|
||||||
|
{#if visibleProductCostingItem.badge}<span class="drawer-badge">{visibleProductCostingItem.badge}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleThroughputItem}
|
||||||
|
{@const Icon = visibleThroughputItem.icon}
|
||||||
|
<a class:active={matchesRoute(visibleThroughputItem.href, pagePath)} href={visibleThroughputItem.href} onclick={closeDrawer}>
|
||||||
|
<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 visibleEditorItem}
|
||||||
|
{@const Icon = visibleEditorItem.icon}
|
||||||
|
<a class:active={matchesRoute(visibleEditorItem.href, pagePath)} href={visibleEditorItem.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{visibleEditorItem.label}</span>
|
||||||
|
{#if visibleEditorItem.badge}<span class="drawer-badge">{visibleEditorItem.badge}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleReportingItem}
|
||||||
|
{@const Icon = visibleReportingItem.icon}
|
||||||
|
<a class:active={matchesRoute(visibleReportingItem.href, pagePath)} href={visibleReportingItem.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{visibleReportingItem.label}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canManageOrdering}
|
||||||
|
{@const GroupIcon = orderingManageGroup.icon}
|
||||||
|
<a class:active={pagePath === '/ordering/manage'} href="/ordering/manage" onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{orderingManageGroup.label}</span>
|
||||||
|
</a>
|
||||||
|
<div class="drawer-sublist">
|
||||||
|
{#each orderingManageChildren as child}
|
||||||
|
{@const ChildIcon = child.icon}
|
||||||
|
<a class:active={matchesRoute(child.href, pagePath, child.exact)} href={child.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><ChildIcon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{child.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if canOpenCustomerOrdering}
|
||||||
|
{@const Icon = orderingItem.icon}
|
||||||
|
<a class:active={matchesRoute(orderingItem.href, pagePath)} href={orderingItem.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{orderingItem.label}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleWorkingDocumentItems.length}
|
||||||
|
<div class="drawer-sublist" id="drawer-working-documents-nav">
|
||||||
|
{#each visibleWorkingDocumentItems as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a class:active={matchesRoute(item.href, pagePath)} href={item.href} onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="drawer-section drawer-actions">
|
||||||
|
{#if canCreateMixWorksheet}
|
||||||
|
<a href="/mixes/new" onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>Create mix worksheet</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if canCreateMixSession}
|
||||||
|
<a href="/mix-calculator" onclick={closeDrawer}>
|
||||||
|
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>Create mix session</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if canOpenSettings}
|
||||||
|
<button type="button" onclick={onOpenSettings}>
|
||||||
|
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>Change settings</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" onclick={onSignOut}>
|
||||||
|
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-footer">
|
||||||
|
{#each visibleFooterLinks as item}
|
||||||
|
<a href={item.href} onclick={closeDrawer}>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<small>{item.shortLabel}</small>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nav-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 48;
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
background: rgba(11, 18, 14, 0.28);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle {
|
||||||
|
width: 2.05rem;
|
||||||
|
height: 2.05rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.68rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle span,
|
||||||
|
.nav-toggle span::before,
|
||||||
|
.nav-toggle span::after {
|
||||||
|
width: 0.88rem;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle span {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle span::before,
|
||||||
|
.nav-toggle span::after {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle span::before {
|
||||||
|
top: -0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle span::after {
|
||||||
|
top: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav,
|
||||||
|
.bottom-drawer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sublist a {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
left: max(0.8rem, env(safe-area-inset-left));
|
||||||
|
right: max(0.8rem, env(safe-area-inset-right));
|
||||||
|
bottom: max(0.8rem, env(safe-area-inset-bottom));
|
||||||
|
z-index: 45;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a,
|
||||||
|
.bottom-nav button {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.34rem;
|
||||||
|
padding: 0.62rem 0.38rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a.active,
|
||||||
|
.bottom-nav button.active {
|
||||||
|
color: var(--color-brand);
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-icon {
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.78rem;
|
||||||
|
color: var(--color-on-brand);
|
||||||
|
background: var(--color-brand);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-drawer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 1.6rem 1.6rem 0 0;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-handle {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 0.34rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-section a,
|
||||||
|
.drawer-section button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.72rem;
|
||||||
|
padding: 0.82rem 0.86rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.96rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-section a.active {
|
||||||
|
color: var(--color-brand-hover);
|
||||||
|
background: color-mix(in srgb, var(--color-brand) 11%, var(--color-bg-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.08rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-warning-tint);
|
||||||
|
color: var(--color-warning-text);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sublist {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.82rem 0.9rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.96rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.drawer-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bottom-nav {
|
||||||
|
left: max(0.55rem, env(safe-area-inset-left));
|
||||||
|
right: max(0.55rem, env(safe-area-inset-right));
|
||||||
|
bottom: max(0.55rem, env(safe-area-inset-bottom));
|
||||||
|
gap: 0.32rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a,
|
||||||
|
.bottom-nav button {
|
||||||
|
padding: 0.55rem 0.2rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-icon {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-drawer {
|
||||||
|
padding: 0.75rem 0.8rem calc(6.3rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { SearchItem } from '$lib/navigation/client-navigation';
|
||||||
|
import type { AppSession } from '$lib/session';
|
||||||
|
|
||||||
|
export const PALETTE_RESULT_LIMIT = 10;
|
||||||
|
|
||||||
|
export function buildSessionKey(session: AppSession | null | undefined): string | null {
|
||||||
|
if (!session) return null;
|
||||||
|
return `${session.role}:${session.email}:${session.user_id ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSearchItems(items: SearchItem[], query: string, limit = PALETTE_RESULT_LIMIT) {
|
||||||
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
|
const matchingItems = items.filter((item) => {
|
||||||
|
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
|
||||||
|
return haystack.includes(trimmedQuery);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredItems = matchingItems.slice(0, limit);
|
||||||
|
return {
|
||||||
|
matchingItems,
|
||||||
|
filteredItems,
|
||||||
|
hiddenResultCount: matchingItems.length - filteredItems.length
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { Check, ChevronDown, Plus, Search, X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
// A category picker with search + explicit "create new". Typing only *searches*
|
||||||
|
// — the committed value changes only when you pick an existing category or
|
||||||
|
// deliberately choose "Create new category". This keeps spelling consistent and
|
||||||
|
// makes creating a brand-new category an obvious, intentional action rather than
|
||||||
|
// a side effect of typing. The menu is portaled to <body> so the ingredients
|
||||||
|
// table's clipped (overflow:hidden) scroll container can never hide it.
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
options = [],
|
||||||
|
placeholder = 'Category',
|
||||||
|
inputId,
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel = 'Category',
|
||||||
|
oncreate
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
options?: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
inputId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Fired when the user deliberately creates a brand-new category, so the
|
||||||
|
* parent can keep it available to every other row. */
|
||||||
|
oncreate?: (category: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// `query` is the ephemeral search text; `value` is the committed category.
|
||||||
|
let query = $state('');
|
||||||
|
let open = $state(false);
|
||||||
|
let highlighted = $state(0);
|
||||||
|
let root = $state<HTMLDivElement | null>(null);
|
||||||
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let menuStyle = $state('');
|
||||||
|
|
||||||
|
// The input shows the live search text while open, and the committed value when
|
||||||
|
// closed — so an in-progress search never looks like it changed the field.
|
||||||
|
const display = $derived(open ? query : value);
|
||||||
|
|
||||||
|
const trimmed = $derived(query.trim());
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = trimmed.toLowerCase();
|
||||||
|
if (!q) return options;
|
||||||
|
return options.filter((option) => option.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Offer "create" only when the typed text isn't already a category.
|
||||||
|
const exactExists = $derived(options.some((option) => option.toLowerCase() === trimmed.toLowerCase()));
|
||||||
|
const showCreate = $derived(trimmed.length > 0 && !exactExists);
|
||||||
|
|
||||||
|
// Selectable rows = filtered options, then the create row (when shown).
|
||||||
|
const rowCount = $derived(filtered.length + (showCreate ? 1 : 0));
|
||||||
|
const createIndex = $derived(showCreate ? filtered.length : -1);
|
||||||
|
|
||||||
|
function positionMenu() {
|
||||||
|
if (!inputEl) return;
|
||||||
|
const rect = inputEl.getBoundingClientRect();
|
||||||
|
menuStyle = `top: ${rect.bottom + 4}px; left: ${rect.left}px; min-width: ${Math.max(rect.width, 220)}px;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu() {
|
||||||
|
if (disabled) return;
|
||||||
|
query = value;
|
||||||
|
open = true;
|
||||||
|
// Highlight the create row when there's nothing to match, else the first option.
|
||||||
|
highlighted = 0;
|
||||||
|
await tick();
|
||||||
|
positionMenu();
|
||||||
|
inputEl?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
open = false;
|
||||||
|
highlighted = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(option: string) {
|
||||||
|
value = option;
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
const created = trimmed;
|
||||||
|
value = created;
|
||||||
|
oncreate?.(created);
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
value = '';
|
||||||
|
query = '';
|
||||||
|
closeMenu();
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitHighlighted() {
|
||||||
|
if (highlighted === createIndex) {
|
||||||
|
createNew();
|
||||||
|
} else if (highlighted >= 0 && highlighted < filtered.length) {
|
||||||
|
choose(filtered[highlighted]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(event: Event) {
|
||||||
|
query = (event.target as HTMLInputElement).value;
|
||||||
|
open = true;
|
||||||
|
highlighted = 0;
|
||||||
|
positionMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!open) {
|
||||||
|
openMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
highlighted = Math.min(highlighted + 1, rowCount - 1);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
highlighted = Math.max(highlighted - 1, 0);
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
if (open && rowCount > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
commitHighlighted();
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
if (open) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusOut(event: FocusEvent) {
|
||||||
|
// The menu lives in <body> (portaled) and its rows use mousedown+preventDefault,
|
||||||
|
// so a click on a row never blurs the input. Any real blur closes the menu and
|
||||||
|
// discards the in-progress search (the committed value is untouched).
|
||||||
|
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the menu to <body> so no ancestor's overflow/transform can clip it.
|
||||||
|
function portal(node: HTMLElement) {
|
||||||
|
if (typeof document !== 'undefined') document.body.appendChild(node);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.parentNode?.removeChild(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the portaled menu glued to the input while scrolling/resizing.
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = () => positionMenu();
|
||||||
|
window.addEventListener('scroll', handler, true);
|
||||||
|
window.addEventListener('resize', handler);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handler, true);
|
||||||
|
window.removeEventListener('resize', handler);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="combo" bind:this={root} onfocusout={onFocusOut}>
|
||||||
|
<span class="combo-icon" aria-hidden="true"><Search size={15} strokeWidth={2.2} /></span>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
bind:this={inputEl}
|
||||||
|
class="combo-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
{placeholder}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={open}
|
||||||
|
role="combobox"
|
||||||
|
aria-controls={inputId ? `${inputId}-list` : undefined}
|
||||||
|
value={display}
|
||||||
|
{disabled}
|
||||||
|
oninput={onInput}
|
||||||
|
onfocus={openMenu}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
/>
|
||||||
|
{#if value && !disabled}
|
||||||
|
<button type="button" class="combo-clear" onmousedown={(e) => { e.preventDefault(); clear(); }} aria-label="Clear category">
|
||||||
|
<X size={14} strokeWidth={2.4} />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="combo-caret" aria-hidden="true"><ChevronDown size={15} strokeWidth={2.2} /></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if open && !disabled}
|
||||||
|
<ul class="menu" use:portal id={inputId ? `${inputId}-list` : undefined} role="listbox" style={menuStyle}>
|
||||||
|
{#if filtered.length}
|
||||||
|
<li class="menu-label" aria-hidden="true">Categories</li>
|
||||||
|
{#each filtered as option, i (option)}
|
||||||
|
<li
|
||||||
|
class="row"
|
||||||
|
class:highlighted={i === highlighted}
|
||||||
|
class:selected={option.toLowerCase() === value.toLowerCase()}
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.toLowerCase() === value.toLowerCase()}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(option);
|
||||||
|
}}
|
||||||
|
onmouseenter={() => (highlighted = i)}
|
||||||
|
>
|
||||||
|
<span class="row-label">{option}</span>
|
||||||
|
{#if option.toLowerCase() === value.toLowerCase()}
|
||||||
|
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<li
|
||||||
|
class="row create"
|
||||||
|
class:highlighted={highlighted === createIndex}
|
||||||
|
role="option"
|
||||||
|
aria-selected={false}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
createNew();
|
||||||
|
}}
|
||||||
|
onmouseenter={() => (highlighted = createIndex)}
|
||||||
|
>
|
||||||
|
<span class="create-icon" aria-hidden="true"><Plus size={15} strokeWidth={2.6} /></span>
|
||||||
|
<span class="create-text">Create new category <strong>“{trimmed}”</strong></span>
|
||||||
|
</li>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<li class="row empty">Start typing to add a category.</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.combo {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the editor's compact inputs (the parent's scoped `input` rule can't
|
||||||
|
reach this child component). */
|
||||||
|
.combo-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.38rem 1.7rem 0.38rem 1.65rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.42rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-input:hover {
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-input:focus-visible {
|
||||||
|
outline: 3px solid var(--color-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-input:disabled {
|
||||||
|
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-caret {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.35rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo-clear:hover {
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The menu is portaled to <body>, so it can't rely on inherited layout — it
|
||||||
|
positions itself fixed against the input's rect. */
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 400;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
padding: 0.3rem 0.55rem 0.2rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.42rem 0.55rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.highlighted {
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.selected {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-check {
|
||||||
|
color: var(--color-brand);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The create action is deliberately prominent: a brand-tinted row with a + icon
|
||||||
|
so "make a new category" reads as a distinct, intentional choice. */
|
||||||
|
.row.create {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
border-top: 1px solid var(--color-divider);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.create.highlighted {
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: var(--color-on-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-text strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { EditorChangeEvent } from '$lib/types';
|
||||||
|
import { Clock, History, X } from 'lucide-svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
title,
|
||||||
|
subtitle = '',
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
entityType: 'mix' | 'ingredient';
|
||||||
|
entityId: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let events = $state<EditorChangeEvent[] | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
events =
|
||||||
|
entityType === 'mix'
|
||||||
|
? await api.editorMixHistory(entityId)
|
||||||
|
: await api.editorIngredientHistory(entityId);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Unable to load history';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
created: 'Created',
|
||||||
|
updated: 'Updated',
|
||||||
|
formula_updated: 'Formula updated',
|
||||||
|
ingredient_added: 'Ingredient added',
|
||||||
|
ingredient_updated: 'Ingredient updated',
|
||||||
|
ingredient_removed: 'Ingredient removed'
|
||||||
|
};
|
||||||
|
|
||||||
|
function actionLabel(action: string) {
|
||||||
|
return ACTION_LABELS[action] ?? action.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhen(value: string) {
|
||||||
|
// Audit times are stored in UTC on the server (datetime.utcnow), serialized
|
||||||
|
// without a timezone suffix. Parse the parts as UTC and let the browser
|
||||||
|
// render them in the viewer's local time, so an edit made at midday in
|
||||||
|
// Australia reads as midday rather than the raw 02:00 UTC value.
|
||||||
|
const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||||
|
if (!match) return value;
|
||||||
|
const [, year, month, day, hour, minute, second] = match;
|
||||||
|
const date = new Date(
|
||||||
|
Date.UTC(+year, +month - 1, +day, +hour, +minute, second ? +second : 0)
|
||||||
|
);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString('en-AU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="history-backdrop" role="presentation" onclick={onClose}>
|
||||||
|
<div
|
||||||
|
class="history-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Change history for ${title}`}
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Escape') onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header class="history-head">
|
||||||
|
<div class="history-title">
|
||||||
|
<span class="kicker"><History size={14} strokeWidth={2.2} /> Change history</span>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<button class="icon-close" type="button" onclick={onClose} aria-label="Close history">
|
||||||
|
<X size={18} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="history-body">
|
||||||
|
{#if error}
|
||||||
|
<p class="state error">{error}</p>
|
||||||
|
{:else if events === null}
|
||||||
|
<p class="state">Loading history…</p>
|
||||||
|
{:else if events.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<Clock size={22} strokeWidth={1.8} />
|
||||||
|
<p>No changes recorded yet.</p>
|
||||||
|
<span>Edits made from here on will appear in this list.</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ol class="timeline">
|
||||||
|
{#each events as event (event.id)}
|
||||||
|
<li class="event">
|
||||||
|
<div class="event-head">
|
||||||
|
<span class="badge">{actionLabel(event.action)}</span>
|
||||||
|
<time>{formatWhen(event.created_at)}</time>
|
||||||
|
</div>
|
||||||
|
<p class="summary">{event.summary}</p>
|
||||||
|
{#if event.changes.length}
|
||||||
|
<ul class="deltas">
|
||||||
|
{#each event.changes as delta}
|
||||||
|
<li>
|
||||||
|
<span class="delta-label">{delta.label}</span>
|
||||||
|
<span class="delta-values">
|
||||||
|
<span class="before">{delta.before ?? '—'}</span>
|
||||||
|
<span class="arrow" aria-hidden="true">→</span>
|
||||||
|
<span class="after">{delta.after ?? '—'}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
<p class="actor">by {event.actor_name}{#if event.actor_role} · {event.actor_role}{/if}</p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 70;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(17, 24, 20, 0.52);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(620px, 100%);
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-close:hover {
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-body {
|
||||||
|
padding: 1rem 1.1rem 1.2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty span {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-head time {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deltas {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deltas li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-label {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-values {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.after {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { Calculator, Info, TriangleAlert, X } from 'lucide-svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
|
||||||
import { featureFlags } from '$lib/features';
|
import { featureFlags } from '$lib/features';
|
||||||
|
import { formatNumber } from '$lib/format';
|
||||||
import { clientSession, hasModuleAccess } from '$lib/session';
|
import { clientSession, hasModuleAccess } from '$lib/session';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import type {
|
import type {
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
MixCalculatorSession
|
MixCalculatorSession
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
||||||
|
import MixCalculatorMixPicker from './MixCalculatorMixPicker.svelte';
|
||||||
|
|
||||||
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
||||||
|
|
||||||
@@ -50,10 +53,16 @@
|
|||||||
let batchSizeKg = $state(initialBatchSizeValue());
|
let batchSizeKg = $state(initialBatchSizeValue());
|
||||||
let preparedByName = $state(initialPreparedByNameValue());
|
let preparedByName = $state(initialPreparedByNameValue());
|
||||||
let notes = $state(initialNotesValue());
|
let notes = $state(initialNotesValue());
|
||||||
|
// Notes are optional and stay hidden behind "+ Add a note" until needed,
|
||||||
|
// matching the throughput composer. Auto-open when a saved session has one.
|
||||||
|
let showNote = $state(Boolean(initialSession?.notes));
|
||||||
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 formHint = $state('Select a mix date and prepared by name, then choose a client to unlock mixes.');
|
||||||
let previewLoading = $state(false);
|
let previewLoading = $state(false);
|
||||||
|
let printPdfUrl = $state<string | null>(null);
|
||||||
|
let printFrame = $state<HTMLIFrameElement | null>(null);
|
||||||
|
let printAfterPdfLoad = $state(false);
|
||||||
|
|
||||||
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
||||||
const availableClients = $derived(
|
const availableClients = $derived(
|
||||||
@@ -91,14 +100,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined, digits = 2) {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.toFixed(digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPayload(): MixCalculatorCreateInput | null {
|
function buildPayload(): MixCalculatorCreateInput | null {
|
||||||
formError = '';
|
formError = '';
|
||||||
formHint = '';
|
formHint = '';
|
||||||
@@ -115,13 +116,13 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!clientName) {
|
if (!clientName) {
|
||||||
formError = 'Select a client to unlock matching products.';
|
formError = 'Select a client to unlock matching mixes.';
|
||||||
formHint = 'Products stay disabled until a client is selected.';
|
formHint = 'Mixes stay disabled until a client is selected.';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
formError = 'Select a product.';
|
formError = 'Select a mix.';
|
||||||
formHint = 'Pick one of the products available for the selected client.';
|
formHint = 'Pick one of the mixes available for the selected client.';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
|
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
|
||||||
@@ -162,6 +163,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapse the note field again, discarding anything typed.
|
||||||
|
function dismissNote() {
|
||||||
|
notes = '';
|
||||||
|
showNote = false;
|
||||||
|
}
|
||||||
|
|
||||||
function clearForm() {
|
function clearForm() {
|
||||||
clientName = '';
|
clientName = '';
|
||||||
productId = 0;
|
productId = 0;
|
||||||
@@ -169,28 +176,29 @@
|
|||||||
batchSizeKg = '';
|
batchSizeKg = '';
|
||||||
preparedByName = $clientSession?.name ?? '';
|
preparedByName = $clientSession?.name ?? '';
|
||||||
notes = '';
|
notes = '';
|
||||||
|
showNote = false;
|
||||||
preview = null;
|
preview = null;
|
||||||
formError = '';
|
formError = '';
|
||||||
formHint = 'Select a mix date and prepared by name, then choose a client to unlock products.';
|
formHint = 'Select a mix date and prepared by name, then choose a client to unlock mixes.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!clientName) {
|
if (!clientName) {
|
||||||
formHint = 'Select a client to unlock the product list.';
|
formHint = 'Select a client to unlock the mix list.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!filteredProducts.length) {
|
if (!filteredProducts.length) {
|
||||||
formHint = `No products are available for ${clientName}.`;
|
formHint = `No mixes are available for ${clientName}.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
formHint = 'Select a product for the chosen client.';
|
formHint = 'Select a mix for the chosen client.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`;
|
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected mix'}.`;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function downloadPdf() {
|
async function downloadPdf() {
|
||||||
@@ -219,27 +227,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPdf() {
|
function revokePrintPdfUrl() {
|
||||||
const tid = toast.loading('Opening styled PDF…');
|
if (printPdfUrl) {
|
||||||
try {
|
URL.revokeObjectURL(printPdfUrl);
|
||||||
|
printPdfUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printCurrent() {
|
||||||
|
if (!preview) {
|
||||||
|
toast.error('Calculate the mix before printing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
toast.dismiss(tid);
|
|
||||||
toast.error(formError || 'Complete the mix details first.');
|
toast.error(formError || 'Complete the mix details first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await api.previewMixCalculatorPdf(payload);
|
const tid = toast.loading('Preparing print...');
|
||||||
const url = URL.createObjectURL(blob);
|
try {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
const blob = initialSession ? await api.mixCalculatorSessionPdf(initialSession.id) : await api.previewMixCalculatorPdf(payload);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
revokePrintPdfUrl();
|
||||||
|
printAfterPdfLoad = true;
|
||||||
|
printPdfUrl = URL.createObjectURL(blob);
|
||||||
toast.dismiss(tid);
|
toast.dismiss(tid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.dismiss(tid);
|
toast.dismiss(tid);
|
||||||
toast.error(error instanceof Error ? error.message : 'Unable to open the styled PDF.');
|
toast.error(error instanceof Error ? error.message : 'Unable to prepare the PDF for printing.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printLoadedPdf() {
|
||||||
|
printFrame?.contentWindow?.focus();
|
||||||
|
printFrame?.contentWindow?.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrintFrameLoad() {
|
||||||
|
if (printAfterPdfLoad) {
|
||||||
|
printAfterPdfLoad = false;
|
||||||
|
requestAnimationFrame(printLoadedPdf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
revokePrintPdfUrl();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !canEdit && !initialSession}
|
{#if !canEdit && !initialSession}
|
||||||
@@ -258,21 +292,20 @@
|
|||||||
<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`}>Open PDF page</a>
|
<a class="primary-button" href={`/mix-calculator/${initialSession.id}/print`}>Print</a>
|
||||||
<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>
|
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="editor-grid">
|
<section class="editor-grid">
|
||||||
<article class="form-card">
|
<article class="composer">
|
||||||
<div class="section-header">
|
<div class="composer-head">
|
||||||
<div>
|
<div class="composer-title">
|
||||||
<h3>Session Inputs</h3>
|
<span class="composer-icon"><Calculator size={18} strokeWidth={2.2} /></span>
|
||||||
<p>Batch size drives the scale factor. Total bags are derived from the selected product unit size.</p>
|
<h2>Mix calculator</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if selectedProduct}
|
{#if selectedProduct && selectedProduct.unit_size_kg > 0}
|
||||||
<div class="product-pill">
|
<div class="product-pill">
|
||||||
<strong>{selectedProduct.unit_size_kg}kg</strong>
|
<strong>{selectedProduct.unit_size_kg}kg</strong>
|
||||||
<span>{selectedProduct.unit_of_measure}</span>
|
<span>{selectedProduct.unit_of_measure}</span>
|
||||||
@@ -280,12 +313,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-body">
|
||||||
{#if formError}
|
{#if formError}
|
||||||
<p class="message error">{formError}</p>
|
<p class="message error"><TriangleAlert size={16} strokeWidth={2.2} /> <span>{formError}</span></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !formError && formHint}
|
{#if !formError && formHint}
|
||||||
<p class="message hint">{formHint}</p>
|
<p class="message hint"><Info size={16} strokeWidth={2.2} /> <span>{formHint}</span></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
@@ -301,7 +335,7 @@
|
|||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Client</span>
|
<span>Client</span>
|
||||||
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching products.">
|
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching mixes.">
|
||||||
<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>
|
||||||
@@ -310,36 +344,46 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Product</span>
|
<span>Mix Name</span>
|
||||||
<select
|
<MixCalculatorMixPicker
|
||||||
bind:value={productId}
|
products={filteredProducts}
|
||||||
|
bind:productId
|
||||||
disabled={!canEdit || !clientName || !filteredProducts.length}
|
disabled={!canEdit || !clientName || !filteredProducts.length}
|
||||||
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No products are available for the selected client.' : 'Select a product.'}
|
inputId="mix-calculator-mix"
|
||||||
>
|
/>
|
||||||
<option value={0}>Select a product</option>
|
|
||||||
{#each filteredProducts as product}
|
|
||||||
<option value={product.product_id}>
|
|
||||||
{product.product_name} · {product.mix_name} · {product.unit_of_measure}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Batch size (kg)</span>
|
<span>Batch size (kg)</span>
|
||||||
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" />
|
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="full-width">
|
<div class="note-area">
|
||||||
<span>Notes</span>
|
{#if showNote}
|
||||||
<textarea bind:value={notes} disabled={!canEdit} placeholder="Optional production notes or shift context" rows="4"></textarea>
|
<div class="note-field">
|
||||||
</label>
|
<textarea
|
||||||
|
class="note-input"
|
||||||
|
bind:value={notes}
|
||||||
|
disabled={!canEdit}
|
||||||
|
placeholder="Note (optional) — production notes or shift context"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
{#if canEdit}
|
||||||
|
<button type="button" class="note-dismiss" title="Remove note" aria-label="Remove note" onclick={dismissNote}>
|
||||||
|
<X size={16} strokeWidth={2.4} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if canEdit}
|
||||||
|
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if canEdit && selectedProduct}
|
{#if canEdit && selectedProduct}
|
||||||
<div class="calculation-note">
|
<div class="calculation-note">
|
||||||
<strong>Source mix</strong>
|
<strong>Formula source</strong>
|
||||||
<span>{selectedProduct.mix_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source mix total.</span>
|
<span>{selectedProduct.product_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source formula total.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -350,38 +394,42 @@
|
|||||||
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
|
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="danger-button" disabled={previewLoading} type="button" onclick={clearForm}>
|
<button class="secondary-button" disabled={previewLoading} 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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<MixCalculatorResultsPanel
|
<MixCalculatorResultsPanel
|
||||||
preview={preview}
|
preview={preview}
|
||||||
sessionNumber={initialSession?.session_number ?? null}
|
sessionNumber={initialSession?.session_number ?? null}
|
||||||
onOpenPdf={preview ? openPdf : null}
|
onPrint={preview ? printCurrent : null}
|
||||||
onDownloadPdf={preview ? downloadPdf : null}
|
onDownloadPdf={preview ? downloadPdf : null}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if preview}
|
{#if printPdfUrl}
|
||||||
<section class="print-only" aria-hidden="true">
|
<iframe
|
||||||
<MixCalculatorPrintDocument session={preview} />
|
bind:this={printFrame}
|
||||||
</section>
|
class="print-pdf-frame"
|
||||||
|
src={printPdfUrl}
|
||||||
|
title="Mix calculator PDF print frame"
|
||||||
|
onload={handlePrintFrameLoad}
|
||||||
|
></iframe>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h2,
|
h2,
|
||||||
h3,
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: #7d8d84;
|
color: var(--color-text-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
@@ -400,9 +448,8 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header p,
|
|
||||||
.calculation-note span {
|
.calculation-note span {
|
||||||
color: var(--muted);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
@@ -418,21 +465,68 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card,
|
/* The composer mirrors the throughput "Add a packing run" surface: a quiet
|
||||||
.locked-card {
|
brand-tinted gradient panel that reads as a distinct, on-brand workspace.
|
||||||
border: 1px solid var(--line);
|
Theme tokens keep it correct in dark mode. */
|
||||||
border-radius: 0.8rem;
|
.composer {
|
||||||
background: var(--panel);
|
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
|
||||||
box-shadow: var(--shadow);
|
border-radius: 0.9rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--color-brand) 7%, var(--color-bg-surface)),
|
||||||
|
color-mix(in srgb, var(--color-brand) 4%, var(--color-bg-surface))
|
||||||
|
);
|
||||||
|
/* Native selects open within the viewport, so the card can stay open for
|
||||||
|
a clean rounded edge without clipping anything. */
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card,
|
.composer-head {
|
||||||
.locked-card {
|
display: flex;
|
||||||
padding: 1.2rem;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem 1.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 1.3rem 1.45rem 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand-tinted badge echoing the throughput overview's section icon. */
|
||||||
|
.composer-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.25rem, 2vw, 1.7rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-body {
|
||||||
|
padding: 0 1.45rem 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card {
|
.locked-card {
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
|
padding: 1.2rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card h2 {
|
.locked-card h2 {
|
||||||
@@ -440,33 +534,28 @@
|
|||||||
font-size: clamp(1.7rem, 3vw, 2.1rem);
|
font-size: clamp(1.7rem, 3vw, 2.1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
/* Quick brand-tinted chip echoing the selected mix's unit, matching the pill
|
||||||
display: flex;
|
language used across the app. */
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header h3 {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-pill {
|
.product-pill {
|
||||||
display: grid;
|
display: inline-flex;
|
||||||
gap: 0.14rem;
|
align-items: baseline;
|
||||||
padding: 0.72rem 0.82rem;
|
gap: 0.4rem;
|
||||||
border: 1px solid var(--line);
|
padding: 0.4rem 0.78rem;
|
||||||
border-radius: 0.65rem;
|
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
|
||||||
background: var(--panel-soft);
|
border-radius: 999px;
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-pill strong {
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-pill span {
|
.product-pill span {
|
||||||
color: var(--muted);
|
font-size: 0.76rem;
|
||||||
font-size: 0.78rem;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-grid {
|
.field-grid {
|
||||||
@@ -485,61 +574,155 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-width {
|
/* Optional note, revealed by "+ Add a note" — mirrors the throughput composer. */
|
||||||
grid-column: 1 / -1;
|
.note-area {
|
||||||
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
.link-button {
|
||||||
select,
|
padding: 0.25rem 0;
|
||||||
textarea {
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-dismiss {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 140ms ease,
|
||||||
|
color 140ms ease,
|
||||||
|
background-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-dismiss:hover {
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-dismiss:focus-visible {
|
||||||
|
outline: 3px solid var(--color-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* White fields on the green tint, with brand-tinted borders and a 48px touch
|
||||||
|
target — the same input language as the throughput composer. */
|
||||||
|
.composer input,
|
||||||
|
.composer select,
|
||||||
|
.composer textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.78rem 0.82rem;
|
min-height: 48px;
|
||||||
border: 1px solid var(--line-strong);
|
padding: 0.62rem 0.78rem;
|
||||||
border-radius: 0.6rem;
|
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||||
background: #fff;
|
border-radius: 0.8rem;
|
||||||
color: var(--text);
|
font-size: 0.98rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
transition:
|
transition:
|
||||||
border-color 160ms ease,
|
border-color 160ms ease,
|
||||||
box-shadow 160ms ease;
|
box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus-visible,
|
.composer input::placeholder,
|
||||||
select:focus-visible,
|
.composer textarea::placeholder {
|
||||||
textarea:focus-visible {
|
color: var(--color-text-muted);
|
||||||
outline: none;
|
opacity: 1;
|
||||||
border-color: var(--color-brand);
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.composer input:focus-visible,
|
||||||
|
.composer select:focus-visible,
|
||||||
|
.composer textarea:focus-visible {
|
||||||
|
outline: 3px solid var(--color-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
min-height: 6.5rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Mix Name select stays locked until a client is chosen; make that state
|
||||||
|
unmistakable rather than looking like an empty, clickable field. */
|
||||||
|
.composer input:disabled,
|
||||||
|
.composer select:disabled,
|
||||||
|
.composer textarea:disabled {
|
||||||
|
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
|
||||||
|
border-color: var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.calculation-note {
|
.calculation-note {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.92rem;
|
padding: 0.92rem;
|
||||||
border-radius: 0.65rem;
|
border: 1px solid var(--color-border);
|
||||||
background: var(--panel-soft);
|
border-radius: var(--radius-row);
|
||||||
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.72rem 0.9rem;
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.7rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message :global(svg) {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: #fff1f0;
|
background: color-mix(in srgb, var(--color-error) 12%, var(--color-bg-surface));
|
||||||
color: #b2463f;
|
color: var(--color-error);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Readable on-brand hint: a white-ish surface with a green info icon, instead
|
||||||
|
of low-contrast grey that washed out against the composer's tint. */
|
||||||
.message.hint {
|
.message.hint {
|
||||||
background: var(--panel-soft);
|
background: color-mix(in srgb, var(--color-brand) 16%, var(--color-bg-app));
|
||||||
color: var(--muted);
|
color: var(--color-text-primary);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid color-mix(in srgb, var(--color-brand) 26%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.hint :global(svg) {
|
||||||
|
color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
@@ -547,15 +730,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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.6rem;
|
border-radius: var(--radius-control);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--color-border);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -566,26 +748,29 @@
|
|||||||
.primary-button {
|
.primary-button {
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--color-brand);
|
background: var(--color-brand);
|
||||||
color: #fff;
|
color: var(--color-on-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Composer buttons take the throughput composer's chunkier 48px shape. */
|
||||||
|
.composer .action-row .primary-button,
|
||||||
|
.composer .action-row .secondary-button {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.62rem 1.28rem;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
background: #fff;
|
background: var(--color-bg-surface);
|
||||||
color: #304038;
|
color: var(--color-text-primary);
|
||||||
}
|
|
||||||
|
|
||||||
.danger-button {
|
|
||||||
background: #c63d32;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #c63d32;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:hover:not(:disabled) {
|
.primary-button:hover:not(:disabled) {
|
||||||
background: #126a33;
|
background: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:active:not(:disabled) {
|
.primary-button:active:not(:disabled) {
|
||||||
background: #0f5a2b;
|
background: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button:hover:not(:disabled) {
|
.secondary-button:hover:not(:disabled) {
|
||||||
@@ -593,14 +778,8 @@
|
|||||||
background: var(--color-bg-app);
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-button:hover:not(:disabled) {
|
|
||||||
background: #b2352b;
|
|
||||||
border-color: #b2352b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-button:focus-visible,
|
.primary-button:focus-visible,
|
||||||
.secondary-button:focus-visible,
|
.secondary-button:focus-visible {
|
||||||
.danger-button:focus-visible {
|
|
||||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -627,42 +806,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.section-header {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-grid {
|
.field-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-only {
|
.print-pdf-frame {
|
||||||
display: none;
|
position: fixed;
|
||||||
}
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
@media print {
|
width: 1px;
|
||||||
:global(body) {
|
height: 1px;
|
||||||
background: #fff !important;
|
border: 0;
|
||||||
margin: 0 !important;
|
opacity: 0;
|
||||||
}
|
pointer-events: none;
|
||||||
|
|
||||||
:global(body *) {
|
|
||||||
visibility: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-only,
|
|
||||||
.print-only :global(*) {
|
|
||||||
visibility: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-only {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: #fff;
|
|
||||||
color: #1a2421;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MixCalculatorProductOption } from '$lib/types';
|
||||||
|
import { Search, X, Check } from 'lucide-svelte';
|
||||||
|
|
||||||
|
// Searchable Mix Name picker for the Mix Calculator. Mirrors the throughput
|
||||||
|
// product search (type to filter, arrow/enter to choose) but keys on the
|
||||||
|
// mix's representative product id. The client is chosen separately, so the
|
||||||
|
// `products` passed in are already narrowed to that client.
|
||||||
|
let {
|
||||||
|
products = [],
|
||||||
|
productId = $bindable(0),
|
||||||
|
disabled = false,
|
||||||
|
inputId = 'mix-calculator-mix'
|
||||||
|
}: {
|
||||||
|
products?: MixCalculatorProductOption[];
|
||||||
|
productId?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
inputId?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let open = $state(false);
|
||||||
|
let highlighted = $state(-1);
|
||||||
|
let focused = $state(false);
|
||||||
|
let root = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
function label(product: MixCalculatorProductOption): string {
|
||||||
|
return product.product_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = $derived(
|
||||||
|
productId ? products.find((p) => p.product_id === productId) ?? null : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return products;
|
||||||
|
return products.filter((product) => product.product_name.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the text box when the selection is cleared from outside (e.g. when the
|
||||||
|
// client changes and the previously chosen mix no longer applies).
|
||||||
|
$effect(() => {
|
||||||
|
if (!productId && !focused) {
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reflect a selection set from outside so the box shows the chosen mix.
|
||||||
|
$effect(() => {
|
||||||
|
if (productId && !focused) {
|
||||||
|
const match = products.find((p) => p.product_id === productId);
|
||||||
|
if (match) query = label(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function choose(product: MixCalculatorProductOption) {
|
||||||
|
productId = product.product_id;
|
||||||
|
query = label(product);
|
||||||
|
open = false;
|
||||||
|
highlighted = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
productId = 0;
|
||||||
|
query = '';
|
||||||
|
open = false;
|
||||||
|
highlighted = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(event: Event) {
|
||||||
|
query = (event.target as HTMLInputElement).value;
|
||||||
|
productId = 0;
|
||||||
|
open = true;
|
||||||
|
highlighted = filtered.length ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
open = true;
|
||||||
|
highlighted = Math.min(highlighted + 1, filtered.length - 1);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
highlighted = Math.max(highlighted - 1, 0);
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
if (open && highlighted >= 0 && highlighted < filtered.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(filtered[highlighted]);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
open = false;
|
||||||
|
highlighted = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusOut(event: FocusEvent) {
|
||||||
|
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focused = false;
|
||||||
|
open = false;
|
||||||
|
highlighted = -1;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
|
||||||
|
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
|
||||||
|
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
class="combo-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Search mix name…"
|
||||||
|
value={query}
|
||||||
|
{disabled}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
oninput={onInput}
|
||||||
|
onfocus={() => (open = true)}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
/>
|
||||||
|
{#if productId}
|
||||||
|
<button type="button" class="combo-clear" onclick={clear} aria-label="Clear mix">
|
||||||
|
<X size={15} strokeWidth={2.4} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if open && !disabled}
|
||||||
|
<ul class="options" id={`${inputId}-list`} role="listbox">
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<li class="option empty">No mixes match.</li>
|
||||||
|
{:else}
|
||||||
|
{#each filtered.slice(0, 50) as product, i (product.product_id)}
|
||||||
|
<li
|
||||||
|
class="option"
|
||||||
|
class:highlighted={i === highlighted}
|
||||||
|
class:selected={product.product_id === productId}
|
||||||
|
role="option"
|
||||||
|
aria-selected={product.product_id === productId}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(product);
|
||||||
|
}}
|
||||||
|
onmouseenter={() => (highlighted = i)}
|
||||||
|
>
|
||||||
|
<span class="option-name">{product.product_name}</span>
|
||||||
|
<span class="option-meta">
|
||||||
|
{#if product.unit_size_kg > 0}
|
||||||
|
<span class="option-unit">{product.unit_size_kg}kg {product.unit_of_measure}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="option-tag">Formula only</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if product.product_id === productId}
|
||||||
|
<span class="option-check" aria-hidden="true"><Check size={15} strokeWidth={2.6} /></span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if filtered.length > 50}
|
||||||
|
<li class="option more">
|
||||||
|
Showing first 50 of {filtered.length} — keep typing to narrow.
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.picker {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.combo {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.combo-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.6rem;
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Self-contained input styling so the picker matches the composer's fields
|
||||||
|
(Svelte scopes the parent's `.composer input` rule to the parent's own
|
||||||
|
markup, so it can't reach this child component's input). */
|
||||||
|
.combo-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.62rem 2rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
.combo-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.combo-input:focus-visible {
|
||||||
|
outline: 3px solid var(--color-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
.combo-input:disabled {
|
||||||
|
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
|
||||||
|
border-color: var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.combo-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.45rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.combo-clear:hover {
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.options {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.option.highlighted {
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
}
|
||||||
|
.option.selected {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.option.empty,
|
||||||
|
.option.more {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.option-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.option-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.option-unit {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.option-tag {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.option-check {
|
||||||
|
color: var(--color-brand);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,47 +1,69 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Download, Printer } from 'lucide-svelte';
|
import { ArrowUpDown, Download, Printer } from 'lucide-svelte';
|
||||||
|
import { formatDate, formatNumber } from '$lib/format';
|
||||||
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
preview,
|
preview,
|
||||||
sessionNumber = null,
|
sessionNumber = null,
|
||||||
onOpenPdf = null,
|
onPrint = null,
|
||||||
onDownloadPdf = null
|
onDownloadPdf = null
|
||||||
}: {
|
}: {
|
||||||
preview: MixCalculatorPreview | MixCalculatorSession | null;
|
preview: MixCalculatorPreview | MixCalculatorSession | null;
|
||||||
sessionNumber?: string | null;
|
sessionNumber?: string | null;
|
||||||
onOpenPdf?: (() => void) | null;
|
onPrint?: (() => void) | null;
|
||||||
onDownloadPdf?: (() => void) | null;
|
onDownloadPdf?: (() => void) | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function formatDate(value: string) {
|
// ── Ingredient sorting ──────────────────────────────────────────
|
||||||
return new Intl.DateTimeFormat('en-NZ', {
|
// Default to the backend's category grouping (ingredients ordered by their
|
||||||
dateStyle: 'medium'
|
// manually-assigned category). Clicking a header toggles direction or switches
|
||||||
}).format(new Date(value));
|
// column. Required kg starts descending; category and name start ascending.
|
||||||
|
type LineSortKey = 'category' | 'raw_material_name' | 'required_kg';
|
||||||
|
let sortKey = $state<LineSortKey>('category');
|
||||||
|
let sortDir = $state<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
|
function toggleSort(key: LineSortKey) {
|
||||||
|
if (sortKey === key) {
|
||||||
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sortKey = key;
|
||||||
|
sortDir = key === 'required_kg' ? 'desc' : 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined, digits = 2) {
|
function ariaSort(key: LineSortKey): 'ascending' | 'descending' | 'none' {
|
||||||
if (value === null || value === undefined) {
|
if (sortKey !== key) return 'none';
|
||||||
return 'N/A';
|
return sortDir === 'asc' ? 'ascending' : 'descending';
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.toFixed(digits);
|
const sortedLines = $derived.by(() => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
return [...(preview?.lines ?? [])].sort((a, b) => {
|
||||||
|
let result: number;
|
||||||
|
if (sortKey === 'required_kg') {
|
||||||
|
result = (a.required_kg ?? 0) - (b.required_kg ?? 0);
|
||||||
|
} else if (sortKey === 'category') {
|
||||||
|
// The backend orders lines by category and renumbers sort_order to match,
|
||||||
|
// so sorting on it reproduces the category grouping.
|
||||||
|
result = (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||||||
|
} else {
|
||||||
|
result = a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
|
||||||
}
|
}
|
||||||
|
return result * dir;
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="result-card">
|
<article class="result-card">
|
||||||
<div class="section-header">
|
|
||||||
<div>
|
|
||||||
<h3>Calculated Output</h3>
|
|
||||||
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
|
|
||||||
</div>
|
|
||||||
{#if sessionNumber}
|
{#if sessionNumber}
|
||||||
|
<div class="result-toolbar">
|
||||||
<div class="session-chip">
|
<div class="session-chip">
|
||||||
<span>Session</span>
|
<span>Session</span>
|
||||||
<strong>{sessionNumber}</strong>
|
<strong>{sessionNumber}</strong>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if preview}
|
{#if preview}
|
||||||
<div class="metric-row">
|
<div class="metric-row">
|
||||||
@@ -76,11 +98,11 @@
|
|||||||
<strong>{preview.client_name}</strong>
|
<strong>{preview.client_name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Product</span>
|
<span>Mix</span>
|
||||||
<strong>{preview.product_name}</strong>
|
<strong>{preview.product_name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Mix source</span>
|
<span>Formula source</span>
|
||||||
<strong>{preview.mix_name}</strong>
|
<strong>{preview.mix_name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -93,17 +115,51 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Raw material</th>
|
<th aria-sort={ariaSort('category')}>
|
||||||
<th>Required kg</th>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort-head"
|
||||||
|
class:active={sortKey === 'category'}
|
||||||
|
onclick={() => toggleSort('category')}
|
||||||
|
>
|
||||||
|
<span>Category</span>
|
||||||
|
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th aria-sort={ariaSort('raw_material_name')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort-head"
|
||||||
|
class:active={sortKey === 'raw_material_name'}
|
||||||
|
onclick={() => toggleSort('raw_material_name')}
|
||||||
|
>
|
||||||
|
<span>Raw material</span>
|
||||||
|
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th aria-sort={ariaSort('required_kg')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort-head"
|
||||||
|
class:active={sortKey === 'required_kg'}
|
||||||
|
onclick={() => toggleSort('required_kg')}
|
||||||
|
>
|
||||||
|
<span>Required kg</span>
|
||||||
|
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each preview.lines as line}
|
{#each sortedLines as line}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td data-label="Category">
|
||||||
|
<span class="category-cell">{line.category || '—'}</span>
|
||||||
|
</td>
|
||||||
<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="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
|
<td data-label="Required kg">{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -111,7 +167,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="output-actions">
|
<div class="output-actions">
|
||||||
<button class="primary-button" disabled={!onOpenPdf} type="button" onclick={() => onOpenPdf?.()}>
|
<button class="primary-button" disabled={!onPrint} type="button" onclick={() => onPrint?.()}>
|
||||||
<Printer size={18} strokeWidth={1.9} aria-hidden="true" />
|
<Printer size={18} strokeWidth={1.9} aria-hidden="true" />
|
||||||
Print
|
Print
|
||||||
</button>
|
</button>
|
||||||
@@ -132,7 +188,7 @@
|
|||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</div>
|
</div>
|
||||||
<strong>No calculation yet</strong>
|
<strong>No calculation yet</strong>
|
||||||
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
|
<span>Choose a client, mix, date, and batch size on the left, then click Calculate mix.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="empty-shimmer-rows">
|
<div class="empty-shimmer-rows">
|
||||||
{#each [1,2,3,4,5] as _}
|
{#each [1,2,3,4,5] as _}
|
||||||
@@ -147,55 +203,50 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h3,
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header p,
|
|
||||||
.metric-card p,
|
.metric-card p,
|
||||||
.summary-grid span,
|
.summary-grid span,
|
||||||
.empty-state span {
|
.empty-state span {
|
||||||
color: var(--muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-card,
|
.result-card,
|
||||||
.metric-card {
|
.metric-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.8rem;
|
background: var(--color-bg-surface);
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-card {
|
.result-card {
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.metric-card {
|
||||||
|
border-radius: var(--radius-row);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
justify-content: flex-end;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header h3 {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-chip {
|
.session-chip {
|
||||||
display: grid;
|
display: inline-flex;
|
||||||
gap: 0.14rem;
|
align-items: baseline;
|
||||||
padding: 0.72rem 0.82rem;
|
gap: 0.4rem;
|
||||||
border: 1px solid var(--line);
|
padding: 0.4rem 0.78rem;
|
||||||
border-radius: 0.65rem;
|
border: 1px solid var(--color-border);
|
||||||
background: var(--panel-soft);
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-chip span {
|
.session-chip span {
|
||||||
color: var(--muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.76rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
@@ -217,7 +268,7 @@
|
|||||||
|
|
||||||
.metric-card span {
|
.metric-card span {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,9 +283,10 @@
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.92rem;
|
padding: 0.92rem;
|
||||||
border-radius: 0.65rem;
|
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||||
background: #fdf6e9;
|
border-radius: var(--radius-row);
|
||||||
color: #8a5a00;
|
background: var(--color-warning-tint);
|
||||||
|
color: var(--color-warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@@ -244,9 +296,9 @@
|
|||||||
|
|
||||||
.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(--color-border);
|
||||||
border-radius: 0.65rem;
|
border-radius: var(--radius-row);
|
||||||
background: var(--panel-soft);
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid span {
|
.summary-grid span {
|
||||||
@@ -279,17 +331,58 @@
|
|||||||
td {
|
td {
|
||||||
padding: 0.9rem 0.85rem;
|
padding: 0.9rem 0.85rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
color: var(--muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-cell {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable header: inherits the th look, adds a sort affordance. */
|
||||||
|
.sort-head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-head :global(svg) {
|
||||||
|
opacity: 0.45;
|
||||||
|
transition: opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-head:hover,
|
||||||
|
.sort-head.active {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-head.active :global(svg) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-head:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -297,8 +390,8 @@
|
|||||||
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.6rem;
|
border-radius: var(--radius-control);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--color-border);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -309,20 +402,20 @@
|
|||||||
.primary-button {
|
.primary-button {
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--color-brand);
|
background: var(--color-brand);
|
||||||
color: #fff;
|
color: var(--color-on-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
background: #fff;
|
background: var(--color-bg-surface);
|
||||||
color: #304038;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:hover:not(:disabled) {
|
.primary-button:hover:not(:disabled) {
|
||||||
background: #126a33;
|
background: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:active:not(:disabled) {
|
.primary-button:active:not(:disabled) {
|
||||||
background: #0f5a2b;
|
background: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button:hover:not(:disabled) {
|
.secondary-button:hover:not(:disabled) {
|
||||||
@@ -347,7 +440,7 @@
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-shimmer-metrics {
|
.empty-shimmer-metrics {
|
||||||
@@ -355,8 +448,8 @@
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--panel-soft);
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shimmer-metric {
|
.shimmer-metric {
|
||||||
@@ -364,9 +457,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.85rem;
|
||||||
background: var(--panel);
|
background: var(--color-bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-copy {
|
.empty-state-copy {
|
||||||
@@ -376,14 +469,14 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--panel);
|
background: var(--color-bg-surface);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-copy strong {
|
.empty-state-copy strong {
|
||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-copy span {
|
.empty-state-copy span {
|
||||||
@@ -419,7 +512,7 @@
|
|||||||
.empty-shimmer-rows {
|
.empty-shimmer-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--panel-soft);
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shimmer-row {
|
.shimmer-row {
|
||||||
@@ -428,7 +521,7 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.78rem 1rem;
|
padding: 0.78rem 1rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shimmer-row:last-child {
|
.shimmer-row:last-child {
|
||||||
@@ -441,7 +534,7 @@
|
|||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--color-border) 25%,
|
var(--color-border) 25%,
|
||||||
color-mix(in srgb, var(--color-border) 40%, white) 50%,
|
color-mix(in srgb, var(--color-border) 45%, var(--color-bg-surface)) 50%,
|
||||||
var(--color-border) 75%
|
var(--color-border) 75%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
@@ -464,10 +557,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.section-header {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -491,14 +580,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: var(--panel-soft);
|
background: var(--color-bg-app);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody td {
|
tbody td {
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody td:last-child {
|
tbody td:last-child {
|
||||||
@@ -509,7 +598,7 @@
|
|||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.24rem;
|
margin-bottom: 0.24rem;
|
||||||
color: var(--muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
|||||||
@@ -299,13 +299,7 @@
|
|||||||
<a href="/">Return to sign-in</a>
|
<a href="/">Return to sign-in</a>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="page-intro">
|
<section class="page-intro page-actions">
|
||||||
<div>
|
|
||||||
<p class="eyebrow">{savedMix ? 'Edit Mix' : 'New Mix'}</p>
|
|
||||||
<h2>{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}</h2>
|
|
||||||
<p>Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="intro-actions">
|
<div class="intro-actions">
|
||||||
<a class="secondary-button" href="/mixes">Back to table</a>
|
<a class="secondary-button" href="/mixes">Back to table</a>
|
||||||
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
||||||
@@ -584,15 +578,13 @@
|
|||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card h2,
|
.locked-card h2 {
|
||||||
.page-intro h2 {
|
|
||||||
margin: 0.3rem 0 0.4rem;
|
margin: 0.3rem 0 0.4rem;
|
||||||
font-size: clamp(1.56rem, 3vw, 2.02rem);
|
font-size: clamp(1.56rem, 3vw, 2.02rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card p:last-of-type,
|
.locked-card p:last-of-type,
|
||||||
.page-intro p:last-child,
|
|
||||||
.metric-card p,
|
.metric-card p,
|
||||||
.summary-card span,
|
.summary-card span,
|
||||||
.factor-list span,
|
.factor-list span,
|
||||||
@@ -619,6 +611,10 @@
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--nav-item-color, #3a4a41);
|
color: var(--nav-item-color, var(--color-text-secondary));
|
||||||
font-size: var(--nav-item-size, 0.93rem);
|
font-size: var(--nav-item-size, 0.93rem);
|
||||||
font-weight: var(--nav-item-weight, 500);
|
font-weight: var(--nav-item-weight, 500);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -83,14 +83,14 @@
|
|||||||
|
|
||||||
.nav-list a:hover,
|
.nav-list a:hover,
|
||||||
.nav-button:hover {
|
.nav-button:hover {
|
||||||
background: var(--nav-item-hover-bg, var(--panel-soft));
|
background: var(--nav-item-hover-bg, var(--color-surface-hover));
|
||||||
color: var(--nav-item-hover-color, #304038);
|
color: var(--nav-item-hover-color, var(--color-text-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-list a.active,
|
.nav-list a.active,
|
||||||
.nav-button.active {
|
.nav-button.active {
|
||||||
background: var(--nav-item-active-bg, var(--color-brand));
|
background: var(--nav-item-active-bg, var(--color-brand));
|
||||||
color: var(--nav-item-active-color, #fff);
|
color: var(--nav-item-active-color, var(--color-on-brand));
|
||||||
font-weight: var(--nav-item-active-weight, 600);
|
font-weight: var(--nav-item-active-weight, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +111,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.08rem 0.4rem;
|
padding: 0.08rem 0.4rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fdf0d2;
|
background: var(--color-warning-tint);
|
||||||
color: #8a5a00;
|
color: var(--color-warning-text);
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
|
|
||||||
.nav-list a.active .nav-badge,
|
.nav-list a.active .nav-badge,
|
||||||
.nav-button.active .nav-badge {
|
.nav-button.active .nav-badge {
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: var(--color-warning-tint);
|
||||||
color: #8a5a00;
|
color: var(--color-warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
@@ -133,18 +133,18 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 1.6rem;
|
width: 1.6rem;
|
||||||
height: 1.6rem;
|
height: 1.6rem;
|
||||||
color: var(--nav-icon-color, #6d7d74);
|
color: var(--nav-icon-color, var(--color-text-muted));
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
transition: color 140ms ease;
|
transition: color 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-list a:hover .nav-icon,
|
.nav-list a:hover .nav-icon,
|
||||||
.nav-button:hover .nav-icon {
|
.nav-button:hover .nav-icon {
|
||||||
color: var(--nav-icon-hover-color, #304038);
|
color: var(--nav-icon-hover-color, var(--color-text-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-list a.active .nav-icon,
|
.nav-list a.active .nav-icon,
|
||||||
.nav-button.active .nav-icon {
|
.nav-button.active .nav-icon {
|
||||||
color: var(--nav-icon-active-color, #fff);
|
color: var(--nav-icon-active-color, var(--color-on-brand));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -75,23 +75,23 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: calc(100vh - 8.5rem);
|
min-height: calc(100vh - 8.5rem);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: color-mix(in srgb, var(--panel-soft) 72%, white);
|
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
--nav-section-label-color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
|
--nav-section-label-color: var(--color-text-muted);
|
||||||
--nav-section-label-size: 0.66rem;
|
--nav-section-label-size: 0.66rem;
|
||||||
--nav-section-label-spacing: 0.14em;
|
--nav-section-label-spacing: 0.14em;
|
||||||
--nav-item-color: #66756d;
|
--nav-item-color: var(--color-text-secondary);
|
||||||
--nav-item-size: 0.88rem;
|
--nav-item-size: 0.88rem;
|
||||||
--nav-item-weight: 450;
|
--nav-item-weight: 450;
|
||||||
--nav-item-hover-bg: color-mix(in srgb, var(--panel) 72%, transparent);
|
--nav-item-hover-bg: var(--color-surface-hover);
|
||||||
--nav-item-hover-color: #425148;
|
--nav-item-hover-color: var(--color-text-primary);
|
||||||
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 7%, transparent);
|
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 11%, transparent);
|
||||||
--nav-item-active-color: #22352d;
|
--nav-item-active-color: var(--color-brand-hover);
|
||||||
--nav-item-active-weight: 560;
|
--nav-item-active-weight: 560;
|
||||||
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 28%, transparent);
|
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 32%, transparent);
|
||||||
--nav-icon-color: #8a9790;
|
--nav-icon-color: var(--color-text-muted);
|
||||||
--nav-icon-hover-color: #607067;
|
--nav-icon-hover-color: var(--color-text-secondary);
|
||||||
--nav-icon-active-color: var(--color-brand);
|
--nav-icon-active-color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
|
|
||||||
.secondary-rail-layout-content > :global(*) {
|
.secondary-rail-layout-content > :global(*) {
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
height: 100%;
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user