diff --git a/backend/Dockerfile b/backend/Dockerfile index f302af4..78cd4f3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,6 +9,7 @@ RUN addgroup --system app && adduser --system --ingroup app app COPY backend /app COPY ["input_data/1.xlsx", "/app/input_data/1.xlsx"] +COPY ["input_data/Operations Throughput.xlsx", "/app/input_data/Operations Throughput.xlsx"] COPY ["Input Cost Spreadsheet(1).xlsx", "/app/Input Cost Spreadsheet(1).xlsx"] RUN pip install --no-cache-dir --upgrade pip && \ diff --git a/backend/app/api/throughput.py b/backend/app/api/throughput.py index 73c6c3e..38e5fd2 100644 --- a/backend/app/api/throughput.py +++ b/backend/app/api/throughput.py @@ -161,6 +161,10 @@ def create_entry( label_correct=payload.label_correct, bag_sealed=payload.bag_sealed, pallet_good_condition=payload.pallet_good_condition, + for_order=payload.for_order, + for_stock=payload.for_stock, + job_number=payload.job_number, + stock_quantity=payload.stock_quantity if payload.for_stock else None, sample_box_no=payload.sample_box_no, test_weight_1=payload.test_weight_1, test_weight_2=payload.test_weight_2, diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index efbe34e..07dc0a2 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -103,6 +103,11 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = ( ("users", "password_hash", "VARCHAR(255)"), ("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"), ("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"), + ("throughput_products", "client_name", "VARCHAR(255)"), + ("production_throughput_entries", "for_order", "BOOLEAN NOT NULL DEFAULT FALSE"), + ("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"), + ("production_throughput_entries", "job_number", "VARCHAR(64)"), + ("production_throughput_entries", "stock_quantity", "FLOAT"), ) diff --git a/backend/app/models/throughput.py b/backend/app/models/throughput.py index 87bcd70..bd67497 100644 --- a/backend/app/models/throughput.py +++ b/backend/app/models/throughput.py @@ -19,6 +19,7 @@ class ThroughputProduct(Base): tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) item_id: Mapped[str | None] = mapped_column(String(64), nullable=True) name: Mapped[str] = mapped_column(String(255)) + client_name: Mapped[str | None] = mapped_column(String(255), nullable=True) default_bag_size: Mapped[float | None] = mapped_column(Float, nullable=True) is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False) active: Mapped[bool] = mapped_column(Boolean, default=True) @@ -48,6 +49,15 @@ class ProductionThroughput(Base): bag_sealed: Mapped[bool] = mapped_column(Boolean, default=True) pallet_good_condition: Mapped[bool] = mapped_column(Boolean, default=True) + # Where the run is destined. A run can be for a client order, for stock, or + # split across both. job_number records the Order Circle job for the order + # portion; stock_quantity records how much of a split goes into stock (in the + # same unit as `quantity`). + for_order: Mapped[bool] = mapped_column(Boolean, default=False) + for_stock: Mapped[bool] = mapped_column(Boolean, default=False) + job_number: Mapped[str | None] = mapped_column(String(64), nullable=True) + stock_quantity: Mapped[float | None] = mapped_column(Float, nullable=True) + sample_box_no: Mapped[str | None] = mapped_column(String(64), nullable=True) test_weight_1: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/backend/app/schemas/throughput.py b/backend/app/schemas/throughput.py index ac993f3..94ede52 100644 --- a/backend/app/schemas/throughput.py +++ b/backend/app/schemas/throughput.py @@ -13,6 +13,7 @@ class ThroughputProductBase(BaseModel): model_config = ConfigDict(extra="forbid") item_id: str | None = Field(default=None, max_length=64) name: str = Field(min_length=1, max_length=255) + client_name: str | None = Field(default=None, max_length=255) default_bag_size: float | None = Field(default=None, ge=0) is_bulka_default: bool = False active: bool = True @@ -28,6 +29,7 @@ class ThroughputProductUpdate(BaseModel): model_config = ConfigDict(extra="forbid") item_id: str | None = Field(default=None, max_length=64) name: str | None = Field(default=None, min_length=1, max_length=255) + client_name: str | None = Field(default=None, max_length=255) default_bag_size: float | None = Field(default=None, ge=0) is_bulka_default: bool | None = None active: bool | None = None @@ -53,6 +55,10 @@ class ThroughputEntryBase(BaseModel): label_correct: bool = True bag_sealed: bool = True pallet_good_condition: bool = True + for_order: bool = False + for_stock: bool = False + job_number: str | None = Field(default=None, max_length=64) + stock_quantity: float | None = Field(default=None, ge=0) sample_box_no: str | None = Field(default=None, max_length=64) test_weight_1: float | None = Field(default=None, ge=0) test_weight_2: float | None = Field(default=None, ge=0) @@ -64,6 +70,14 @@ class ThroughputEntryBase(BaseModel): staff_name: str | None = Field(default=None, max_length=255) notes: str | None = Field(default=None, max_length=2000) + @field_validator("job_number") + @classmethod + def _normalize_job_number(cls, value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + @field_validator("staff_name") @classmethod def _normalize_staff(cls, value: str | None) -> str | None: @@ -87,6 +101,10 @@ class ThroughputEntryUpdate(BaseModel): label_correct: bool | None = None bag_sealed: bool | None = None pallet_good_condition: bool | None = None + for_order: bool | None = None + for_stock: bool | None = None + job_number: str | None = Field(default=None, max_length=64) + stock_quantity: float | None = Field(default=None, ge=0) sample_box_no: str | None = Field(default=None, max_length=64) test_weight_1: float | None = Field(default=None, ge=0) test_weight_2: float | None = Field(default=None, ge=0) @@ -110,6 +128,10 @@ class ThroughputEntryRead(BaseModel): label_correct: bool bag_sealed: bool pallet_good_condition: bool + for_order: bool + for_stock: bool + job_number: str | None + stock_quantity: float | None sample_box_no: str | None test_weight_1: float | None test_weight_2: float | None diff --git a/backend/app/seed.py b/backend/app/seed.py index 3433b7d..bf190de 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -794,15 +794,19 @@ def seed_throughput_products_from_costing(db) -> dict[str, int]: default_bag_size = _infer_throughput_bag_size(costing_product) is_bulka_default = _infer_throughput_bulka_default(costing_product) + client_name = (costing_product.client_name or "").strip() or None product = (by_item.get(item_id) if item_id else None) or by_name.get(name_key) if product is None: product = ThroughputProduct( tenant_id=TENANT_ID, item_id=item_id, name=name, + client_name=client_name, default_bag_size=default_bag_size, is_bulka_default=is_bulka_default, - active=costing_product.visible, + # Every costing SKU should be selectable in the throughput picker + # (the Client filter + search keep the long list manageable). + active=True, is_stock_item=True, notes="Seeded from costing products", ) @@ -830,8 +834,11 @@ def seed_throughput_products_from_costing(db) -> dict[str, int]: if product.is_bulka_default != is_bulka_default: product.is_bulka_default = is_bulka_default changed = True - if product.active != costing_product.visible: - product.active = costing_product.visible + if product.client_name != client_name: + product.client_name = client_name + changed = True + if product.active is not True: + product.active = True changed = True if product.is_stock_item is not True: product.is_stock_item = True diff --git a/backend/app/seed_access.py b/backend/app/seed_access.py index 1966dda..99426cf 100644 --- a/backend/app/seed_access.py +++ b/backend/app/seed_access.py @@ -51,6 +51,7 @@ ROLE_DEFINITIONS: dict[str, dict] = { "edit_raw_materials", "view_products", "view_mixes", + "edit_mixes", "view_throughput", "edit_throughput", "view_users", diff --git a/backend/app/services/throughput_service.py b/backend/app/services/throughput_service.py index 6c70125..0b7f570 100644 --- a/backend/app/services/throughput_service.py +++ b/backend/app/services/throughput_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import os from datetime import date, datetime from pathlib import Path from typing import Iterable @@ -16,6 +17,10 @@ logger = logging.getLogger("data_entry_app.throughput") PRODUCTION_SHEET = "Production" NAMES_SHEET = "Names" +# The historical throughput export. Bundled into the image under input_data/ so +# the seed can import it on a fresh deployment (e.g. a new Postgres volume). +WORKBOOK_FILENAME = "Operations Throughput.xlsx" + # Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count. _BULKA_BAG_SIZE_THRESHOLD = 100.0 @@ -57,6 +62,10 @@ def serialize_entry(entry: ProductionThroughput) -> dict: "label_correct": entry.label_correct, "bag_sealed": entry.bag_sealed, "pallet_good_condition": entry.pallet_good_condition, + "for_order": entry.for_order, + "for_stock": entry.for_stock, + "job_number": entry.job_number, + "stock_quantity": entry.stock_quantity, "sample_box_no": entry.sample_box_no, "test_weight_1": entry.test_weight_1, "test_weight_2": entry.test_weight_2, @@ -323,16 +332,30 @@ def import_workbook(db: Session, workbook_path: Path, tenant_id: str) -> dict: def workbook_candidates() -> Iterable[Path]: repo_root = Path(__file__).resolve().parents[3] + cwd = Path.cwd() + + env_value = os.getenv("THROUGHPUT_WORKBOOK_PATH") + env_path = Path(env_value.strip()) if isinstance(env_value, str) and env_value.strip() else None + + # input_data/ is where the workbook is bundled in the image; in the + # container the working directory is /app, so cwd/input_data resolves it. candidates = [ - repo_root / "Operations Throughput.xlsx", - repo_root.parent / "Operations Throughput.xlsx", - Path.cwd() / "Operations Throughput.xlsx", - Path("/srv/lean101-clients") / "Operations Throughput.xlsx", - Path("/app") / "Operations Throughput.xlsx", + env_path, + repo_root / "input_data" / WORKBOOK_FILENAME, + cwd / "input_data" / WORKBOOK_FILENAME, + Path("/app") / "input_data" / WORKBOOK_FILENAME, + Path("/srv/lean101-clients") / "input_data" / WORKBOOK_FILENAME, + repo_root / WORKBOOK_FILENAME, + repo_root.parent / WORKBOOK_FILENAME, + cwd / WORKBOOK_FILENAME, + Path("/srv/lean101-clients") / WORKBOOK_FILENAME, + Path("/app") / WORKBOOK_FILENAME, ] seen: set[str] = set() ordered: list[Path] = [] for candidate in candidates: + if candidate is None: + continue key = str(candidate) if key in seen: continue diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4af15b9..87eb17b 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.8" +version = "0.1.9" 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 new file mode 100644 index 0000000..26f27c3 --- /dev/null +++ b/backend/tests/_repro_throughput_post.py @@ -0,0 +1,62 @@ +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_throughput.py b/backend/tests/test_throughput.py index f9e4d60..5dcd095 100644 --- a/backend/tests/test_throughput.py +++ b/backend/tests/test_throughput.py @@ -197,9 +197,12 @@ def test_seed_throughput_products_from_costing_products(): assert products[0].default_bag_size == 20 assert products[0].is_bulka_default is False assert products[0].active is True + assert products[0].client_name == "Hunter" assert products[1].default_bag_size is None assert products[1].is_bulka_default is True - assert products[1].active is False + # Every costing SKU is selectable in the throughput picker, even hidden ones. + assert products[1].active is True + assert products[1].client_name == "Hunter" def test_seed_throughput_products_from_costing_updates_existing_by_item_id(): diff --git a/deploy/migrate-to-postgres.sh b/deploy/migrate-to-postgres.sh index b76fb88..aafd26e 100644 --- a/deploy/migrate-to-postgres.sh +++ b/deploy/migrate-to-postgres.sh @@ -405,6 +405,8 @@ TABLE_ORDER = [ "costing_results", "mix_calculator_sessions", "mix_calculator_session_lines", + "throughput_products", + "production_throughput_entries", ] def migrate(): diff --git a/frontend/package.json b/frontend/package.json index 4aa8a0a..4743465 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/throughput/ThroughputProductPicker.svelte b/frontend/src/lib/components/throughput/ThroughputProductPicker.svelte new file mode 100644 index 0000000..b26e091 --- /dev/null +++ b/frontend/src/lib/components/throughput/ThroughputProductPicker.svelte @@ -0,0 +1,338 @@ + + +
Note{entry.notes}
@@ -526,32 +571,11 @@ border: 1px solid #bfe6c8; border-radius: 0.9rem; } - .status-band.has-issues { - background: #fdf6e9; - border-color: #ecd9a8; - } .qa-status { display: flex; align-items: center; gap: 0.9rem; } - .qa-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 3rem; - height: 3rem; - flex-shrink: 0; - border-radius: 50%; - background: #fff; - color: var(--color-success); - } - .status-band.has-issues .qa-icon { - color: var(--color-warning); - } - .qa-icon-quiet { - color: var(--color-text-muted); - } .qa-words { display: flex; flex-direction: column; @@ -794,12 +818,6 @@ .row:hover { background: #fafbfc; } - .row.needs-attention { - background: #fdf6e9; - } - .row.needs-attention:hover { - background: #fbf0db; - } .row.just-added { animation: flash-in 1.8s ease-out; } @@ -816,24 +834,16 @@ .product-name { font-weight: 600; } - .stock-tag { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.18rem 0.55rem; - border-radius: 999px; - font-size: 0.82rem; - font-weight: 650; - line-height: 1.2; - background: #e8f1fc; - color: #0b5cad; + .col-dest { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; } - .stock-tag::before { - content: ''; - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: currentColor; + .dest-detail { + font-size: 0.88rem; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; } .col-packed { display: flex; @@ -868,13 +878,17 @@ font-weight: 650; white-space: nowrap; } - .pill-pass { + .pill-stock { + background: #e8f1fc; + color: #0b5cad; + } + .pill-order { background: var(--color-brand-tint); color: var(--color-success); } - .pill-attention { - background: #fbedcf; - color: #8a5a00; + .pill-split { + background: #f3e8fc; + color: #6b21a8; } .row-notes { grid-column: 1 / -1; @@ -888,7 +902,7 @@ /* ── Inline spreadsheet add row ───────────────────────────── */ .add-row { display: grid; - grid-template-columns: 9rem minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1fr) 11rem auto; + grid-template-columns: 9rem minmax(0, 1.6fr) minmax(0, 1.3fr) minmax(0, 0.9fr) minmax(0, 1.6fr) auto; gap: 0.6rem 1rem; align-items: start; padding: 1rem 1.5rem 1.1rem; @@ -947,35 +961,49 @@ color: var(--color-success); font-variant-numeric: tabular-nums; } - .qa-checks { + .add-dest { + gap: 0.4rem; + } + .dest-options { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 0.35rem; } - .qa-check { + .dest-toggle { display: inline-flex; align-items: center; - gap: 0.5rem; - font-size: 0.95rem; - font-weight: 500; - color: var(--color-text-primary); - line-height: 1.15; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + border: 1px solid #aedcbb; + border-radius: 0.5rem; + background: var(--color-bg-surface); + font-size: 0.92rem; + font-weight: 600; + color: var(--color-text-secondary); cursor: pointer; user-select: none; } - .qa-check input { - width: 1.3rem; - height: 1.3rem; + .dest-toggle input { + width: 1.2rem; + height: 1.2rem; min-height: 0; margin: 0; flex-shrink: 0; accent-color: var(--color-brand); cursor: pointer; } - .qa-check input:focus-visible { + .dest-toggle input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } + .dest-toggle.on { + border-color: var(--color-brand); + background: var(--color-brand-tint); + color: var(--color-success); + } + .dest-input { + width: 100%; + } .add-action { justify-content: center; } @@ -1017,32 +1045,6 @@ gap: 1rem; flex-wrap: wrap; } - .stock-toggle { - display: inline-flex; - align-items: center; - gap: 0.45rem; - padding: 0.35rem 0.7rem; - border: 1px solid #aedcbb; - border-radius: 0.5rem; - background: var(--color-bg-surface); - font-size: 0.95rem; - font-weight: 600; - color: var(--color-text-secondary); - cursor: pointer; - user-select: none; - } - .stock-toggle input { - width: 1.2rem; - height: 1.2rem; - margin: 0; - accent-color: #0b5cad; - cursor: pointer; - } - .stock-toggle.on { - border-color: #9ccaf3; - background: #e8f1fc; - color: #0b5cad; - } .link-button { padding: 0.25rem 0; background: none; @@ -1146,9 +1148,6 @@ border: 1px solid var(--color-border); border-radius: 0.7rem; } - .row.needs-attention { - border-color: #ecd9a8; - } .col-product, .row-notes { grid-column: 1 / -1; @@ -1161,7 +1160,7 @@ .col-product, .col-packed, .col-staff, - .col-qa { + .col-dest { display: flex; flex-direction: column; gap: 0.05rem; @@ -1180,6 +1179,7 @@ display: block; } .add-cell:nth-child(2), + .add-dest, .add-action { grid-column: 1 / -1; } diff --git a/frontend/src/routes/throughput/add/+page.svelte b/frontend/src/routes/throughput/add/+page.svelte index 8cde531..365ed26 100644 --- a/frontend/src/routes/throughput/add/+page.svelte +++ b/frontend/src/routes/throughput/add/+page.svelte @@ -7,6 +7,7 @@ ThroughputQuantityType } from '$lib/types'; import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte'; + import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte'; let { data } = $props<{ data: { products: ThroughputProduct[] } }>(); const products = $derived(data.products ?? []); @@ -18,10 +19,10 @@ let bagSize = $state