Dockerfile updates
This commit is contained in:
@@ -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 && \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -51,6 +51,7 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"edit_raw_materials",
|
||||
"view_products",
|
||||
"view_mixes",
|
||||
"edit_mixes",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
"view_users",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user