2026-04-25 20:43:37 +12:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
|
|
2026-04-25 22:51:36 +12:00
|
|
|
from app.api.deps import AuthSession, require_client_session
|
2026-04-25 20:43:37 +12:00
|
|
|
from app.db.session import get_db
|
|
|
|
|
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
|
|
|
|
from app.schemas.raw_material import (
|
|
|
|
|
RawMaterialCreate,
|
|
|
|
|
RawMaterialPriceVersionCreate,
|
|
|
|
|
RawMaterialPriceVersionRead,
|
|
|
|
|
RawMaterialRead,
|
|
|
|
|
RawMaterialUpdate,
|
|
|
|
|
)
|
|
|
|
|
from app.services.costing_engine import calculate_raw_material_cost, serialize_raw_material
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/raw-materials", tags=["raw-materials"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> dict:
|
|
|
|
|
price_comp = calculate_raw_material_cost(material, price)
|
|
|
|
|
return {
|
|
|
|
|
"id": price.id,
|
|
|
|
|
"market_value": price.market_value,
|
|
|
|
|
"waste_percentage": price.waste_percentage,
|
|
|
|
|
"effective_date": price.effective_date,
|
|
|
|
|
"status": price.status,
|
|
|
|
|
"notes": price.notes,
|
|
|
|
|
"created_at": price.created_at,
|
|
|
|
|
"loss_cost": price_comp.loss_cost,
|
|
|
|
|
"cost_per_unit": price_comp.cost_per_unit,
|
|
|
|
|
"cost_per_kg": price_comp.cost_per_kg,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=list[RawMaterialRead])
|
2026-04-25 22:51:36 +12:00
|
|
|
def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
|
|
|
|
materials = db.scalars(
|
|
|
|
|
select(RawMaterial)
|
|
|
|
|
.where(RawMaterial.tenant_id == session.tenant_id)
|
|
|
|
|
.options(selectinload(RawMaterial.price_versions))
|
|
|
|
|
.order_by(RawMaterial.name)
|
|
|
|
|
).all()
|
2026-04-25 20:43:37 +12:00
|
|
|
return [serialize_raw_material(material) for material in materials]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED)
|
2026-04-25 22:51:36 +12:00
|
|
|
def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
2026-04-25 20:43:37 +12:00
|
|
|
material = RawMaterial(
|
2026-04-25 22:51:36 +12:00
|
|
|
tenant_id=session.tenant_id,
|
2026-04-25 20:43:37 +12:00
|
|
|
name=payload.name,
|
|
|
|
|
supplier=payload.supplier,
|
|
|
|
|
unit_of_measure=payload.unit_of_measure,
|
|
|
|
|
kg_per_unit=payload.kg_per_unit,
|
|
|
|
|
status=payload.status,
|
|
|
|
|
notes=payload.notes,
|
|
|
|
|
)
|
|
|
|
|
material.price_versions.append(
|
|
|
|
|
RawMaterialPriceVersion(
|
2026-04-25 22:51:36 +12:00
|
|
|
tenant_id=session.tenant_id,
|
2026-04-25 20:43:37 +12:00
|
|
|
market_value=payload.initial_price.market_value,
|
|
|
|
|
waste_percentage=payload.initial_price.waste_percentage,
|
|
|
|
|
effective_date=payload.initial_price.effective_date,
|
|
|
|
|
status=payload.initial_price.status,
|
|
|
|
|
notes=payload.initial_price.notes,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
db.add(material)
|
|
|
|
|
db.commit()
|
|
|
|
|
db.refresh(material)
|
|
|
|
|
return serialize_raw_material(material)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{raw_material_id}", response_model=RawMaterialRead)
|
2026-04-25 22:51:36 +12:00
|
|
|
def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
2026-04-25 20:43:37 +12:00
|
|
|
material = db.scalar(
|
2026-04-25 22:51:36 +12:00
|
|
|
select(RawMaterial)
|
|
|
|
|
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
|
|
|
|
|
.options(selectinload(RawMaterial.price_versions))
|
2026-04-25 20:43:37 +12:00
|
|
|
)
|
|
|
|
|
if material is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Raw material not found")
|
|
|
|
|
return serialize_raw_material(material)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{raw_material_id}", response_model=RawMaterialRead)
|
2026-04-25 22:51:36 +12:00
|
|
|
def update_raw_material(
|
|
|
|
|
raw_material_id: int,
|
|
|
|
|
payload: RawMaterialUpdate,
|
|
|
|
|
session: AuthSession = Depends(require_client_session),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
):
|
2026-04-25 20:43:37 +12:00
|
|
|
material = db.scalar(
|
2026-04-25 22:51:36 +12:00
|
|
|
select(RawMaterial)
|
|
|
|
|
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
|
|
|
|
|
.options(selectinload(RawMaterial.price_versions))
|
2026-04-25 20:43:37 +12:00
|
|
|
)
|
|
|
|
|
if material is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Raw material not found")
|
|
|
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
|
|
|
setattr(material, field, value)
|
|
|
|
|
db.commit()
|
|
|
|
|
db.refresh(material)
|
|
|
|
|
return serialize_raw_material(material)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{raw_material_id}/prices", response_model=RawMaterialPriceVersionRead, status_code=status.HTTP_201_CREATED)
|
2026-04-25 22:51:36 +12:00
|
|
|
def add_price_version(
|
|
|
|
|
raw_material_id: int,
|
|
|
|
|
payload: RawMaterialPriceVersionCreate,
|
|
|
|
|
session: AuthSession = Depends(require_client_session),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
):
|
|
|
|
|
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
|
2026-04-25 20:43:37 +12:00
|
|
|
if material is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Raw material not found")
|
|
|
|
|
price = RawMaterialPriceVersion(
|
2026-04-25 22:51:36 +12:00
|
|
|
tenant_id=session.tenant_id,
|
2026-04-25 20:43:37 +12:00
|
|
|
raw_material_id=raw_material_id,
|
|
|
|
|
market_value=payload.market_value,
|
|
|
|
|
waste_percentage=payload.waste_percentage,
|
|
|
|
|
effective_date=payload.effective_date,
|
|
|
|
|
status=payload.status,
|
|
|
|
|
notes=payload.notes,
|
|
|
|
|
)
|
|
|
|
|
db.add(price)
|
|
|
|
|
db.commit()
|
|
|
|
|
db.refresh(price)
|
|
|
|
|
return _serialize_price(material, price)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
|
2026-04-25 22:51:36 +12:00
|
|
|
def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
|
|
|
|
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
|
2026-04-25 20:43:37 +12:00
|
|
|
if material is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Raw material not found")
|
|
|
|
|
prices = db.scalars(
|
|
|
|
|
select(RawMaterialPriceVersion)
|
2026-04-25 22:51:36 +12:00
|
|
|
.where(
|
|
|
|
|
RawMaterialPriceVersion.raw_material_id == raw_material_id,
|
|
|
|
|
RawMaterialPriceVersion.tenant_id == session.tenant_id,
|
|
|
|
|
)
|
2026-04-25 20:43:37 +12:00
|
|
|
.order_by(RawMaterialPriceVersion.effective_date.desc())
|
|
|
|
|
).all()
|
|
|
|
|
items = []
|
|
|
|
|
for price in prices:
|
|
|
|
|
items.append(_serialize_price(material, price))
|
|
|
|
|
return items
|