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.
This commit is contained in:
2025-12-09 16:00:46 -06:00
parent ef1a8d743e
commit 7e9c8397ef
7 changed files with 1094 additions and 9 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"])

View File

@@ -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)