From 349e4a4b5ba4e57d2226652d2cbe4c39380eae7a Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Tue, 9 Jun 2026 21:28:53 +1200 Subject: [PATCH] v0.1.12 --- .env.alpha | 2 +- .env.alpha.example | 2 +- .env.example | 2 +- .env.production.example | 2 +- TECH_DEBT.md | 335 ++++ backend/app/api/dashboard.py | 167 ++ backend/app/api/product_costing.py | 247 +++ backend/app/core/config.py | 2 +- backend/app/db/migrations.py | 6 + backend/app/main.py | 2 + backend/app/models/__init__.py | 14 + backend/app/models/product_costing.py | 99 ++ backend/app/schemas/product_costing.py | 97 + backend/app/seed.py | 216 ++- backend/app/services/mix_calculator_pdf.py | 4 +- .../app/services/mix_calculator_service.py | 50 +- .../app/services/product_costing_service.py | 338 ++++ .../data_entry_app_backend.egg-info/PKG-INFO | 3 +- .../SOURCES.txt | 52 +- .../requires.txt | 1 + backend/pyproject.toml | 2 +- backend/tests/_repro_throughput_post.py | 62 - backend/tests/test_costing_engine.py | 14 +- backend/tests/test_product_costing.py | 169 ++ backend/tests/test_schema.py | 27 + docker-compose.alpha.yml | 2 +- docker-compose.production.yml | 2 +- docker-compose.yml | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/app.html | 15 + frontend/src/lib/api.ts | 87 +- frontend/src/lib/components/AdminShell.svelte | 6 +- .../src/lib/components/ClientShell.svelte | 536 +----- .../MixCalculatorPrintDocument.svelte | 4 +- .../components/MixCalculatorPrintSheet.svelte | 146 +- .../src/lib/components/ThemeToggle.svelte | 42 + .../mix-calculator/MixCalculatorEditor.svelte | 166 +- .../MixCalculatorResultsPanel.svelte | 26 +- .../navigation/AppNavSection.svelte | 22 +- .../navigation/AppSecondaryRail.svelte | 20 +- .../navigation/ClientPrimaryRail.svelte | 537 +++++- .../components/navigation/ClientTopbar.svelte | 67 +- .../navigation/WorkspaceSearchTrigger.svelte | 14 +- frontend/src/lib/format.test.ts | 25 + frontend/src/lib/format.ts | 39 + .../src/lib/navigation/client-navigation.ts | 90 +- frontend/src/lib/styles/theme.css | 541 ++++++ frontend/src/lib/theme.ts | 56 + frontend/src/lib/types.ts | 100 ++ frontend/src/lib/workspace-access.ts | 10 + frontend/src/routes/+layout.svelte | 18 +- frontend/src/routes/+page.svelte | 488 +++++- frontend/src/routes/+page.ts | 3 +- frontend/src/routes/editor/+page.svelte | 338 ++-- frontend/src/routes/load-fetch.test.ts | 17 +- .../src/routes/mix-calculator/+page.svelte | 4 +- .../src/routes/product-costing/+page.svelte | 1557 +++++++++++++++++ frontend/src/routes/product-costing/+page.ts | 30 + frontend/src/routes/throughput/+page.svelte | 847 +++++---- .../src/routes/throughput/add/+page.svelte | 8 +- 61 files changed, 6404 insertions(+), 1382 deletions(-) create mode 100644 TECH_DEBT.md create mode 100644 backend/app/api/product_costing.py create mode 100644 backend/app/models/product_costing.py create mode 100644 backend/app/schemas/product_costing.py create mode 100644 backend/app/services/product_costing_service.py delete mode 100644 backend/tests/_repro_throughput_post.py create mode 100644 backend/tests/test_product_costing.py create mode 100644 backend/tests/test_schema.py create mode 100644 frontend/src/lib/components/ThemeToggle.svelte create mode 100644 frontend/src/lib/format.test.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/styles/theme.css create mode 100644 frontend/src/lib/theme.ts create mode 100644 frontend/src/routes/product-costing/+page.svelte create mode 100644 frontend/src/routes/product-costing/+page.ts diff --git a/.env.alpha b/.env.alpha index f353be5..33edc24 100644 --- a/.env.alpha +++ b/.env.alpha @@ -1,4 +1,4 @@ -APP_NAME=Lean 101 Clients API +APP_NAME=Hunter App CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=alex@lean-101.com CLIENT_PASSWORD=JBBwVCDqmPA7 diff --git a/.env.alpha.example b/.env.alpha.example index edae4ee..205fe6f 100644 --- a/.env.alpha.example +++ b/.env.alpha.example @@ -1,4 +1,4 @@ -APP_NAME=Lean 101 Clients API +APP_NAME=Hunter App APP_ENV=alpha CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=operator@example.com diff --git a/.env.example b/.env.example index dfb20e3..f78a011 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_NAME=Lean 101 Clients API +APP_NAME=Hunter App APP_ENV=production CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=operator@example.com diff --git a/.env.production.example b/.env.production.example index 5ec6e9d..5238174 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,4 +1,4 @@ -APP_NAME=Lean 101 Clients API +APP_NAME=Hunter App APP_ENV=production CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=operator@example.com diff --git a/TECH_DEBT.md b/TECH_DEBT.md new file mode 100644 index 0000000..768b48e --- /dev/null +++ b/TECH_DEBT.md @@ -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 +`` 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. diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 567aa2a..93e4f0b 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -7,6 +7,9 @@ breakdowns, scenarios, data-quality) and only used summaries from each. """ from __future__ import annotations +from datetime import date +import json + from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -15,7 +18,9 @@ from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.mix import Mix from app.models.product import Product +from app.models.product_costing import ProductCostItem from app.models.raw_material import RawMaterial +from app.models.throughput import ProductionThroughput, ThroughputProduct from app.services.client_access_service import has_access_level from app.services.costing_engine import ( calculate_mix_cost, @@ -33,6 +38,166 @@ def _can(session: AuthSession, module_key: str) -> bool: return has_access_level(permissions.get(module_key), "view") +def _month_start(today: date) -> date: + return today.replace(day=1) + + +def _warnings(item: ProductCostItem) -> list[str]: + if not item.warnings: + return [] + try: + parsed = json.loads(item.warnings) + return parsed if isinstance(parsed, list) else [str(parsed)] + except json.JSONDecodeError: + return [item.warnings] + + +def _pricing_key(value: str | None) -> str: + return (value or "").strip().lower() + + +def _find_pricing_item( + entry: ProductionThroughput, + product: ThroughputProduct | None, + by_item_id: dict[str, ProductCostItem], + by_name: dict[str, ProductCostItem], +) -> ProductCostItem | None: + if product and product.item_id and product.item_id in by_item_id: + return by_item_id[product.item_id] + return by_name.get(_pricing_key(entry.product_name_snapshot)) or by_name.get(_pricing_key(product.name if product else None)) + + +def _operations_summary(session: AuthSession, db: Session) -> dict | None: + if not (_can(session, "operations_throughput") or _can(session, "products") or _can(session, "dashboard")): + return None + + today = date.today() + start = _month_start(today) + entries = db.scalars( + select(ProductionThroughput) + .where( + ProductionThroughput.tenant_id == session.tenant_id, + ProductionThroughput.production_date >= start, + ProductionThroughput.production_date <= today, + ) + .options(selectinload(ProductionThroughput.product)) + .order_by(ProductionThroughput.production_date.desc()) + ).all() + pricing_items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)).all() + by_item_id = {item.item_id: item for item in pricing_items if item.item_id} + by_name: dict[str, ProductCostItem] = {} + for item in pricing_items: + by_name.setdefault(_pricing_key(item.product_name), item) + by_name.setdefault(_pricing_key(item.mix_product_name), item) + + product_totals: dict[str, dict] = {} + client_totals: dict[str, float] = {} + produced_not_priced: dict[str, dict] = {} + total_kg = 0.0 + total_bags = 0.0 + estimated_wholesale_value = 0.0 + wholesale_rows = 0 + + for entry in entries: + kg = entry.calculated_kg or 0.0 + bags = entry.quantity if entry.quantity_type == "bags" else 0.0 + total_kg += kg + total_bags += bags + + product = entry.product + name = entry.product_name_snapshot or product.name if product else entry.product_name_snapshot + bucket = product_totals.setdefault( + name, + {"product_name": name, "client_name": product.client_name if product else None, "kg": 0.0, "bags": 0.0, "entries": 0}, + ) + bucket["kg"] += kg + bucket["bags"] += bags + bucket["entries"] += 1 + + client = product.client_name if product and product.client_name else "Unassigned" + client_totals[client] = client_totals.get(client, 0.0) + kg + + pricing = _find_pricing_item(entry, product, by_item_id, by_name) + pricing_warnings = _warnings(pricing) if pricing else ["Missing product pricing"] + wholesale_price = pricing.wholesale_price if pricing else None + unit_kg = pricing.unit_kg if pricing else None + if wholesale_price is not None: + units = kg / unit_kg if unit_kg and unit_kg > 0 else entry.quantity + estimated_wholesale_value += units * wholesale_price + wholesale_rows += 1 + if pricing is None or pricing_warnings or wholesale_price is None: + missing = produced_not_priced.setdefault( + name, + { + "product_name": name, + "kg": 0.0, + "status": "Missing pricing" if pricing is None else "Needs review", + "warnings": pricing_warnings[:2], + }, + ) + missing["kg"] += kg + + issue_counts = { + "missing_lookup": 0, + "missing_unit_kg": 0, + "missing_pallet_qty": 0, + "missing_price": 0, + "invalid_margin": 0, + } + for item in pricing_items: + warnings = " ".join(_warnings(item)).lower() + if "lookup" in warnings: + issue_counts["missing_lookup"] += 1 + if "unit kg" in warnings: + issue_counts["missing_unit_kg"] += 1 + if "pallet" in warnings: + issue_counts["missing_pallet_qty"] += 1 + if item.distributor_price is None or item.wholesale_price is None: + issue_counts["missing_price"] += 1 + if "margin" in warnings: + issue_counts["invalid_margin"] += 1 + + top_products = sorted(product_totals.values(), key=lambda row: row["kg"], reverse=True)[:5] + clients = [ + {"client_name": client, "kg": round(kg, 2)} + for client, kg in sorted(client_totals.items(), key=lambda item: item[1], reverse=True)[:5] + ] + produced_not_priced_rows = sorted(produced_not_priced.values(), key=lambda row: row["kg"], reverse=True)[:5] + + return { + "period_label": "This month", + "total_kg": round(total_kg, 2), + "total_bags": round(total_bags, 2), + "entry_count": len(entries), + "estimated_wholesale_value": round(estimated_wholesale_value, 2), + "priced_entry_count": wholesale_rows, + "top_products": [ + { + "product_name": row["product_name"], + "client_name": row["client_name"], + "kg": round(row["kg"], 2), + "bags": round(row["bags"], 2), + "entries": row["entries"], + } + for row in top_products + ], + "client_totals": clients, + "pricing_issues": { + **issue_counts, + "total": sum(issue_counts.values()), + }, + "produced_not_priced": [ + { + "product_name": row["product_name"], + "kg": round(row["kg"], 2), + "status": row["status"], + "warnings": row["warnings"], + } + for row in produced_not_priced_rows + ], + } + + @router.get("/summary") def dashboard_summary( session: AuthSession = Depends(require_client_module_access("dashboard")), @@ -44,6 +209,7 @@ def dashboard_summary( raw_series: list[float] = [] mix_series: list[float] = [] product_series: list[float] = [] + operations_summary = _operations_summary(session, db) if _can(session, "raw_materials") or _can(session, "dashboard"): materials = db.scalars( @@ -147,4 +313,5 @@ def dashboard_summary( "mix_cost_per_kg": mix_series, "product_finished_delivered": product_series, }, + "operations": operations_summary, } diff --git a/backend/app/api/product_costing.py b/backend/app/api/product_costing.py new file mode 100644 index 0000000..e7f5b6b --- /dev/null +++ b/backend/app/api/product_costing.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f366fd1..7b80cb2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -62,7 +62,7 @@ class Settings: @classmethod def from_env(cls) -> "Settings": 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")), host=os.getenv("HOST", "0.0.0.0"), port=int(os.getenv("PORT", "8000")), diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 07dc0a2..acb805b 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -29,6 +29,12 @@ TENANT_TABLES = { "mix_calculator_sessions": None, "mix_calculator_session_lines": None, "products": None, + "product_cost_items": None, + "product_cost_base_inputs": None, + "product_cost_process_inputs": None, + "product_cost_client_inputs": None, + "product_cost_bag_inputs": None, + "product_cost_freight_inputs": None, "scenarios": None, "costing_results": None, "process_cost_rules": None, diff --git a/backend/app/main.py b/backend/app/main.py index 3bac65b..87f36a2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,7 @@ from app.api.editor import router as editor_router from app.api.mix_calculator import router as mix_calculator_router from app.api.mixes import router as mixes_router from app.api.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.raw_materials import router as raw_materials_router from app.api.scenarios import router as scenarios_router @@ -199,6 +200,7 @@ app.include_router(editor_router) app.include_router(raw_materials_router) app.include_router(mixes_router) app.include_router(mix_calculator_router) +app.include_router(product_costing_router) app.include_router(products_router) app.include_router(scenarios_router) app.include_router(throughput_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9deaf84..e25b49e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,14 @@ from app.models.client_access import ClientAccessAuditEvent, ClientAccount, Clie from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine from app.models.mix import Mix, MixIngredient from app.models.product import Product, ProductIngredient +from app.models.product_costing import ( + ProductCostBagInput, + ProductCostBaseInput, + ProductCostClientInput, + ProductCostFreightInput, + ProductCostItem, + ProductCostProcessInput, +) from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.scenario import CostingResult, Scenario from app.models.throughput import ProductionThroughput, ThroughputProduct @@ -24,6 +32,12 @@ __all__ = [ "Permission", "ProcessCostRule", "Product", + "ProductCostBagInput", + "ProductCostBaseInput", + "ProductCostClientInput", + "ProductCostFreightInput", + "ProductCostItem", + "ProductCostProcessInput", "ProductIngredient", "ProductionThroughput", "ThroughputProduct", diff --git a/backend/app/models/product_costing.py b/backend/app/models/product_costing.py new file mode 100644 index 0000000..f97b7bd --- /dev/null +++ b/backend/app/models/product_costing.py @@ -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) diff --git a/backend/app/schemas/product_costing.py b/backend/app/schemas/product_costing.py new file mode 100644 index 0000000..528bd96 --- /dev/null +++ b/backend/app/schemas/product_costing.py @@ -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 diff --git a/backend/app/seed.py b/backend/app/seed.py index fc7a078..6222c2a 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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.mix import Mix, MixIngredient from app.models.product import Product, ProductIngredient +from app.models.product_costing import ( + ProductCostBagInput, + ProductCostBaseInput, + ProductCostClientInput, + ProductCostFreightInput, + ProductCostItem, + ProductCostProcessInput, +) from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.throughput import ProductionThroughput, ThroughputProduct from app.seed_access import seed_access from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role from app.services.throughput_service import import_workbook as import_throughput_workbook from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path +from app.services.product_costing_service import ( + BAG_INPUTS, + FREIGHT_INPUTS, + PROCESS_NAMES, + recalculate_all_product_cost_items, +) TENANT_ID = "hunter-premium-produce" @@ -691,7 +705,36 @@ def _upsert_product_ingredients( for key, formula in product_ingredient_rows.items(): matched_products = products_by_formula_key.get(key, []) 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: existing_ingredients = { @@ -1060,6 +1103,174 @@ def seed_throughput_products(db): return +def _unit_type_from_product(product: Product) -> str: + sale_type = (product.sale_type or "").lower() + unit = (product.unit_of_measure or "").lower() + if sale_type == "bulka" or "bulka" in unit: + return "Bulka" + if "1.5kg" in unit or "1.5 kg" in unit: + return "1.5 kg" + if sale_type == "per_unit": + return "Per Unit" + return "Standard" + + +def _own_bag_label(product: Product) -> str | None: + if product.own_bag: + return "No Bag" if "no bag" in (product.unit_of_measure or "").lower() else "Yes" + return None + + +def seed_product_costing_module(db) -> dict[str, int]: + tenant_id = TENANT_ID + base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id)) + if base is None: + process_rules = db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all() + grading_per_kg = max((rule.grading_cost for rule in process_rules), default=0.0) + cracking_per_kg = max((rule.cracking_cost for rule in process_rules), default=0.0) + base = ProductCostBaseInput( + tenant_id=tenant_id, + grading_per_tonne=round(grading_per_kg * 1000, 4), + grading_per_kg=round(grading_per_kg, 4), + cracking_per_tonne=round(cracking_per_kg * 1000, 4), + cracking_per_kg=round(cracking_per_kg, 4), + ) + db.add(base) + + existing_processes = { + row.process_name: row + for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all() + } + process_rule_map = { + rule.process_name: rule + for rule in db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all() + } + for process_name in PROCESS_NAMES: + if process_name in existing_processes: + continue + normalized_key = _build_process_key(process_name, 0.0, 0.0, 0.0) + rule = process_rule_map.get(normalized_key or process_name) or process_rule_map.get(process_name) + db.add( + ProductCostProcessInput( + tenant_id=tenant_id, + process_name=process_name, + cost_per_kg=round(rule.bagging_cost, 4) if rule else 0.0, + ) + ) + for process_name, rule in process_rule_map.items(): + if process_name not in existing_processes: + db.add( + ProductCostProcessInput( + tenant_id=tenant_id, + process_name=process_name, + cost_per_kg=round(rule.bagging_cost, 4), + ) + ) + + bag_defaults = { + "20kg_bag": 0.0, + "bulka_bag": 0.0, + "own_bag_credit": 0.0, + "1_5kg_bagging": 0.0, + "peckish_bag": 0.0, + } + for rule in db.scalars(select(PackagingCostRule).where(PackagingCostRule.tenant_id == tenant_id)).all(): + unit = (rule.unit_of_measure or "").lower() + if "1.5kg" in unit or "1.5 kg" in unit: + bag_defaults["1_5kg_bagging"] = max(bag_defaults["1_5kg_bagging"], rule.bag_cost) + elif "peckish" in unit: + bag_defaults["peckish_bag"] = max(bag_defaults["peckish_bag"], rule.bag_cost) + elif "bulka" in unit: + bag_defaults["bulka_bag"] = max(bag_defaults["bulka_bag"], rule.bag_cost) + elif "20kg" in unit: + bag_defaults["20kg_bag"] = max(bag_defaults["20kg_bag"], rule.bag_cost) + + existing_bags = { + row.input_key + for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all() + } + for key, label in BAG_INPUTS.items(): + if key not in existing_bags: + db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(bag_defaults.get(key, 0.0), 4))) + + freight_defaults = { + "freight_per_pallet": 0.0, + "peckish_freight_per_pallet": 0.0, + "hay_straw_freight_per_pallet": 0.0, + } + for rule in db.scalars(select(FreightCostRule).where(FreightCostRule.tenant_id == tenant_id)).all(): + unit = (rule.unit_of_measure or "").lower() + if "peckish" in unit: + freight_defaults["peckish_freight_per_pallet"] = max(freight_defaults["peckish_freight_per_pallet"], rule.cost_per_unit) + elif "hay" in unit or "straw" in unit: + freight_defaults["hay_straw_freight_per_pallet"] = max(freight_defaults["hay_straw_freight_per_pallet"], rule.cost_per_unit) + else: + freight_defaults["freight_per_pallet"] = max(freight_defaults["freight_per_pallet"], rule.cost_per_unit) + + existing_freight = { + row.input_key + for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all() + } + for key, label in FREIGHT_INPUTS.items(): + if key not in existing_freight: + db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(freight_defaults.get(key, 0.0), 4))) + + existing_clients = { + row.client_category + for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all() + } + products = db.scalars(select(Product).where(Product.tenant_id == tenant_id).options(selectinload(Product.mix))).all() + margins: dict[str, list[tuple[float | None, float | None]]] = {} + for product in products: + margins.setdefault(product.client_name, []).append((product.distributor_margin, product.wholesale_margin)) + for client_name, rows in margins.items(): + if client_name in existing_clients: + continue + distributor_values = [value for value, _ in rows if value is not None] + wholesale_values = [value for _, value in rows if value is not None] + db.add( + ProductCostClientInput( + tenant_id=tenant_id, + client_category=client_name, + distributor_margin=round(sum(distributor_values) / len(distributor_values), 6) if distributor_values else None, + wholesale_margin=round(sum(wholesale_values) / len(wholesale_values), 6) if wholesale_values else None, + ) + ) + + existing_items = { + item.item_id: item + for item in db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all() + if item.item_id + } + created = 0 + for product in products: + if not product.item_id: + continue + item = existing_items.get(product.item_id) + if item is not None: + continue + item = ProductCostItem( + tenant_id=tenant_id, + client_category=product.client_name, + item_id=product.item_id, + product_name=product.name, + mix_product_name=product.mix.name if product.mix else product.name, + unit_type=_unit_type_from_product(product), + own_bag=_own_bag_label(product), + unit_kg=_infer_throughput_bag_size(product) or 1.0, + items_per_pallet=product.items_per_pallet, + bagging_process=product.bagging_process, + manual_distributor_margin=product.distributor_margin, + manual_wholesale_margin=product.wholesale_margin, + ) + db.add(item) + created += 1 + + db.flush() + recalculated = recalculate_all_product_cost_items(db, tenant_id) + return {"created": created, "recalculated": recalculated} + + def seed_startup_basics(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: @@ -1069,6 +1280,9 @@ def seed_startup_basics(): 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() diff --git a/backend/app/services/mix_calculator_pdf.py b/backend/app/services/mix_calculator_pdf.py index 4932dc0..99df75a 100644 --- a/backend/app/services/mix_calculator_pdf.py +++ b/backend/app/services/mix_calculator_pdf.py @@ -239,7 +239,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt current_y, detail_width, detail_height, - "Product", + "Mix", session_record.product_name, value_font_size=12, ) @@ -251,7 +251,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt current_y, detail_width, detail_height, - "Mix source", + "Formula source", session_record.mix_name, value_font_size=11, ) diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index b19cde6..84453e8 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -76,6 +76,21 @@ def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_mea ) +def _mix_calculator_label(product: Product) -> str: + return product.mix.name if product.mix else product.name + + +def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]: + unit_label = (product.unit_of_measure or "").lower() + unit_size = extract_unit_quantity_kg(product.unit_of_measure) + return ( + 0 if abs(unit_size - 20) < 1e-9 and "bag" in unit_label and "bulka" not in unit_label else 1, + 0 if "bulka" not in unit_label else 1, + unit_size if unit_size > 0 else 999999, + product.id, + ) + + def calculate_mix_calculator_preview( db: Session, *, @@ -117,12 +132,15 @@ def calculate_mix_calculator_preview( } ) + mix_label = _mix_calculator_label(product) return { "client_name": product.client_name, "product_id": product.id, - "product_name": product.name, + # The source workbook labels this as Product, but for the calculator + # it is the mix/formula being produced. + "product_name": mix_label, "mix_id": product.mix_id, - "mix_name": product.mix.name if product.mix else product.name, + "mix_name": mix_label, "mix_date": values["mix_date"], "batch_size_kg": round(batch_size_kg, 4), "total_bags": total_bags, @@ -156,21 +174,43 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: ).all() mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows} + product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id) products = db.scalars( select(Product) - .where(Product.tenant_id == tenant_id, 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)) .order_by(Product.client_name, Product.name) ).all() + 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), + ) + clients = sorted({product.client_name for product in products}) product_rows = [ { "product_id": product.id, "client_name": product.client_name, - "product_name": product.name, + "product_name": _mix_calculator_label(product), "mix_id": product.mix_id, - "mix_name": product.mix.name if product.mix else "", + "mix_name": _mix_calculator_label(product), "unit_of_measure": product.unit_of_measure, "unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4), "mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)), diff --git a/backend/app/services/product_costing_service.py b/backend/app/services/product_costing_service.py new file mode 100644 index 0000000..137ac4a --- /dev/null +++ b/backend/app/services/product_costing_service.py @@ -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, + } diff --git a/backend/data_entry_app_backend.egg-info/PKG-INFO b/backend/data_entry_app_backend.egg-info/PKG-INFO index 528dafb..94ce8af 100644 --- a/backend/data_entry_app_backend.egg-info/PKG-INFO +++ b/backend/data_entry_app_backend.egg-info/PKG-INFO @@ -1,10 +1,11 @@ Metadata-Version: 2.4 Name: data-entry-app-backend -Version: 0.1.5 +Version: 0.1.12 Summary: Costing platform MVP backend Requires-Python: >=3.11 Requires-Dist: fastapi<1.0,>=0.115 Requires-Dist: openpyxl<4.0,>=3.1 +Requires-Dist: rich<15.0,>=13.9 Requires-Dist: uvicorn[standard]<1.0,>=0.30 Requires-Dist: sqlalchemy<3.0,>=2.0 Requires-Dist: pydantic<3.0,>=2.8 diff --git a/backend/data_entry_app_backend.egg-info/SOURCES.txt b/backend/data_entry_app_backend.egg-info/SOURCES.txt index b7354a9..b4d60a7 100644 --- a/backend/data_entry_app_backend.egg-info/SOURCES.txt +++ b/backend/data_entry_app_backend.egg-info/SOURCES.txt @@ -9,16 +9,23 @@ pyproject.toml ./app/api/client_access.py ./app/api/dashboard.py ./app/api/deps.py +./app/api/editor.py ./app/api/mix_calculator.py ./app/api/mixes.py ./app/api/powerbi.py +./app/api/product_costing.py ./app/api/products.py ./app/api/raw_materials.py ./app/api/scenarios.py +./app/api/throughput.py ./app/core/__init__.py ./app/core/access.py ./app/core/config.py +./app/core/http.py +./app/core/logging.py +./app/core/rate_limit.py ./app/core/security.py +./app/core/security_logging.py ./app/db/__init__.py ./app/db/migrations.py ./app/db/session.py @@ -29,53 +36,96 @@ pyproject.toml ./app/models/mix.py ./app/models/mix_calculator.py ./app/models/product.py +./app/models/product_costing.py ./app/models/raw_material.py ./app/models/scenario.py +./app/models/throughput.py ./app/schemas/__init__.py ./app/schemas/client_access.py +./app/schemas/editor.py ./app/schemas/mix.py ./app/schemas/mix_calculator.py ./app/schemas/product.py +./app/schemas/product_costing.py ./app/schemas/raw_material.py ./app/schemas/scenario.py +./app/schemas/throughput.py ./app/services/__init__.py ./app/services/client_access_service.py ./app/services/costing_engine.py ./app/services/mix_calculator_filenames.py ./app/services/mix_calculator_pdf.py ./app/services/mix_calculator_service.py +./app/services/product_costing_service.py ./app/services/scenario_engine.py +./app/services/throughput_service.py app/__init__.py app/main.py app/seed.py +app/seed_access.py app/api/__init__.py +app/api/access.py +app/api/auth.py +app/api/client_access.py +app/api/dashboard.py +app/api/deps.py +app/api/editor.py +app/api/mix_calculator.py app/api/mixes.py app/api/powerbi.py +app/api/product_costing.py app/api/products.py app/api/raw_materials.py app/api/scenarios.py +app/api/throughput.py app/core/__init__.py +app/core/access.py app/core/config.py +app/core/http.py +app/core/logging.py +app/core/rate_limit.py +app/core/security.py +app/core/security_logging.py app/db/__init__.py +app/db/migrations.py app/db/session.py app/models/__init__.py +app/models/access.py app/models/assumption.py +app/models/client_access.py app/models/mix.py +app/models/mix_calculator.py app/models/product.py +app/models/product_costing.py app/models/raw_material.py app/models/scenario.py +app/models/throughput.py app/schemas/__init__.py +app/schemas/client_access.py +app/schemas/editor.py app/schemas/mix.py +app/schemas/mix_calculator.py app/schemas/product.py +app/schemas/product_costing.py app/schemas/raw_material.py app/schemas/scenario.py +app/schemas/throughput.py app/services/__init__.py +app/services/client_access_service.py app/services/costing_engine.py +app/services/mix_calculator_filenames.py +app/services/mix_calculator_pdf.py +app/services/mix_calculator_service.py +app/services/product_costing_service.py app/services/scenario_engine.py +app/services/throughput_service.py data_entry_app_backend.egg-info/PKG-INFO data_entry_app_backend.egg-info/SOURCES.txt data_entry_app_backend.egg-info/dependency_links.txt data_entry_app_backend.egg-info/requires.txt data_entry_app_backend.egg-info/top_level.txt tests/test_access.py -tests/test_costing_engine.py \ No newline at end of file +tests/test_costing_engine.py +tests/test_product_costing.py +tests/test_schema.py +tests/test_throughput.py \ No newline at end of file diff --git a/backend/data_entry_app_backend.egg-info/requires.txt b/backend/data_entry_app_backend.egg-info/requires.txt index c97d2eb..01011a9 100644 --- a/backend/data_entry_app_backend.egg-info/requires.txt +++ b/backend/data_entry_app_backend.egg-info/requires.txt @@ -1,5 +1,6 @@ fastapi<1.0,>=0.115 openpyxl<4.0,>=3.1 +rich<15.0,>=13.9 uvicorn[standard]<1.0,>=0.30 sqlalchemy<3.0,>=2.0 pydantic<3.0,>=2.8 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 87eb17b..cc45669 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "data-entry-app-backend" -version = "0.1.9" +version = "0.1.12" description = "Costing platform MVP backend" requires-python = ">=3.11" dependencies = [ diff --git a/backend/tests/_repro_throughput_post.py b/backend/tests/_repro_throughput_post.py deleted file mode 100644 index 26f27c3..0000000 --- a/backend/tests/_repro_throughput_post.py +++ /dev/null @@ -1,62 +0,0 @@ -from fastapi import FastAPI -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -from app.db.session import Base, get_db -from app.core.access import INTERNAL_USER_SUBJECT, INTERNAL_USER_TENANT_ID -from app.core.security import issue_token -from app.models.access import User -from app.models.throughput import ThroughputProduct -from app.api.throughput import router as throughput_router -from app.seed import seed_access - - -def run(): - engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) - Base.metadata.create_all(bind=engine) - db = sessionmaker(bind=engine, expire_on_commit=False)() - seed_access(db) - - product = ThroughputProduct( - tenant_id=INTERNAL_USER_TENANT_ID, - name="Test Product 20kg", - default_bag_size=20, - active=True, - ) - db.add(product) - db.commit() - - app = FastAPI() - app.dependency_overrides[get_db] = lambda: iter([db]) - app.include_router(throughput_router) - client = TestClient(app) - - ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one() - token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": ops.id, "email": ops.email}) - - payload = { - "production_date": "2026-06-01", - "product_id": product.id, - "product_name_snapshot": "Test Product 20kg", - "bag_size": 20, - "quantity": 10, - "quantity_type": "bags", - "for_order": True, - "for_stock": False, - "job_number": "JOB123", - "stock_quantity": None, - "staff_name": "Jake", - "notes": None, - } - resp = client.post( - "/api/throughput/entries", - json=payload, - headers={"Authorization": f"Bearer {token}"}, - ) - print("STATUS:", resp.status_code) - print("BODY:", resp.text[:1500]) - - -if __name__ == "__main__": - run() diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index eeaf711..502b3eb 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -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") 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 @@ -482,8 +482,12 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) assert options_response.status_code == 200 options_payload = options_response.json() - assert len(options_payload["products"]) >= 100 - seeded_product = next(product for product in options_payload["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg") + assert len(options_payload["products"]) == 84 + seeded_product = next( + product + for product in options_payload["products"] + if product["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix" + ) assert seeded_product["unit_size_kg"] == 20 create_response = client.post( @@ -540,7 +544,9 @@ def test_mix_calculator_pdf_endpoint_returns_pdf(): options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) seeded_product = next( - product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg" + product + for product in options_response.json()["products"] + if product["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix" ) create_response = client.post( diff --git a/backend/tests/test_product_costing.py b/backend/tests/test_product_costing.py new file mode 100644 index 0000000..288eddb --- /dev/null +++ b/backend/tests/test_product_costing.py @@ -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 diff --git a/backend/tests/test_schema.py b/backend/tests/test_schema.py new file mode 100644 index 0000000..def5eb4 --- /dev/null +++ b/backend/tests/test_schema.py @@ -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 diff --git a/docker-compose.alpha.yml b/docker-compose.alpha.yml index d8019b8..807fc22 100644 --- a/docker-compose.alpha.yml +++ b/docker-compose.alpha.yml @@ -6,7 +6,7 @@ services: dockerfile: backend/Dockerfile restart: unless-stopped environment: - APP_NAME: ${APP_NAME:-Lean 101 Clients API} + APP_NAME: ${APP_NAME:-Hunter App} DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com} diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 04b41a3..b36fcfc 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -23,7 +23,7 @@ services: dockerfile: backend/Dockerfile restart: unless-stopped environment: - APP_NAME: ${APP_NAME:-Lean 101 Clients API} + APP_NAME: ${APP_NAME:-Hunter App} APP_ENV: ${APP_ENV:-production} DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} diff --git a/docker-compose.yml b/docker-compose.yml index 8c3693d..fcd5725 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: dockerfile: backend/Dockerfile restart: unless-stopped environment: - APP_NAME: ${APP_NAME:-Lean 101 Clients API} + APP_NAME: ${APP_NAME:-Hunter App} APP_ENV: ${APP_ENV:-development} DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c40b550..86a70de 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.11b", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.11b", + "version": "0.1.12", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 490f1c5..79778d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.11b", + "version": "0.1.12", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/app.html b/frontend/src/app.html index 8f99454..12a3f94 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,6 +4,21 @@ + %sveltekit.head% diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f1cca33..72ec36c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,16 +1,5 @@ import { env } from '$env/dynamic/public'; import { browser } from '$app/environment'; -import { - mockClientAccess, - mockClientAccessExport, - mockCosts, - mockMixCalculatorOptions, - mockMixCalculatorSessions, - mockMixes, - mockProducts, - mockRawMaterials, - mockScenarios -} from '$lib/mock'; import type { ClientAccessAccount, ClientAccessPowerBiExport, @@ -34,6 +23,9 @@ import type { MixUpdateInput, Product, ProductCostBreakdown, + ProductCostingInputs, + ProductCostingItem, + ProductCostingItemUpdateInput, RawMaterial, RawMaterialCreateInput, RawMaterialPriceCreateInput, @@ -136,23 +128,17 @@ function normalizeRequestError(error: unknown) { return new Error('An unexpected error occurred while contacting the server.'); } -async function fetchJson(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise { +async function fetchJson(path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise { try { const response = await fetcher(resolveRequestUrl(path, fetcher), { credentials: 'include' }); if (!response.ok) { - if (auth !== 'none') { - throw new Error(response.statusText || 'Unauthorized'); - } - return fallback; + throw new Error(response.statusText || 'Request failed'); } return (await response.json()) as T; } catch (error) { - if (auth !== 'none') { - throw normalizeRequestError(error); - } - return fallback; + throw normalizeRequestError(error); } } @@ -172,13 +158,12 @@ function makeCacheKey(path: string, auth: AuthMode) { async function cachedFetchJson( path: string, - fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch ): Promise { // Bypass the cache during SSR (no localStorage, no shared session). if (!browser) { - return fetchJson(path, fallback, auth, fetcher); + return fetchJson(path, auth, fetcher); } const key = makeCacheKey(path, auth); @@ -194,7 +179,7 @@ async function cachedFetchJson( return existing as Promise; } - const promise = fetchJson(path, fallback, auth, fetcher) + const promise = fetchJson(path, auth, fetcher) .then((value) => { responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS }); return value; @@ -290,13 +275,13 @@ async function requestBlob( } export const api = { - rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson('/api/raw-materials', mockRawMaterials, 'client', fetcher), - mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher), + rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson('/api/raw-materials', 'client', fetcher), + mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', 'client', fetcher), mix: (mixId: number, fetcher?: ApiFetch) => request(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher), mixCalculatorOptions: (fetcher?: ApiFetch) => - cachedFetchJson('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher), + cachedFetchJson('/api/mix-calculator/options', 'client', fetcher), mixCalculatorSessions: (fetcher?: ApiFetch) => - cachedFetchJson('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher), + cachedFetchJson('/api/mix-calculator', 'client', fetcher), mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) => request(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher), mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) => @@ -321,7 +306,7 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), - products: (fetcher?: ApiFetch) => cachedFetchJson('/api/products', mockProducts, 'client', fetcher), + products: (fetcher?: ApiFetch) => cachedFetchJson('/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); @@ -329,7 +314,7 @@ export const api = { 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(path, [], 'client', fetcher); + return cachedFetchJson(path, 'client', fetcher); }, updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) => request(`/api/editor/products/${productId}`, { @@ -358,10 +343,28 @@ export const api = { method: 'DELETE' }, 'client'), productCosts: (fetcher?: ApiFetch) => - cachedFetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), - scenarios: (fetcher?: ApiFetch) => cachedFetchJson('/api/scenarios', mockScenarios, 'client', fetcher), + cachedFetchJson('/api/powerbi/product-costs', 'client', fetcher), + productCostingItems: (fetcher?: ApiFetch) => + cachedFetchJson('/api/product-costing/items', 'client', fetcher), + productCostingItemsFresh: () => + request(`/api/product-costing/items?_=${Date.now()}`, { method: 'GET' }, 'client'), + productCostingInputs: (fetcher?: ApiFetch) => + cachedFetchJson('/api/product-costing/inputs', 'client', fetcher), + updateProductCostingInputs: (payload: Partial) => + request('/api/product-costing/inputs', { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + updateProductCostingItem: (itemId: number, payload: ProductCostingItemUpdateInput) => + request(`/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('/api/scenarios', 'client', fetcher), throughputProducts: (fetcher?: ApiFetch) => - cachedFetchJson('/api/throughput/products', [], 'client', fetcher), + cachedFetchJson('/api/throughput/products', 'client', fetcher), throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => { const search = new URLSearchParams(); if (params?.date_from) search.set('date_from', params.date_from); @@ -372,7 +375,7 @@ export const api = { if (params?.limit) search.set('limit', String(params.limit)); const qs = search.toString(); const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries'; - return cachedFetchJson(path, [], 'client', fetcher); + return cachedFetchJson(path, 'client', fetcher); }, createThroughputEntry: (payload: ThroughputEntryCreateInput) => request('/api/throughput/entries', { @@ -389,22 +392,12 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), - clientAccess: (fetcher?: ApiFetch) => cachedFetchJson('/api/client-access', mockClientAccess, 'manager', fetcher), + clientAccess: (fetcher?: ApiFetch) => cachedFetchJson('/api/client-access', 'manager', fetcher), clientAccessExport: (fetcher?: ApiFetch) => - cachedFetchJson('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher), - dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher), + cachedFetchJson('/api/powerbi/client-access', 'manager', fetcher), + dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', 'client', fetcher), dashboardSummary: (fetcher?: ApiFetch) => - cachedFetchJson( - '/api/dashboard/summary', - { - raw_materials: null, - mixes: null, - products: null, - trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] } - }, - 'client', - fetcher - ), + cachedFetchJson('/api/dashboard/summary', 'client', fetcher), clientLogin: (email: string, password: string) => request('/api/auth/client/login', { method: 'POST', diff --git a/frontend/src/lib/components/AdminShell.svelte b/frontend/src/lib/components/AdminShell.svelte index a5081fd..81b29ae 100644 --- a/frontend/src/lib/components/AdminShell.svelte +++ b/frontend/src/lib/components/AdminShell.svelte @@ -163,10 +163,8 @@ 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; + background: var(--color-bg-app); + color: var(--color-text-primary); } .admin-sidebar { diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index 537aa90..f2a0526 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -17,6 +17,7 @@ canOpenEditor as sessionCanOpenEditor, canOpenMixCalculator as sessionCanOpenMixCalculator, canOpenMixMaster as sessionCanOpenMixMaster, + canOpenProductCosting as sessionCanOpenProductCosting, canOpenReporting as sessionCanOpenReporting, canOpenSettings as sessionCanOpenSettings, canOpenThroughput as sessionCanOpenThroughput, @@ -28,6 +29,7 @@ import { accessControlItem, baseSearchItems, + buildClientNavEntries, clientBreadcrumbs, dashboardItem, editorItem, @@ -35,6 +37,7 @@ matchesRoute, mixCalculatorItem, pageTitle, + productCostingItem, reportingItem, throughputItem, type FooterLink, @@ -96,10 +99,27 @@ }) ); const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null); + const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null); const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession)); const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null); const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null); const visibleEditorItem = $derived(canOpenEditor ? editorItem : null); + // Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the + // standalone operations/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, + costing: [ + ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), + ...(visibleProductCostingItem ? [visibleProductCostingItem] : []), + ...(visibleEditorItem ? [visibleEditorItem] : []), + ...visibleWorkingDocumentItems + ], + throughput: visibleThroughputItem, + reporting: visibleReportingItem + }) + ); const isOperationsUser = $derived($clientSession?.role_name === 'Operations'); const workspaceRole = $derived(getWorkspaceRole($clientSession)); const visibleFooterLinks = $derived([ @@ -112,6 +132,7 @@ [ ...(visibleDashboardItem ? [visibleDashboardItem] : []), ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), + ...(visibleProductCostingItem ? [visibleProductCostingItem] : []), ...visibleWorkingDocumentItems.slice(0, 2) ] ); @@ -124,6 +145,7 @@ 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 === '/reporting') return sessionCanOpenReporting($clientSession); if (item.href === '/settings') return canOpenSettings; @@ -377,15 +399,8 @@ {#if !showBottomNav} {/if} + {#if visibleProductCostingItem} + {@const Icon = visibleProductCostingItem.icon} + (navOpen = false)}> + + {visibleProductCostingItem.label} + {#if visibleProductCostingItem.badge}{visibleProductCostingItem.badge}{/if} + + {/if} + {#if visibleThroughputItem} {@const Icon = visibleThroughputItem.icon} (navOpen = false)}> @@ -659,433 +683,9 @@ {/if} diff --git a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte index cfbf9fc..9ae85c9 100644 --- a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte +++ b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte @@ -1,7 +1,8 @@ {#if !canEdit && !initialSession} @@ -258,8 +280,7 @@ Session history {/if} {#if initialSession} - Open PDF page - + Print {/if} @@ -270,7 +291,7 @@

Session Inputs

-

Batch size drives the scale factor. Total bags are derived from the selected product unit size.

+

Batch size drives the scale factor. Total bags are derived from the selected mix unit size.

{#if selectedProduct}
@@ -301,7 +322,7 @@
- Product + Mix {preview.product_name}
- Mix source + Formula source {preview.mix_name}
@@ -111,7 +99,7 @@
- @@ -132,7 +120,7 @@
No calculation yet - Choose a client, product, date, and batch size on the left, then click Calculate mix. + Choose a client, mix, date, and batch size on the left, then click Calculate mix.
{#each [1,2,3,4,5] as _} diff --git a/frontend/src/lib/components/navigation/AppNavSection.svelte b/frontend/src/lib/components/navigation/AppNavSection.svelte index 24c4665..342b510 100644 --- a/frontend/src/lib/components/navigation/AppNavSection.svelte +++ b/frontend/src/lib/components/navigation/AppNavSection.svelte @@ -73,7 +73,7 @@ border: none; border-radius: 0.7rem; 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-weight: var(--nav-item-weight, 500); text-align: left; @@ -83,14 +83,14 @@ .nav-list a:hover, .nav-button:hover { - background: var(--nav-item-hover-bg, var(--panel-soft)); - color: var(--nav-item-hover-color, #304038); + background: var(--nav-item-hover-bg, var(--color-surface-hover)); + color: var(--nav-item-hover-color, var(--color-text-primary)); } .nav-list a.active, .nav-button.active { 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); } @@ -111,8 +111,8 @@ flex-shrink: 0; padding: 0.08rem 0.4rem; border-radius: 999px; - background: #fdf0d2; - color: #8a5a00; + background: var(--color-warning-tint); + color: var(--color-warning-text); font-size: 0.62rem; font-weight: 700; letter-spacing: 0.06em; @@ -122,8 +122,8 @@ .nav-list a.active .nav-badge, .nav-button.active .nav-badge { - background: rgba(255, 255, 255, 0.92); - color: #8a5a00; + background: var(--color-warning-tint); + color: var(--color-warning-text); } .nav-icon { @@ -133,18 +133,18 @@ flex-shrink: 0; width: 1.6rem; height: 1.6rem; - color: var(--nav-icon-color, #6d7d74); + color: var(--nav-icon-color, var(--color-text-muted)); border-radius: 0.55rem; transition: color 140ms ease; } .nav-list a: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-button.active .nav-icon { - color: var(--nav-icon-active-color, #fff); + color: var(--nav-icon-active-color, var(--color-on-brand)); } diff --git a/frontend/src/lib/components/navigation/AppSecondaryRail.svelte b/frontend/src/lib/components/navigation/AppSecondaryRail.svelte index a381bde..e2804d9 100644 --- a/frontend/src/lib/components/navigation/AppSecondaryRail.svelte +++ b/frontend/src/lib/components/navigation/AppSecondaryRail.svelte @@ -75,23 +75,23 @@ height: 100%; min-height: calc(100vh - 8.5rem); 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); 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-spacing: 0.14em; - --nav-item-color: #66756d; + --nav-item-color: var(--color-text-secondary); --nav-item-size: 0.88rem; --nav-item-weight: 450; - --nav-item-hover-bg: color-mix(in srgb, var(--panel) 72%, transparent); - --nav-item-hover-color: #425148; - --nav-item-active-bg: color-mix(in srgb, var(--color-brand) 7%, transparent); - --nav-item-active-color: #22352d; + --nav-item-hover-bg: var(--color-surface-hover); + --nav-item-hover-color: var(--color-text-primary); + --nav-item-active-bg: color-mix(in srgb, var(--color-brand) 11%, transparent); + --nav-item-active-color: var(--color-brand-hover); --nav-item-active-weight: 560; - --nav-item-active-marker: color-mix(in srgb, var(--color-brand) 28%, transparent); - --nav-icon-color: #8a9790; - --nav-icon-hover-color: #607067; + --nav-item-active-marker: color-mix(in srgb, var(--color-brand) 32%, transparent); + --nav-icon-color: var(--color-text-muted); + --nav-icon-hover-color: var(--color-text-secondary); --nav-icon-active-color: var(--color-brand); } diff --git a/frontend/src/lib/components/navigation/ClientPrimaryRail.svelte b/frontend/src/lib/components/navigation/ClientPrimaryRail.svelte index 124d153..b239e32 100644 --- a/frontend/src/lib/components/navigation/ClientPrimaryRail.svelte +++ b/frontend/src/lib/components/navigation/ClientPrimaryRail.svelte @@ -1,14 +1,19 @@ +{#snippet leafLink(item: NavItem, showIcon: boolean)} + {@const Icon = item.icon} + + {#if showIcon && Icon} + + {/if} + {item.label} + {#if item.badge}{item.badge}{/if} + +{/snippet} + +{#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)} + {@const RowIcon = Icon} + +{/snippet} + diff --git a/frontend/src/lib/components/navigation/ClientTopbar.svelte b/frontend/src/lib/components/navigation/ClientTopbar.svelte index d8951b0..f7625bb 100644 --- a/frontend/src/lib/components/navigation/ClientTopbar.svelte +++ b/frontend/src/lib/components/navigation/ClientTopbar.svelte @@ -1,6 +1,7 @@ -
-
-
- - Editor - {visibleRows.length} products across {uniqueMixCount} mixes - + + {#snippet rail()} +
+

Mix Editor

+ +
+ +
+

Filter products

+

{visibleRows.length} matching rows

+
+
+ +
+ + +
+ Status +
+ + + +
+
+ + + + + + + + {#if filtersActive} + + {/if} +
+ {/snippet} -
-
-
Products
-
{visibleRows.length}
-
-
-
Mixes
-
{uniqueMixCount}
-
-
-
Unsaved
-
{dirtyCount}
-
-
-
- -
-
-
-
-
+
{/each} -
-
+
+
+ diff --git a/frontend/src/routes/product-costing/+page.ts b/frontend/src/routes/product-costing/+page.ts new file mode 100644 index 0000000..c8a394d --- /dev/null +++ b/frontend/src/routes/product-costing/+page.ts @@ -0,0 +1,30 @@ +import { redirect } from '@sveltejs/kit'; +import { api } from '$lib/api'; +import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session'; +import { canOpenProductCosting, getWorkspaceHomeHref } from '$lib/workspace-access'; + +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { items: [], inputs: null }; + } + + const session = getStoredClientSession(); + if (!canOpenProductCosting(session)) { + throw redirect(307, getWorkspaceHomeHref(session)); + } + + try { + const canRead = hasModuleAccess(session, 'products') || session?.role === 'internal'; + if (!canRead) { + return { items: [], inputs: null }; + } + + const [items, inputs] = await Promise.all([ + api.productCostingItems(fetch), + api.productCostingInputs(fetch) + ]); + return { items, inputs }; + } catch { + return { items: [], inputs: null }; + } +} diff --git a/frontend/src/routes/throughput/+page.svelte b/frontend/src/routes/throughput/+page.svelte index 2d2ce44..f3ee7ab 100644 --- a/frontend/src/routes/throughput/+page.svelte +++ b/frontend/src/routes/throughput/+page.svelte @@ -6,14 +6,29 @@ ThroughputProduct, ThroughputQuantityType } from '$lib/types'; - import { Plus, TriangleAlert, Search, X } from 'lucide-svelte'; + import { ArrowUpDown, ChevronLeft, ChevronRight, History, Plus, TriangleAlert, Search, X } from 'lucide-svelte'; import { fade } from 'svelte/transition'; import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte'; + import { formatDate as formatDisplayDate, formatLocaleNumber, toNum } from '$lib/format'; let { data } = $props<{ data: { entries: ThroughputEntry[]; products: ThroughputProduct[] } }>(); - let entries = $state(data.entries ?? []); + let entries = $state([]); + let entriesInitialized = $state(false); const products = $derived(data.products ?? []); + type SortKey = 'date' | 'product' | 'packed' | 'staff' | 'destination' | 'notes'; + type SortDirection = 'asc' | 'desc'; + const PAGE_SIZE = 20; + let sortKey = $state('date'); + let sortDirection = $state('desc'); + let page = $state(1); + + $effect(() => { + if (!entriesInitialized) { + entries = data.entries ?? []; + entriesInitialized = true; + } + }); // A run is "going into stock" when the operator ticks the stock checkpoint. // Older imported rows pre-date that flag, so fall back to the notes marker. @@ -86,13 +101,6 @@ return bag === null ? null : qty * bag; }); - function toNum(value: string): number | null { - const trimmed = value.trim(); - if (!trimmed) return null; - const n = Number(trimmed); - return Number.isFinite(n) ? n : null; - } - async function addEntry() { addError = ''; if (!nProductId) { @@ -153,6 +161,7 @@ notes: nNotes.trim() || null }); entries = [created, ...entries]; + page = 1; highlightId = created.id; setTimeout(() => { if (highlightId === created.id) highlightId = null; @@ -206,6 +215,7 @@ if (staffFilter.trim()) params.staff_name = staffFilter.trim(); if (typeFilter) params.quantity_type = typeFilter; entries = await api.throughputEntries(params); + page = 1; } catch (err) { errorMessage = err instanceof Error ? err.message : 'Could not load entries. Please try again.'; } finally { @@ -237,15 +247,11 @@ }); function formatDate(value: string) { - if (!value) return '—'; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return value; - return d.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: 'numeric' }); + return formatDisplayDate(value, { day: '2-digit', month: 'short', year: 'numeric' }, 'en-AU'); } function formatNumber(value: number | null | undefined, digits = 0) { - if (value === null || value === undefined) return '—'; - return value.toLocaleString('en-AU', { maximumFractionDigits: digits }); + return formatLocaleNumber(value, digits, 'en-AU'); } function packedMain(entry: ThroughputEntry) { @@ -263,126 +269,87 @@ } return 'Bulka / bulk'; } + + function toggleSort(key: SortKey) { + if (sortKey === key) { + sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + return; + } + + sortKey = key; + sortDirection = key === 'date' ? 'desc' : 'asc'; + page = 1; + } + + function compareText(a: string | null | undefined, b: string | null | undefined) { + return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' }); + } + + const sortedEntries = $derived.by(() => { + const direction = sortDirection === 'asc' ? 1 : -1; + return [...entries].sort((a, b) => { + let result = 0; + + if (sortKey === 'date') { + result = compareText(a.production_date, b.production_date); + } else if (sortKey === 'product') { + result = compareText(a.product_name_snapshot, b.product_name_snapshot); + } else if (sortKey === 'packed') { + result = (a.calculated_kg ?? 0) - (b.calculated_kg ?? 0); + if (result === 0) result = (a.quantity ?? 0) - (b.quantity ?? 0); + } else if (sortKey === 'staff') { + result = compareText(a.staff_name, b.staff_name); + } else if (sortKey === 'destination') { + const aDest = destinationOf(a); + const bDest = destinationOf(b); + result = compareText(`${aDest.label} ${aDest.detail ?? ''}`, `${bDest.label} ${bDest.detail ?? ''}`); + } else if (sortKey === 'notes') { + result = compareText(a.notes, b.notes); + } + + return result * direction; + }); + }); + + const totalPages = $derived(Math.max(1, Math.ceil(sortedEntries.length / PAGE_SIZE))); + const paginatedEntries = $derived(sortedEntries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)); + const pageStart = $derived(sortedEntries.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1); + const pageEnd = $derived(Math.min(page * PAGE_SIZE, sortedEntries.length)); + + $effect(() => { + if (page > totalPages) page = totalPages; + });
-
-
- - {#if totals.count > 0} - Production log - {formatNumber(totals.stockCount)} of {formatNumber(totals.count)} {totals.count === 1 ? 'run' : 'runs'} going to stock. - {:else} - Nothing logged yet - Fill the green row below to add your first entry. - {/if} - -
- +
-
Entries
+
Entries logged
{formatNumber(totals.count)}
-
Bags
+
Bags packed
{formatNumber(totals.totalBags)}
-
Total kg
+
Total kilograms
{formatNumber(totals.totalKg)}
-
+ -
-
-

{filtersActive ? 'Filtered entries' : 'Recent entries'}

- {#if !filtersActive}Last 30 days{/if} -
- -
- - {#if showFilters} -
{ - e.preventDefault(); - applyFilters(); - }} - > - - - - - -
- - {#if filtersActive} - - {/if} +
+
+
+
+

Inline entry

+

Add a packing run

+
+ Open full form
- - {/if} - {#if errorMessage} - - {/if} - -
- - -
{ e.preventDefault(); addEntry(); }}> + { e.preventDefault(); addEntry(); }}>
Date @@ -484,180 +451,341 @@ {#if addError} {addError} {/if} - Open full form (sample box & test weights)
-
+ +
- {#if isLoading} - {#each Array(5) as _, i (i)} -