Dockerfile updates

This commit is contained in:
2026-06-02 15:41:53 +12:00
parent 84792c0947
commit f5a588d631
18 changed files with 742 additions and 220 deletions
+1
View File
@@ -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 && \
+4
View File
@@ -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,
+5
View File
@@ -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"),
)
+10
View File
@@ -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)
+22
View File
@@ -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
+10 -3
View File
@@ -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
+1
View File
@@ -51,6 +51,7 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_raw_materials",
"view_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_users",
+28 -5
View File
@@ -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
+1 -1
View File
@@ -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 = [
+62
View File
@@ -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()
+4 -1
View File
@@ -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():