hesabixArc/hesabixAPI/app/services/bom_service.py

329 lines
14 KiB
Python
Raw Normal View History

2025-10-27 22:17:45 +03:30
from __future__ import annotations
from typing import Dict, Any, Optional, List
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.core.responses import ApiError
from adapters.db.models.product import Product
from adapters.db.models.product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation
from adapters.db.repositories.product_bom_repository import ProductBOMRepository
from adapters.api.v1.schema_models.product_bom import (
ProductBOMCreateRequest,
ProductBOMUpdateRequest,
BOMExplosionRequest,
2025-10-31 21:32:23 +03:30
ProductionDraftRequest,
2025-10-27 22:17:45 +03:30
)
def _to_bom_dict(bom: ProductBOM, db: Session) -> Dict[str, Any]:
items = db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom.id).order_by(ProductBOMItem.line_no).all()
outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom.id).order_by(ProductBOMOutput.line_no).all()
operations = db.query(ProductBOMOperation).filter(ProductBOMOperation.bom_id == bom.id).order_by(ProductBOMOperation.line_no).all()
return {
"id": bom.id,
"business_id": bom.business_id,
"product_id": bom.product_id,
"version": bom.version,
"name": bom.name,
"is_default": bom.is_default,
"effective_from": bom.effective_from,
"effective_to": bom.effective_to,
"yield_percent": bom.yield_percent,
"wastage_percent": bom.wastage_percent,
"status": bom.status,
"notes": bom.notes,
"created_at": bom.created_at,
"updated_at": bom.updated_at,
"items": [
{
"line_no": it.line_no,
"component_product_id": it.component_product_id,
"qty_per": it.qty_per,
"uom": it.uom,
"wastage_percent": it.wastage_percent,
"is_optional": it.is_optional,
"substitute_group": it.substitute_group,
"suggested_warehouse_id": it.suggested_warehouse_id,
}
for it in items
],
"outputs": [
{
"line_no": ot.line_no,
"output_product_id": ot.output_product_id,
"ratio": ot.ratio,
"uom": ot.uom,
}
for ot in outputs
],
"operations": [
{
"line_no": op.line_no,
"operation_name": op.operation_name,
"cost_fixed": op.cost_fixed,
"cost_per_unit": op.cost_per_unit,
"cost_uom": op.cost_uom,
"work_center": op.work_center,
}
for op in operations
],
}
def create_bom(db: Session, business_id: int, payload: ProductBOMCreateRequest) -> Dict[str, Any]:
# product must belong to business
product = db.get(Product, payload.product_id)
if not product or product.business_id != business_id:
raise ApiError("INVALID_PRODUCT", "کالای انتخابی معتبر نیست", http_status=400)
repo = ProductBOMRepository(db)
bom = repo.create_bom(
business_id=business_id,
product_id=payload.product_id,
version=payload.version.strip(),
name=payload.name.strip(),
is_default=bool(payload.is_default),
effective_from=payload.effective_from,
effective_to=payload.effective_to,
yield_percent=payload.yield_percent,
wastage_percent=payload.wastage_percent,
status=payload.status,
notes=payload.notes,
)
# Replace child rows
items = [it.model_dump() for it in payload.items]
outputs = [ot.model_dump() for ot in payload.outputs]
operations = [op.model_dump() for op in payload.operations]
repo.replace_items(bom.id, items)
repo.replace_outputs(bom.id, outputs)
repo.replace_operations(bom.id, operations)
# enforce single default per product
if bom.is_default:
db.query(ProductBOM).filter(
and_(ProductBOM.business_id == business_id, ProductBOM.product_id == payload.product_id, ProductBOM.id != bom.id)
).update({ProductBOM.is_default: False})
db.commit()
return {"message": "BOM_CREATED", "data": _to_bom_dict(bom, db)}
def get_bom(db: Session, business_id: int, bom_id: int) -> Optional[Dict[str, Any]]:
repo = ProductBOMRepository(db)
bom = repo.get_bom(bom_id, business_id)
if not bom:
return None
return _to_bom_dict(bom, db)
def list_boms(db: Session, business_id: int, product_id: Optional[int] = None) -> Dict[str, Any]:
repo = ProductBOMRepository(db)
rows = repo.list_boms(business_id, product_id)
return {"items": [_to_bom_dict(b, db) for b in rows]}
def update_bom(db: Session, business_id: int, bom_id: int, payload: ProductBOMUpdateRequest) -> Optional[Dict[str, Any]]:
repo = ProductBOMRepository(db)
bom = repo.get_bom(bom_id, business_id)
if not bom:
return None
updated = repo.update_bom(
bom_id,
version=payload.version.strip() if isinstance(payload.version, str) else None,
name=payload.name.strip() if isinstance(payload.name, str) else None,
is_default=payload.is_default if payload.is_default is not None else None,
effective_from=payload.effective_from,
effective_to=payload.effective_to,
yield_percent=payload.yield_percent,
wastage_percent=payload.wastage_percent,
status=payload.status,
notes=payload.notes,
)
if not updated:
return None
if payload.items is not None:
repo.replace_items(bom_id, [it.model_dump() for it in payload.items])
if payload.outputs is not None:
repo.replace_outputs(bom_id, [ot.model_dump() for ot in payload.outputs])
if payload.operations is not None:
repo.replace_operations(bom_id, [op.model_dump() for op in payload.operations])
if updated.is_default:
db.query(ProductBOM).filter(
and_(ProductBOM.business_id == business_id, ProductBOM.product_id == updated.product_id, ProductBOM.id != updated.id)
).update({ProductBOM.is_default: False})
db.commit()
return {"message": "BOM_UPDATED", "data": _to_bom_dict(updated, db)}
def delete_bom(db: Session, business_id: int, bom_id: int) -> bool:
repo = ProductBOMRepository(db)
bom = repo.get_bom(bom_id, business_id)
if not bom:
return False
return repo.delete_bom(bom_id)
def explode_bom(db: Session, business_id: int, req: BOMExplosionRequest) -> Dict[str, Any]:
# minimal explosion without stock checks
if not req.bom_id and not req.product_id:
raise ApiError("INVALID_REQUEST", "bom_id یا product_id الزامی است", http_status=400)
bom: Optional[ProductBOM] = None
if req.bom_id:
bom = db.get(ProductBOM, req.bom_id)
if not bom or bom.business_id != business_id:
raise ApiError("NOT_FOUND", "BOM یافت نشد", http_status=404)
else:
# pick default bom for product
bom = db.query(ProductBOM).filter(
and_(ProductBOM.business_id == business_id, ProductBOM.product_id == req.product_id, ProductBOM.is_default == True)
).first()
if not bom:
raise ApiError("NOT_FOUND", "برای این کالا فرمول پیش‌فرضی تعریف نشده است", http_status=404)
items = db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom.id).order_by(ProductBOMItem.line_no).all()
outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom.id).order_by(ProductBOMOutput.line_no).all()
2025-10-31 21:32:23 +03:30
# Prepare product lookup for enriching names/units in response
product_ids: set[int] = set([it.component_product_id for it in items] + [ot.output_product_id for ot in outputs])
products_by_id: dict[int, Product] = {}
if product_ids:
for p in db.query(Product).filter(Product.id.in_(product_ids)).all():
products_by_id[p.id] = p
2025-10-27 22:17:45 +03:30
qty = Decimal(str(req.quantity))
2025-10-31 21:32:23 +03:30
# Apply BOM-level wastage and yield to inputs
# factor_inputs scales required input quantities. Example: yield 80% => factor 1.25; wastage 5% => factor * 1.05
factor_inputs = Decimal("1")
if bom.wastage_percent:
factor_inputs *= (Decimal("1.0") + Decimal(str(bom.wastage_percent)) / Decimal("100"))
if bom.yield_percent:
try:
y = Decimal(str(bom.yield_percent))
if y > 0:
factor_inputs *= (Decimal("100") / y)
except Exception:
pass
2025-10-27 22:17:45 +03:30
explosion_items: List[Dict[str, Any]] = []
for it in items:
base = Decimal(str(it.qty_per)) * qty
# apply line wastage
if it.wastage_percent:
base = base * (Decimal("1.0") + Decimal(str(it.wastage_percent)) / Decimal("100"))
2025-10-31 21:32:23 +03:30
# apply BOM-level factors
base = base * factor_inputs
prod = products_by_id.get(it.component_product_id)
# Unit conversion to main unit (if BOM line uom equals secondary and factor exists)
required_qty_main_unit = None
main_unit = getattr(prod, "main_unit", None) if prod else None
secondary_unit = getattr(prod, "secondary_unit", None) if prod else None
unit_factor = getattr(prod, "unit_conversion_factor", None) if prod else None
if it.uom and prod and secondary_unit and main_unit and unit_factor is not None:
try:
# When line uom is secondary, convert to main by multiplying factor
if str(it.uom) == str(secondary_unit):
required_qty_main_unit = base * Decimal(str(unit_factor))
elif str(it.uom) == str(main_unit):
required_qty_main_unit = base
else:
required_qty_main_unit = None
except Exception:
required_qty_main_unit = None
2025-10-27 22:17:45 +03:30
explosion_items.append({
"component_product_id": it.component_product_id,
"required_qty": base,
"uom": it.uom,
"suggested_warehouse_id": it.suggested_warehouse_id,
"is_optional": it.is_optional,
"substitute_group": it.substitute_group,
2025-10-31 21:32:23 +03:30
# enriched (optional) fields for UI friendliness
"component_product_name": getattr(prod, "name", None) if prod else None,
"component_product_code": getattr(prod, "code", None) if prod else None,
"component_product_main_unit": getattr(prod, "main_unit", None) if prod else None,
"required_qty_main_unit": required_qty_main_unit,
"main_unit": main_unit,
2025-10-27 22:17:45 +03:30
})
# outputs scaling
out_scaled = []
for ot in outputs:
2025-10-31 21:32:23 +03:30
prod = products_by_id.get(ot.output_product_id)
# Convert output to main unit if needed
ratio_val = Decimal(str(ot.ratio)) * qty
ratio_main_unit = None
try:
main_unit = getattr(prod, "main_unit", None) if prod else None
secondary_unit = getattr(prod, "secondary_unit", None) if prod else None
unit_factor = getattr(prod, "unit_conversion_factor", None) if prod else None
if ot.uom and prod and secondary_unit and main_unit and unit_factor is not None:
if str(ot.uom) == str(secondary_unit):
ratio_main_unit = ratio_val * Decimal(str(unit_factor))
elif str(ot.uom) == str(main_unit):
ratio_main_unit = ratio_val
except Exception:
ratio_main_unit = None
2025-10-27 22:17:45 +03:30
out_scaled.append({
"line_no": ot.line_no,
"output_product_id": ot.output_product_id,
2025-10-31 21:32:23 +03:30
"ratio": ratio_val,
2025-10-27 22:17:45 +03:30
"uom": ot.uom,
2025-10-31 21:32:23 +03:30
# enriched optional fields
"output_product_name": getattr(prod, "name", None) if prod else None,
"output_product_code": getattr(prod, "code", None) if prod else None,
"ratio_main_unit": ratio_main_unit,
"main_unit": getattr(prod, "main_unit", None) if prod else None,
2025-10-27 22:17:45 +03:30
})
return {"items": explosion_items, "outputs": out_scaled}
2025-10-31 21:32:23 +03:30
def produce_draft(db: Session, business_id: int, req: ProductionDraftRequest) -> Dict[str, Any]:
"""Create a draft payload for a production document based on BOM explosion (no persistence)."""
exp = explode_bom(db, business_id, BOMExplosionRequest(product_id=req.product_id, bom_id=req.bom_id, quantity=req.quantity))
# Build draft lines: for UI to prefill later; debit/credit left 0 to be set by user
lines: list[dict[str, Any]] = []
for it in exp["items"]:
lines.append({
"product_id": it["component_product_id"],
"quantity": it["required_qty"],
"debit": 0,
"credit": 0,
"description": f"مصرف مواد برای تولید",
"extra_info": {
"uom": it.get("uom"),
"suggested_warehouse_id": it.get("suggested_warehouse_id"),
"is_optional": it.get("is_optional"),
"substitute_group": it.get("substitute_group"),
},
})
for ot in exp["outputs"]:
lines.append({
"product_id": ot["output_product_id"],
"quantity": ot["ratio"],
"debit": 0,
"credit": 0,
"description": "خروجی تولید",
"extra_info": {"uom": ot.get("uom")},
})
desc = "پیش‌نویس سند تولید بر اساس BOM"
return {
"document_type": "production",
"description": desc,
"lines": lines,
"extra_info": {"source": "bom", "bom_id": int(req.bom_id) if req.bom_id else None, "product_id": int(req.product_id) if req.product_id else None, "quantity": str(req.quantity)},
}