v0.1.12
This commit is contained in:
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from app.services.product_costing_service import (
|
||||
ProductCostAssumptions,
|
||||
ProductCostInputItem,
|
||||
calculate_product_cost_item,
|
||||
)
|
||||
|
||||
|
||||
def assumptions() -> ProductCostAssumptions:
|
||||
return ProductCostAssumptions(
|
||||
grading_per_kg=0.05,
|
||||
cracking_per_kg=0.03,
|
||||
process_costs={
|
||||
"Bagging + Grading": 0.04,
|
||||
"Standard Bagging": 0.02,
|
||||
"PHF Horse Mixes": 0.01,
|
||||
"Peckish": 0.08,
|
||||
"Hay & Straw": 0.09,
|
||||
},
|
||||
client_margins={
|
||||
"Specialty": {"distributor_margin": 0.2, "wholesale_margin": 0.1},
|
||||
"Peckish": {"distributor_margin": 0.25, "wholesale_margin": 0.15},
|
||||
"Hay & Straw": {"distributor_margin": 0.3, "wholesale_margin": 0.2},
|
||||
"Straight Grain": {"distributor_margin": 0.1, "wholesale_margin": 0.08},
|
||||
"PHF Horse Mixes": {"distributor_margin": 0.18, "wholesale_margin": 0.12},
|
||||
},
|
||||
bag_costs={
|
||||
"20kg_bag": 0.6,
|
||||
"bulka_bag": 22.0,
|
||||
"own_bag_credit": 0.2,
|
||||
"1_5kg_bagging": 0.35,
|
||||
"peckish_bag": 0.4,
|
||||
},
|
||||
freight_costs={
|
||||
"freight_per_pallet": 80.0,
|
||||
"peckish_freight_per_pallet": 96.0,
|
||||
"hay_straw_freight_per_pallet": 120.0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def item(**overrides) -> ProductCostInputItem:
|
||||
values = {
|
||||
"client_category": "Specialty",
|
||||
"product_name": "Pigeon Mix 20kg",
|
||||
"mix_product_name": "Pigeon Mix",
|
||||
"unit_type": "Standard",
|
||||
"own_bag": None,
|
||||
"unit_kg": 20.0,
|
||||
"items_per_pallet": 40,
|
||||
"bagging_process": "Bagging + Grading",
|
||||
"manual_distributor_margin": None,
|
||||
"manual_wholesale_margin": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return ProductCostInputItem(**values)
|
||||
|
||||
|
||||
def test_standard_product_uses_per_kg_components_unit_bag_freight_and_default_margins():
|
||||
result = calculate_product_cost_item(item(), assumptions(), 0.5)
|
||||
|
||||
assert result.grading_cost_per_kg == 0.05
|
||||
assert result.bagging_cost_per_kg == 0.04
|
||||
assert result.bag_cost_per_unit == 0.6
|
||||
assert result.freight_cost_per_unit == 2.0
|
||||
assert result.finished_product_delivered_cost == 14.4
|
||||
assert result.distributor_price == 18.0
|
||||
assert result.wholesale_price == 16.0
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_bulka_uses_per_kg_delivered_formula_and_bulka_bag_and_freight_divided_by_unit_kg():
|
||||
result = calculate_product_cost_item(item(unit_type="Bulka", unit_kg=1000, items_per_pallet=1), assumptions(), 0.5)
|
||||
|
||||
assert result.bag_cost_per_unit == 0.022
|
||||
assert result.freight_cost_per_unit == 0.08
|
||||
assert result.finished_product_delivered_cost == 0.692
|
||||
|
||||
|
||||
def test_per_unit_uses_per_kg_delivered_formula_but_standard_pallet_freight():
|
||||
result = calculate_product_cost_item(item(unit_type="Per Unit", unit_kg=1, items_per_pallet=10), assumptions(), 0.5)
|
||||
|
||||
assert result.freight_cost_per_unit == 8.0
|
||||
assert result.finished_product_delivered_cost == 8.59
|
||||
|
||||
|
||||
def test_peckish_uses_peckish_bag_freight_and_zero_grading():
|
||||
result = calculate_product_cost_item(
|
||||
item(client_category="Peckish", bagging_process="Peckish", items_per_pallet=24),
|
||||
assumptions(),
|
||||
0.5,
|
||||
)
|
||||
|
||||
assert result.grading_cost_per_kg == 0
|
||||
assert result.bagging_cost_per_kg == 0.08
|
||||
assert result.bag_cost_per_unit == 0.4
|
||||
assert result.freight_cost_per_unit == 4.0
|
||||
|
||||
|
||||
def test_hay_and_straw_uses_hay_freight_and_zero_grading():
|
||||
result = calculate_product_cost_item(
|
||||
item(client_category="Hay & Straw", bagging_process="Hay & Straw", items_per_pallet=30),
|
||||
assumptions(),
|
||||
0.5,
|
||||
)
|
||||
|
||||
assert result.grading_cost_per_kg == 0
|
||||
assert result.freight_cost_per_unit == 4.0
|
||||
|
||||
|
||||
def test_phf_horse_mixes_have_zero_grading():
|
||||
result = calculate_product_cost_item(
|
||||
item(client_category="PHF Horse Mixes", bagging_process="PHF Horse Mixes"),
|
||||
assumptions(),
|
||||
0.5,
|
||||
)
|
||||
|
||||
assert result.grading_cost_per_kg == 0
|
||||
|
||||
|
||||
def test_own_bag_subtracts_credit_and_no_bag_sets_bag_cost_to_zero():
|
||||
own_bag = calculate_product_cost_item(item(own_bag="Yes"), assumptions(), 0.5)
|
||||
no_bag = calculate_product_cost_item(item(own_bag="No Bag"), assumptions(), 0.5)
|
||||
|
||||
assert own_bag.bag_cost_per_unit == 0.4
|
||||
assert no_bag.bag_cost_per_unit == 0
|
||||
|
||||
|
||||
def test_one_point_five_kg_branch_multiplies_pack_formula_by_eight():
|
||||
result = calculate_product_cost_item(item(unit_type="1.5 kg", unit_kg=1.5), assumptions(), 0.5)
|
||||
|
||||
assert result.bag_cost_per_unit == 0.35
|
||||
assert result.finished_product_delivered_cost == 10.84
|
||||
|
||||
|
||||
def test_cracked_product_adds_cracking_cost_and_manual_margins_override_defaults():
|
||||
result = calculate_product_cost_item(
|
||||
item(product_name="Cracked Maize 20kg", manual_distributor_margin=0.1, manual_wholesale_margin=0.05),
|
||||
assumptions(),
|
||||
0.5,
|
||||
)
|
||||
|
||||
assert result.cracking_cost_per_kg == 0.03
|
||||
assert result.distributor_price == 16.6667
|
||||
assert result.wholesale_price == 15.8
|
||||
|
||||
|
||||
def test_straight_grain_bulka_wholesale_rounds_up_to_two_decimals():
|
||||
result = calculate_product_cost_item(
|
||||
item(client_category="Straight Grain", unit_type="Bulka", unit_kg=1000, items_per_pallet=1),
|
||||
assumptions(),
|
||||
0.5,
|
||||
)
|
||||
|
||||
assert result.wholesale_price == 0.76
|
||||
|
||||
|
||||
def test_missing_lookup_and_invalid_inputs_generate_warnings_without_prices():
|
||||
result = calculate_product_cost_item(
|
||||
item(unit_kg=None, items_per_pallet=0, manual_distributor_margin=1.2),
|
||||
assumptions(),
|
||||
None,
|
||||
)
|
||||
|
||||
assert "Missing mix/product cost lookup" in result.warnings
|
||||
assert "Missing unit kg" in result.warnings
|
||||
assert "Missing pallet quantity" in result.warnings
|
||||
assert "Invalid distributor margin" in result.warnings
|
||||
assert result.finished_product_delivered_cost is None
|
||||
assert result.distributor_price is None
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import app.models # noqa: F401 - import all model modules before reading metadata
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
def test_fresh_sqlite_schema_matches_model_metadata():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
inspector = inspect(engine)
|
||||
|
||||
actual_tables = set(inspector.get_table_names())
|
||||
expected_tables = set(Base.metadata.tables)
|
||||
assert actual_tables == expected_tables
|
||||
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
actual_columns = {column["name"] for column in inspector.get_columns(table_name)}
|
||||
expected_columns = {column.name for column in table.columns}
|
||||
assert actual_columns == expected_columns
|
||||
Reference in New Issue
Block a user