from datetime import datetime, timezone from decimal import Decimal from fastapi import HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session from ..accounts.models import Account from ..service_requests.models import ServiceRequest from ..suppliers.models import Supplier from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate from .models import Quote, QuoteItem def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool: if _id is None: return True return ( db.query(model.id) .filter( model.id == _id, model.tenant_id == tenant_id, model.company_id == company_id, model.deleted_at.is_(None), ) .first() is not None ) def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None: if not _exists(db, Account, data.get("account_id"), tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe") if not _exists(db, ServiceRequest, data.get("service_request_id"), tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La solicitud asociada no existe") def _recompute_totals(db: Session, quote: Quote) -> None: """Recalcula total_cost/total_sale a partir de los conceptos vigentes.""" cost, sale = ( db.query( func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_cost), 0), func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_sale), 0), ) .filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)) .one() ) quote.total_cost = Decimal(cost or 0) quote.total_sale = Decimal(sale or 0) # ----- Quotes ----- def get_quotes( db: Session, tenant_id: int, company_id: int, search: str | None = None, quote_status: str | None = None, account_id: int | None = None, ) -> list[Quote]: query = db.query(Quote).filter( Quote.tenant_id == tenant_id, Quote.company_id == company_id, Quote.deleted_at.is_(None), ) if quote_status: query = query.filter(Quote.status == quote_status) if account_id is not None: query = query.filter(Quote.account_id == account_id) if search: query = query.filter(Quote.reference.ilike(f"%{search}%")) return query.order_by(Quote.created_at.desc()).all() def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: obj = ( db.query(Quote) .filter( Quote.id == quote_id, Quote.tenant_id == tenant_id, Quote.company_id == company_id, Quote.deleted_at.is_(None), ) .first() ) if not obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada") return obj def create_quote( db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Quote: data = payload.model_dump() _validate_refs(db, data, tenant_id, company_id) obj = Quote(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) db.add(obj) db.commit() db.refresh(obj) return obj def update_quote( db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Quote: obj = get_quote(db, quote_id, tenant_id, company_id) data = payload.model_dump(exclude_unset=True) _validate_refs(db, data, tenant_id, company_id) for field, value in data.items(): setattr(obj, field, value) obj.updated_by = user_id db.commit() db.refresh(obj) return obj def delete_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> None: obj = get_quote(db, quote_id, tenant_id, company_id) obj.deleted_at = datetime.now(timezone.utc) db.commit() def _set_service_request_status(db: Session, quote: Quote, new_status: str) -> None: if quote.service_request_id: sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first() if sr: sr.status = new_status def send_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: quote = get_quote(db, quote_id, tenant_id, company_id) quote.status = "enviada" quote.sent_at = datetime.now(timezone.utc) _set_service_request_status(db, quote, "cotizada") db.commit() db.refresh(quote) return quote def accept_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: quote = get_quote(db, quote_id, tenant_id, company_id) quote.status = "aceptada" quote.accepted_at = datetime.now(timezone.utc) _set_service_request_status(db, quote, "aceptada") db.commit() db.refresh(quote) return quote def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: quote = get_quote(db, quote_id, tenant_id, company_id) quote.status = "rechazada" quote.rejected_at = datetime.now(timezone.utc) _set_service_request_status(db, quote, "rechazada") db.commit() db.refresh(quote) return quote # ----- Quote items ----- def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]: get_quote(db, quote_id, tenant_id, company_id) # valida scope return ( db.query(QuoteItem) .filter( QuoteItem.quote_id == quote_id, QuoteItem.tenant_id == tenant_id, QuoteItem.company_id == company_id, QuoteItem.deleted_at.is_(None), ) .order_by(QuoteItem.id.asc()) .all() ) def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> QuoteItem: item = ( db.query(QuoteItem) .filter( QuoteItem.id == item_id, QuoteItem.tenant_id == tenant_id, QuoteItem.company_id == company_id, QuoteItem.deleted_at.is_(None), ) .first() ) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado") return item def create_quote_item(db: Session, payload: QuoteItemCreate, tenant_id: int, company_id: int) -> QuoteItem: quote = get_quote(db, payload.quote_id, tenant_id, company_id) if not _exists(db, Supplier, payload.supplier_id, tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe") item = QuoteItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) db.add(item) db.flush() _recompute_totals(db, quote) db.commit() db.refresh(item) return item def update_quote_item( db: Session, item_id: int, payload: QuoteItemUpdate, tenant_id: int, company_id: int ) -> QuoteItem: item = _get_item(db, item_id, tenant_id, company_id) data = payload.model_dump(exclude_unset=True) if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe") for field, value in data.items(): setattr(item, field, value) db.flush() quote = get_quote(db, item.quote_id, tenant_id, company_id) _recompute_totals(db, quote) db.commit() db.refresh(item) return item def delete_quote_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None: item = _get_item(db, item_id, tenant_id, company_id) quote_id = item.quote_id item.deleted_at = datetime.now(timezone.utc) db.flush() quote = get_quote(db, quote_id, tenant_id, company_id) _recompute_totals(db, quote) db.commit()