From 7e9c8397ef4df7d396cb1f504ceccb24601ba737 Mon Sep 17 00:00:00 2001 From: acazares Date: Tue, 9 Dec 2025 16:00:46 -0600 Subject: [PATCH] feat: Implement invoice management module with CRUD operations - Added InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics, InvoiceSalesDetails, and InvoiceCollections models. - Created schemas for invoice operations including create, update, and response schemas. - Developed services for handling business logic related to invoices, including retrieval, creation, updating, and deletion of invoices and their related data. - Introduced routes for invoice management, enabling CRUD operations through a RESTful API. - Integrated invoice routes into the main application router. - Removed unused routers from the core module to streamline the API structure. --- backend/api/v1/modules/a76/invoices/models.py | 169 ++++++++ backend/api/v1/modules/a76/invoices/routes.py | 283 ++++++++++++++ .../api/v1/modules/a76/invoices/schemas.py | 257 ++++++++++++ .../api/v1/modules/a76/invoices/services.py | 369 ++++++++++++++++++ backend/api/v1/modules/a76/router.py | 11 +- backend/api/v1/modules/core/router.py | 12 + backend/api/v1/router.py | 2 + 7 files changed, 1094 insertions(+), 9 deletions(-) create mode 100644 backend/api/v1/modules/a76/invoices/models.py create mode 100644 backend/api/v1/modules/a76/invoices/routes.py create mode 100644 backend/api/v1/modules/a76/invoices/schemas.py create mode 100644 backend/api/v1/modules/a76/invoices/services.py create mode 100644 backend/api/v1/modules/core/router.py diff --git a/backend/api/v1/modules/a76/invoices/models.py b/backend/api/v1/modules/a76/invoices/models.py new file mode 100644 index 00000000..8c0feee9 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/models.py @@ -0,0 +1,169 @@ +from typing import Optional, List +from sqlalchemy import BigInteger, Date, ForeignKey, Integer, Numeric, String, Text, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column, relationship +from core.database import Base +from datetime import datetime +from ....common.base_models import TenantScopedMixin, TimestampMixin + +# --- 1. Invoice Header (invoice_header) --- +class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_header" + __tableargs__ = {'schema': 'a76'} + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + + # Identifiers + legacy_id: Mapped[Optional[int]] = mapped_column(Integer) # CONSECUTIVO + operation_type: Mapped[str] = mapped_column(String(20)) # TIPOMOVIMIENTO / Clasifica IMP/EXP/SM + invoice_number: Mapped[Optional[str]] = mapped_column(String(20)) # FACTURAIMPO/FACTURAEXPO/FACTURAREMISION + project_number: Mapped[Optional[str]] = mapped_column(String(14)) # NUMPROYECTO + purchase_order: Mapped[Optional[str]] = mapped_column(String(50)) # ORDENCOMPRA + related_doc_id: Mapped[Optional[int]] = mapped_column(Integer) # IDRELDOC (Para Rectificaciones) + + # Dates + invoice_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAFACTURA + capture_date: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=False), default=datetime.now) # FECHACAPTURA + HORAACTUAL + + # Status & Control + status: Mapped[Optional[str]] = mapped_column(String(5)) # ESTATUS + traffic_light_status: Mapped[Optional[str]] = mapped_column(String(50)) # SEMAFORO / SEMAFOROEXPO/IMPO + process_log: Mapped[Optional[str]] = mapped_column(String(300)) # COMOFUEPROCESADA **<-- AÑADIDO** + comments: Mapped[Optional[str]] = mapped_column(Text) # OBSERVACIONE + OBSERVACIONI + COMENTARIOSESTATUS + + # Digital Archive Links + cfdi_uuid: Mapped[Optional[str]] = mapped_column(String(100)) # CFDIUUID + path_pdf: Mapped[Optional[str]] = mapped_column(String(500)) # CFDIPATHPDF **<-- AÑADIDO** + path_xml: Mapped[Optional[str]] = mapped_column(String(500)) # CFDIPATHXML **<-- AÑADIDO** + + # Relationships (Para navegacion ORM) + compliance_mx: Mapped["InvoiceComplianceMx"] = relationship(back_populates="header", cascade="all, delete-orphan") + financials: Mapped["InvoiceFinancials"] = relationship(back_populates="header", cascade="all, delete-orphan") + details: Mapped[List["InvoiceSalesDetails"]] = relationship(back_populates="header", cascade="all, delete-orphan") + collections: Mapped[List["InvoiceCollections"]] = relationship(back_populates="header", cascade="all, delete-orphan") + logistics: Mapped[List["InvoiceLogistics"]] = relationship(back_populates="header", cascade="all, delete-orphan") + + +# --- 2. Compliance MX (invoice_compliance_mx) --- +class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_compliance_mx" + __tableargs__ = {'schema': 'a76'} + + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id"), primary_key=True) + + # Core Customs Data + pedimento: Mapped[Optional[str]] = mapped_column(String(19)) # PEDIMENTO/PEDIMENTOIMPO/EXPO + pedimento_code: Mapped[Optional[str]] = mapped_column(String(5)) # PEDIMENTOR1, K1 + remesa: Mapped[Optional[int]] = mapped_column(Integer) + aduana: Mapped[Optional[str]] = mapped_column(String(5)) # ADUANA_CRUCE + customs_agent: Mapped[Optional[str]] = mapped_column(String(10)) # AADUANAL + + # Flags & Specific Regimes + is_mixed: Mapped[Optional[str]] = mapped_column(String(2)) # ESMIXTO **<-- AÑADIDO** + waste_type: Mapped[Optional[str]] = mapped_column(String(1)) # TIPODESPERDICIO **<-- AÑADIDO** + appendix_17: Mapped[Optional[int]] = mapped_column(Integer) # APENDICE17 + + # VUCEM / Digital + edocument: Mapped[Optional[str]] = mapped_column(String(50)) # EDOCUMENT + electronic_signature: Mapped[Optional[str]] = mapped_column(String(999)) # FIRMAELECTRONICA + sem_id: Mapped[Optional[int]] = mapped_column(Integer) # SEM (de SFacEntradaSM/SFacSalidaSM) + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="compliance_mx") + + +# --- 3. Financials (invoice_financials) --- +class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_financials" + __tableargs__ = {'schema': 'a76'} + + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id"), primary_key=True) + + currency: Mapped[Optional[str]] = mapped_column(String(3)) # CLAVEMONEDA + exchange_rate: Mapped[Optional[float]] = mapped_column(Numeric(13, 6)) # TIPOCAMBIO + + # Merchandise Values + value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORIMPOMN/EXPOMN + value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) + customs_value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORADUANASMN **<-- AÑADIDO** + + # Costs & Taxes + freight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # FLETE + insurance: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # VALSEGUROS/SEGUROS + iva_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # IVAEXPOMN / VALORIVAMN + iva_factor: Mapped[Optional[float]] = mapped_column(Numeric(17, 4)) # FACTORIVA + + # Weights & Quantities + total_quantity: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # CANTEXPO / CANTIMPO + gross_weight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # PESOBRUTO **<-- AÑADIDO** + net_weight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # PESONETO **<-- AÑADIDO** + bundle_count: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="financials") + + +# --- 4. Logistics (invoice_logistics) --- +class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_logistics" + __tableargs__ = {'schema': 'a76'} + + logistics_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id")) + + # Carrier Info + carrier_id: Mapped[Optional[str]] = mapped_column(String(10)) # TRANSPORTISTA + transport_mode: Mapped[Optional[str]] = mapped_column(String(15)) # TRANSPORTE + driver_name: Mapped[Optional[str]] = mapped_column(String(80)) # CONDUCTOR + is_rail: Mapped[Optional[str]] = mapped_column(String(2)) # ESFERROCARRIL + rail_id: Mapped[Optional[str]] = mapped_column(String(31)) # IDFERRORCARRIL + + # Vehicle & Tracking + vehicle_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMVEHICULO / NUMTRAILER + license_plate: Mapped[Optional[str]] = mapped_column(String(20)) # NUMTRASPORTE + seal_number: Mapped[Optional[str]] = mapped_column(String(15)) # PRECINTO + guide_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMEROGUIA + + # Logistics Dates + entry_exit_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAENTRADA / FECHAENVIO + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="logistics") + + +# --- 5. Sales Order Details (invoice_sales_details) --- +class InvoiceSalesDetails(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_sales_details" + __tableargs__ = {'schema': 'a76'} + + detail_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id")) + + line_number: Mapped[int] = mapped_column(Integer) # LINEA + sales_order: Mapped[Optional[str]] = mapped_column(String(20)) # ORDENVENTA + + # Specific Custom Fields + colors_description: Mapped[Optional[str]] = mapped_column(String(49)) # COLORES + square_color_code: Mapped[Optional[str]] = mapped_column(String(1)) # COLORCUADRITO + line_bundles: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS (de la linea) + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="details") + + +# --- 6. Collections (invoice_collections) --- +class InvoiceCollections(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_collections" + __tableargs__ = {'schema': 'a76'} + + collection_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id")) + + concept: Mapped[Optional[str]] = mapped_column(String(100)) # CONCEPTO + is_collected: Mapped[Optional[int]] = mapped_column(Integer) # COBRADO + collection_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHACOBRANZA + amount: Mapped[Optional[float]] = mapped_column(Numeric(23, 8)) # VALOR + + collector_user: Mapped[Optional[str]] = mapped_column(String(20)) # FACTCOBRADOR + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="collections") \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py new file mode 100644 index 00000000..a2160a6b --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -0,0 +1,283 @@ +from typing import Dict, Any +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from fastapi import APIRouter, Depends, HTTPException, Query, Path +from sqlalchemy.orm import Session + +from . import schemas, services + +# Create main router +router = APIRouter() + +# Create CRUD routes for Invoice Header using TenantCRUDRoutes +invoice_crud = TenantCRUDRoutes( + service=services.InvoiceService, + create_schema=schemas.InvoiceHeaderCreate, + update_schema=schemas.InvoiceHeaderUpdate, + response_schema=schemas.InvoiceHeaderResponse, + prefix="/invoices", + tags=[], + resource_name="Invoice", + id_name="invoice_id", + id_type=int, + enable_list=True, # Enable list endpoint with pagination + enable_filters=True, # Enable filters for status, operation_type, etc. + default_page_size=50, + max_page_size=200, +) + +# Include the main CRUD routes +router.include_router(invoice_crud.router) + + +# Additional nested routes for child resources + +# --- Logistics Routes --- + +@router.get( + "/invoices/{invoice_id}/logistics", + response_model=list[schemas.InvoiceLogisticsResponse], + summary="Get all logistics for an invoice", +) +def get_invoice_logistics( + invoice_id: int = Path(..., description="Invoice ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all logistics entries for a specific invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + logistics = services.InvoiceLogisticsService.get_all_by_invoice( + db, invoice_id) + return logistics + + +@router.post( + "/invoices/{invoice_id}/logistics", + response_model=schemas.InvoiceLogisticsResponse, + status_code=201, + summary="Add logistics to an invoice", +) +def create_invoice_logistics( + invoice_id: int = Path(..., description="Invoice ID"), + logistics_data: schemas.InvoiceLogisticsCreate = ..., + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Add a new logistics entry to an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + logistics = services.InvoiceLogisticsService.create( + db, logistics_data, invoice_id, tenant_id, company_id) + return logistics + + +@router.delete( + "/invoices/{invoice_id}/logistics/{logistics_id}", + status_code=204, + summary="Delete logistics from an invoice", +) +def delete_invoice_logistics( + invoice_id: int = Path(..., description="Invoice ID"), + logistics_id: int = Path(..., description="Logistics ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a logistics entry from an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + success = services.InvoiceLogisticsService.delete( + db, logistics_id, invoice_id) + if not success: + raise HTTPException( + status_code=404, detail="Logistics entry not found") + + return None + + +# --- Sales Details Routes --- + +@router.get( + "/invoices/{invoice_id}/details", + response_model=list[schemas.InvoiceSalesDetailsResponse], + summary="Get all sales details for an invoice", +) +def get_invoice_details( + invoice_id: int = Path(..., description="Invoice ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all sales details for a specific invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + details = services.InvoiceSalesDetailsService.get_all_by_invoice( + db, invoice_id) + return details + + +@router.post( + "/invoices/{invoice_id}/details", + response_model=schemas.InvoiceSalesDetailsResponse, + status_code=201, + summary="Add sales detail to an invoice", +) +def create_invoice_detail( + invoice_id: int = Path(..., description="Invoice ID"), + detail_data: schemas.InvoiceSalesDetailsCreate = ..., + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Add a new sales detail to an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + detail = services.InvoiceSalesDetailsService.create( + db, detail_data, invoice_id, tenant_id, company_id) + return detail + + +@router.delete( + "/invoices/{invoice_id}/details/{detail_id}", + status_code=204, + summary="Delete sales detail from an invoice", +) +def delete_invoice_detail( + invoice_id: int = Path(..., description="Invoice ID"), + detail_id: int = Path(..., description="Detail ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a sales detail from an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + success = services.InvoiceSalesDetailsService.delete( + db, detail_id, invoice_id) + if not success: + raise HTTPException(status_code=404, detail="Sales detail not found") + + return None + + +# --- Collections Routes --- + +@router.get( + "/invoices/{invoice_id}/collections", + response_model=list[schemas.InvoiceCollectionsResponse], + summary="Get all collections for an invoice", +) +def get_invoice_collections( + invoice_id: int = Path(..., description="Invoice ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all collections for a specific invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + collections = services.InvoiceCollectionsService.get_all_by_invoice( + db, invoice_id) + return collections + + +@router.post( + "/invoices/{invoice_id}/collections", + response_model=schemas.InvoiceCollectionsResponse, + status_code=201, + summary="Add collection to an invoice", +) +def create_invoice_collection( + invoice_id: int = Path(..., description="Invoice ID"), + collection_data: schemas.InvoiceCollectionsCreate = ..., + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Add a new collection to an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + collection = services.InvoiceCollectionsService.create( + db, collection_data, invoice_id, tenant_id, company_id) + return collection + + +@router.delete( + "/invoices/{invoice_id}/collections/{collection_id}", + status_code=204, + summary="Delete collection from an invoice", +) +def delete_invoice_collection( + invoice_id: int = Path(..., description="Invoice ID"), + collection_id: int = Path(..., description="Collection ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a collection from an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + success = services.InvoiceCollectionsService.delete( + db, collection_id, invoice_id) + if not success: + raise HTTPException(status_code=404, detail="Collection not found") + + return None diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py new file mode 100644 index 00000000..a9b01b3b --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -0,0 +1,257 @@ +from typing import Optional, List +from datetime import datetime, date +from decimal import Decimal +from pydantic import BaseModel, Field + + +# --- Base Schemas --- + +class InvoiceHeaderBase(BaseModel): + """Base fields for Invoice Header""" + legacy_id: Optional[int] = Field( + None, description="Legacy consecutive ID (CONSECUTIVO)") + operation_type: Optional[str] = Field( + None, max_length=20, description="Operation type: IMP/EXP/SM") + invoice_number: Optional[str] = Field( + None, max_length=20, description="Invoice number") + project_number: Optional[str] = Field( + None, max_length=14, description="Project number") + purchase_order: Optional[str] = Field( + None, max_length=50, description="Purchase order") + related_doc_id: Optional[int] = Field( + None, description="Related document ID for rectifications") + invoice_date: Optional[date] = Field(None, description="Invoice date") + status: Optional[str] = Field(None, max_length=5, description="Status") + traffic_light_status: Optional[str] = Field( + None, max_length=50, description="Traffic light status (SEMAFORO)") + process_log: Optional[str] = Field( + None, max_length=300, description="Processing log") + comments: Optional[str] = Field( + None, description="Comments and observations") + cfdi_uuid: Optional[str] = Field( + None, max_length=100, description="CFDI UUID") + path_pdf: Optional[str] = Field( + None, max_length=500, description="Path to PDF file") + path_xml: Optional[str] = Field( + None, max_length=500, description="Path to XML file") + + +class InvoiceComplianceMxBase(BaseModel): + """Base fields for Compliance MX""" + pedimento: Optional[str] = Field( + None, max_length=19, description="Pedimento number") + pedimento_code: Optional[str] = Field( + None, max_length=5, description="Pedimento code (R1/K1)") + remesa: Optional[int] = Field(None, description="Remesa") + aduana: Optional[str] = Field( + None, max_length=5, description="Customs office") + customs_agent: Optional[str] = Field( + None, max_length=10, description="Customs agent") + is_mixed: Optional[str] = Field( + None, max_length=2, description="Is mixed operation") + waste_type: Optional[str] = Field( + None, max_length=1, description="Waste type") + appendix_17: Optional[int] = Field(None, description="Appendix 17") + edocument: Optional[str] = Field( + None, max_length=50, description="E-document") + electronic_signature: Optional[str] = Field( + None, max_length=999, description="Electronic signature") + sem_id: Optional[int] = Field(None, description="SEM ID") + + +class InvoiceFinancialsBase(BaseModel): + """Base fields for Financials""" + currency: Optional[str] = Field( + None, max_length=3, description="Currency code") + exchange_rate: Optional[Decimal] = Field(None, description="Exchange rate") + value_mn: Optional[Decimal] = Field(None, description="Value in MXN") + value_me: Optional[Decimal] = Field( + None, description="Value in foreign currency") + customs_value_mn: Optional[Decimal] = Field( + None, description="Customs value in MXN") + freight: Optional[Decimal] = Field(None, description="Freight cost") + insurance: Optional[Decimal] = Field(None, description="Insurance cost") + iva_mn: Optional[Decimal] = Field(None, description="IVA in MXN") + iva_factor: Optional[Decimal] = Field(None, description="IVA factor") + total_quantity: Optional[Decimal] = Field( + None, description="Total quantity") + gross_weight: Optional[Decimal] = Field(None, description="Gross weight") + net_weight: Optional[Decimal] = Field(None, description="Net weight") + bundle_count: Optional[int] = Field(None, description="Bundle count") + + +class InvoiceLogisticsBase(BaseModel): + """Base fields for Logistics""" + carrier_id: Optional[str] = Field( + None, max_length=10, description="Carrier ID") + transport_mode: Optional[str] = Field( + None, max_length=15, description="Transport mode") + driver_name: Optional[str] = Field( + None, max_length=80, description="Driver name") + is_rail: Optional[str] = Field( + None, max_length=2, description="Is rail transport") + rail_id: Optional[str] = Field(None, max_length=31, description="Rail ID") + vehicle_num: Optional[str] = Field( + None, max_length=20, description="Vehicle number") + license_plate: Optional[str] = Field( + None, max_length=20, description="License plate") + seal_number: Optional[str] = Field( + None, max_length=15, description="Seal number") + guide_number: Optional[str] = Field( + None, max_length=20, description="Guide number") + entry_exit_date: Optional[date] = Field( + None, description="Entry/Exit date") + + +class InvoiceSalesDetailsBase(BaseModel): + """Base fields for Sales Details""" + line_number: int = Field(..., description="Line number") + sales_order: Optional[str] = Field( + None, max_length=20, description="Sales order") + colors_description: Optional[str] = Field( + None, max_length=49, description="Colors description") + square_color_code: Optional[str] = Field( + None, max_length=1, description="Square color code") + line_bundles: Optional[int] = Field(None, description="Line bundles count") + + +class InvoiceCollectionsBase(BaseModel): + """Base fields for Collections""" + concept: Optional[str] = Field(None, max_length=100, description="Concept") + is_collected: Optional[int] = Field(None, description="Is collected flag") + collection_date: Optional[date] = Field( + None, description="Collection date") + amount: Optional[Decimal] = Field(None, description="Amount") + collector_user: Optional[str] = Field( + None, max_length=20, description="Collector user") + + +# --- Create Schemas --- + +class InvoiceComplianceMxCreate(InvoiceComplianceMxBase): + """Schema for creating Compliance MX""" + pass + + +class InvoiceFinancialsCreate(InvoiceFinancialsBase): + """Schema for creating Financials""" + pass + + +class InvoiceLogisticsCreate(InvoiceLogisticsBase): + """Schema for creating Logistics""" + pass + + +class InvoiceSalesDetailsCreate(InvoiceSalesDetailsBase): + """Schema for creating Sales Details""" + pass + + +class InvoiceCollectionsCreate(InvoiceCollectionsBase): + """Schema for creating Collections""" + pass + + +class InvoiceHeaderCreate(InvoiceHeaderBase): + """Schema for creating Invoice Header with nested relations""" + compliance_mx: Optional[InvoiceComplianceMxCreate] = None + financials: Optional[InvoiceFinancialsCreate] = None + logistics: Optional[List[InvoiceLogisticsCreate]] = None + details: Optional[List[InvoiceSalesDetailsCreate]] = None + collections: Optional[List[InvoiceCollectionsCreate]] = None + + +# --- Update Schemas --- + +class InvoiceComplianceMxUpdate(InvoiceComplianceMxBase): + """Schema for updating Compliance MX""" + pass + + +class InvoiceFinancialsUpdate(InvoiceFinancialsBase): + """Schema for updating Financials""" + pass + + +class InvoiceLogisticsUpdate(InvoiceLogisticsBase): + """Schema for updating Logistics""" + pass + + +class InvoiceSalesDetailsUpdate(InvoiceSalesDetailsBase): + """Schema for updating Sales Details""" + line_number: Optional[int] = None + + +class InvoiceCollectionsUpdate(InvoiceCollectionsBase): + """Schema for updating Collections""" + pass + + +class InvoiceHeaderUpdate(InvoiceHeaderBase): + """Schema for updating Invoice Header with nested relations""" + compliance_mx: Optional[InvoiceComplianceMxUpdate] = None + financials: Optional[InvoiceFinancialsUpdate] = None + logistics: Optional[List[InvoiceLogisticsUpdate]] = None + details: Optional[List[InvoiceSalesDetailsUpdate]] = None + collections: Optional[List[InvoiceCollectionsUpdate]] = None + + +# --- Response Schemas --- + +class InvoiceComplianceMxResponse(InvoiceComplianceMxBase): + """Schema for Compliance MX response""" + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceFinancialsResponse(InvoiceFinancialsBase): + """Schema for Financials response""" + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceLogisticsResponse(InvoiceLogisticsBase): + """Schema for Logistics response""" + logistics_id: int + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceSalesDetailsResponse(InvoiceSalesDetailsBase): + """Schema for Sales Details response""" + detail_id: int + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceCollectionsResponse(InvoiceCollectionsBase): + """Schema for Collections response""" + collection_id: int + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceHeaderResponse(InvoiceHeaderBase): + """Schema for Invoice Header response with nested relations""" + id: int + capture_date: datetime + compliance_mx: Optional[InvoiceComplianceMxResponse] = None + financials: Optional[InvoiceFinancialsResponse] = None + logistics: List[InvoiceLogisticsResponse] = [] + details: List[InvoiceSalesDetailsResponse] = [] + collections: List[InvoiceCollectionsResponse] = [] + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py new file mode 100644 index 00000000..c74fea63 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -0,0 +1,369 @@ +from typing import Optional, List, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from . import models, schemas + + +class InvoiceService: + """Service for Invoice Header operations""" + + @staticmethod + def get_by_id(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Optional[models.InvoiceHeader]: + """Get an invoice by ID with tenant/company validation""" + return ( + db.query(models.InvoiceHeader) + .filter( + models.InvoiceHeader.id == invoice_id, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ) + .first() + ) + + @staticmethod + def get_all( + db: Session, + tenant_id: int, + company_id: int, + skip: int = 0, + limit: int = 100, + filters: Optional[dict] = None, + ) -> Tuple[List[models.InvoiceHeader], int]: + """Get all invoices for a tenant/company with pagination and optional filters""" + query = db.query(models.InvoiceHeader).filter( + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("status"): + query = query.filter( + models.InvoiceHeader.status == filters["status"]) + if filters.get("operation_type"): + query = query.filter( + models.InvoiceHeader.operation_type == filters["operation_type"]) + if filters.get("invoice_number"): + query = query.filter(models.InvoiceHeader.invoice_number.ilike( + f"%{filters['invoice_number']}%")) + if filters.get("pedimento"): + query = query.join(models.InvoiceComplianceMx).filter( + models.InvoiceComplianceMx.pedimento.ilike( + f"%{filters['pedimento']}%") + ) + + total = query.count() + items = query.offset(skip).limit(limit).all() + return items, total + + @staticmethod + def create( + db: Session, + invoice_data: schemas.InvoiceHeaderCreate, + tenant_id: int, + company_id: int + ) -> models.InvoiceHeader: + """Create a new invoice with all related data""" + # Extract nested data + compliance_data = invoice_data.compliance_mx + financials_data = invoice_data.financials + logistics_data = invoice_data.logistics or [] + details_data = invoice_data.details or [] + collections_data = invoice_data.collections or [] + + # Create main invoice header + invoice_dict = invoice_data.model_dump( + exclude={"compliance_mx", "financials", + "logistics", "details", "collections"} + ) + invoice_dict["tenant_id"] = tenant_id + invoice_dict["company_id"] = company_id + + new_invoice = models.InvoiceHeader(**invoice_dict) + db.add(new_invoice) + db.flush() # Flush to get the invoice ID + + # Create compliance_mx if provided + if compliance_data: + compliance_dict = compliance_data.model_dump() + compliance_dict["invoice_id"] = new_invoice.id + compliance_dict["tenant_id"] = tenant_id + compliance_dict["company_id"] = company_id + new_compliance = models.InvoiceComplianceMx(**compliance_dict) + db.add(new_compliance) + + # Create financials if provided + if financials_data: + financials_dict = financials_data.model_dump() + financials_dict["invoice_id"] = new_invoice.id + financials_dict["tenant_id"] = tenant_id + financials_dict["company_id"] = company_id + new_financials = models.InvoiceFinancials(**financials_dict) + db.add(new_financials) + + # Create logistics entries + for logistics_item in logistics_data: + logistics_dict = logistics_item.model_dump() + logistics_dict["invoice_id"] = new_invoice.id + logistics_dict["tenant_id"] = tenant_id + logistics_dict["company_id"] = company_id + new_logistics = models.InvoiceLogistics(**logistics_dict) + db.add(new_logistics) + + # Create sales details + for detail_item in details_data: + detail_dict = detail_item.model_dump() + detail_dict["invoice_id"] = new_invoice.id + detail_dict["tenant_id"] = tenant_id + detail_dict["company_id"] = company_id + new_detail = models.InvoiceSalesDetails(**detail_dict) + db.add(new_detail) + + # Create collections + for collection_item in collections_data: + collection_dict = collection_item.model_dump() + collection_dict["invoice_id"] = new_invoice.id + collection_dict["tenant_id"] = tenant_id + collection_dict["company_id"] = company_id + new_collection = models.InvoiceCollections(**collection_dict) + db.add(new_collection) + + db.commit() + db.refresh(new_invoice) + return new_invoice + + @staticmethod + def update( + db: Session, + invoice_id: int, + tenant_id: int, + invoice_data: schemas.InvoiceHeaderUpdate, + company_id: int + ) -> Optional[models.InvoiceHeader]: + """Update an existing invoice and its related data""" + invoice = InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + return None + + # Update main invoice header fields + update_dict = invoice_data.model_dump( + exclude={"compliance_mx", "financials", + "logistics", "details", "collections"}, + exclude_unset=True + ) + for key, value in update_dict.items(): + setattr(invoice, key, value) + + # Update compliance_mx if provided + if invoice_data.compliance_mx is not None: + if invoice.compliance_mx: + for key, value in invoice_data.compliance_mx.model_dump(exclude_unset=True).items(): + setattr(invoice.compliance_mx, key, value) + else: + compliance_dict = invoice_data.compliance_mx.model_dump() + compliance_dict["invoice_id"] = invoice.id + compliance_dict["tenant_id"] = tenant_id + compliance_dict["company_id"] = company_id + new_compliance = models.InvoiceComplianceMx(**compliance_dict) + db.add(new_compliance) + + # Update financials if provided + if invoice_data.financials is not None: + if invoice.financials: + for key, value in invoice_data.financials.model_dump(exclude_unset=True).items(): + setattr(invoice.financials, key, value) + else: + financials_dict = invoice_data.financials.model_dump() + financials_dict["invoice_id"] = invoice.id + financials_dict["tenant_id"] = tenant_id + financials_dict["company_id"] = company_id + new_financials = models.InvoiceFinancials(**financials_dict) + db.add(new_financials) + + # Note: For logistics, details, and collections, we're not handling updates here + # as they are typically managed through separate endpoints for complex operations + + db.commit() + db.refresh(invoice) + return invoice + + @staticmethod + def delete(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> bool: + """Delete an invoice and all related data (cascade delete)""" + invoice = InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if invoice: + db.delete(invoice) + db.commit() + return True + return False + + +class InvoiceLogisticsService: + """Service for Invoice Logistics operations""" + + @staticmethod + def get_by_id(db: Session, logistics_id: int, invoice_id: int) -> Optional[models.InvoiceLogistics]: + """Get a logistics entry by ID""" + return ( + db.query(models.InvoiceLogistics) + .filter( + models.InvoiceLogistics.logistics_id == logistics_id, + models.InvoiceLogistics.invoice_id == invoice_id, + ) + .first() + ) + + @staticmethod + def get_all_by_invoice(db: Session, invoice_id: int) -> List[models.InvoiceLogistics]: + """Get all logistics entries for an invoice""" + return ( + db.query(models.InvoiceLogistics) + .filter(models.InvoiceLogistics.invoice_id == invoice_id) + .all() + ) + + @staticmethod + def create( + db: Session, + logistics_data: schemas.InvoiceLogisticsCreate, + invoice_id: int, + tenant_id: int, + company_id: int + ) -> models.InvoiceLogistics: + """Create a new logistics entry""" + logistics_dict = logistics_data.model_dump() + logistics_dict["invoice_id"] = invoice_id + logistics_dict["tenant_id"] = tenant_id + logistics_dict["company_id"] = company_id + + new_logistics = models.InvoiceLogistics(**logistics_dict) + db.add(new_logistics) + db.commit() + db.refresh(new_logistics) + return new_logistics + + @staticmethod + def delete(db: Session, logistics_id: int, invoice_id: int) -> bool: + """Delete a logistics entry""" + logistics = InvoiceLogisticsService.get_by_id( + db, logistics_id, invoice_id) + if logistics: + db.delete(logistics) + db.commit() + return True + return False + + +class InvoiceSalesDetailsService: + """Service for Invoice Sales Details operations""" + + @staticmethod + def get_by_id(db: Session, detail_id: int, invoice_id: int) -> Optional[models.InvoiceSalesDetails]: + """Get a sales detail entry by ID""" + return ( + db.query(models.InvoiceSalesDetails) + .filter( + models.InvoiceSalesDetails.detail_id == detail_id, + models.InvoiceSalesDetails.invoice_id == invoice_id, + ) + .first() + ) + + @staticmethod + def get_all_by_invoice(db: Session, invoice_id: int) -> List[models.InvoiceSalesDetails]: + """Get all sales details for an invoice""" + return ( + db.query(models.InvoiceSalesDetails) + .filter(models.InvoiceSalesDetails.invoice_id == invoice_id) + .all() + ) + + @staticmethod + def create( + db: Session, + detail_data: schemas.InvoiceSalesDetailsCreate, + invoice_id: int, + tenant_id: int, + company_id: int + ) -> models.InvoiceSalesDetails: + """Create a new sales detail entry""" + detail_dict = detail_data.model_dump() + detail_dict["invoice_id"] = invoice_id + detail_dict["tenant_id"] = tenant_id + detail_dict["company_id"] = company_id + + new_detail = models.InvoiceSalesDetails(**detail_dict) + db.add(new_detail) + db.commit() + db.refresh(new_detail) + return new_detail + + @staticmethod + def delete(db: Session, detail_id: int, invoice_id: int) -> bool: + """Delete a sales detail entry""" + detail = InvoiceSalesDetailsService.get_by_id( + db, detail_id, invoice_id) + if detail: + db.delete(detail) + db.commit() + return True + return False + + +class InvoiceCollectionsService: + """Service for Invoice Collections operations""" + + @staticmethod + def get_by_id(db: Session, collection_id: int, invoice_id: int) -> Optional[models.InvoiceCollections]: + """Get a collection entry by ID""" + return ( + db.query(models.InvoiceCollections) + .filter( + models.InvoiceCollections.collection_id == collection_id, + models.InvoiceCollections.invoice_id == invoice_id, + ) + .first() + ) + + @staticmethod + def get_all_by_invoice(db: Session, invoice_id: int) -> List[models.InvoiceCollections]: + """Get all collections for an invoice""" + return ( + db.query(models.InvoiceCollections) + .filter(models.InvoiceCollections.invoice_id == invoice_id) + .all() + ) + + @staticmethod + def create( + db: Session, + collection_data: schemas.InvoiceCollectionsCreate, + invoice_id: int, + tenant_id: int, + company_id: int + ) -> models.InvoiceCollections: + """Create a new collection entry""" + collection_dict = collection_data.model_dump() + collection_dict["invoice_id"] = invoice_id + collection_dict["tenant_id"] = tenant_id + collection_dict["company_id"] = company_id + + new_collection = models.InvoiceCollections(**collection_dict) + db.add(new_collection) + db.commit() + db.refresh(new_collection) + return new_collection + + @staticmethod + def delete(db: Session, collection_id: int, invoice_id: int) -> bool: + """Delete a collection entry""" + collection = InvoiceCollectionsService.get_by_id( + db, collection_id, invoice_id) + if collection: + db.delete(collection) + db.commit() + return True + return False diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index aed3aa5a..8bfee681 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -8,7 +8,7 @@ from fastapi import APIRouter from .customs_brokers.routes import router as customs_broker_router # Importar routers de módulos -from ..core.auth import router as auth_router +from .invoices.routes import router as invoices_router from .classes import router as classes_router from .clients_and_providers import router as client_and_provider_router from .general_catalogs.company import router as company_router @@ -17,7 +17,6 @@ from .transportation.drivers.routes import router as drivers_router from .general_catalogs.exchange_rate.routes import router as exchange_rate_router from .general_catalogs.identifiers.routes import router as identifiers_router from .fraction_rule_octave.routes import router as fraction_rule_octave_router -from ..core.licenses import router as licenses_router from .general_catalogs.packages.routes import router as package_router from .general_catalogs.ports.routes import router as ports_router from .parts import router as parts_router @@ -38,21 +37,15 @@ from .general_catalogs.error_catalogs.routes import router as error_catalogs_rou from .general_catalogs.doda.routes import router as doda_router from .general_catalogs.prevalidators.routes import router as prevalidators_router from .general_catalogs.electronic_notices.routes import router as electronic_notices_router -from ..core.tenants import router as tenants_router from .transportation.trailers.routes import router as trailers_router from .transportation.transporters.routes import router as transporters_router -from ..core.user_tenant.routes import router as user_tenant_router from .transportation.vehicles.routes import router as vehicles_router # Router principal router = APIRouter() # Registrar módulos -router.include_router(auth_router) -router.include_router(tenants_router, prefix="/a76", tags=["a76 / tenants"]) -router.include_router(user_tenant_router, prefix="/a76", - tags=["a76 / user-tenants"]) -router.include_router(licenses_router, prefix="/a76", tags=["a76 / licenses"]) +router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"]) router.include_router(pedimentos_router, prefix="/a76") router.include_router( client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"] diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py new file mode 100644 index 00000000..66b5b0d2 --- /dev/null +++ b/backend/api/v1/modules/core/router.py @@ -0,0 +1,12 @@ +from .auth.routes import router as auth_router +from .licenses.routes import router as licenses_router +from .tenants.routes import router as tenants_router +from .user_tenant.routes import router as user_tenant_router +from fastapi import APIRouter + +router = APIRouter() + +router.include_router(auth_router) +router.include_router(tenants_router, prefix="/core", tags=["core / tenants"]) +router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"]) +router.include_router(licenses_router, prefix="/core", tags=["core / licenses"]) \ No newline at end of file diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 5d7dc302..9fd2618e 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -6,6 +6,7 @@ Agrega todos los módulos de la aplicación from fastapi import APIRouter # Importar routers de módulos +from .modules.core.router import router as core_router from .modules.a76.router import router as a76_router from .modules.public.router import router as public_router @@ -13,6 +14,7 @@ from .modules.public.router import router as public_router router = APIRouter() # Registrar módulos +router.include_router(core_router) router.include_router(a76_router) router.include_router(public_router)