Merge origin/development: resolved conflicts in scripts/init_first_time.sh, backend/api/v1/modules/a76/invoices/services.py, .gitignore and others
This commit is contained in:
@@ -221,8 +221,13 @@ class ClassWithFADataResponse(BaseModel):
|
||||
|
||||
# FA-specific fields (embedded from a24.fa_classes)
|
||||
fa_class_id: Optional[int] = None
|
||||
import_tariff_code: Optional[str] = None
|
||||
import_tariff_type: Optional[str] = None
|
||||
export_tariff_code: Optional[str] = None
|
||||
export_tariff_type: Optional[str] = None
|
||||
depreciation_rate: Optional[Decimal] = None
|
||||
fda_code: Optional[str] = None
|
||||
eccn_code: Optional[str] = None
|
||||
class_enabled: Optional[bool] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -150,8 +150,13 @@ class ClassService:
|
||||
"updated_at": base_class.updated_at,
|
||||
# FA extension fields (None if no FA record exists)
|
||||
"fa_class_id": fa_class.id if fa_class else None,
|
||||
"import_tariff_code": fa_class.import_tariff_code if fa_class else None,
|
||||
"import_tariff_type": fa_class.import_tariff_type if fa_class else None,
|
||||
"export_tariff_code": fa_class.export_tariff_code if fa_class else None,
|
||||
"export_tariff_type": fa_class.export_tariff_type if fa_class else None,
|
||||
"depreciation_rate": fa_class.depreciation_rate if fa_class else None,
|
||||
"fda_code": fa_class.fda_code if fa_class else None,
|
||||
"eccn_code": fa_class.eccn_code if fa_class else None,
|
||||
"class_enabled": fa_class.class_enabled if fa_class else None,
|
||||
}
|
||||
combined.append(class_dict)
|
||||
|
||||
@@ -46,7 +46,7 @@ class ClientProviderProgramsDTO(BaseModel):
|
||||
program_number: Optional[str] = Field(
|
||||
None, max_length=40, description="Program number"
|
||||
)
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec: Optional[str] = Field(None, max_length=8, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(
|
||||
None, max_length=20, description="PROSEC authorization"
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Program information
|
||||
program: Mapped[Optional[str]] = mapped_column(String(7))
|
||||
program_number: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
prosec: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import List, Optional
|
||||
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
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from .models import ClientOrProviderEnum
|
||||
|
||||
@@ -44,7 +44,10 @@ async def get_clients_and_providers(
|
||||
"""Get clients and providers"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
query = db.query(ClientProvider).filter(
|
||||
query = db.query(ClientProvider).options(
|
||||
joinedload(ClientProvider.address),
|
||||
joinedload(ClientProvider.programs)
|
||||
).filter(
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
|
||||
broker_key: str
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
vu: Optional["CustomsBrokerVUResponseDTO"] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -75,25 +76,30 @@ class CustomsBrokerDTO(BaseModel):
|
||||
|
||||
|
||||
class CustomsBrokerVUCreateDTO(BaseModel):
|
||||
certificate_path: Optional[str]
|
||||
key_path: Optional[str]
|
||||
access_key: Optional[str]
|
||||
fiel_format: Optional[str]
|
||||
signature_read_path: Optional[str]
|
||||
archive_path: Optional[str]
|
||||
fiel_access_key: Optional[str]
|
||||
web_service_user: Optional[str]
|
||||
web_service_access_key: Optional[str]
|
||||
vu_email: Optional[str]
|
||||
vu_figure_type: Optional[str]
|
||||
xml_files_path: Optional[str]
|
||||
query_tax_id: Optional[str]
|
||||
doda_certificate_path: Optional[str]
|
||||
doda_key_path: Optional[str]
|
||||
doda_web_service_user: Optional[str]
|
||||
doda_web_service_access_key: Optional[str]
|
||||
doda_fiel_access_key: Optional[str]
|
||||
doda_xml_files_path: Optional[str]
|
||||
certificate_path: Optional[str] = None
|
||||
key_path: Optional[str] = None
|
||||
access_key: Optional[str] = None
|
||||
fiel_format: Optional[str] = None
|
||||
signature_read_path: Optional[str] = None
|
||||
archive_path: Optional[str] = None
|
||||
fiel_access_key: Optional[str] = None
|
||||
web_service_user: Optional[str] = None
|
||||
web_service_access_key: Optional[str] = None
|
||||
vu_email: Optional[str] = None
|
||||
vu_figure_type: Optional[str] = None
|
||||
xml_files_path: Optional[str] = None
|
||||
query_tax_id: Optional[str] = None
|
||||
doda_certificate_path: Optional[str] = None
|
||||
doda_key_path: Optional[str] = None
|
||||
doda_web_service_user: Optional[str] = None
|
||||
doda_web_service_access_key: Optional[str] = None
|
||||
doda_fiel_access_key: Optional[str] = None
|
||||
doda_xml_files_path: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
company_id: Optional[int] = None
|
||||
|
||||
class CustomsBrokerVUResponseDTO(CustomsBrokerVUCreateDTO):
|
||||
customs_broker_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -102,15 +108,17 @@ class CustomsBrokerVUCreateDTO(BaseModel):
|
||||
class CustomsBrokerPersonnelDTO(BaseModel):
|
||||
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
|
||||
line: int
|
||||
name: Optional[str]
|
||||
tax_id: Optional[str]
|
||||
personal_id: Optional[str]
|
||||
position: Optional[str]
|
||||
name: Optional[str] = None
|
||||
tax_id: Optional[str] = None
|
||||
personal_id: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
middle_name: Optional[str]
|
||||
email: Optional[str]
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
middle_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
company_id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -32,7 +32,7 @@ class CustomsBroker(Base, TenantScopedMixin, TimestampMixin):
|
||||
contact = Column(String(80), nullable=True)
|
||||
|
||||
vu = relationship(
|
||||
"CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete"
|
||||
"CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete", uselist=False
|
||||
)
|
||||
personnel = relationship(
|
||||
"CustomsBrokerPersonnel", back_populates="customs_broker", cascade="all, delete"
|
||||
|
||||
@@ -66,7 +66,7 @@ def update_customs_broker(
|
||||
|
||||
@router.put(
|
||||
"/customs-broker-vu/{broker_key}",
|
||||
response_model=dto.CustomsBrokerVUCreateDTO,
|
||||
response_model=dto.CustomsBrokerVUResponseDTO,
|
||||
)
|
||||
def update_customs_broker_vu(
|
||||
broker_key: str,
|
||||
@@ -81,7 +81,7 @@ def update_customs_broker_vu(
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data)
|
||||
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data, tenant_id, company_id)
|
||||
if not updated_vu:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker VU not found")
|
||||
return updated_vu
|
||||
@@ -106,7 +106,7 @@ def update_customs_broker_personnel(
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_personnel = services.CustomsBrokerPersonnelService.update_personnel(
|
||||
db, broker_key, line, personnel_data
|
||||
db, broker_key, line, personnel_data, tenant_id, company_id
|
||||
)
|
||||
if not updated_personnel:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -76,7 +76,8 @@ class CustomsBrokerVUService:
|
||||
def get_by_broker_key(db: Session, broker_key: str):
|
||||
return (
|
||||
db.query(models.CustomsBrokerVU)
|
||||
.filter(models.CustomsBrokerVU.broker_key == broker_key)
|
||||
.join(models.CustomsBroker)
|
||||
.filter(models.CustomsBroker.broker_key == broker_key)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -89,14 +90,39 @@ class CustomsBrokerVUService:
|
||||
return new_vu
|
||||
|
||||
@staticmethod
|
||||
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO):
|
||||
vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key)
|
||||
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO, tenant_id: int, company_id: int):
|
||||
# We need the custom broker ID to insert a new VU
|
||||
broker = (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not broker:
|
||||
return None
|
||||
|
||||
vu = db.query(models.CustomsBrokerVU).filter(models.CustomsBrokerVU.customs_broker_id == broker.id).first()
|
||||
|
||||
if vu:
|
||||
for key, value in vu_data.dict(exclude_unset=True).items():
|
||||
# Update existing
|
||||
for key, value in vu_data.model_dump(exclude_unset=True).items():
|
||||
setattr(vu, key, value)
|
||||
db.commit()
|
||||
db.refresh(vu)
|
||||
return vu
|
||||
return vu
|
||||
else:
|
||||
# Create new
|
||||
new_vu_data = vu_data.model_dump()
|
||||
new_vu_data["tenant_id"] = tenant_id
|
||||
new_vu_data["company_id"] = company_id
|
||||
new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data)
|
||||
db.add(new_vu)
|
||||
db.commit()
|
||||
db.refresh(new_vu)
|
||||
return new_vu
|
||||
|
||||
@staticmethod
|
||||
def delete_vu(db: Session, broker_key: str):
|
||||
@@ -109,19 +135,37 @@ class CustomsBrokerVUService:
|
||||
|
||||
class CustomsBrokerPersonnelService:
|
||||
@staticmethod
|
||||
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int):
|
||||
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int, tenant_id: int, company_id: int):
|
||||
return (
|
||||
db.query(models.CustomsBrokerPersonnel)
|
||||
.join(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBrokerPersonnel.broker_key == broker_key,
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBrokerPersonnel.line == line,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_personnel(db: Session, personnel_data: dto.CustomsBrokerPersonnelDTO):
|
||||
new_personnel = models.CustomsBrokerPersonnel(**personnel_data.dict())
|
||||
def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO, tenant_id: int, company_id: int):
|
||||
broker = (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not broker:
|
||||
return None
|
||||
|
||||
new_personnel_data = personnel_data.model_dump()
|
||||
new_personnel_data["tenant_id"] = tenant_id
|
||||
new_personnel_data["company_id"] = company_id
|
||||
new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data)
|
||||
db.add(new_personnel)
|
||||
db.commit()
|
||||
db.refresh(new_personnel)
|
||||
@@ -133,16 +177,22 @@ class CustomsBrokerPersonnelService:
|
||||
broker_key: str,
|
||||
line: int,
|
||||
personnel_data: dto.CustomsBrokerPersonnelDTO,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
):
|
||||
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
|
||||
db, broker_key, line
|
||||
db, broker_key, line, tenant_id, company_id
|
||||
)
|
||||
if personnel:
|
||||
for key, value in personnel_data.dict(exclude_unset=True).items():
|
||||
for key, value in personnel_data.model_dump(exclude_unset=True).items():
|
||||
setattr(personnel, key, value)
|
||||
db.commit()
|
||||
db.refresh(personnel)
|
||||
return personnel
|
||||
return personnel
|
||||
else:
|
||||
return CustomsBrokerPersonnelService.create_personnel(
|
||||
db, broker_key, personnel_data, tenant_id, company_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_personnel(db: Session, broker_key: str, line: int):
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from core.security import get_current_user, get_tenant_from_token
|
||||
|
||||
from .dto import (
|
||||
TariffFractionCreateDTO,
|
||||
@@ -27,6 +27,7 @@ router = APIRouter(prefix="/tariff-fractions", tags=["a76 / general catalogs / t
|
||||
description="Get paginated list of Tariff Fractions with optional search filter (global catalog)",
|
||||
)
|
||||
async def list_tariff_fractions(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=10000, description="Page size"),
|
||||
search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"),
|
||||
@@ -47,8 +48,9 @@ async def list_tariff_fractions(
|
||||
# Service.get_all calls Sitar (async) or DB (sync).
|
||||
# This should be fine.
|
||||
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id = current_user.get("company_id") # Assuming user is context-aware or we use a default?
|
||||
tenant_id = get_tenant_from_token(current_user)
|
||||
if tenant_id is None:
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
# If using headers for selected company, it might be in current_user context if middleware sets it.
|
||||
|
||||
items, total = await TariffFractionService.get_all(
|
||||
@@ -121,7 +123,7 @@ async def create_tariff_fraction(
|
||||
pass
|
||||
|
||||
us_dto = USTariffFractionCreateDTO(
|
||||
code=fraction_data.code,
|
||||
code=fraction_data.fraction, # Store the punctuated fraction in the DB
|
||||
description=fraction_data.description,
|
||||
unit_of_measure=fraction_data.umt,
|
||||
ad_valorem=ad_valorem,
|
||||
@@ -131,7 +133,7 @@ async def create_tariff_fraction(
|
||||
fixed_cost=None
|
||||
)
|
||||
|
||||
created = USTariffFractionService.create(db, tenant_id, company_id, us_dto)
|
||||
created = USTariffFractionService.create(db, us_dto, tenant_id, company_id)
|
||||
return TariffFractionService.to_domain_usa_local(created)
|
||||
|
||||
else:
|
||||
@@ -171,12 +173,13 @@ async def update_tariff_fraction(
|
||||
pass
|
||||
|
||||
us_dto = USTariffFractionUpdateDTO(
|
||||
code=fraction_data.fraction,
|
||||
description=fraction_data.description,
|
||||
unit_of_measure=fraction_data.umt,
|
||||
ad_valorem=ad_valorem
|
||||
)
|
||||
|
||||
updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto)
|
||||
updated = USTariffFractionService.update(db, tariff_fraction_id, tenant_id, us_dto, company_id)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
|
||||
return TariffFractionService.to_domain_usa_local(updated)
|
||||
@@ -202,7 +205,7 @@ async def delete_tariff_fraction(
|
||||
|
||||
if catalog == "american":
|
||||
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
|
||||
success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id)
|
||||
success = USTariffFractionService.delete(db, tariff_fraction_id, tenant_id, company_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
|
||||
return {"ok": True}
|
||||
|
||||
@@ -96,10 +96,14 @@ class TariffFractionService:
|
||||
# US format: 1234.56.78.90. For now return as is or use helper if available.
|
||||
# item is USTariffFraction (imported inside method to avoid circular import if needed, or assumed available)
|
||||
|
||||
# Remove formatting (e.g. dots) for the 'code' property
|
||||
code_str = str(item.code)
|
||||
clean_code = code_str.replace(".", "").replace("-", "")
|
||||
|
||||
return TariffFraction(
|
||||
id=item.id,
|
||||
code=item.code,
|
||||
fraction=item.code, # TODO: Format if needed
|
||||
code=clean_code,
|
||||
fraction=code_str,
|
||||
description=item.description or "(Sin descripción)",
|
||||
nico=None,
|
||||
umt=item.unit_of_measure,
|
||||
@@ -133,11 +137,11 @@ class TariffFractionService:
|
||||
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
|
||||
|
||||
# Use local service directly
|
||||
usa_items, total = USTariffFractionService._get_all_local(
|
||||
usa_items, total = await USTariffFractionService.get_all(
|
||||
db, tenant_id, company_id, skip, limit, filters
|
||||
)
|
||||
|
||||
items = [TariffFractionMapper.to_domain_usa_local(item) for item in usa_items]
|
||||
items = [TariffFractionService.to_domain_usa_local(item) for item in usa_items]
|
||||
return items, total
|
||||
|
||||
# USA CATALOG HANDLING (API - 'Fracciones US')
|
||||
|
||||
@@ -3,9 +3,9 @@ DTOs para fracciones arancelarias americanas
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
|
||||
|
||||
class USTariffFractionCreateDTO(BaseModel):
|
||||
@@ -23,6 +23,7 @@ class USTariffFractionCreateDTO(BaseModel):
|
||||
class USTariffFractionUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar fracción arancelaria americana"""
|
||||
|
||||
code: Optional[str] = Field(None, max_length=16)
|
||||
prefix: Optional[str] = Field(None, max_length=10)
|
||||
type_code: Optional[str] = Field(None, max_length=10)
|
||||
ad_valorem: Optional[float] = None
|
||||
@@ -38,6 +39,7 @@ class USTariffFractionResponseDTO(BaseModel):
|
||||
|
||||
id: int
|
||||
code: str
|
||||
fraction: Optional[str] = None
|
||||
prefix: Optional[str] = None
|
||||
type_code: Optional[str] = None
|
||||
ad_valorem: Optional[float] = None
|
||||
@@ -46,3 +48,36 @@ class USTariffFractionResponseDTO(BaseModel):
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def format_code_and_fraction(cls, data: Any) -> Any:
|
||||
# Check if data is an ORM model or dict
|
||||
if hasattr(data, "code"):
|
||||
raw_code = data.code
|
||||
elif isinstance(data, dict):
|
||||
raw_code = data.get("code")
|
||||
else:
|
||||
return data
|
||||
|
||||
if raw_code:
|
||||
code_str = str(raw_code)
|
||||
# fraction keeps the original formatted string
|
||||
fraction = code_str
|
||||
# code strips dots and hyphens
|
||||
code = code_str.replace(".", "").replace("-", "")
|
||||
|
||||
if isinstance(data, dict):
|
||||
data["code"] = code
|
||||
data["fraction"] = fraction
|
||||
else:
|
||||
# If it's an ORM object, we can't easily modify the object's attribute
|
||||
# cleanly without side effects for other things, so we convert it to dict
|
||||
new_data = {
|
||||
c.name: getattr(data, c.name) for c in data.__table__.columns
|
||||
}
|
||||
new_data["code"] = code
|
||||
new_data["fraction"] = fraction
|
||||
return new_data
|
||||
|
||||
return data
|
||||
|
||||
@@ -17,10 +17,20 @@ from .dto import (
|
||||
)
|
||||
from .service import USTariffFractionService
|
||||
|
||||
# Create base router with generic CRUD routes - REMOVED strictly read-only from Sitar
|
||||
# Writes are disabled at API level, but Service still supports fallback writes if needed internally
|
||||
# Create router using TenantCRUDRoutes factory for basic CRUD operations
|
||||
crud_router = TenantCRUDRoutes(
|
||||
service=USTariffFractionService,
|
||||
create_schema=USTariffFractionCreateDTO,
|
||||
update_schema=USTariffFractionUpdateDTO,
|
||||
response_schema=USTariffFractionResponseDTO,
|
||||
prefix="/us-tariff-fractions",
|
||||
tags=["a76 / general catalogs / us tariff fractions"],
|
||||
resource_name="US Tariff Fraction",
|
||||
id_name="id",
|
||||
enable_list=False, # We implement our custom list endpoint
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"])
|
||||
router = crud_router.router
|
||||
|
||||
# Custom list endpoint with search filter
|
||||
@router.get(
|
||||
@@ -44,7 +54,6 @@ async def list_us_tariff_fractions(
|
||||
if search:
|
||||
filters["search"] = search
|
||||
|
||||
# Updated to async call with Sitar integration
|
||||
items, total = await USTariffFractionService.get_all(
|
||||
db, tenant_id, company_id, skip, page_size, filters
|
||||
)
|
||||
@@ -56,24 +65,3 @@ async def list_us_tariff_fractions(
|
||||
"page_size": page_size,
|
||||
"pages": (total + page_size - 1) // page_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{us_tariff_fraction_id}",
|
||||
response_model=USTariffFractionResponseDTO,
|
||||
summary="Get US Tariff Fraction by ID",
|
||||
description="Get a specific US tariff fraction by ID (Lookups in Local DB for legacy compatibility)",
|
||||
)
|
||||
async def get_us_tariff_fraction(
|
||||
us_tariff_fraction_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
item = USTariffFractionService.get_by_id(db, tenant_id, company_id, us_tariff_fraction_id)
|
||||
if not item:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
|
||||
return USTariffFractionResponseDTO.model_validate(item)
|
||||
|
||||
@@ -1,66 +1,49 @@
|
||||
"""
|
||||
Service para fracciones arancelarias americanas
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException
|
||||
import zlib
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import USTariffFraction
|
||||
from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO
|
||||
from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService
|
||||
from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class USTariffFractionMapper:
|
||||
"""Helper to map Sitar USA responses to Local domain objects"""
|
||||
|
||||
"""Maps Sitar FraccionesUSAResponse objects to USTariffFraction domain objects"""
|
||||
|
||||
@staticmethod
|
||||
def to_domain(fraccion: FraccionesUSAResponse, tenant_id: int, company_id: int) -> USTariffFraction:
|
||||
# Generate a deterministic numeric ID based on the unique code
|
||||
# We use CRC32 to get a consistent integer implementation-independent
|
||||
fake_id = zlib.crc32((fraccion.FRACCION_SIN_PUNTO or "").encode('utf-8'))
|
||||
|
||||
# Parse numeric values safely
|
||||
ad_valorem = None
|
||||
if fraccion.TARIFA1:
|
||||
def to_domain(
|
||||
item: Any, tenant_id: int, company_id: int
|
||||
) -> USTariffFraction:
|
||||
"""Convert a FraccionesUSAResponse to a USTariffFraction instance (not persisted)"""
|
||||
code = item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR or item.FRACCION_SIN_PUNTO or ""
|
||||
ad_valorem: Optional[float] = None
|
||||
if item.TARIFA1:
|
||||
try:
|
||||
# Extract numbers from string like "5.2%" or similar if present
|
||||
# Assuming TARIFA1 might be clean number or percentage string
|
||||
clean_val = re.sub(r'[^\d.]', '', str(fraccion.TARIFA1))
|
||||
if clean_val:
|
||||
ad_valorem = Decimal(clean_val)
|
||||
except:
|
||||
pass
|
||||
|
||||
fixed_cost = None
|
||||
if fraccion.ESPECIFICO:
|
||||
try:
|
||||
clean_val = re.sub(r'[^\d.]', '', str(fraccion.ESPECIFICO))
|
||||
if clean_val:
|
||||
fixed_cost = Decimal(clean_val)
|
||||
except:
|
||||
pass
|
||||
ad_valorem = float(str(item.TARIFA1).replace("%", "").strip())
|
||||
except (ValueError, TypeError):
|
||||
ad_valorem = None
|
||||
|
||||
return USTariffFraction(
|
||||
id=fake_id, # Updated to use fake_id instead of sitar consecutive if needed, or consistent hash
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
code=fraccion.FRACCION_SIN_PUNTO or "",
|
||||
prefix=None, # Not mapped from Sitar response currently
|
||||
type_code=None,
|
||||
ad_valorem=ad_valorem,
|
||||
fixed_cost=fixed_cost,
|
||||
unit_of_measure=fraccion.UNIDADCANTIDAD,
|
||||
description=fraccion.DESCRIPCION
|
||||
)
|
||||
fraction = USTariffFraction()
|
||||
fraction.id = item.CONSECUTIVO
|
||||
fraction.tenant_id = tenant_id
|
||||
fraction.company_id = company_id
|
||||
fraction.code = code
|
||||
fraction.prefix = item.FRACCION_SIN_PUNTO
|
||||
fraction.type_code = str(item.NIVEL) if item.NIVEL is not None else None
|
||||
fraction.ad_valorem = ad_valorem
|
||||
fraction.fixed_cost = None
|
||||
fraction.unit_of_measure = item.UNIDADCANTIDAD
|
||||
fraction.description = item.DESCRIPCION
|
||||
now = datetime.now()
|
||||
fraction.created_at = now
|
||||
fraction.updated_at = now
|
||||
return fraction
|
||||
|
||||
|
||||
class USTariffFractionService:
|
||||
@@ -100,9 +83,9 @@ class USTariffFractionService:
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# If Sitar returns empty list AND we didn't have specific filters, attempt fallback
|
||||
if not sitar_items and not has_filters:
|
||||
logger.warning("Sitar return empty list for USA broad query. Attempting fallback to local DB.")
|
||||
# If Sitar returns empty list, attempt fallback to local DB
|
||||
if not sitar_items:
|
||||
logger.info(f"Sitar returned no results for USA query (filters={has_filters}). Attempting fallback to local DB.")
|
||||
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
|
||||
|
||||
# Map items
|
||||
@@ -148,15 +131,14 @@ class USTariffFractionService:
|
||||
total = query.count()
|
||||
items = query.order_by(USTariffFraction.code).offset(skip).limit(limit).all()
|
||||
|
||||
return items, total
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(
|
||||
db: Session, tenant_id: int, company_id: int, fraction_id: int
|
||||
db: Session, fraction_id: int, tenant_id: int, company_id: int
|
||||
) -> Optional[USTariffFraction]:
|
||||
"""
|
||||
Obtiene por ID.
|
||||
Legacy: Consulta Local DB.
|
||||
Obtiene por ID local.
|
||||
"""
|
||||
return (
|
||||
db.query(USTariffFraction)
|
||||
@@ -168,14 +150,12 @@ class USTariffFractionService:
|
||||
.first()
|
||||
)
|
||||
|
||||
# WRITE OPERATIONS - DEPRECATED / LOCAL ONLY
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session,
|
||||
fraction_data: USTariffFractionCreateDTO,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
fraction_data: USTariffFractionCreateDTO,
|
||||
) -> USTariffFraction:
|
||||
try:
|
||||
db_fraction = USTariffFraction(
|
||||
@@ -198,13 +178,13 @@ class USTariffFractionService:
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
fraction_id: int,
|
||||
tenant_id: int,
|
||||
fraction_data: USTariffFractionUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[USTariffFraction]:
|
||||
db_fraction = USTariffFractionService.get_by_id(
|
||||
db, tenant_id, company_id, fraction_id
|
||||
db, fraction_id, tenant_id, company_id
|
||||
)
|
||||
if not db_fraction:
|
||||
return None
|
||||
@@ -219,10 +199,10 @@ class USTariffFractionService:
|
||||
|
||||
@staticmethod
|
||||
def delete(
|
||||
db: Session, tenant_id: int, company_id: int, fraction_id: int
|
||||
db: Session, fraction_id: int, tenant_id: int, company_id: int
|
||||
) -> bool:
|
||||
db_fraction = USTariffFractionService.get_by_id(
|
||||
db, tenant_id, company_id, fraction_id
|
||||
db, fraction_id, tenant_id, company_id
|
||||
)
|
||||
if not db_fraction:
|
||||
return False
|
||||
|
||||
@@ -135,12 +135,14 @@ class InvoiceCatalogService:
|
||||
|
||||
# Drivers
|
||||
try:
|
||||
drivers, _ = DriverService.get_all(db, tenant_id, company_id, limit=1000)
|
||||
drivers = DriverService.list_drivers(db, str(company_id), str(tenant_id))
|
||||
response.drivers = [
|
||||
DriverResponseDTO.model_validate(d) for d in drivers
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error fetching drivers: {e}")
|
||||
# Initialize drivers as empty list if an error occurs
|
||||
drivers = []
|
||||
|
||||
# Trailers
|
||||
try:
|
||||
|
||||
@@ -36,17 +36,17 @@ def validate_update(
|
||||
|
||||
# Validar campos requeridos según el tipo de operación
|
||||
invoice_dict = {
|
||||
'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else None,
|
||||
'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else None,
|
||||
'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else None,
|
||||
'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else None,
|
||||
'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else None,
|
||||
'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else None,
|
||||
'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None),
|
||||
'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None),
|
||||
'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None),
|
||||
'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None),
|
||||
'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None),
|
||||
'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None),
|
||||
}
|
||||
|
||||
validate_required_fields_by_operation(
|
||||
invoice_data=invoice_dict,
|
||||
operation_type=invoice_data.operation_type or 'IMP',
|
||||
operation_type=invoice_data.operation_type or (existing_invoice.operation_type or 'imp'),
|
||||
errors=errors
|
||||
)
|
||||
|
||||
@@ -57,34 +57,36 @@ def validate_update(
|
||||
# Siguiendo la lógica del código Clarion original
|
||||
|
||||
# Columna A: Pedimento (si no viene en CSV, usar el existente)
|
||||
if invoice_data.compliance_mx.pedimento_id:
|
||||
invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id
|
||||
else:
|
||||
invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if invoice_data.compliance_mx.pedimento_id:
|
||||
invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id
|
||||
else:
|
||||
invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna B: Remesa
|
||||
if invoice_data.compliance_mx.remesa:
|
||||
invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa
|
||||
else:
|
||||
invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if invoice_data.compliance_mx.remesa:
|
||||
invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa
|
||||
else:
|
||||
invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna C: Factura (OBLIGATORIO)
|
||||
invoice_data.invoice_number = clean_str(invoice_data.invoice_number)
|
||||
if not invoice_data.invoice_number:
|
||||
errors.add_required_error("invoice_number")
|
||||
if invoice_data.invoice_number is not None:
|
||||
invoice_data.invoice_number = clean_str(invoice_data.invoice_number)
|
||||
if not invoice_data.invoice_number:
|
||||
errors.add_required_error("invoice_number")
|
||||
else:
|
||||
invoice_data.invoice_number = existing_invoice.invoice_number
|
||||
|
||||
# Columna D: Fecha
|
||||
if invoice_data.invoice_date:
|
||||
invoice_data.invoice_date = invoice_data.invoice_date
|
||||
else:
|
||||
if not invoice_data.invoice_date:
|
||||
invoice_data.invoice_date = existing_invoice.invoice_date
|
||||
|
||||
# Columna E: Tipo Cambio
|
||||
if invoice_data.financials and invoice_data.financials.exchange_rate is not None:
|
||||
invoice_data.financials.exchange_rate = invoice_data.financials.exchange_rate
|
||||
else:
|
||||
if existing_invoice.financials:
|
||||
invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate
|
||||
if invoice_data.financials:
|
||||
if invoice_data.financials.exchange_rate is None:
|
||||
if existing_invoice.financials:
|
||||
invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate
|
||||
|
||||
# Columna F: Régimen
|
||||
if invoice_data.document_type:
|
||||
@@ -93,157 +95,161 @@ def validate_update(
|
||||
invoice_data.document_type = existing_invoice.document_type
|
||||
|
||||
# Columna G: Clave Proveedor
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.provider_id is not None:
|
||||
invoice_data.compliance_mx.provider_id = invoice_data.compliance_mx.provider_id
|
||||
else:
|
||||
invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if invoice_data.compliance_mx.provider_id is None:
|
||||
invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna H: Clave Vendido A
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.sold_to_id is not None:
|
||||
invoice_data.compliance_mx.sold_to_id = invoice_data.compliance_mx.sold_to_id
|
||||
else:
|
||||
invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if invoice_data.compliance_mx.sold_to_id is None:
|
||||
invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna I: Clave Enviado A
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.shipped_to_id is not None:
|
||||
invoice_data.compliance_mx.shipped_to_id = invoice_data.compliance_mx.shipped_to_id
|
||||
else:
|
||||
invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if invoice_data.compliance_mx.shipped_to_id is None:
|
||||
invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna J: Clave A. Aduanal
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.customs_broker_id is not None:
|
||||
invoice_data.compliance_mx.customs_broker_id = invoice_data.compliance_mx.customs_broker_id
|
||||
else:
|
||||
invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if invoice_data.compliance_mx.customs_broker_id is None:
|
||||
invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna K: Clave Transportista
|
||||
if invoice_data.logistics and invoice_data.logistics.carrier_id is not None:
|
||||
invoice_data.logistics.carrier_id = invoice_data.logistics.carrier_id
|
||||
else:
|
||||
invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None
|
||||
if invoice_data.logistics:
|
||||
# Note: logistics in update schema seems to be a single object, but in model it's a list.
|
||||
# This validator seems to expect a single object (InvoiceLogisticsUpdate).
|
||||
# We'll stick to the existing logic but make it safe.
|
||||
if hasattr(invoice_data.logistics, 'carrier_id') and invoice_data.logistics.carrier_id is None:
|
||||
invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None
|
||||
|
||||
# Columna L: Nombre Conductor
|
||||
if invoice_data.logistics and invoice_data.logistics.driver_name:
|
||||
invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name)
|
||||
else:
|
||||
invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None
|
||||
if invoice_data.logistics:
|
||||
if hasattr(invoice_data.logistics, 'driver_name') and not invoice_data.logistics.driver_name:
|
||||
invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None
|
||||
elif hasattr(invoice_data.logistics, 'driver_name'):
|
||||
invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name)
|
||||
|
||||
# Columna M: Tipo Transporte
|
||||
if invoice_data.logistics and invoice_data.logistics.transport_type:
|
||||
invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type)
|
||||
else:
|
||||
invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None
|
||||
if invoice_data.logistics:
|
||||
if hasattr(invoice_data.logistics, 'transport_type') and not invoice_data.logistics.transport_type:
|
||||
invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None
|
||||
elif hasattr(invoice_data.logistics, 'transport_type'):
|
||||
invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type)
|
||||
|
||||
# Columna N: Número de Transporte
|
||||
if invoice_data.logistics and invoice_data.logistics.transport_num:
|
||||
invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num)
|
||||
else:
|
||||
invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None
|
||||
if invoice_data.logistics:
|
||||
if hasattr(invoice_data.logistics, 'transport_num') and not invoice_data.logistics.transport_num:
|
||||
invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None
|
||||
elif hasattr(invoice_data.logistics, 'transport_num'):
|
||||
invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num)
|
||||
|
||||
# Columna O: Tipo de Moneda
|
||||
if invoice_data.financials and invoice_data.financials.currency:
|
||||
invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower()
|
||||
else:
|
||||
invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None
|
||||
if invoice_data.financials:
|
||||
if not invoice_data.financials.currency:
|
||||
invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None
|
||||
else:
|
||||
invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower()
|
||||
|
||||
# Columna P: Clave Moneda
|
||||
if invoice_data.financials and invoice_data.financials.currency_type:
|
||||
invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper()
|
||||
else:
|
||||
invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None
|
||||
if invoice_data.financials:
|
||||
if not invoice_data.financials.currency_type:
|
||||
invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None
|
||||
else:
|
||||
invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper()
|
||||
|
||||
# Columna Q: Flete
|
||||
if invoice_data.financials and invoice_data.financials.freight is not None:
|
||||
invoice_data.financials.freight = invoice_data.financials.freight
|
||||
else:
|
||||
invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None
|
||||
if invoice_data.financials:
|
||||
if invoice_data.financials.freight is None:
|
||||
invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None
|
||||
|
||||
# Columna R: Val Seguros
|
||||
if invoice_data.financials and invoice_data.financials.insurance_value is not None:
|
||||
invoice_data.financials.insurance_value = invoice_data.financials.insurance_value
|
||||
else:
|
||||
invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None
|
||||
if invoice_data.financials:
|
||||
if invoice_data.financials.insurance_value is None:
|
||||
invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None
|
||||
|
||||
# Columna S: Seguros
|
||||
if invoice_data.financials and invoice_data.financials.insurance is not None:
|
||||
invoice_data.financials.insurance = invoice_data.financials.insurance
|
||||
else:
|
||||
invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None
|
||||
if invoice_data.financials:
|
||||
if invoice_data.financials.insurance is None:
|
||||
invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None
|
||||
|
||||
# Columna T: Embalaje
|
||||
if invoice_data.financials and invoice_data.financials.packaging is not None:
|
||||
invoice_data.financials.packaging = invoice_data.financials.packaging
|
||||
else:
|
||||
invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None
|
||||
if invoice_data.financials:
|
||||
if invoice_data.financials.packaging is None:
|
||||
invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None
|
||||
|
||||
# Columna U: Otros Incrementables
|
||||
if invoice_data.financials and invoice_data.financials.other_increments is not None:
|
||||
invoice_data.financials.other_increments = invoice_data.financials.other_increments
|
||||
else:
|
||||
invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None
|
||||
if invoice_data.financials:
|
||||
if invoice_data.financials.other_increments is None:
|
||||
invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None
|
||||
|
||||
# Columna V: Incoterms
|
||||
if invoice_data.logistics and invoice_data.logistics.incoterm:
|
||||
invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper()
|
||||
else:
|
||||
invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None
|
||||
if invoice_data.logistics:
|
||||
if hasattr(invoice_data.logistics, 'incoterm') and not invoice_data.logistics.incoterm:
|
||||
invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None
|
||||
elif hasattr(invoice_data.logistics, 'incoterm'):
|
||||
invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper()
|
||||
|
||||
# Columna W: Precinto
|
||||
if invoice_data.logistics and invoice_data.logistics.seal_number:
|
||||
invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number)
|
||||
else:
|
||||
invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None
|
||||
if invoice_data.logistics:
|
||||
if hasattr(invoice_data.logistics, 'seal_number') and not invoice_data.logistics.seal_number:
|
||||
invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None
|
||||
elif hasattr(invoice_data.logistics, 'seal_number'):
|
||||
invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number)
|
||||
|
||||
# Columna X: Fecha de Emisión
|
||||
if invoice_data.emission_date:
|
||||
invoice_data.emission_date = invoice_data.emission_date
|
||||
else:
|
||||
if not invoice_data.emission_date:
|
||||
invoice_data.emission_date = existing_invoice.emission_date
|
||||
|
||||
# Columna Y: Tipo de Peso (Opcional)
|
||||
if invoice_data.logistics and invoice_data.logistics.weight_type:
|
||||
invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper()
|
||||
else:
|
||||
invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None
|
||||
if invoice_data.logistics:
|
||||
if hasattr(invoice_data.logistics, 'weight_type') and not invoice_data.logistics.weight_type:
|
||||
invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None
|
||||
elif hasattr(invoice_data.logistics, 'weight_type'):
|
||||
invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper()
|
||||
|
||||
# Columna Z: E-Document (Opcional)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.edocument:
|
||||
invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument)
|
||||
else:
|
||||
invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if not invoice_data.compliance_mx.edocument:
|
||||
invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None
|
||||
else:
|
||||
invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument)
|
||||
|
||||
# Columna AA: Num. Operación (Opcional)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.vucem_operation_num:
|
||||
invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num)
|
||||
else:
|
||||
invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if not invoice_data.compliance_mx.vucem_operation_num:
|
||||
invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None
|
||||
else:
|
||||
invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num)
|
||||
|
||||
# Columna AB: Aduana (OBLIGATORIO)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.aduana:
|
||||
invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana)
|
||||
else:
|
||||
invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if not invoice_data.compliance_mx.aduana:
|
||||
invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None
|
||||
else:
|
||||
invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana)
|
||||
|
||||
# Validar que aduana sea obligatorio (excepto para MEX)
|
||||
if existing_invoice.invoice_type != "MEX":
|
||||
if not invoice_data.compliance_mx or not invoice_data.compliance_mx.aduana:
|
||||
current_aduana = invoice_data.compliance_mx.aduana if invoice_data.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None)
|
||||
if not current_aduana:
|
||||
errors.add_required_error("aduana")
|
||||
|
||||
# Columna AC: Sección de Despacho / Puerto de Entrada (Opcional)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.port_of_entry:
|
||||
invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry)
|
||||
else:
|
||||
invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None
|
||||
if invoice_data.compliance_mx:
|
||||
if not invoice_data.compliance_mx.port_of_entry:
|
||||
invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None
|
||||
else:
|
||||
invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry)
|
||||
|
||||
# Columna AD: Observación en Español (Opcional)
|
||||
if invoice_data.observation_es:
|
||||
invoice_data.observation_es = clean_str(invoice_data.observation_es)
|
||||
else:
|
||||
if not invoice_data.observation_es:
|
||||
invoice_data.observation_es = existing_invoice.observation_es
|
||||
else:
|
||||
invoice_data.observation_es = clean_str(invoice_data.observation_es)
|
||||
|
||||
# Columna AD: Observación en Inglés (Opcional)
|
||||
if invoice_data.observation_en:
|
||||
invoice_data.observation_en = clean_str(invoice_data.observation_en)
|
||||
else:
|
||||
if not invoice_data.observation_en:
|
||||
invoice_data.observation_en = existing_invoice.observation_en
|
||||
else:
|
||||
invoice_data.observation_en = clean_str(invoice_data.observation_en)
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class InvoiceComplianceMxBase(BaseModel):
|
||||
None, max_length=20, description="Shipped by header"
|
||||
)
|
||||
shipped_by_id: Optional[int] = Field(None, description="Shipped by ID")
|
||||
customs_broker_id: Optional[int] = Field(None, description="Customs broker ID")
|
||||
customs_broker_id: int = Field(None, description="Customs broker ID")
|
||||
customs_broker_us_id: Optional[int] = Field(
|
||||
None, description="US customs broker ID"
|
||||
)
|
||||
@@ -152,7 +152,7 @@ class InvoiceComplianceMxBase(BaseModel):
|
||||
)
|
||||
value_method: Optional[str] = Field(None, max_length=2, description="Value method")
|
||||
act_value: Optional[str] = Field(None, max_length=5, description="Act value")
|
||||
is_pedimento_pending: bool = Field(..., description="Is pedimento pending")
|
||||
is_pedimento_pending: Optional[bool] = Field(False, description="Is pedimento pending")
|
||||
is_owner_of_goods: Optional[bool] = Field(False, description="Is owner of goods")
|
||||
generate_balances: Optional[bool] = Field(False, description="Generate balances")
|
||||
was_reviewed_by_company: Optional[bool] = Field(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import traceback
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from core.exceptions import ErrorCollector, DuplicateResourceException
|
||||
from core.context import get_user_context
|
||||
from .common.mappers import clean_dict
|
||||
from .imports.temporary.validators.create import validate_create
|
||||
from .imports.temporary.validators.update import validate_update
|
||||
@@ -10,6 +12,26 @@ from .common.common_validators import invoice_exists
|
||||
from . import models, schemas
|
||||
|
||||
|
||||
def _get_current_username() -> str:
|
||||
"""Helper to get current username from context or fallback to System"""
|
||||
try:
|
||||
context = get_user_context()
|
||||
if context:
|
||||
# Token usually has 'preferred_username' or 'name' or 'sub'
|
||||
username = (
|
||||
context.get("preferred_username")
|
||||
or context.get("email")
|
||||
or context.get("sub")
|
||||
or "System"
|
||||
)
|
||||
print(f"DEBUG: _get_current_username found context: {username}")
|
||||
return username
|
||||
except Exception:
|
||||
pass
|
||||
print("DEBUG: _get_current_username NO context found, using System")
|
||||
return "System"
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
"""Service for Invoice Header operations"""
|
||||
|
||||
@@ -130,6 +152,11 @@ class InvoiceService:
|
||||
invoice_dict["tenant_id"] = tenant_id
|
||||
invoice_dict["company_id"] = company_id
|
||||
|
||||
# Automatic status and audit fields
|
||||
username = _get_current_username()
|
||||
invoice_dict["capture_user"] = username
|
||||
invoice_dict["who_updated"] = username
|
||||
|
||||
# Ensure document_type respects DB constraints for MEX invoices (bypass clean_dict)
|
||||
if invoice_dict.get("invoice_type") == "MEX" and not invoice_dict.get("document_type"):
|
||||
invoice_dict["document_type"] = None
|
||||
@@ -254,6 +281,7 @@ class InvoiceService:
|
||||
# Update main invoice header fields
|
||||
update_dict = invoice_data.model_dump(
|
||||
exclude={
|
||||
"id",
|
||||
"compliance_mx",
|
||||
"financials",
|
||||
"logistics",
|
||||
@@ -265,6 +293,16 @@ class InvoiceService:
|
||||
for key, value in update_dict.items():
|
||||
setattr(invoice, key, value)
|
||||
|
||||
# Audit update fields
|
||||
username = _get_current_username()
|
||||
invoice.who_updated = username
|
||||
invoice.updated_date = func.now()
|
||||
|
||||
# Backfill capture_user if missing or previous generic 'System'
|
||||
if not invoice.capture_user or invoice.capture_user == "System":
|
||||
if username != "System":
|
||||
invoice.capture_user = username
|
||||
|
||||
# Update compliance_mx if provided
|
||||
if invoice_data.compliance_mx is not None:
|
||||
print(f"DEBUG: 更新 compliance_mx para factura {invoice.id}: {invoice_data.compliance_mx}")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics
|
||||
from core.exceptions import ErrorCollector
|
||||
|
||||
from ....models import LineItem
|
||||
from ....line_financials.models import LineFinancial
|
||||
from ....line_financials.schemas import LineFinancialCreate
|
||||
from ....line_quantities.models import LineQuantity
|
||||
from ....line_quantities.schemas import LineQuantityCreate
|
||||
from ....line_customs.models import LineCustom
|
||||
from ....line_customs.schemas import LineCustomCreate
|
||||
from ....line_descriptions.models import LineDescription
|
||||
from ....line_descriptions.schemas import LineDescriptionCreate
|
||||
from ....line_references.models import LineReference
|
||||
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
from ....models import LineItem
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
|
||||
|
||||
def apply_calculations(
|
||||
db: Session, line: LineItem, tenant_id: int, company_id: int, line_number: int
|
||||
):
|
||||
#TODO: SSisGen Logic
|
||||
# if ssisgen.calcularcostounitarioenbaseavalortotalscaf = 1:
|
||||
# unit_cost_capture = line.financial.total_value / line.financial.total_value <-- habria que revisar por que esta asi, por que para mi no tiene sentido, pero es lo que esta en clarion
|
||||
caluclate_values(db, line, tenant_id, company_id)
|
||||
|
||||
if not line.fa_data.is_subitem:
|
||||
line.fa_data.subitem_number = None
|
||||
|
||||
invoice_date = db.query(InvoiceHeader.invoice_date).filter(InvoiceHeader.id == line.invoice_id, InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id).scalar()
|
||||
|
||||
line.depreciation_date = invoice_date
|
||||
|
||||
if (not line.description.description_spanish and not line.description.description_english) and (line.part_info.description_spanish and line.part_info.description_english):
|
||||
line.description.description_spanish = line.part_info.description_spanish
|
||||
line.description.description_english = line.part_info.description_english
|
||||
else:
|
||||
if not line.description.description_spanish:
|
||||
class_desc = (
|
||||
db.query(Class.description_es, Class.description_en)
|
||||
.filter(Class.id == line.class_id, Class.tenant_id == tenant_id, Class.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
if class_desc:
|
||||
line.description.description_spanish, line.description.description_english = class_desc
|
||||
|
||||
|
||||
def caluclate_values(
|
||||
db: Session, line: LineItem, tenant_id: int, company_id: int
|
||||
):
|
||||
result = (
|
||||
db.query(InvoiceFinancials.currency, InvoiceFinancials.exchange_rate)
|
||||
.filter(InvoiceFinancials.invoice_id == line.invoice_id, InvoiceFinancials.tenant_id == tenant_id, InvoiceFinancials.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
if not result:
|
||||
return
|
||||
|
||||
currency, exchange_rate = result
|
||||
|
||||
if currency == "foreign":
|
||||
line.financial.unit_cost_usd = line.financial.unit_cost_capture
|
||||
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
|
||||
line.financial.unit_cost_mxn = line.financial.unit_cost_capture * exchange_rate
|
||||
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
|
||||
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
|
||||
elif currency == "local":
|
||||
line.financial.unit_cost_mxn = line.financial.unit_cost_capture
|
||||
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
|
||||
line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate
|
||||
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
|
||||
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
|
||||
elif currency == "manual":
|
||||
line.financial.unit_cost_usd = line.financial.unit_cost_capture/exchange_rate
|
||||
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
|
||||
line.financial.unit_cost_mxn = line.financial.unit_cost_usd * exchange_rate
|
||||
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
|
||||
line.financial.value_mc = line.financial.unit_cost_capture * line.quantity.quantity
|
||||
@@ -188,6 +188,23 @@ def validate_create(
|
||||
line.financial.unit_cost_mxn = unit_cost_capture
|
||||
# Si es otro tipo de moneda, dejamos el costo como está
|
||||
|
||||
# Calcular valores totales basados en cantidad y costo unitario
|
||||
quantity = line.quantity.quantity or Decimal("0")
|
||||
|
||||
# Valor Comercial
|
||||
if line.financial.unit_cost_usd is not None:
|
||||
line.financial.value_usd = line.financial.unit_cost_usd * quantity
|
||||
if line.financial.unit_cost_mxn is not None:
|
||||
line.financial.value_mxn = line.financial.unit_cost_mxn * quantity
|
||||
|
||||
# Valor Aduanas (asumiendo que es igual al Valor Comercial por defecto)
|
||||
line.financial.customs_value_usd = line.financial.value_usd
|
||||
line.financial.customs_value_mxn = line.financial.value_mxn
|
||||
|
||||
# Valor MP Temp (Materia Prima Temporal)
|
||||
line.financial.value_temp_material_usd = line.financial.value_usd
|
||||
line.financial.value_temp_material_mxn = line.financial.value_mxn
|
||||
|
||||
# ==========================================
|
||||
# VALIDAR Y CONVERTIR PESOS NETOS
|
||||
# ==========================================
|
||||
|
||||
@@ -64,6 +64,30 @@ def validate_update(
|
||||
# Costo unitario
|
||||
if line.financial.unit_cost_capture is None:
|
||||
line.financial.unit_cost_capture = existing_line.financial.unit_cost_capture
|
||||
|
||||
# Recalcular valores monetarios si el costo o la cantidad cambian
|
||||
currency_type = invoice.financials.currency_type
|
||||
unit_cost_capture = line.financial.unit_cost_capture or Decimal("0")
|
||||
|
||||
if currency_type in ["USD", "ME"]:
|
||||
line.financial.unit_cost_usd = unit_cost_capture
|
||||
line.financial.unit_cost_mxn = unit_cost_capture * exchange_rate
|
||||
elif currency_type in ["MXN", "MN"]:
|
||||
line.financial.unit_cost_usd = (unit_cost_capture / exchange_rate) if exchange_rate else Decimal("0")
|
||||
line.financial.unit_cost_mxn = unit_cost_capture
|
||||
|
||||
quantity = line.quantity.quantity if line.quantity.quantity is not None else existing_line.quantity.quantity
|
||||
|
||||
if line.financial.unit_cost_usd is not None:
|
||||
line.financial.value_usd = line.financial.unit_cost_usd * quantity
|
||||
if line.financial.unit_cost_mxn is not None:
|
||||
line.financial.value_mxn = line.financial.unit_cost_mxn * quantity
|
||||
|
||||
line.financial.customs_value_usd = line.financial.value_usd
|
||||
line.financial.customs_value_mxn = line.financial.value_mxn
|
||||
|
||||
line.financial.value_temp_material_usd = line.financial.value_usd
|
||||
line.financial.value_temp_material_mxn = line.financial.value_mxn
|
||||
|
||||
# Convertir peso neto si se proporcionó
|
||||
invoice_weight_type = invoice.logistics.weight_type
|
||||
|
||||
@@ -4,7 +4,7 @@ Complete nested one-to-one structure:
|
||||
LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, Union
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
@@ -58,13 +58,13 @@ class LineItemBase(BaseModel):
|
||||
line_number: int = Field(..., description="Line number")
|
||||
|
||||
# Part identification
|
||||
part_number_id: Optional[int] = Field(
|
||||
part_number_id: Union[int, str, None] = Field(
|
||||
None,
|
||||
description="Part number",
|
||||
alias="part_number",
|
||||
serialization_alias="part_number_id",
|
||||
)
|
||||
component_part_number_id: Optional[int] = Field(
|
||||
component_part_number_id: Union[int, str, None] = Field(
|
||||
None,
|
||||
description="Component part number",
|
||||
alias="component_part_number",
|
||||
|
||||
@@ -36,6 +36,7 @@ from .line_references.models import LineReference
|
||||
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
from .models import LineItem
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +46,33 @@ class ItemService:
|
||||
Service for managing Items and related entities with tenant/company isolation
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_part_number(
|
||||
db: Session,
|
||||
part_number: Optional[str],
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> Optional[int]:
|
||||
"""Try to resolve a part number string to its database ID."""
|
||||
if not part_number:
|
||||
return None
|
||||
|
||||
# If it's already an integer (or a string representing an integer), it might be the ID
|
||||
try:
|
||||
return int(part_number)
|
||||
except (ValueError, TypeError):
|
||||
# It's a string part number (e.g., "MAQ-001"), look it up
|
||||
part = (
|
||||
db.query(Part)
|
||||
.filter(
|
||||
Part.part_number == part_number,
|
||||
Part.tenant_id == tenant_id,
|
||||
Part.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return part.id if part else None
|
||||
|
||||
@staticmethod
|
||||
def _get_next_line_number(db: Session, invoice_id: int) -> int:
|
||||
"""Calculate the next line_number for a given invoice based on database."""
|
||||
@@ -282,6 +310,24 @@ class ItemService:
|
||||
# Calculate the next line number for this single item
|
||||
line_number = ItemService._get_next_line_number(db, item_data.invoice_id)
|
||||
|
||||
# Resolve part ID if a string is provided in part_number (alias for part_number_id)
|
||||
if item_data.part_number_id and not isinstance(item_data.part_number_id, int):
|
||||
resolved_id = ItemService._resolve_part_number(
|
||||
db, str(item_data.part_number_id), tenant_id, company_id
|
||||
)
|
||||
if resolved_id:
|
||||
item_data.part_number_id = resolved_id
|
||||
|
||||
# Resolve component part ID
|
||||
if item_data.component_part_number_id and not isinstance(
|
||||
item_data.component_part_number_id, int
|
||||
):
|
||||
resolved_id = ItemService._resolve_part_number(
|
||||
db, str(item_data.component_part_number_id), tenant_id, company_id
|
||||
)
|
||||
if resolved_id:
|
||||
item_data.component_part_number_id = resolved_id
|
||||
|
||||
# Validar el item
|
||||
validate_create(
|
||||
db,
|
||||
@@ -378,6 +424,24 @@ class ItemService:
|
||||
):
|
||||
errors.raise_if_errors("Error al actualizar el item")
|
||||
|
||||
# Resolve part ID if a string is provided in part_number (alias for part_number_id)
|
||||
if hasattr(item_data, 'part_number_id') and item_data.part_number_id and not isinstance(item_data.part_number_id, int):
|
||||
resolved_id = ItemService._resolve_part_number(
|
||||
db, str(item_data.part_number_id), tenant_id, company_id
|
||||
)
|
||||
if resolved_id:
|
||||
item_data.part_number_id = resolved_id
|
||||
|
||||
# Resolve component part ID
|
||||
if hasattr(item_data, 'component_part_number_id') and item_data.component_part_number_id and not isinstance(
|
||||
item_data.component_part_number_id, int
|
||||
):
|
||||
resolved_id = ItemService._resolve_part_number(
|
||||
db, str(item_data.component_part_number_id), tenant_id, company_id
|
||||
)
|
||||
if resolved_id:
|
||||
item_data.component_part_number_id = resolved_id
|
||||
|
||||
# Validar el item que se va a actualizar
|
||||
validate_update(
|
||||
db,
|
||||
|
||||
@@ -6,6 +6,8 @@ from sqlalchemy.orm import Session
|
||||
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
|
||||
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
|
||||
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
|
||||
from api.v1.modules.public.reference_data.transport_types.models import TransportType
|
||||
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
|
||||
|
||||
# Import A76 Services
|
||||
from api.v1.modules.a76.customs_brokers.services import CustomsBrokerService
|
||||
@@ -15,6 +17,8 @@ from api.v1.modules.a76.clients_and_providers.service import ClientProviderServi
|
||||
from api.v1.modules.public.reference_data.pedimento_codes.dto import PedimentoCodeDTO
|
||||
from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO
|
||||
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
|
||||
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
|
||||
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
|
||||
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
|
||||
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
|
||||
from .dtos.pedimentos import PedimentosResponse
|
||||
@@ -53,6 +57,20 @@ class PedimentoCatalogService:
|
||||
except Exception as e:
|
||||
print(f"Error fetching code_pedimento_regimens: {e}")
|
||||
|
||||
try:
|
||||
response.transport_types = [
|
||||
TransportTypeDTO.model_validate(obj) for obj in db.query(TransportType).limit(200).all()
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error fetching transport_types: {e}")
|
||||
|
||||
try:
|
||||
response.transport_modes = [
|
||||
TransportModeDTO.model_validate(obj) for obj in db.query(TransportMode).limit(100).all()
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error fetching transport_modes: {e}")
|
||||
|
||||
# Helper to fetch tenant/company specific data
|
||||
def fetch_tenant_data():
|
||||
# Customs Brokers
|
||||
|
||||
@@ -17,10 +17,10 @@ class PedimentoDecrementablesBase(BaseModel):
|
||||
others: Optional[Decimal] = Field(None, description="Others")
|
||||
currency: Optional[str] = Field(None, max_length=3, description="Currency")
|
||||
currency_factor: Optional[Decimal] = Field(None, description="Currency factor")
|
||||
not_affect_usd_value: Optional[bool] = Field(
|
||||
not_affect_usd_value: Optional[int] = Field(
|
||||
None, description="Not affect USD value"
|
||||
)
|
||||
not_affect_customs_value: Optional[bool] = Field(
|
||||
not_affect_customs_value: Optional[int] = Field(
|
||||
None, description="Not affect customs value"
|
||||
)
|
||||
|
||||
@@ -41,8 +41,8 @@ class PedimentoDecrementablesUpdate(BaseModel):
|
||||
others: Optional[Decimal] = None
|
||||
currency: Optional[str] = Field(None, max_length=3)
|
||||
currency_factor: Optional[Decimal] = None
|
||||
not_affect_usd_value: Optional[bool] = None
|
||||
not_affect_customs_value: Optional[bool] = None
|
||||
not_affect_usd_value: Optional[int] = None
|
||||
not_affect_customs_value: Optional[int] = None
|
||||
|
||||
|
||||
class PedimentoDecrementablesResponse(PedimentoDecrementablesBase):
|
||||
|
||||
@@ -18,10 +18,10 @@ class PedimentoIncrementablesBase(BaseModel):
|
||||
deductibles: Optional[Decimal] = Field(None, description="Deductibles")
|
||||
currency: Optional[str] = Field(None, max_length=3, description="Currency")
|
||||
currency_factor: Optional[Decimal] = Field(None, description="Currency factor")
|
||||
not_affect_usd_value: Optional[bool] = Field(
|
||||
not_affect_usd_value: Optional[int] = Field(
|
||||
None, description="Not affect USD value"
|
||||
)
|
||||
not_affect_customs_value: Optional[bool] = Field(
|
||||
not_affect_customs_value: Optional[int] = Field(
|
||||
None, description="Not affect customs value"
|
||||
)
|
||||
|
||||
@@ -43,8 +43,8 @@ class PedimentoIncrementablesUpdate(BaseModel):
|
||||
deductibles: Optional[Decimal] = None
|
||||
currency: Optional[str] = Field(None, max_length=3)
|
||||
currency_factor: Optional[Decimal] = None
|
||||
not_affect_usd_value: Optional[bool] = None
|
||||
not_affect_customs_value: Optional[bool] = None
|
||||
not_affect_usd_value: Optional[int] = None
|
||||
not_affect_customs_value: Optional[int] = None
|
||||
|
||||
|
||||
class PedimentoIncrementablesResponse(PedimentoIncrementablesBase):
|
||||
|
||||
@@ -40,21 +40,21 @@ class PedimentoRectificationOrigin(Base, TenantScopedMixin, TimestampMixin):
|
||||
id: Mapped[int] = mapped_column(Integer)
|
||||
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
original_pedimento_year: Mapped[str] = mapped_column(String(2))
|
||||
original_customs_office: Mapped[str] = mapped_column(String(3))
|
||||
original_license: Mapped[str] = mapped_column(String(4))
|
||||
original_pedimento_number: Mapped[str] = mapped_column(String(7))
|
||||
original_pedimento_code: Mapped[str] = mapped_column(String(2))
|
||||
original_payment_date: Mapped[datetime] = mapped_column(DateTime)
|
||||
total_cash: Mapped[int] = mapped_column(Integer)
|
||||
total_others: Mapped[int] = mapped_column(Integer)
|
||||
reason: Mapped[str] = mapped_column(String(255))
|
||||
charge_to_client: Mapped[int] = mapped_column(SmallInteger)
|
||||
use_original_payment_date_for_interest_calc: Mapped[int] = mapped_column(
|
||||
SmallInteger
|
||||
original_pedimento_year: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
original_customs_office: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
original_license: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
original_pedimento_number: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
original_pedimento_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
original_payment_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
total_cash: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
total_others: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
charge_to_client: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
||||
use_original_payment_date_for_interest_calc: Mapped[int | None] = mapped_column(
|
||||
SmallInteger, nullable=True
|
||||
)
|
||||
manual_calculation: Mapped[int] = mapped_column(SmallInteger)
|
||||
original_pedimento_norms: Mapped[int] = mapped_column(SmallInteger)
|
||||
manual_calculation: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
||||
original_pedimento_norms: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
||||
|
||||
pedimento: Mapped["Pedimentos"] = relationship(
|
||||
"Pedimentos", back_populates="pedimento_rectification_origin"
|
||||
|
||||
@@ -11,6 +11,8 @@ from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSec
|
||||
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
|
||||
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
|
||||
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
|
||||
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
|
||||
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
|
||||
from .dtos.pedimentos import PedimentosResponse
|
||||
|
||||
|
||||
@@ -22,6 +24,8 @@ class PedimentoCatalogsResponse(BaseModel):
|
||||
code_pedimento_regimens: List[CodePedimentoRegimenDTO] = []
|
||||
customs_brokers: List[CustomsBrokerResponseDTO] = []
|
||||
clients: List[ClientProviderResponseDTO] = []
|
||||
transport_types: List[TransportTypeDTO] = []
|
||||
transport_modes: List[TransportModeDTO] = []
|
||||
|
||||
|
||||
class PedimentoCreationResponse(PedimentoCatalogsResponse):
|
||||
|
||||
@@ -28,6 +28,7 @@ class PedimentoRectificationDestinationService:
|
||||
.filter(
|
||||
PedimentoRectificationDestination.pedimento_id == pedimento_id,
|
||||
PedimentoRectificationDestination.tenant_id == tenant_id,
|
||||
PedimentoRectificationDestination.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ class PedimentoRectificationOriginService:
|
||||
.filter(
|
||||
PedimentoRectificationOrigin.pedimento_id == pedimento_id,
|
||||
PedimentoRectificationOrigin.tenant_id == tenant_id,
|
||||
PedimentoRectificationOrigin.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -148,7 +148,7 @@ class PedimentosService:
|
||||
Pedimento or None if not found
|
||||
"""
|
||||
query = db.query(Pedimentos).filter(
|
||||
Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id
|
||||
Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id
|
||||
)
|
||||
|
||||
if company_id is not None:
|
||||
@@ -199,6 +199,25 @@ class PedimentosService:
|
||||
Created pedimento
|
||||
"""
|
||||
try:
|
||||
# Check for existing pedimento with same key (Year, Aduana, Patente, Number)
|
||||
# This avoids IntegrityError in many cases and provides a better error message.
|
||||
existing = db.query(Pedimentos).filter(
|
||||
Pedimentos.tenant_id == tenant_id,
|
||||
Pedimentos.company_id == company_id,
|
||||
Pedimentos.year == pedimento_data.year,
|
||||
Pedimentos.customs_office == pedimento_data.customs_office,
|
||||
Pedimentos.license == pedimento_data.license,
|
||||
Pedimentos.pedimento_number == pedimento_data.pedimento_number,
|
||||
Pedimentos.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise ValueError(
|
||||
f"Ya existe un pedimento con estos datos: {pedimento_data.year}-{pedimento_data.customs_office}-{pedimento_data.license}-{pedimento_data.pedimento_number}"
|
||||
)
|
||||
|
||||
# Extraer datos de tablas relacionadas
|
||||
|
||||
# Extraer datos de tablas relacionadas
|
||||
related_data = {
|
||||
'pedimento_dates': pedimento_data.pedimento_dates,
|
||||
@@ -329,11 +348,23 @@ class PedimentosService:
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
# Detectar si es un error de pedimento duplicado
|
||||
error_msg = str(e.orig)
|
||||
if 'pedimentos_unique_key' in error_msg or 'duplicate key value violates unique constraint' in error_msg:
|
||||
logger.warning(f"Attempted to create duplicate pedimento: {e}")
|
||||
raise ValueError("Ya existe un pedimento con estos datos (Año, Aduana, Patente, Número)")
|
||||
# Detectar si es un error de integridad de duplicados o similar
|
||||
error_msg = str(e.orig).lower()
|
||||
|
||||
# Case-insensitive check and support for both Spanish and English common error patterns
|
||||
is_unique_violation = any(kw in error_msg for kw in [
|
||||
'pedimentos_unique_key',
|
||||
'unique constraint',
|
||||
'duplicate key',
|
||||
'duplicada',
|
||||
'unicidad',
|
||||
'ya existe'
|
||||
])
|
||||
|
||||
if is_unique_violation:
|
||||
logger.warning(f"Attempted to create duplicate pedimento or common record: {e}")
|
||||
raise ValueError("Ya existe un pedimento o registro relacionado con estos datos. Verifica los campos únicos.")
|
||||
|
||||
logger.error(f"Integrity error creating pedimento: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -363,6 +394,9 @@ class PedimentosService:
|
||||
if not pedimento:
|
||||
return None
|
||||
|
||||
# Ensure company_id is set from the existing record
|
||||
company_id = pedimento.company_id
|
||||
|
||||
try:
|
||||
# Actualizar campos principales del pedimento
|
||||
update_data = pedimento_data.model_dump(exclude_unset=True, exclude={
|
||||
|
||||
@@ -71,12 +71,18 @@ class ConsolidadoImportacionMexService:
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
"""
|
||||
Formatea un número con separadores de miles y decimales especificados.
|
||||
Retorna una cadena formateada para mostrar en reportes.
|
||||
"""
|
||||
if valor is None:
|
||||
return 0.0
|
||||
valor = 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
num = round(float(valor), decimales)
|
||||
# Formatear con separadores de miles y decimales
|
||||
return f"{num:,.{decimales}f}"
|
||||
except:
|
||||
return 0.0
|
||||
return f"0.{'0' * decimales}"
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
|
||||
@@ -103,12 +103,18 @@ class FacturaImportacionMexService:
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
"""
|
||||
Formatea un número con separadores de miles y decimales especificados.
|
||||
Retorna una cadena formateada para mostrar en reportes.
|
||||
"""
|
||||
if valor is None:
|
||||
return 0.0
|
||||
valor = 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
num = round(float(valor), decimales)
|
||||
# Formatear con separadores de miles y decimales
|
||||
return f"{num:,.{decimales}f}"
|
||||
except:
|
||||
return 0.0
|
||||
return f"0.{'0' * decimales}"
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
|
||||
@@ -41,9 +41,10 @@ async def trigger_descarga_factura(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(..., description="ID de la empresa"),
|
||||
invoice_type: str = Query('mexican', description="Tipo de factura: 'mexican' o 'american'"),
|
||||
currency_code: str = Query('ORIGINAL', description="Moneda: 'MXN', 'USD', o 'ORIGINAL'"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db)
|
||||
):
|
||||
validate_access_to_resource(db, company_id, current_user)
|
||||
task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type)
|
||||
task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type, currency_code)
|
||||
return {"task_id": task.id, "message": "Generación iniciada"}
|
||||
@@ -103,11 +103,12 @@ class FacturaImportacionUsaService:
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None:
|
||||
return 0.0
|
||||
valor = 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
num = round(float(valor), decimales)
|
||||
return f"{num:,.{decimales}f}"
|
||||
except:
|
||||
return 0.0
|
||||
return f"0.{'0' * decimales}"
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
|
||||
@@ -66,10 +66,18 @@ class PackingListService:
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return 0.0
|
||||
"""
|
||||
Formatea un número con separadores de miles y decimales especificados.
|
||||
Retorna una cadena formateada para mostrar en reportes.
|
||||
"""
|
||||
if valor is None:
|
||||
valor = 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
except: return 0.0
|
||||
num = round(float(valor), decimales)
|
||||
# Formatear con separadores de miles y decimales
|
||||
return f"{num:,.{decimales}f}"
|
||||
except:
|
||||
return f"0.{'0' * decimales}"
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
CSV generation utilities for invoice movement reports.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from typing import List, Union
|
||||
from datetime import datetime, date
|
||||
|
||||
from .schemas import MovementItem, MovementItemDetailed, AllMovementsFilter
|
||||
|
||||
|
||||
def generate_csv_from_movements(
|
||||
movements: List[Union[MovementItem, MovementItemDetailed]],
|
||||
filters: AllMovementsFilter
|
||||
) -> str:
|
||||
"""
|
||||
Generate CSV content from movement items.
|
||||
|
||||
Args:
|
||||
movements: List of movement items (normal or detailed)
|
||||
filters: Filter object containing report parameters
|
||||
|
||||
Returns:
|
||||
CSV content as string
|
||||
"""
|
||||
output = io.StringIO()
|
||||
|
||||
if filters.report_type.value.lower() == "normal":
|
||||
# Normal report
|
||||
fieldnames = [
|
||||
# Identification
|
||||
'Factura', 'Pedimento', 'FechaFactura', 'ClavePed',
|
||||
# Values
|
||||
'ValorComercialMN', 'ValorMPTemp', 'TipoCambio', 'ValorAgre',
|
||||
# Classification
|
||||
'TipoMovTemDef', 'Estatus', 'TipoExpo', 'EsCambioRegimen',
|
||||
# Dates
|
||||
'Fecha_Pago',
|
||||
# References
|
||||
'PedimentoR1', 'EDocument', 'NumOperacionVU',
|
||||
# Logistics
|
||||
'NumCaja', 'NumGafUni', 'AduanaCru',
|
||||
# Metadata
|
||||
'BaseDeDatos', 'UsuarioCap', 'UsuarioAcr'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
|
||||
for movement in movements:
|
||||
row = movement.model_dump()
|
||||
# Format datetime fields
|
||||
row['FechaFactura'] = _format_datetime(row.get('FechaFactura'))
|
||||
row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago'))
|
||||
|
||||
# Format numeric fields
|
||||
row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN'))
|
||||
row['TipoCambio'] = _format_decimal(row.get('TipoCambio'))
|
||||
row['ValorMPTemp'] = _format_decimal(row.get('ValorMPTemp'))
|
||||
row['ValorAgre'] = _format_decimal(row.get('ValorAgre'))
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
else:
|
||||
# Detailed report
|
||||
fieldnames = [
|
||||
# Identification
|
||||
'Linea', 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed',
|
||||
# Parties
|
||||
'Proveedor', 'RFCProveedor', 'ProveedorTaxID',
|
||||
'VendidoA', 'VendidoARFC', 'VendidoATaxID',
|
||||
# Customs broker
|
||||
'AgenteAduanal', 'Patente',
|
||||
# Product
|
||||
'NumParte', 'DescripcionE', 'DescripcionI', 'CantidadIE', 'UniMed',
|
||||
# Classification
|
||||
'FraccionArancelaria', 'FraccionAmericana', 'ECCN', 'Sector', 'PaisOrigen',
|
||||
# Values
|
||||
'ValorComercialMN', 'TipoCambio', 'PesoNeto', 'PesoBruto',
|
||||
# Customs
|
||||
'TipoMovTemDef', 'Regimen', 'Aduana', 'Advalorem', 'Preferencia',
|
||||
# References
|
||||
'OrdenCompraVenta', 'Remesa', 'PedimentoR1', 'EDocument', 'NumOperacionVU',
|
||||
# Identifiers
|
||||
'Series', 'Marca', 'Modelo', 'SimboloEx',
|
||||
# Dates
|
||||
'Fecha_Pago', 'Fecha_Inicio', 'Fecha_Fin', 'FechaEmision',
|
||||
# Logistics
|
||||
'Transportista', 'NumCaja', 'NumGafUni', 'AduanaCru', 'Lote',
|
||||
# Metadata
|
||||
'Estatus', 'BaseDeDatos', 'TipoExpo', 'EsCambioRegimen', 'Pedimento18',
|
||||
'UsuarioCap', 'UsuarioAcr'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
|
||||
for movement in movements:
|
||||
row = movement.model_dump()
|
||||
# Format datetime fields
|
||||
row['FechaFactura'] = _format_datetime(row.get('FechaFactura'))
|
||||
row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago'))
|
||||
row['Fecha_Inicio'] = _format_datetime(row.get('Fecha_Inicio'))
|
||||
row['Fecha_Fin'] = _format_datetime(row.get('Fecha_Fin'))
|
||||
row['FechaEmision'] = _format_datetime(row.get('FechaEmision'))
|
||||
|
||||
# Format numeric fields
|
||||
row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN'))
|
||||
row['TipoCambio'] = _format_decimal(row.get('TipoCambio'))
|
||||
row['CantidadIE'] = _format_decimal(row.get('CantidadIE'))
|
||||
row['PesoNeto'] = _format_decimal(row.get('PesoNeto'))
|
||||
row['PesoBruto'] = _format_decimal(row.get('PesoBruto'))
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
csv_content = output.getvalue()
|
||||
output.close()
|
||||
return csv_content
|
||||
|
||||
|
||||
def _format_datetime(dt) -> str:
|
||||
"""Format datetime for CSV export."""
|
||||
if not dt or dt == '' or dt == '-' or dt == '0':
|
||||
return ''
|
||||
try:
|
||||
if isinstance(dt, str):
|
||||
if 'T' in dt:
|
||||
dt_obj = datetime.strptime(dt.split('T')[0], '%Y-%m-%d')
|
||||
elif len(dt) == 8 and dt.isdigit():
|
||||
dt_obj = datetime.strptime(dt, '%Y%m%d')
|
||||
elif '-' in dt:
|
||||
dt_obj = datetime.strptime(dt, '%Y-%m-%d')
|
||||
else:
|
||||
return dt
|
||||
elif isinstance(dt, (datetime, date)):
|
||||
dt_obj = dt
|
||||
else:
|
||||
return ''
|
||||
return dt_obj.strftime('%d/%m/%Y')
|
||||
except (ValueError, TypeError):
|
||||
return str(dt) if dt else ''
|
||||
|
||||
|
||||
def _format_decimal(value, decimals: int = 2) -> str:
|
||||
"""Format decimal values for CSV export."""
|
||||
if value is None:
|
||||
return ''
|
||||
try:
|
||||
return f"{float(value):.{decimals}f}"
|
||||
except (ValueError, TypeError):
|
||||
return str(value) if value else ''
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Unified service for invoice movement operations.
|
||||
This service delegates to specialized handlers for each import type.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from .schemas import (
|
||||
ImportTemporaryFilter,
|
||||
ImportDefinitiveFilter,
|
||||
ImportRepairFilter,
|
||||
ExportFilter,
|
||||
ExportRepairFilter,
|
||||
AllMovementsFilter,
|
||||
MovementItem,
|
||||
MovementItemDetailed,
|
||||
ReportType
|
||||
)
|
||||
from .services.temporary import TemporaryImportService
|
||||
from .services.definitive import DefinitiveImportService
|
||||
from .services.repair import RepairImportService
|
||||
from .services.export import ExportService
|
||||
from .services.export_repair import ExportRepairService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MovementService:
|
||||
"""
|
||||
Unified service for handling all types of movements.
|
||||
Delegates to specialized services for each movement type.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.temporary_service = TemporaryImportService()
|
||||
self.definitive_service = DefinitiveImportService()
|
||||
self.repair_service = RepairImportService()
|
||||
self.export_service = ExportService()
|
||||
self.export_repair_service = ExportRepairService()
|
||||
|
||||
# ===== TEMPORARY IMPORTS =====
|
||||
|
||||
def get_temporary_import_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportTemporaryFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get temporary import movements (normal mode - grouped by invoice)."""
|
||||
return self.temporary_service.get_movements(db, filters)
|
||||
|
||||
def get_temporary_import_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportTemporaryFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get temporary import movements (detailed mode - line by line)."""
|
||||
return self.temporary_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== DEFINITIVE IMPORTS =====
|
||||
|
||||
def get_definitive_import_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportDefinitiveFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get definitive import movements (normal mode - grouped by invoice)."""
|
||||
return self.definitive_service.get_movements(db, filters)
|
||||
|
||||
def get_definitive_import_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportDefinitiveFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get definitive import movements (detailed mode - line by line)."""
|
||||
return self.definitive_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== REPAIR IMPORTS =====
|
||||
|
||||
def get_repair_import_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportRepairFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get repair import movements (normal mode - grouped by invoice)."""
|
||||
return self.repair_service.get_movements(db, filters)
|
||||
|
||||
def get_repair_import_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportRepairFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get repair import movements (detailed mode - line by line)."""
|
||||
return self.repair_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== EXPORTS =====
|
||||
|
||||
def get_export_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get export movements (normal mode - grouped by invoice)."""
|
||||
return self.export_service.get_movements(db, filters)
|
||||
|
||||
def get_export_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get export movements (detailed mode - line by line)."""
|
||||
return self.export_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== EXPORT REPAIRS =====
|
||||
|
||||
def get_export_repair_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportRepairFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get export repair movements (normal mode - grouped by invoice)."""
|
||||
return self.export_repair_service.get_movements(db, filters)
|
||||
|
||||
def get_export_repair_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportRepairFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get export repair movements (detailed mode - line by line)."""
|
||||
return self.export_repair_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== ALL MOVEMENTS =====
|
||||
|
||||
def get_all_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: AllMovementsFilter
|
||||
) -> List[MovementItem]:
|
||||
"""
|
||||
Get all invoice movements (all types combined).
|
||||
|
||||
This combines:
|
||||
- Temporary imports
|
||||
- Definitive imports
|
||||
- Repair imports
|
||||
- All export types
|
||||
- Export repairs
|
||||
|
||||
Returns a unified list sorted by date.
|
||||
"""
|
||||
all_movements = []
|
||||
|
||||
# Convert AllMovementsFilter to individual filter types
|
||||
# We'll use the same filter parameters for all queries
|
||||
|
||||
# Determine which services to call based on granular flags
|
||||
# Default behavior: If granular flags are all defaults (True) but operation_type is set,
|
||||
# we might need to respect operation_type.
|
||||
# But for simplicity, we assume granular flags from frontend are the source of truth.
|
||||
# If frontend didn't set them (legacy call?), they default to True.
|
||||
|
||||
# Override based on operation_type if provided (legacy compatibility or coarse filter)
|
||||
if filters.operation_type == 'imp':
|
||||
filters.export_def = False
|
||||
filters.export_rep = False
|
||||
elif filters.operation_type == 'exp':
|
||||
filters.import_temp = False
|
||||
filters.import_def = False
|
||||
filters.import_rep = False
|
||||
|
||||
logger.info(f"Fetching movements with flags: Temp={filters.import_temp}, Def={filters.import_def}, Rep={filters.import_rep}, ExpDef={filters.export_def}, ExpRep={filters.export_rep}")
|
||||
|
||||
# 1. Temporary Imports
|
||||
if filters.import_temp:
|
||||
temp_filter = ImportTemporaryFilter(
|
||||
range_type=filters.range_type,
|
||||
start_date=filters.start_date,
|
||||
end_date=filters.end_date,
|
||||
include_cancelled=filters.include_cancelled,
|
||||
provider=filters.provider,
|
||||
buyer=filters.buyer,
|
||||
pedimento_code=filters.pedimento_code,
|
||||
report_type=filters.report_type,
|
||||
currency_type=filters.currency_type,
|
||||
exchange_rate_type=filters.exchange_rate_type,
|
||||
is_shelter=filters.is_shelter,
|
||||
database_name='default'
|
||||
)
|
||||
if filters.report_type == ReportType.DETAILED:
|
||||
temp_movements = self.temporary_service.get_movements_detailed(db, temp_filter)
|
||||
else:
|
||||
temp_movements = self.temporary_service.get_movements(db, temp_filter)
|
||||
all_movements.extend(temp_movements)
|
||||
logger.info(f"Added {len(temp_movements)} temporary import movements")
|
||||
|
||||
# 2. Definitive Imports
|
||||
if filters.import_def:
|
||||
def_filter = ImportDefinitiveFilter(
|
||||
range_type=filters.range_type,
|
||||
start_date=filters.start_date,
|
||||
end_date=filters.end_date,
|
||||
include_cancelled=filters.include_cancelled,
|
||||
provider=filters.provider,
|
||||
buyer=filters.buyer,
|
||||
pedimento_code=filters.pedimento_code,
|
||||
report_type=filters.report_type,
|
||||
currency_type=filters.currency_type,
|
||||
exchange_rate_type=filters.exchange_rate_type,
|
||||
is_shelter=filters.is_shelter,
|
||||
database_name='default',
|
||||
movement_type='ALL',
|
||||
use_transport_method=False
|
||||
)
|
||||
if filters.report_type == ReportType.DETAILED:
|
||||
def_movements = self.definitive_service.get_movements_detailed(db, def_filter)
|
||||
else:
|
||||
def_movements = self.definitive_service.get_movements(db, def_filter)
|
||||
all_movements.extend(def_movements)
|
||||
logger.info(f"Added {len(def_movements)} definitive import movements")
|
||||
|
||||
# 3. Repair Imports
|
||||
if filters.import_rep:
|
||||
repair_filter = ImportRepairFilter(
|
||||
range_type=filters.range_type,
|
||||
start_date=filters.start_date,
|
||||
end_date=filters.end_date,
|
||||
include_cancelled=filters.include_cancelled,
|
||||
provider=filters.provider,
|
||||
buyer=filters.buyer,
|
||||
pedimento_code=filters.pedimento_code,
|
||||
report_type=filters.report_type,
|
||||
currency_type=filters.currency_type,
|
||||
exchange_rate_type=filters.exchange_rate_type,
|
||||
is_shelter=filters.is_shelter,
|
||||
database_name='default',
|
||||
discharge_filter='ALL',
|
||||
use_transport_method=False
|
||||
)
|
||||
if filters.report_type == ReportType.DETAILED:
|
||||
repair_movements = self.repair_service.get_movements_detailed(db, repair_filter)
|
||||
else:
|
||||
repair_movements = self.repair_service.get_movements(db, repair_filter)
|
||||
all_movements.extend(repair_movements)
|
||||
logger.info(f"Added {len(repair_movements)} repair import movements")
|
||||
|
||||
# 4. Exports (Definitive)
|
||||
if filters.export_def:
|
||||
export_filter = ExportFilter(
|
||||
range_type=filters.range_type,
|
||||
start_date=filters.start_date,
|
||||
end_date=filters.end_date,
|
||||
include_cancelled=filters.include_cancelled,
|
||||
provider=filters.provider,
|
||||
buyer=filters.buyer,
|
||||
pedimento_code=filters.pedimento_code,
|
||||
report_type=filters.report_type,
|
||||
currency_type=filters.currency_type,
|
||||
exchange_rate_type=filters.exchange_rate_type,
|
||||
is_shelter=filters.is_shelter,
|
||||
database_name='default',
|
||||
movement_type='ALL',
|
||||
discharge_filter='ALL',
|
||||
use_transport_method=False
|
||||
)
|
||||
if filters.report_type == ReportType.DETAILED:
|
||||
export_movements = self.export_service.get_movements_detailed(db, export_filter)
|
||||
else:
|
||||
export_movements = self.export_service.get_movements(db, export_filter)
|
||||
all_movements.extend(export_movements)
|
||||
logger.info(f"Added {len(export_movements)} export movements")
|
||||
|
||||
# 5. Export Repairs
|
||||
if filters.export_rep:
|
||||
export_repair_filter = ExportRepairFilter(
|
||||
range_type=filters.range_type,
|
||||
start_date=filters.start_date,
|
||||
end_date=filters.end_date,
|
||||
include_cancelled=filters.include_cancelled,
|
||||
provider=filters.provider,
|
||||
buyer=filters.buyer,
|
||||
pedimento_code=filters.pedimento_code,
|
||||
report_type=filters.report_type,
|
||||
currency_type=filters.currency_type,
|
||||
exchange_rate_type=filters.exchange_rate_type,
|
||||
is_shelter=filters.is_shelter,
|
||||
database_name='default',
|
||||
movement_type='ALL',
|
||||
discharge_filter='ALL'
|
||||
)
|
||||
if filters.report_type == ReportType.DETAILED:
|
||||
export_repair_movements = self.export_repair_service.get_movements_detailed(db, export_repair_filter)
|
||||
else:
|
||||
export_repair_movements = self.export_repair_service.get_movements(db, export_repair_filter)
|
||||
all_movements.extend(export_repair_movements)
|
||||
logger.info(f"Added {len(export_repair_movements)} export repair movements")
|
||||
|
||||
# Sort all movements by date (Fecha field)
|
||||
# Handle mixed datetime and string types
|
||||
def get_sort_key(movement):
|
||||
fecha = movement.FechaFactura
|
||||
if not fecha:
|
||||
return ""
|
||||
# Convert datetime to string for consistent comparison
|
||||
if hasattr(fecha, 'strftime'):
|
||||
return fecha.strftime('%Y%m%d')
|
||||
return str(fecha)
|
||||
|
||||
all_movements.sort(key=get_sort_key)
|
||||
|
||||
logger.info(f"Total movements combined: {len(all_movements)}")
|
||||
return all_movements
|
||||
|
||||
|
||||
# Singleton instance
|
||||
movement_service = MovementService()
|
||||
748
backend/api/v1/modules/a76/reports/movements/invoices/routes.py
Normal file
748
backend/api/v1/modules/a76/reports/movements/invoices/routes.py
Normal file
@@ -0,0 +1,748 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Union
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from .schemas import (
|
||||
ImportTemporaryFilter,
|
||||
ImportDefinitiveFilter,
|
||||
ImportRepairFilter,
|
||||
ExportFilter,
|
||||
ExportRepairFilter,
|
||||
AllMovementsFilter,
|
||||
MovementItem,
|
||||
MovementItemDetailed
|
||||
)
|
||||
from .movement_service import movement_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
tags=["Reports - Movement Invoices"]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/temporary",
|
||||
response_model=List[MovementItem],
|
||||
summary="Get Temporary Import Movements",
|
||||
description="""
|
||||
Retrieve temporary import movements from legacy database based on filter criteria.
|
||||
This endpoint corresponds to the 'LLENADOTEMPORAL' (Fill Temporary) logic from the legacy system.
|
||||
|
||||
**Note**: Requires connection to legacy SQL Server database.
|
||||
"""
|
||||
)
|
||||
def get_temporary_import_movements(
|
||||
filters: ImportTemporaryFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get temporary import movements based on filters.
|
||||
|
||||
Args:
|
||||
filters: Filter criteria for querying movements
|
||||
db: Database session
|
||||
current_user: Authenticated user information
|
||||
|
||||
Returns:
|
||||
List of movement items matching the criteria
|
||||
|
||||
Raises:
|
||||
HTTPException: If database query fails or user is unauthorized
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"User {current_user.get('preferred_username', 'unknown')} "
|
||||
f"requesting temporary import movements"
|
||||
)
|
||||
movements = movement_service.get_temporary_import_movements(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} movements")
|
||||
return movements
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error fetching movements: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing import temporary movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/temporary-detailed",
|
||||
response_model=List[MovementItemDetailed],
|
||||
summary="Get Detailed Temporary Import Movements",
|
||||
description="""
|
||||
Retrieve detailed temporary import movements (line by line) from legacy database.
|
||||
This endpoint corresponds to the 'LLENADOTEMPORAL - DETALLADO' logic from the legacy system.
|
||||
|
||||
Each line/partida is returned separately with complete information including:
|
||||
- Provider and buyer details (name, RFC, Tax ID)
|
||||
- Customs broker information
|
||||
- Item descriptions and specifications
|
||||
- Series information
|
||||
- All related metadata
|
||||
|
||||
**Note**: Requires connection to legacy SQL Server database.
|
||||
"""
|
||||
)
|
||||
def get_temporary_import_movements_detailed(
|
||||
filters: ImportTemporaryFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed temporary import movements (line by line) based on filters.
|
||||
|
||||
Args:
|
||||
filters: Filter criteria for querying movements
|
||||
db: Database session
|
||||
current_user: Authenticated user information
|
||||
|
||||
Returns:
|
||||
List of detailed movement items matching the criteria
|
||||
|
||||
Raises:
|
||||
HTTPException: If database query fails or user is unauthorized
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"User {current_user.get('preferred_username', 'unknown')} "
|
||||
f"requesting DETAILED temporary import movements"
|
||||
)
|
||||
movements = movement_service.get_temporary_import_movements_detailed(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} detailed movements")
|
||||
return movements
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error fetching detailed movements: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing detailed import temporary movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/definitive",
|
||||
response_model=List[MovementItem],
|
||||
summary="Get Definitive Import Movements",
|
||||
description="""
|
||||
Retrieve definitive import movements from legacy database based on filter criteria.
|
||||
This endpoint corresponds to the 'LLENADODEFINITIVO - NORMAL' logic from the legacy system.
|
||||
|
||||
Definitive imports are aggregated by invoice number and can be filtered by:
|
||||
- Movement type (COMEX or IMPDF based on ProvImpoDefCR field)
|
||||
- Date range (invoice date or payment date)
|
||||
- Provider and buyer
|
||||
- Pedimento code
|
||||
- Status (active or including cancelled)
|
||||
|
||||
**Special Features**:
|
||||
- Supports shelter company logic for exchange rate calculations
|
||||
- MetTrans# = 1 logic for specific pedimento types (1, 4, 98E)
|
||||
- Retrieves driver badge information
|
||||
- Handles rectification pedimento lookups
|
||||
|
||||
**Note**: Requires connection to legacy SQL Server database.
|
||||
"""
|
||||
)
|
||||
def get_definitive_import_movements(
|
||||
filters: ImportDefinitiveFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get definitive import movements based on filters.
|
||||
|
||||
Args:
|
||||
filters: Filter criteria for querying movements
|
||||
db: Database session
|
||||
current_user: Authenticated user information
|
||||
|
||||
Returns:
|
||||
List of movement items matching the criteria
|
||||
|
||||
Raises:
|
||||
HTTPException: If database query fails or user is unauthorized
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"User {current_user.get('preferred_username', 'unknown')} "
|
||||
f"requesting definitive import movements"
|
||||
)
|
||||
movements = movement_service.get_definitive_import_movements(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} definitive movements")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching definitive movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing definitive import movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/definitive-detailed",
|
||||
response_model=List[MovementItemDetailed],
|
||||
summary="Get Detailed Definitive Import Movements",
|
||||
description="""
|
||||
Retrieve detailed definitive import movements (line by line) from legacy database.
|
||||
This endpoint corresponds to the 'LLENADODEFINITIVO - DETALLADO' logic from the legacy system.
|
||||
|
||||
Each line/partida is returned separately with complete information including:
|
||||
- Provider and buyer details (name, RFC, Tax ID)
|
||||
- Customs broker information
|
||||
- Item descriptions and specifications
|
||||
- Series information from QSeriesDef table
|
||||
- All related metadata
|
||||
|
||||
**Special Logic**:
|
||||
- Only Partidas (EsSubPartida = 'P') have values calculated
|
||||
- Subpartidas (EsSubPartida = 'S') return with zero values
|
||||
- Series formatted as: "1) SERIE123. Modelo: MOD1. Parte: PART1 | 2) SERIE456..."
|
||||
- Exchange rate calculation supports shelter and non-shelter logic
|
||||
- MetTrans# = 1 logic for pedimento types 1, 4, 98E
|
||||
|
||||
**Note**: Requires connection to legacy SQL Server database.
|
||||
"""
|
||||
)
|
||||
def get_definitive_import_movements_detailed(
|
||||
filters: ImportDefinitiveFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed definitive import movements (line by line) based on filters.
|
||||
|
||||
Args:
|
||||
filters: Filter criteria for querying movements
|
||||
db: Database session
|
||||
current_user: Authenticated user information
|
||||
|
||||
Returns:
|
||||
List of detailed movement items matching the criteria
|
||||
|
||||
Raises:
|
||||
HTTPException: If database query fails or user is unauthorized
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"User {current_user.get('preferred_username', 'unknown')} "
|
||||
f"requesting DETAILED definitive import movements"
|
||||
)
|
||||
movements = movement_service.get_definitive_import_movements_detailed(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} detailed definitive movements")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed definitive movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing detailed definitive import movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repair",
|
||||
response_model=List[MovementItem],
|
||||
summary="Get Repair Import Movements",
|
||||
description="""
|
||||
Retrieve repair import movements from legacy database based on filter criteria.
|
||||
This endpoint corresponds to the 'LLENADOIMP_REPARACION - NORMAL' logic from the legacy system.
|
||||
|
||||
Repair imports are aggregated by invoice number and can be filtered by:
|
||||
- Discharge status (SiDes: discharged, NoDes: not discharged, ALL: no filter)
|
||||
- Date range (invoice date or payment date)
|
||||
- Provider and buyer
|
||||
- Pedimento code
|
||||
- Status (active or including cancelled)
|
||||
|
||||
**Special Features**:
|
||||
- Excludes regime changes (EsCambioRegimen <> 'S')
|
||||
- Supports discharge filter (unique to repair imports)
|
||||
- Exchange rate calculation with shelter/non-shelter logic
|
||||
- MetTrans# = 1 logic for specific pedimento types (1, 4, 98E)
|
||||
- Retrieves driver badge information
|
||||
|
||||
**Database Tables**:
|
||||
- QFacImpRep: Repair import invoices
|
||||
- QEqiMaqRep: Repair import items/partidas
|
||||
- QPedimentos: Pedimentos (customs declarations)
|
||||
|
||||
**Note**: Requires connection to legacy SQL Server database.
|
||||
"""
|
||||
)
|
||||
def get_repair_import_movements(
|
||||
filters: ImportRepairFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get repair import movements based on filters.
|
||||
|
||||
Args:
|
||||
filters: Filter criteria for querying movements
|
||||
db: Database session
|
||||
current_user: Authenticated user information
|
||||
|
||||
Returns:
|
||||
List of movement items matching the criteria
|
||||
|
||||
Raises:
|
||||
HTTPException: If database query fails or user is unauthorized
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"User {current_user.get('preferred_username', 'unknown')} "
|
||||
f"requesting repair import movements"
|
||||
)
|
||||
movements = movement_service.get_repair_import_movements(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} repair movements")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching repair movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing repair import movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repair-detailed",
|
||||
response_model=List[MovementItemDetailed],
|
||||
summary="Get detailed repair import movements",
|
||||
description="""
|
||||
Retrieve detailed repair import movements (IMPRE) with individual partida lines.
|
||||
|
||||
**LLENADOIMP_REPARACION - DETALLADO**
|
||||
|
||||
Returns individual partida (line item) records for repair imports with full detail including:
|
||||
- Complete invoice and customs clearance information
|
||||
- Series, model, and part numbers for each item
|
||||
- Client/supplier and sold-to information with tax IDs
|
||||
- Exchange rate calculations (MN/ME) based on filter options
|
||||
- Customs agent and customs section details
|
||||
- Driver badge unique number
|
||||
- All partida-level fields (part number, descriptions, quantities, weights, etc.)
|
||||
|
||||
**Discharge Filter Options:**
|
||||
- `SiDes`: Only include discharged items (Descarga = 1)
|
||||
- `NoDes`: Only include non-discharged items (Descarga = 0)
|
||||
- `ALL`: Include all items regardless of discharge status
|
||||
|
||||
**Database Tables Used:**
|
||||
- QFacImpRep: Repair import invoices
|
||||
- QPedimentos: Customs declarations
|
||||
- QEqiMaqRep: Repair import partidas (line items)
|
||||
- QSeriesImpoRep: Series information
|
||||
- GClientesPro: Suppliers
|
||||
- GCliVendido: Sold-to clients
|
||||
- GAAduanal: Customs agents
|
||||
- GAduanaSec: Customs sections
|
||||
- GConductor: Drivers (for badge numbers)
|
||||
- GTipoCambio: Exchange rates
|
||||
""",
|
||||
tags=["Import Movements - Repair"]
|
||||
)
|
||||
async def get_import_repair_movements_detailed(
|
||||
filters: ImportRepairFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed repair import movements based on filter criteria.
|
||||
Returns partida-level detail with series information and full client/customs data.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"User {current_user.get('sub')} requesting detailed repair movements")
|
||||
movements = movement_service.get_repair_import_movements_detailed(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} detailed repair partidas")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed repair movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing detailed repair import movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/export",
|
||||
response_model=List[MovementItem],
|
||||
summary="Get export movements",
|
||||
description="""
|
||||
Retrieve export movements (EXPO DEF) grouped by invoice.
|
||||
|
||||
**LLENADOEXPORTACION - NORMAL**
|
||||
|
||||
Returns aggregated data grouped by invoice number for export movements.
|
||||
|
||||
**Movement Type Options:**
|
||||
- `AFIJO`: Fixed assets
|
||||
- `NODES`: No discharge
|
||||
- `SCRAP`: Scrap materials
|
||||
- `REEXP`: Re-exports
|
||||
- `DONAC`: Donations
|
||||
- `VEMEX`: Sales to Mexico
|
||||
- `ALL`: All movement types
|
||||
|
||||
**Discharge Filter Options:**
|
||||
- `SiDes`: Only discharged items (Descarga = 1)
|
||||
- `NoDes`: Only non-discharged items (Descarga = 0)
|
||||
- `ALL`: All items regardless of discharge status
|
||||
|
||||
**Database Tables Used:**
|
||||
- QFacExp: Export invoices
|
||||
- QEqeMaq: Export partidas (line items)
|
||||
- QPedimentos: Customs declarations
|
||||
- QClaAct: Part classifications
|
||||
- GAAduanal: Customs agents
|
||||
- GAduanaSec: Customs sections
|
||||
- GConductor: Drivers
|
||||
- GTipoCambio: Exchange rates
|
||||
|
||||
Automatically excludes regime changes (EsCambioRegimen = 'N')
|
||||
""",
|
||||
tags=["Export Movements"]
|
||||
)
|
||||
async def get_export_movements(
|
||||
filters: ExportFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get export movements based on filter criteria.
|
||||
Returns aggregated data grouped by invoice.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"User {current_user.get('sub')} requesting export movements")
|
||||
movements = movement_service.get_export_movements(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} export movements")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching export movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing export movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/export-detailed",
|
||||
response_model=List[MovementItemDetailed],
|
||||
summary="Get detailed export movements",
|
||||
description="""
|
||||
Retrieve detailed export movements with individual partida lines.
|
||||
|
||||
**LLENADOEXPORTACION - DETALLADO**
|
||||
|
||||
Returns individual partida (line item) records for exports with full detail including:
|
||||
- Complete invoice and customs clearance information
|
||||
- Series, model, and part numbers for each item
|
||||
- Client/supplier and buyer information with tax IDs
|
||||
- Exchange rate calculations (MN/ME) based on filter options
|
||||
- Customs agent and customs section details
|
||||
- Driver badge unique number
|
||||
- All partida-level fields
|
||||
|
||||
**Movement Type Options:**
|
||||
- `AFIJO`: Fixed assets
|
||||
- `NODES`: No discharge
|
||||
- `SCRAP`: Scrap materials
|
||||
- `REEXP`: Re-exports
|
||||
- `DONAC`: Donations
|
||||
- `VEMEX`: Sales to Mexico
|
||||
- `ALL`: All movement types
|
||||
|
||||
**Discharge Filter Options:**
|
||||
- `SiDes`: Only discharged items
|
||||
- `NoDes`: Only non-discharged items
|
||||
- `ALL`: All items
|
||||
|
||||
**Database Tables Used:**
|
||||
- QFacExp: Export invoices
|
||||
- QEqeMaq: Export partidas
|
||||
- QSeriesExpo: Serial numbers
|
||||
- QPedimentos: Customs declarations
|
||||
- GClientesPro: Suppliers
|
||||
- GCliVendido: Buyers
|
||||
- GAAduanal: Customs agents
|
||||
- GAduanaSec: Customs sections
|
||||
- GConductor: Drivers
|
||||
- GTipoCambio: Exchange rates
|
||||
""",
|
||||
tags=["Export Movements"]
|
||||
)
|
||||
async def get_export_movements_detailed(
|
||||
filters: ExportFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed export movements based on filter criteria.
|
||||
Returns partida-level detail with series information and full client/customs data.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"User {current_user.get('sub')} requesting detailed export movements")
|
||||
movements = movement_service.get_export_movements_detailed(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} detailed export partidas")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed export movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing detailed export movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export-repair", response_model=List[MovementItem])
|
||||
def get_export_repair_movements(
|
||||
filters: ExportRepairFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
**LLENADOEXP_REPARACION - NORMAL**
|
||||
|
||||
Get export repair movements (EXPO REP) based on filter criteria.
|
||||
Groups results by invoice (FacturaExpo).
|
||||
|
||||
Clarion logic:
|
||||
- Query from QFacExpRep, QEqeMaqRep tables
|
||||
- Filters: date range (FF/FP), provider, buyer, pedimento code
|
||||
- Movement types: AFIJO, NODES
|
||||
- Discharge filter: SiDes, NoDes, or ALL
|
||||
- Calculates totals from partidas where EsSubpartida = 'P'
|
||||
- Exchange rate logic based on currency type and Scaii.ini MetTrans
|
||||
- Always filters by EsCambioRegimen = 'N'
|
||||
"""
|
||||
try:
|
||||
logger.info(f"User {current_user.get('sub')} requesting export repair movements")
|
||||
movements = movement_service.get_export_repair_movements(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} export repair invoices")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching export repair movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing export repair movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export-repair-detailed", response_model=List[MovementItemDetailed])
|
||||
def get_export_repair_movements_detailed(
|
||||
filters: ExportRepairFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed export repair movements based on filter criteria.
|
||||
Returns partida-level detail with series information and full client/customs data.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"User {current_user.get('sub')} requesting detailed export repair movements")
|
||||
movements = movement_service.get_export_repair_movements_detailed(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} detailed export repair partidas")
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed export repair movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing detailed export repair movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/all",
|
||||
response_model=Union[List[MovementItemDetailed], List[MovementItem]],
|
||||
summary="Get All Invoice Movements",
|
||||
description="""
|
||||
Retrieve all invoice movements (imports and exports of all types) from database.
|
||||
This endpoint combines temporary, definitive, and repair imports with all export types.
|
||||
|
||||
Use this when "TODAS" checkbox is selected to get a comprehensive view of all movements
|
||||
regardless of their specific type.
|
||||
|
||||
If send_email is True, the report will be sent to the authenticated user's email address.
|
||||
"""
|
||||
)
|
||||
async def get_all_movements(
|
||||
filters: AllMovementsFilter,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get all invoice movements (all types combined) based on filters.
|
||||
|
||||
Args:
|
||||
filters: Filter criteria for querying movements
|
||||
db: Database session
|
||||
current_user: Authenticated user information
|
||||
|
||||
Returns:
|
||||
List of all movement items matching the criteria
|
||||
|
||||
Raises:
|
||||
HTTPException: If database query fails or user is unauthorized
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"User {current_user.get('preferred_username', 'unknown')} "
|
||||
f"requesting all invoice movements (send_email={filters.send_email})"
|
||||
)
|
||||
movements = movement_service.get_all_movements(
|
||||
db=db,
|
||||
filters=filters
|
||||
)
|
||||
logger.info(f"Successfully retrieved {len(movements)} total movements")
|
||||
|
||||
# Send email if requested
|
||||
if filters.send_email:
|
||||
user_email = current_user.get('email')
|
||||
if not user_email:
|
||||
logger.warning(f"User {current_user.get('sub')} has no email address - skipping email")
|
||||
else:
|
||||
try:
|
||||
from core.email import EmailService
|
||||
from .csv_utils import generate_csv_from_movements
|
||||
from datetime import datetime
|
||||
|
||||
# Generate CSV
|
||||
csv_content = generate_csv_from_movements(
|
||||
movements=movements,
|
||||
filters=filters
|
||||
)
|
||||
|
||||
# Generate filename
|
||||
filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
# Send email
|
||||
email_sent = await EmailService.send_report_email(
|
||||
recipient_email=user_email,
|
||||
subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}",
|
||||
body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.",
|
||||
csv_content=csv_content,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
if email_sent:
|
||||
logger.info(f"Report emailed successfully to {user_email}")
|
||||
else:
|
||||
logger.warning(f"Failed to send email to {user_email} - SMTP may not be configured correctly")
|
||||
|
||||
except Exception as email_error:
|
||||
logger.warning(f"Email sending failed: {str(email_error)} - continuing with report generation")
|
||||
|
||||
return movements
|
||||
return movements
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error fetching all movements: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching all movements: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error processing all movements: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate",
|
||||
summary="Generate Invoice Report (Async)",
|
||||
description="Trigger background generation of invoice report."
|
||||
)
|
||||
def generate_invoice_report_async(
|
||||
filters: AllMovementsFilter,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger background generation of invoice report.
|
||||
Returns task_id to poll status.
|
||||
"""
|
||||
from .tasks import generate_invoice_movements_async
|
||||
|
||||
logger.info(f"User {current_user.get('preferred_username', 'unknown')} triggering async report generation")
|
||||
|
||||
# Serialize filters to dict for Celery
|
||||
filter_data = filters.model_dump()
|
||||
user_email = current_user.get('email')
|
||||
|
||||
# Trigger task
|
||||
task = generate_invoice_movements_async.delay(filter_data, user_email)
|
||||
|
||||
return {"task_id": task.id}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/task/{task_id}",
|
||||
summary="Get Async Task Status",
|
||||
description="Check status of background report generation task."
|
||||
)
|
||||
def get_task_status(task_id: str):
|
||||
"""
|
||||
Get status of background task.
|
||||
"""
|
||||
from celery.result import AsyncResult
|
||||
from core.celery_app import celery_app
|
||||
|
||||
task_result = AsyncResult(task_id, app=celery_app)
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"status": task_result.status,
|
||||
}
|
||||
|
||||
if task_result.state == 'PROCESSING':
|
||||
response["meta"] = task_result.info
|
||||
|
||||
if task_result.ready():
|
||||
response["result"] = task_result.result
|
||||
|
||||
return response
|
||||
609
backend/api/v1/modules/a76/reports/movements/invoices/schemas.py
Normal file
609
backend/api/v1/modules/a76/reports/movements/invoices/schemas.py
Normal file
@@ -0,0 +1,609 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RangeType(str, Enum):
|
||||
"""Date range type for filtering"""
|
||||
INVOICE_DATE = "FF" # Filter by invoice date
|
||||
PAYMENT_DATE = "FP" # Filter by payment date
|
||||
|
||||
|
||||
class ReportType(str, Enum):
|
||||
"""Report type"""
|
||||
NORMAL = "Normal"
|
||||
DETAILED = "Detallado"
|
||||
|
||||
|
||||
class CurrencyType(str, Enum):
|
||||
"""Currency type for calculations"""
|
||||
FOREIGN = "ME" # Foreign currency (Moneda Extranjera)
|
||||
LOCAL = "MN" # Local currency (Moneda Nacional)
|
||||
|
||||
|
||||
class ExchangeRateType(str, Enum):
|
||||
"""Exchange rate calculation type"""
|
||||
PAYMENT = "FP" # Use payment date
|
||||
INVOICE = "FF" # Use invoice date
|
||||
|
||||
|
||||
class MovementTypeFilter(str, Enum):
|
||||
"""Movement type filter for definitive imports"""
|
||||
COMEX = "COMEX" # ProvImpoDefCR = 'P'
|
||||
IMPDF = "IMPDF" # ProvImpoDefCR != 'P'
|
||||
ALL = "ALL" # No filter
|
||||
|
||||
|
||||
class DischargeFilter(str, Enum):
|
||||
"""Discharge filter for repair imports"""
|
||||
DISCHARGED = "SiDes" # RepPim.Descarga = 1
|
||||
NOT_DISCHARGED = "NoDes" # RepPim.Descarga = 0
|
||||
ALL = "ALL" # No filter
|
||||
|
||||
|
||||
class ExportMovementType(str, Enum):
|
||||
"""Export movement type filter"""
|
||||
AFIJO = "AFIJO" # Fixed assets
|
||||
NODES = "NODES" # No discharge
|
||||
SCRAP = "SCRAP" # Scrap
|
||||
REEXP = "REEXP" # Re-export
|
||||
DONAC = "DONAC" # Donation
|
||||
VEMEX = "VEMEX" # Sale to Mexico
|
||||
ALL = "ALL" # All types
|
||||
|
||||
|
||||
class AllMovementsFilter(BaseModel):
|
||||
"""Filters for all movements query (all types combined)"""
|
||||
range_type: RangeType = Field(
|
||||
default=RangeType.INVOICE_DATE,
|
||||
description="Date range type: FF for invoice date, FP for payment date"
|
||||
)
|
||||
start_date: str = Field(
|
||||
...,
|
||||
description="Start date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
end_date: str = Field(
|
||||
...,
|
||||
description="End date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
include_cancelled: bool = Field(
|
||||
default=False,
|
||||
description="Include cancelled invoices (Estatus != 'AC')"
|
||||
)
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by provider code"
|
||||
)
|
||||
buyer: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by buyer code"
|
||||
)
|
||||
pedimento_code: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by pedimento code (ClavePed)"
|
||||
)
|
||||
report_type: ReportType = Field(
|
||||
default=ReportType.NORMAL,
|
||||
description="Report type: Normal (grouped by invoice) or Detailed (line by line)"
|
||||
)
|
||||
currency_type: CurrencyType = Field(
|
||||
default=CurrencyType.FOREIGN,
|
||||
description="Currency type for value calculations"
|
||||
)
|
||||
exchange_rate_type: ExchangeRateType = Field(
|
||||
default=ExchangeRateType.PAYMENT,
|
||||
description="Exchange rate calculation method"
|
||||
)
|
||||
is_shelter: bool = Field(
|
||||
default=False,
|
||||
description="Use shelter company logic"
|
||||
)
|
||||
operation_type: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by operation type: 'imp' for imports only, 'exp' for exports only, None for all"
|
||||
)
|
||||
send_email: bool = Field(
|
||||
default=False,
|
||||
description="Send report via email to current user"
|
||||
)
|
||||
|
||||
# Granular movement selection
|
||||
import_temp: bool = Field(default=True, description="Include temporary imports (IMTEM)")
|
||||
import_def: bool = Field(default=True, description="Include definitive imports (IMPDF/COMEX)")
|
||||
import_rep: bool = Field(default=True, description="Include repair imports (IMPRE)")
|
||||
export_def: bool = Field(default=True, description="Include definitive exports")
|
||||
export_rep: bool = Field(default=True, description="Include repair exports")
|
||||
|
||||
# Specific filters
|
||||
export_types: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="Specific export legacy codes to include (AFIJO, NODES, etc)"
|
||||
)
|
||||
discharge_filter: DischargeFilter = Field(
|
||||
default=DischargeFilter.ALL,
|
||||
description="Global discharge filter for repair movements"
|
||||
)
|
||||
|
||||
|
||||
class ImportTemporaryFilter(BaseModel):
|
||||
"""Filters for temporary import movements query"""
|
||||
range_type: RangeType = Field(
|
||||
default=RangeType.INVOICE_DATE,
|
||||
description="Date range type: FF for invoice date, FP for payment date"
|
||||
)
|
||||
start_date: str = Field(
|
||||
...,
|
||||
description="Start date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
end_date: str = Field(
|
||||
...,
|
||||
description="End date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
include_cancelled: bool = Field(
|
||||
default=False,
|
||||
description="Include cancelled invoices (Estatus != 'AC')"
|
||||
)
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by provider code"
|
||||
)
|
||||
buyer: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by buyer code"
|
||||
)
|
||||
pedimento_code: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by pedimento code (ClavePed)"
|
||||
)
|
||||
report_type: ReportType = Field(
|
||||
default=ReportType.NORMAL,
|
||||
description="Report type: Normal or Detailed"
|
||||
)
|
||||
currency_type: CurrencyType = Field(
|
||||
default=CurrencyType.FOREIGN,
|
||||
description="Currency type for value calculations"
|
||||
)
|
||||
exchange_rate_type: ExchangeRateType = Field(
|
||||
default=ExchangeRateType.PAYMENT,
|
||||
description="Exchange rate calculation method"
|
||||
)
|
||||
is_shelter: bool = Field(
|
||||
default=False,
|
||||
description="Use shelter company logic"
|
||||
)
|
||||
database_name: str = Field(
|
||||
...,
|
||||
description="Legacy database name to query from"
|
||||
)
|
||||
send_email: bool = Field(
|
||||
default=False,
|
||||
description="Send report via email to current user"
|
||||
)
|
||||
|
||||
|
||||
class ImportDefinitiveFilter(BaseModel):
|
||||
"""Filters for definitive import movements query"""
|
||||
range_type: RangeType = Field(
|
||||
default=RangeType.INVOICE_DATE,
|
||||
description="Date range type: FF for invoice date, FP for payment date"
|
||||
)
|
||||
start_date: str = Field(
|
||||
...,
|
||||
description="Start date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
end_date: str = Field(
|
||||
...,
|
||||
description="End date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
include_cancelled: bool = Field(
|
||||
default=False,
|
||||
description="Include cancelled invoices (Estatus != 'AC')"
|
||||
)
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by provider code"
|
||||
)
|
||||
buyer: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by buyer code (VendidoA)"
|
||||
)
|
||||
pedimento_code: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by pedimento code (ClavePed)"
|
||||
)
|
||||
movement_type: MovementTypeFilter = Field(
|
||||
default=MovementTypeFilter.ALL,
|
||||
description="Movement type filter: COMEX, IMPDF, or ALL"
|
||||
)
|
||||
report_type: ReportType = Field(
|
||||
default=ReportType.NORMAL,
|
||||
description="Report type: Normal or Detailed"
|
||||
)
|
||||
currency_type: CurrencyType = Field(
|
||||
default=CurrencyType.FOREIGN,
|
||||
description="Currency type for value calculations"
|
||||
)
|
||||
exchange_rate_type: ExchangeRateType = Field(
|
||||
default=ExchangeRateType.PAYMENT,
|
||||
description="Exchange rate calculation method"
|
||||
)
|
||||
is_shelter: bool = Field(
|
||||
default=False,
|
||||
description="Use shelter company logic"
|
||||
)
|
||||
use_transport_method: bool = Field(
|
||||
default=False,
|
||||
description="Use MetTrans# = 1 logic for specific pedimento types"
|
||||
)
|
||||
database_name: str = Field(
|
||||
...,
|
||||
description="Legacy database name to query from"
|
||||
)
|
||||
send_email: bool = Field(
|
||||
default=False,
|
||||
description="Send report via email to current user"
|
||||
)
|
||||
|
||||
class MovementItem(BaseModel):
|
||||
"""Movement item representing a temporary import invoice"""
|
||||
Factura: Optional[str] = Field(None, description="Invoice number")
|
||||
Pedimento: Optional[str] = Field(None, description="Pedimento number")
|
||||
FechaFactura: Optional[datetime] = Field(None, description="Invoice date")
|
||||
Estatus: Optional[str] = Field(None, description="Status (AC=Active, etc)")
|
||||
ClavePed: Optional[str] = Field(None, description="Pedimento code")
|
||||
TipoMovTemDef: Optional[str] = Field(None, description="Movement type (IMTEM=Temporary Import)")
|
||||
EsCambioRegimen: Optional[str] = Field(None, description="Is regime change (S/N)")
|
||||
ValorMPTemp: Optional[float] = Field(None, description="Temporary raw material value")
|
||||
ValorComercialMN: Optional[float] = Field(None, description="Commercial value in MN")
|
||||
TipoCambio: Optional[float] = Field(None, description="Exchange rate used")
|
||||
ValorAgre: Optional[float] = Field(default=0.0, description="Aggregate value")
|
||||
TipoExpo: Optional[str] = Field(default='', description="Export type")
|
||||
PedimentoR1: Optional[str] = Field(None, description="Rectification pedimento")
|
||||
EDocument: Optional[str] = Field(None, description="Electronic document")
|
||||
NumOperacionVU: Optional[str] = Field(None, description="VU operation number")
|
||||
BaseDeDatos: Optional[str] = Field(None, description="Source database name")
|
||||
NumGafUni: Optional[str] = Field(None, description="Unique badge number (driver)")
|
||||
UsuarioCap: Optional[str] = Field(None, description="Capture user")
|
||||
UsuarioAcr: Optional[str] = Field(None, description="Update user")
|
||||
Fecha_Pago: Optional[datetime] = Field(None, description="Payment date")
|
||||
NumCaja: Optional[str] = Field(None, description="Box/Container number")
|
||||
Pedimento18: Optional[str] = Field(None, description="18-digit pedimento")
|
||||
AduanaCru: Optional[str] = Field(None, description="Crossing customs")
|
||||
Lote: Optional[str] = Field(None, description="Lot number")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"Factura": "F-2024-001",
|
||||
"Pedimento": "24 47 3807 8001234",
|
||||
"FechaFactura": "2024-01-15T00:00:00",
|
||||
"Estatus": "AC",
|
||||
"ClavePed": "IM",
|
||||
"TipoMovTemDef": "IMTEM",
|
||||
"ValorMPTemp": 10000.50,
|
||||
"TipoCambio": 17.25
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ImportRepairFilter(BaseModel):
|
||||
"""Filters for repair import movements query"""
|
||||
range_type: RangeType = Field(
|
||||
default=RangeType.INVOICE_DATE,
|
||||
description="Date range type: FF for invoice date, FP for payment date"
|
||||
)
|
||||
start_date: str = Field(
|
||||
...,
|
||||
description="Start date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
end_date: str = Field(
|
||||
...,
|
||||
description="End date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
include_cancelled: bool = Field(
|
||||
default=False,
|
||||
description="Include cancelled invoices (Estatus != 'AC')"
|
||||
)
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by provider code"
|
||||
)
|
||||
buyer: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by buyer code (VendidoA)"
|
||||
)
|
||||
pedimento_code: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by pedimento code (ClavePed)"
|
||||
)
|
||||
discharge_filter: DischargeFilter = Field(
|
||||
default=DischargeFilter.ALL,
|
||||
description="Discharge filter: SiDes (discharged), NoDes (not discharged), or ALL"
|
||||
)
|
||||
report_type: ReportType = Field(
|
||||
default=ReportType.NORMAL,
|
||||
description="Report type: Normal or Detailed"
|
||||
)
|
||||
currency_type: CurrencyType = Field(
|
||||
default=CurrencyType.FOREIGN,
|
||||
description="Currency type for value calculations"
|
||||
)
|
||||
exchange_rate_type: ExchangeRateType = Field(
|
||||
default=ExchangeRateType.PAYMENT,
|
||||
description="Exchange rate calculation method"
|
||||
)
|
||||
is_shelter: bool = Field(
|
||||
default=False,
|
||||
description="Use shelter company logic"
|
||||
)
|
||||
use_transport_method: bool = Field(
|
||||
default=False,
|
||||
description="Use MetTrans# = 1 logic for specific pedimento types"
|
||||
)
|
||||
database_name: str = Field(
|
||||
...,
|
||||
description="Legacy database name to query from"
|
||||
)
|
||||
send_email: bool = Field(
|
||||
default=False,
|
||||
description="Send report via email to current user"
|
||||
)
|
||||
|
||||
|
||||
class MovementItemDetailed(BaseModel):
|
||||
"""Detailed movement item with all line-level information"""
|
||||
Linea: Optional[int] = Field(None, description="Line number")
|
||||
Factura: Optional[str] = Field(None, description="Invoice number")
|
||||
Pedimento: Optional[str] = Field(None, description="Pedimento number")
|
||||
FechaFactura: Optional[datetime] = Field(None, description="Invoice date")
|
||||
Estatus: Optional[str] = Field(None, description="Status (AC=Active, etc)")
|
||||
ClavePed: Optional[str] = Field(None, description="Pedimento code")
|
||||
TipoMovTemDef: Optional[str] = Field(None, description="Movement type")
|
||||
EsCambioRegimen: Optional[str] = Field(None, description="Is regime change (S/N)")
|
||||
Regimen: Optional[str] = Field(None, description="Regime")
|
||||
Fecha_Inicio: Optional[datetime] = Field(None, description="Start date")
|
||||
Fecha_Fin: Optional[datetime] = Field(None, description="End date")
|
||||
Fecha_Pago: Optional[datetime] = Field(None, description="Payment date")
|
||||
Remesa: Optional[str] = Field(None, description="Remesa")
|
||||
|
||||
# Provider information
|
||||
Proveedor: Optional[str] = Field(None, description="Provider name")
|
||||
RFCProveedor: Optional[str] = Field(None, description="Provider RFC")
|
||||
ProveedorTaxID: Optional[str] = Field(None, description="Provider Tax ID")
|
||||
|
||||
# Buyer information
|
||||
VendidoA: Optional[str] = Field(None, description="Buyer name")
|
||||
VendidoARFC: Optional[str] = Field(None, description="Buyer RFC")
|
||||
VendidoATaxID: Optional[str] = Field(None, description="Buyer Tax ID")
|
||||
|
||||
# Customs broker
|
||||
AgenteAduanal: Optional[str] = Field(None, description="Customs broker name")
|
||||
Patente: Optional[str] = Field(None, description="Customs broker patent")
|
||||
|
||||
# Item details
|
||||
NumParte: Optional[str] = Field(None, description="Part number")
|
||||
DescripcionE: Optional[str] = Field(None, description="Spanish description")
|
||||
DescripcionI: Optional[str] = Field(None, description="English description")
|
||||
CantidadIE: Optional[float] = Field(None, description="Quantity")
|
||||
UniMed: Optional[str] = Field(None, description="Unit of measure")
|
||||
ValorComercialMN: Optional[float] = Field(None, description="Commercial value in MN")
|
||||
TipoCambio: Optional[float] = Field(None, description="Exchange rate")
|
||||
PesoNeto: Optional[float] = Field(None, description="Net weight")
|
||||
PesoBruto: Optional[float] = Field(None, description="Gross weight")
|
||||
|
||||
# Additional fields
|
||||
OrdenCompraVenta: Optional[str] = Field(None, description="Purchase order")
|
||||
FraccionArancelaria: Optional[str] = Field(None, description="Tariff fraction")
|
||||
Preferencia: Optional[str] = Field(None, description="Preference")
|
||||
Sector: Optional[str] = Field(None, description="Sector")
|
||||
PaisOrigen: Optional[str] = Field(None, description="Country of origin")
|
||||
Aduana: Optional[str] = Field(None, description="Customs office")
|
||||
Advalorem: Optional[str] = Field(None, description="Ad valorem")
|
||||
TipoExpo: Optional[str] = Field(default='', description="Export type")
|
||||
PedimentoR1: Optional[str] = Field(None, description="Rectification pedimento")
|
||||
EDocument: Optional[str] = Field(None, description="Electronic document")
|
||||
NumOperacionVU: Optional[str] = Field(None, description="VU operation number")
|
||||
|
||||
# Series information
|
||||
Series: Optional[str] = Field(None, description="Serial numbers")
|
||||
Marca: Optional[str] = Field(None, description="Brand")
|
||||
Modelo: Optional[str] = Field(None, description="Model")
|
||||
FraccionAmericana: Optional[str] = Field(None, description="American tariff fraction")
|
||||
ECCN: Optional[str] = Field(None, description="ECCN code")
|
||||
SimboloEx: Optional[str] = Field(None, description="Export symbol/license")
|
||||
FechaEmision: Optional[datetime] = Field(None, description="Emission date")
|
||||
|
||||
# Metadata
|
||||
BaseDeDatos: Optional[str] = Field(None, description="Source database")
|
||||
NumGafUni: Optional[str] = Field(None, description="Unique badge number")
|
||||
UsuarioCap: Optional[str] = Field(None, description="Capture user")
|
||||
UsuarioAcr: Optional[str] = Field(None, description="Update user")
|
||||
Transportista: Optional[str] = Field(None, description="Transporter")
|
||||
NumCaja: Optional[str] = Field(None, description="Box number")
|
||||
Pedimento18: Optional[str] = Field(None, description="18-digit pedimento")
|
||||
AduanaCru: Optional[str] = Field(None, description="Crossing customs")
|
||||
Lote: Optional[str] = Field(None, description="Lot number")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"Linea": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExportFilter(BaseModel):
|
||||
"""Filters for export movements query"""
|
||||
range_type: RangeType = Field(
|
||||
default=RangeType.INVOICE_DATE,
|
||||
description="Date range type: FF for invoice date, FP for payment date"
|
||||
)
|
||||
start_date: str = Field(
|
||||
...,
|
||||
description="Start date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
end_date: str = Field(
|
||||
...,
|
||||
description="End date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
include_cancelled: bool = Field(
|
||||
default=False,
|
||||
description="Include cancelled invoices (Estatus = 'NA')"
|
||||
)
|
||||
provider: Optional[str] = Field(
|
||||
None,
|
||||
description="Filter by provider code"
|
||||
)
|
||||
buyer: Optional[str] = Field(
|
||||
None,
|
||||
description="Filter by buyer code (VendidoA)"
|
||||
)
|
||||
pedimento_code: Optional[str] = Field(
|
||||
None,
|
||||
description="Filter by pedimento code (ClavePed)"
|
||||
)
|
||||
movement_type: ExportMovementType = Field(
|
||||
default=ExportMovementType.ALL,
|
||||
description="Filter by export movement type (AFIJO, NODES, SCRAP, REEXP, DONAC, VEMEX)"
|
||||
)
|
||||
discharge_filter: DischargeFilter = Field(
|
||||
default=DischargeFilter.ALL,
|
||||
description="Filter by discharge status: SiDes, NoDes, or ALL"
|
||||
)
|
||||
report_type: ReportType = Field(
|
||||
default=ReportType.NORMAL,
|
||||
description="Normal (grouped by invoice) or Detallado (line by line)"
|
||||
)
|
||||
currency_type: CurrencyType = Field(
|
||||
default=CurrencyType.FOREIGN,
|
||||
description="Currency type: ME (foreign) or MN (local)"
|
||||
)
|
||||
exchange_rate_type: ExchangeRateType = Field(
|
||||
default=ExchangeRateType.PAYMENT,
|
||||
description="Exchange rate type: FP (payment date) or FF (invoice date)"
|
||||
)
|
||||
is_shelter: bool = Field(
|
||||
default=False,
|
||||
description="Shelter company flag"
|
||||
)
|
||||
use_transport_method: bool = Field(
|
||||
default=False,
|
||||
description="Use transport method for exchange rate logic"
|
||||
)
|
||||
database_name: str = Field(
|
||||
...,
|
||||
description="Legacy database name"
|
||||
)
|
||||
send_email: bool = Field(
|
||||
default=False,
|
||||
description="Send report via email to current user"
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"range_type": "FF",
|
||||
"start_date": "20240101",
|
||||
"end_date": "20240131",
|
||||
"include_cancelled": False,
|
||||
"provider": None,
|
||||
"buyer": None,
|
||||
"pedimento_code": None,
|
||||
"movement_type": "ALL",
|
||||
"discharge_filter": "ALL",
|
||||
"report_type": "Normal",
|
||||
"currency_type": "ME",
|
||||
"exchange_rate_type": "FP",
|
||||
"is_shelter": False,
|
||||
"use_transport_method": False,
|
||||
"database_name": "MYDB"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExportRepairFilter(BaseModel):
|
||||
"""Filters for export repair movements query (EXPO REP)"""
|
||||
range_type: RangeType = Field(
|
||||
default=RangeType.INVOICE_DATE,
|
||||
description="Date range type: FF for invoice date, FP for payment date"
|
||||
)
|
||||
start_date: str = Field(
|
||||
...,
|
||||
description="Start date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
end_date: str = Field(
|
||||
...,
|
||||
description="End date in YYYYMMDD format or ISO format"
|
||||
)
|
||||
include_cancelled: bool = Field(
|
||||
default=False,
|
||||
description="Include cancelled invoices (Estatus = 'NA')"
|
||||
)
|
||||
provider: Optional[str] = Field(
|
||||
None,
|
||||
description="Filter by provider code"
|
||||
)
|
||||
buyer: Optional[str] = Field(
|
||||
None,
|
||||
description="Filter by buyer code (VendidoA)"
|
||||
)
|
||||
pedimento_code: Optional[str] = Field(
|
||||
None,
|
||||
description="Filter by pedimento code (ClavePed)"
|
||||
)
|
||||
movement_type: ExportMovementType = Field(
|
||||
default=ExportMovementType.ALL,
|
||||
description="Filter by movement type (AFIJO, NODES for repair exports)"
|
||||
)
|
||||
discharge_filter: DischargeFilter = Field(
|
||||
default=DischargeFilter.ALL,
|
||||
description="Filter by discharge status: SiDes, NoDes, or ALL"
|
||||
)
|
||||
report_type: ReportType = Field(
|
||||
default=ReportType.NORMAL,
|
||||
description="Normal (grouped by invoice) or Detallado (line by line)"
|
||||
)
|
||||
currency_type: CurrencyType = Field(
|
||||
default=CurrencyType.FOREIGN,
|
||||
description="Currency type: ME (foreign) or MN (local)"
|
||||
)
|
||||
exchange_rate_type: ExchangeRateType = Field(
|
||||
default=ExchangeRateType.PAYMENT,
|
||||
description="Exchange rate type: FP (payment date) or FF (invoice date)"
|
||||
)
|
||||
is_shelter: bool = Field(
|
||||
default=False,
|
||||
description="Shelter company flag"
|
||||
)
|
||||
database_name: str = Field(
|
||||
...,
|
||||
description="Legacy database name"
|
||||
)
|
||||
send_email: bool = Field(
|
||||
default=False,
|
||||
description="Send report via email to current user"
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"range_type": "FF",
|
||||
"start_date": "20240101",
|
||||
"end_date": "20240131",
|
||||
"include_cancelled": False,
|
||||
"provider": None,
|
||||
"buyer": None,
|
||||
"pedimento_code": None,
|
||||
"movement_type": "ALL",
|
||||
"discharge_filter": "ALL",
|
||||
"report_type": "Normal",
|
||||
"currency_type": "ME",
|
||||
"exchange_rate_type": "FP",
|
||||
"is_shelter": False,
|
||||
"database_name": "MYDB"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Unified service for invoice movement operations.
|
||||
This service delegates to specialized handlers for each import type.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from .schemas import (
|
||||
ImportTemporaryFilter,
|
||||
ImportDefinitiveFilter,
|
||||
ImportRepairFilter,
|
||||
ExportFilter,
|
||||
MovementItem,
|
||||
MovementItemDetailed
|
||||
)
|
||||
from .services.temporary import TemporaryImportService
|
||||
from .services.definitive import DefinitiveImportService
|
||||
from .services.repair import RepairImportService
|
||||
from .services.export import ExportService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MovementService:
|
||||
"""
|
||||
Unified service for handling all types of movements.
|
||||
Delegates to specialized services for each movement type.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.temporary_service = TemporaryImportService()
|
||||
self.definitive_service = DefinitiveImportService()
|
||||
self.repair_service = RepairImportService()
|
||||
self.export_service = ExportService()
|
||||
|
||||
# ===== TEMPORARY IMPORTS =====
|
||||
|
||||
def get_temporary_import_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportTemporaryFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get temporary import movements (normal mode - grouped by invoice)."""
|
||||
return self.temporary_service.get_movements(db, filters)
|
||||
|
||||
def get_temporary_import_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportTemporaryFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get temporary import movements (detailed mode - line by line)."""
|
||||
return self.temporary_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== DEFINITIVE IMPORTS =====
|
||||
|
||||
def get_definitive_import_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportDefinitiveFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get definitive import movements (normal mode - grouped by invoice)."""
|
||||
return self.definitive_service.get_movements(db, filters)
|
||||
|
||||
def get_definitive_import_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportDefinitiveFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get definitive import movements (detailed mode - line by line)."""
|
||||
return self.definitive_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== REPAIR IMPORTS =====
|
||||
|
||||
def get_repair_import_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportRepairFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get repair import movements (normal mode - grouped by invoice)."""
|
||||
return self.repair_service.get_movements(db, filters)
|
||||
|
||||
def get_repair_import_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ImportRepairFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get repair import movements (detailed mode - line by line)."""
|
||||
return self.repair_service.get_movements_detailed(db, filters)
|
||||
|
||||
# ===== EXPORTS =====
|
||||
|
||||
def get_export_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportFilter
|
||||
) -> List[MovementItem]:
|
||||
"""Get export movements (normal mode - grouped by invoice)."""
|
||||
return self.export_service.get_movements(db, filters)
|
||||
|
||||
def get_export_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""Get export movements (detailed mode - line by line)."""
|
||||
return self.export_service.get_movements_detailed(db, filters)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
movement_service = MovementService()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Invoice Movement Services Module
|
||||
|
||||
This package contains the business logic for handling different types of movements:
|
||||
- Temporary imports (IMTEM)
|
||||
- Definitive imports (COMEX/IMPDF)
|
||||
- Repair imports (IMPRE)
|
||||
- Exports (EXPO DEF)
|
||||
- Export repairs (EXPO REP)
|
||||
|
||||
The services are organized into specialized modules for better maintainability.
|
||||
"""
|
||||
|
||||
from .temporary import TemporaryImportService
|
||||
from .definitive import DefinitiveImportService
|
||||
from .repair import RepairImportService
|
||||
from .export import ExportService
|
||||
from .export_repair import ExportRepairService
|
||||
|
||||
__all__ = [
|
||||
'TemporaryImportService',
|
||||
'DefinitiveImportService',
|
||||
'RepairImportService',
|
||||
'ExportService',
|
||||
'ExportRepairService',
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Base utilities and configuration helpers for invoice movement services.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import configparser
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigHelper:
|
||||
"""Helper for reading configuration files."""
|
||||
|
||||
@staticmethod
|
||||
def get_met_trans_config() -> int:
|
||||
"""
|
||||
Read MetTrans configuration from Scaii.ini file.
|
||||
|
||||
Returns:
|
||||
MetTrans value (0 or 1)
|
||||
"""
|
||||
try:
|
||||
config = configparser.ConfigParser()
|
||||
config.read('Scaii.ini')
|
||||
met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0)
|
||||
logger.debug(f"INI met_trans value: {met_trans}")
|
||||
return met_trans
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
class StringHelper:
|
||||
"""Helper for string manipulation."""
|
||||
|
||||
@staticmethod
|
||||
def remove_commas(text: Optional[str]) -> Optional[str]:
|
||||
"""Remove commas from text for CSV compatibility."""
|
||||
if not text:
|
||||
return text
|
||||
return text.replace(',', '')
|
||||
|
||||
@staticmethod
|
||||
def clean_text(text: Optional[str]) -> Optional[str]:
|
||||
"""Clean text by stripping whitespace and removing special characters."""
|
||||
if not text:
|
||||
return None
|
||||
# Remove special characters and extra whitespace
|
||||
cleaned = text.strip()
|
||||
return cleaned if cleaned else None
|
||||
|
||||
|
||||
class DateHelper:
|
||||
"""Helper for date-related operations."""
|
||||
|
||||
@staticmethod
|
||||
def get_fecha_tipo_cambio(
|
||||
fecha_pago,
|
||||
fecha_inicio,
|
||||
tipo_pedimento: str,
|
||||
use_transport_method: bool,
|
||||
met_trans: int
|
||||
):
|
||||
"""
|
||||
Determine which date to use for exchange rate lookup based on MetTrans logic.
|
||||
|
||||
Args:
|
||||
fecha_pago: Payment date
|
||||
fecha_inicio: Start/entry date
|
||||
tipo_pedimento: Pedimento type code
|
||||
use_transport_method: Whether to apply transport method logic
|
||||
met_trans: MetTrans configuration value
|
||||
|
||||
Returns:
|
||||
Date to use for exchange rate lookup
|
||||
"""
|
||||
fecha = fecha_pago
|
||||
|
||||
# MetTrans# = 1 logic: use fecha_inicio for specific pedimento types
|
||||
if use_transport_method and met_trans == 1:
|
||||
if tipo_pedimento in ('1', '4', '98E'):
|
||||
fecha = fecha_inicio
|
||||
|
||||
return fecha
|
||||
@@ -0,0 +1,520 @@
|
||||
"""
|
||||
Database query helpers for invoice movements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DatabaseHelper:
|
||||
"""Helper for common database operations."""
|
||||
|
||||
@staticmethod
|
||||
def get_database_name(db: Session) -> Optional[str]:
|
||||
"""
|
||||
Get the current database name from the session.
|
||||
|
||||
Returns:
|
||||
Database name or None if not found
|
||||
"""
|
||||
try:
|
||||
result = db.execute(text("SELECT current_database()")).fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database name: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_exchange_rate(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
fecha,
|
||||
is_shelter: bool = False,
|
||||
raise_on_missing: bool = False,
|
||||
pedimento_number: Optional[str] = None
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Get exchange rate for the given date from exchange_rate table.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Legacy database name (kept for compatibility, not used)
|
||||
fecha: Date for exchange rate lookup
|
||||
is_shelter: Shelter company flag (when True and rate not found, raises detailed error)
|
||||
raise_on_missing: If True, raises ValueError when rate not found
|
||||
pedimento_number: Pedimento number for error messages
|
||||
|
||||
Returns:
|
||||
Exchange rate as float, or None if not found
|
||||
|
||||
Raises:
|
||||
ValueError: When is_shelter=True and exchange rate not found
|
||||
"""
|
||||
if not fecha:
|
||||
return None
|
||||
|
||||
try:
|
||||
# TODO: Verify exchange_rate table structure and column names
|
||||
sql_tc = text("""
|
||||
SELECT rate
|
||||
FROM a76.exchange_rate
|
||||
WHERE rate_date = :fecha
|
||||
ORDER BY rate_date DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
res = db.execute(sql_tc, {"fecha": fecha}).fetchone()
|
||||
if res and res[0]:
|
||||
return float(res[0])
|
||||
else:
|
||||
# Clarion logic: For Shelter operations with FP, missing exchange rate is an error
|
||||
if is_shelter and raise_on_missing:
|
||||
fecha_str = fecha.strftime('%d/%m/%Y') if hasattr(fecha, 'strftime') else str(fecha)
|
||||
ped_info = f" del Pedimento: {pedimento_number}" if pedimento_number else ""
|
||||
raise ValueError(
|
||||
f"Falta el tipo de cambio del día {fecha_str}. "
|
||||
f"Por favor regístralo en el catálogo de Tipos de Cambio."
|
||||
)
|
||||
logger.warning(f"Exchange rate not found for date {fecha}")
|
||||
return None
|
||||
except ValueError:
|
||||
raise # Re-raise validation errors
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching exchange rate for date {fecha}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_client_info(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
client_code: str,
|
||||
is_supplier: bool = True
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Get client or supplier information (name, RFC, TaxID).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Database name (kept for compatibility, not used)
|
||||
client_code: Client/supplier code
|
||||
is_supplier: True for suppliers, False for clients
|
||||
|
||||
Returns:
|
||||
Dict with 'name', 'rfc', 'tax_id' keys
|
||||
"""
|
||||
if not client_code:
|
||||
return {"name": None, "rfc": None, "tax_id": None}
|
||||
|
||||
client_type = 'PROVIDER' if is_supplier else 'CLIENT'
|
||||
|
||||
try:
|
||||
sql = text("""
|
||||
SELECT cp.name, cp.rfc, cpp.tax_id
|
||||
FROM a76.clients_and_providers cp
|
||||
LEFT JOIN a76.clients_and_providers_programs cpp ON cpp.client_id = cp.id
|
||||
WHERE cp.id = :client_code AND cp.client_or_provider = :client_type
|
||||
""")
|
||||
result = db.execute(sql, {"client_code": client_code, "client_type": client_type}).fetchone()
|
||||
|
||||
if result:
|
||||
return {
|
||||
"name": result[0],
|
||||
"rfc": result[1],
|
||||
"tax_id": result[2]
|
||||
}
|
||||
else:
|
||||
logger.debug(f"Client {client_code} not found as {client_type}")
|
||||
return {"name": None, "rfc": None, "tax_id": None}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching client info for {client_code}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_customs_agent_info(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
agent_code: str
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Get customs agent information (name, license).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Database name (kept for compatibility, not used)
|
||||
agent_code: Customs agent code
|
||||
|
||||
Returns:
|
||||
Dict with 'name', 'license' keys
|
||||
"""
|
||||
if not agent_code:
|
||||
return {"name": None, "license": None}
|
||||
|
||||
try:
|
||||
sql = text("""
|
||||
SELECT name, license
|
||||
FROM a76.customs_brokers
|
||||
WHERE id = :agent_code
|
||||
LIMIT 1
|
||||
""")
|
||||
result = db.execute(sql, {"agent_code": agent_code}).fetchone()
|
||||
|
||||
if result:
|
||||
return {
|
||||
"name": result[0],
|
||||
"license": result[1]
|
||||
}
|
||||
else:
|
||||
logger.debug(f"Customs agent {agent_code} not found")
|
||||
return {"name": None, "license": None}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching customs agent info for {agent_code}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_aduana_seccion_nombre(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
aduana_seccion: str
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get customs section name.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Database name
|
||||
aduana_seccion: Customs section code
|
||||
|
||||
Returns:
|
||||
Customs section name or None
|
||||
"""
|
||||
if not aduana_seccion:
|
||||
return None
|
||||
|
||||
try:
|
||||
query = text("""
|
||||
SELECT section_name
|
||||
FROM public.customs_sections
|
||||
WHERE customs_code = :code
|
||||
""")
|
||||
result = db.execute(query, {"code": aduana_seccion}).fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching customs section name: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_series_info(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
invoice_id: int,
|
||||
linea: str,
|
||||
is_shelter: bool
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get series information for import items.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Legacy database name (not used in PostgreSQL)
|
||||
invoice_id: Invoice header ID
|
||||
linea: Line number
|
||||
is_shelter: Shelter flag (not used)
|
||||
|
||||
Returns:
|
||||
Formatted series string or None
|
||||
"""
|
||||
if not invoice_id or not linea:
|
||||
return None
|
||||
|
||||
try:
|
||||
query = text("""
|
||||
SELECT serial_numbers, model, brand
|
||||
FROM a76.item_line_series ils
|
||||
INNER JOIN a76.item_lines il ON ils.line_item_id = il.id
|
||||
WHERE il.invoice_id = :invoice_id
|
||||
AND il.line_number = :linea
|
||||
ORDER BY ils.id
|
||||
LIMIT 1
|
||||
""")
|
||||
result = db.execute(query, {
|
||||
"invoice_id": invoice_id,
|
||||
"linea": linea
|
||||
}).fetchone()
|
||||
|
||||
if result:
|
||||
serial_numbers, model, brand = result
|
||||
parts = []
|
||||
if serial_numbers:
|
||||
parts.append(serial_numbers)
|
||||
if model:
|
||||
parts.append(model)
|
||||
if brand:
|
||||
parts.append(brand)
|
||||
return " / ".join(parts) if parts else None
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching series info: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_series_info_export(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
invoice_id: int,
|
||||
linea: str,
|
||||
is_shelter: bool
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get series information for export items.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Legacy database name
|
||||
invoice_id: Invoice header ID
|
||||
linea: LineaExpo value
|
||||
is_shelter: Shelter flag
|
||||
|
||||
Returns:
|
||||
Formatted series string or None
|
||||
"""
|
||||
if not invoice_id or not linea:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Note: Postgres items table calls it expo_brad (typo in DB schema)
|
||||
# ItemLineSeries FK is line_item_id, not item_line_id
|
||||
query = text("""
|
||||
SELECT serial_numbers, model, expo_brad
|
||||
FROM a76.item_line_series ils
|
||||
INNER JOIN a76.item_lines il ON ils.line_item_id = il.id
|
||||
WHERE il.invoice_id = :invoice_id
|
||||
AND il.line_number = :linea
|
||||
ORDER BY ils.id
|
||||
LIMIT 1
|
||||
""")
|
||||
result = db.execute(query, {
|
||||
"invoice_id": invoice_id,
|
||||
"linea": linea
|
||||
}).fetchone()
|
||||
|
||||
if result:
|
||||
serial_numbers, model, expo_brand = result
|
||||
parts = []
|
||||
if serial_numbers:
|
||||
parts.append(serial_numbers)
|
||||
if model:
|
||||
parts.append(model)
|
||||
if expo_brand:
|
||||
parts.append(expo_brand)
|
||||
return " | ".join(parts) if parts else None
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching export series info: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_rectification_pedimento(
|
||||
db: Session,
|
||||
pedimento: str,
|
||||
ped_rectifica: Optional[str],
|
||||
is_shelter: bool = False
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get final pedimento rectification number following the chain recursively.
|
||||
|
||||
Clarion logic:
|
||||
- IF Loc:OpcionShelter = 1 THEN: use direct field value (PedRectifica)
|
||||
- ELSE: call BuscarRectificacion() - follows rectification chain recursively
|
||||
|
||||
BuscarRectificacion follows the chain:
|
||||
Example: A1 -> A2 -> A3 -> A4 (returns A4, the final rectification)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
pedimento: Original pedimento number
|
||||
ped_rectifica: Initial rectification pedimento from database field
|
||||
(already resolved from pedimento_rectification_origin JOIN in query)
|
||||
is_shelter: Shelter company flag
|
||||
|
||||
Returns:
|
||||
Final rectification pedimento number in the chain, or None/empty if no rectification
|
||||
"""
|
||||
if is_shelter:
|
||||
# Shelter: use direct value from PedRectifica field
|
||||
result = ped_rectifica
|
||||
else:
|
||||
# Non-Shelter: implement BuscarRectificacion logic
|
||||
result = DatabaseHelper._buscar_rectificacion(db, pedimento, ped_rectifica)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _buscar_rectificacion(
|
||||
db: Session,
|
||||
pedimento_orig: str,
|
||||
ped_rec: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
BUSCA ULTIMO PEDIMENTO DE RECTIFICACION
|
||||
Returns the rectification origin pedimento string already resolved by the query
|
||||
builder's JOIN on pedimento_rectification_origin.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
pedimento_orig: Original pedimento number (e.g. "1234567")
|
||||
ped_rec: Rectification pedimento origin string already computed by the SQL JOIN
|
||||
(e.g. "25-470-8000-1234567")
|
||||
|
||||
Returns:
|
||||
The rectification origin string, or empty string if none.
|
||||
"""
|
||||
if not ped_rec:
|
||||
return ''
|
||||
|
||||
# The ped_rec value already comes from the JOIN on pedimento_rectification_origin
|
||||
# in the query builder, so it is the directly stored origin pedimento.
|
||||
# Return it directly without any further recursive DB lookup.
|
||||
return ped_rec
|
||||
|
||||
@staticmethod
|
||||
def _busca_pedimento_r1(
|
||||
db: Session,
|
||||
pedimento: str,
|
||||
visited: set
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
BUSCA_PEDIMENTO_R1 ROUTINE - Recursive search for final rectification pedimento
|
||||
using pedimento_rectification_origin table.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
pedimento: Current pedimento number to check
|
||||
visited: Set of already visited pedimentos (prevents infinite loops)
|
||||
|
||||
Returns:
|
||||
Final pedimento in chain, or None if circular reference detected
|
||||
"""
|
||||
if pedimento in visited:
|
||||
# Circular reference detected (ERRORCODE = 30 equivalent)
|
||||
logger.warning(f"Circular reference detected in rectification chain: {pedimento}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Query pedimento_rectification_origin for the next pedimento in the chain.
|
||||
# NOTE: The a76.pedimentos table does NOT have a ped_rectifica column.
|
||||
# Rectification data lives in pedimento_rectification_origin.
|
||||
sql = text("""
|
||||
SELECT
|
||||
pro.original_pedimento_year || '-' || pro.original_customs_office ||
|
||||
'-' || pro.original_license || '-' || pro.original_pedimento_number AS ped_origen
|
||||
FROM a76.pedimento_rectification_origin pro
|
||||
INNER JOIN a76.pedimentos ped ON ped.id = pro.pedimento_id
|
||||
WHERE ped.pedimento_number = :pedimento
|
||||
AND pro.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
""")
|
||||
result = db.execute(sql, {"pedimento": pedimento}).fetchone()
|
||||
|
||||
if result and result[0] and result[0].replace('-', '').strip():
|
||||
ped_rectifica_next = result[0]
|
||||
|
||||
# Add current pedimento to visited set
|
||||
visited.add(pedimento)
|
||||
|
||||
# Recurse with next rectification origin
|
||||
final_ped = DatabaseHelper._busca_pedimento_r1(
|
||||
db, ped_rectifica_next, visited
|
||||
)
|
||||
|
||||
return final_ped if final_ped else pedimento
|
||||
else:
|
||||
# No more rectifications, this is the final pedimento
|
||||
return pedimento
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching rectification origin for pedimento {pedimento}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_driver_badge(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
factura: str
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get driver unique badge number (NUMGAFETEUNICO) for invoice.
|
||||
|
||||
Clarion query:
|
||||
SELECT NUMGAFETEUNICO FROM GConductor
|
||||
LEFT JOIN QFacImp ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR
|
||||
WHERE FacturaImpo = '<factura>'
|
||||
|
||||
Modern schema:
|
||||
- invoice_header has invoice_number
|
||||
- invoice_logistics links to invoice via invoice_id and has driver_name
|
||||
- driver table has unique_badge_number and driver_name
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Database name (not used in modern schema)
|
||||
factura: Invoice number
|
||||
|
||||
Returns:
|
||||
Driver unique badge number or None
|
||||
"""
|
||||
if not factura:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Join invoice_header -> invoice_logistics -> driver via driver_name
|
||||
query = text("""
|
||||
SELECT d.unique_badge_number
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.driver d ON d.driver_name = log.driver_name
|
||||
WHERE ih.invoice_number = :factura
|
||||
AND d.unique_badge_number IS NOT NULL
|
||||
LIMIT 1
|
||||
""")
|
||||
result = db.execute(query, {"factura": factura}).fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching driver badge for invoice {factura}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_part_export_symbol(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
num_parte: str,
|
||||
is_shelter: bool
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get export symbol/license for a part number.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Database name (not used in PostgreSQL, kept for compatibility)
|
||||
num_parte: Part number
|
||||
is_shelter: Shelter flag (not used, kept for compatibility)
|
||||
|
||||
Returns:
|
||||
Export symbol/license or None
|
||||
"""
|
||||
if not num_parte:
|
||||
return None
|
||||
|
||||
try:
|
||||
query = text("""
|
||||
SELECT exclusion_symbol
|
||||
FROM a76.parts
|
||||
WHERE part_number = :num_parte
|
||||
LIMIT 1
|
||||
""")
|
||||
result = db.execute(query, {"num_parte": num_parte}).fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching export symbol for part {num_parte}: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Definitive import service - handles COMEX/IMPDF movements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..schemas import ImportDefinitiveFilter, MovementItem, MovementItemDetailed
|
||||
|
||||
from .base import ConfigHelper, StringHelper
|
||||
from .database_helpers import DatabaseHelper
|
||||
from .exchange_rate import ExchangeRateCalculator
|
||||
from .query_builders import DefinitiveImportQueries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string in YYYYMMDD format to datetime."""
|
||||
if not date_str or date_str == '':
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, '%Y%m%d')
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class DefinitiveImportService:
|
||||
"""Service for handling definitive import movements (COMEX/IMPDF)."""
|
||||
|
||||
def get_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: "ImportDefinitiveFilter"
|
||||
) -> List["MovementItem"]:
|
||||
"""
|
||||
Get definitive import movements (normal mode - grouped by invoice).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of movement items grouped by invoice
|
||||
"""
|
||||
from ..schemas import MovementItem
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching definitive import movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Execute optimized aggregated query for NORMAL mode
|
||||
sql = text(DefinitiveImportQueries.build_aggregated_query(filters.database_name, where_clause))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} definitive import invoices")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
factura = row[0] # C1 - FacturaImpoDef
|
||||
estatus = row[3] # C4 - Estatus (AC o NA)
|
||||
|
||||
# Filtrar facturas según include_cancelled
|
||||
# Si include_cancelled=False, solo mostrar AC (is_updated=true)
|
||||
# Si include_cancelled=True, mostrar todas (AC y NA)
|
||||
if not filters.include_cancelled and estatus != 'AC':
|
||||
continue
|
||||
|
||||
invoice_id = row[16] # C35 - invoice ID
|
||||
|
||||
# Helper to safely convert to float
|
||||
def to_float(val):
|
||||
if val is None or val == '':
|
||||
return 0.0
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
# Totals come directly from GROUP BY query (no N+1 problem)
|
||||
total_me = to_float(row[28]) # total_me from SUM aggregation
|
||||
total_mn = to_float(row[29]) # total_mn from SUM aggregation
|
||||
sum_value_usd = to_float(row[30])
|
||||
sum_value_mxn = to_float(row[31])
|
||||
|
||||
total_me = sum_value_usd if sum_value_usd > 0 else total_me
|
||||
total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn
|
||||
|
||||
# Calculate exchange rate and value
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
valor_me=total_me,
|
||||
valor_mn=total_mn,
|
||||
tipo_cambio_db=to_float(row[20]), # C51 - TipoCambio
|
||||
fecha_pago=row[8], # C13 - Fecha_Pago
|
||||
fecha_inicio=row[6], # C11 - Fecha_Inicio
|
||||
tipo_pedimento=row[4], # C5 - ClavePed (Fix: using C5 instead of empty C59)
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
is_shelter=filters.is_shelter,
|
||||
use_transport_method=False,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Get pedimento rectification
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C2 - PedimentoImpoDef
|
||||
row[17], # C42 - PedRectifica
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
num_gaf_uni = DatabaseHelper.get_driver_badge(
|
||||
db, filters.database_name, factura
|
||||
)
|
||||
|
||||
# Build movement item
|
||||
movement = MovementItem(
|
||||
Factura=factura,
|
||||
Pedimento=row[1], # C2 - PedimentoImpoDef
|
||||
FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura
|
||||
Estatus=row[3], # C4 - Estatus
|
||||
ClavePed=row[4], # C5 - ClavePed
|
||||
TipoMovTemDef='IMPDF',
|
||||
EsCambioRegimen='N',
|
||||
ValorMPTemp=valor_comercial,
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
ValorAgre=0.0,
|
||||
TipoExpo='',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[18], # C43 - EDocument
|
||||
NumOperacionVU=row[19], # C44 - NumOperacionVU
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[22], # C53 - UsuarioCap
|
||||
UsuarioAcr=row[23], # C54 - UsuarioAct
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[8]), # C13 - Fecha_Pago
|
||||
NumCaja=row[24], # C56 - Transporte + NumTrasporte
|
||||
Pedimento18=row[25], # C57 - empty (index 25)
|
||||
AduanaCru=row[15], # C39 - Aduana_Cruce
|
||||
Lote=row[26] # C58 - empty (index 26)
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} definitive import movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching definitive import movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: "ImportDefinitiveFilter"
|
||||
) -> List["MovementItemDetailed"]:
|
||||
"""
|
||||
Get definitive import movements (detailed mode - line by line).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of detailed movement items (one per partida)
|
||||
"""
|
||||
from ..schemas import MovementItemDetailed
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching detailed definitive import movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Execute main query
|
||||
sql = text(DefinitiveImportQueries.build_main_query(filters.database_name, where_clause))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} detailed definitive import partidas")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
# Skip cancelled if not included
|
||||
if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus
|
||||
continue
|
||||
|
||||
# Get additional detailed information
|
||||
# Provider and client names now come directly from query (row[7], row[8])
|
||||
# But we still need RFC and TaxID from the helper
|
||||
proveedor_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[15], is_supplier=True
|
||||
)
|
||||
vendido_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[16], is_supplier=False
|
||||
)
|
||||
agente_info = DatabaseHelper.get_customs_agent_info(
|
||||
db, filters.database_name, row[17]
|
||||
)
|
||||
aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre(
|
||||
db, filters.database_name, row[38]
|
||||
)
|
||||
|
||||
# Calculate values using unified method
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
es_subpartida=row[39], # C40 - EsSubPartida
|
||||
valor_me=row[26], # C27 - ValorImpoME
|
||||
valor_mn_direct=row[24], # C25 - ValorImpoMN
|
||||
fecha_pago=row[12], # C13 - Fecha_Pago
|
||||
fecha_inicio=row[10], # C1 entry_date
|
||||
clave_ped=row[57] if len(row) > 57 else '', # C58 - TIPOPEDIMENTOTRANSPORTEE
|
||||
tipo_cambio_partida=row[49], # C50 - TipoCambio
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Set peso values based on subpartida flag
|
||||
if row[39] == 'P': # C40 - EsSubPartida
|
||||
peso_neto = float(row[28]) if row[28] else 0.0 # C29
|
||||
peso_bruto = float(row[29]) if row[29] else 0.0 # C30
|
||||
else:
|
||||
peso_neto = 0.0
|
||||
peso_bruto = 0.0
|
||||
|
||||
series_info = DatabaseHelper.get_series_info(
|
||||
db, filters.database_name, row[38], row[43], filters.is_shelter # C39, C44
|
||||
)
|
||||
|
||||
simbolo_ex = None
|
||||
if row[48]: # C49 - Part Number
|
||||
simbolo_ex = DatabaseHelper.get_part_export_symbol(
|
||||
db, filters.database_name, row[48], filters.is_shelter
|
||||
)
|
||||
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db, row[1], row[40], filters.is_shelter # C2, C41
|
||||
)
|
||||
|
||||
num_gaf_uni = DatabaseHelper.get_driver_badge(
|
||||
db, filters.database_name, row[0] # C1
|
||||
)
|
||||
|
||||
movement = MovementItemDetailed(
|
||||
Linea=row[43], # C44
|
||||
Factura=row[0], # C1
|
||||
Pedimento=row[1], # C2
|
||||
FechaFactura=parse_yyyymmdd_date(row[2]), # C3
|
||||
Estatus=row[3], # C4
|
||||
ClavePed=row[4], # C5
|
||||
TipoMovTemDef='IMPDF',
|
||||
EsCambioRegimen='N',
|
||||
Regimen=row[9], # C10
|
||||
Fecha_Inicio=parse_yyyymmdd_date(row[10]), # C11
|
||||
Fecha_Fin=parse_yyyymmdd_date(row[11]), # C12
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[12]), # C13
|
||||
Remesa=str(row[13]) if row[13] is not None else None, # C14
|
||||
Proveedor=row[7], # C8 - Provider name (from JOIN)
|
||||
RFCProveedor=proveedor_info.get('rfc'),
|
||||
ProveedorTaxID=proveedor_info.get('tax_id'),
|
||||
VendidoA=row[8], # C9 - Client name (from JOIN)
|
||||
VendidoARFC=vendido_info.get('rfc'),
|
||||
VendidoATaxID=vendido_info.get('tax_id'),
|
||||
AgenteAduanal=agente_info.get('name'),
|
||||
Patente=agente_info.get('license'),
|
||||
NumParte=row[48], # C49
|
||||
DescripcionE=StringHelper.clean_text(row[20]), # C21
|
||||
DescripcionI=StringHelper.clean_text(row[21]), # C22
|
||||
CantidadIE=float(row[22]) if row[22] else 0.0, # C23
|
||||
UniMed=row[23], # C24
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
PesoNeto=peso_neto,
|
||||
PesoBruto=peso_bruto,
|
||||
OrdenCompraVenta=row[30], # C31
|
||||
FraccionArancelaria=row[31], # C32
|
||||
Preferencia=row[32], # C33
|
||||
Sector=row[34], # C35
|
||||
PaisOrigen=row[36], # C37
|
||||
Aduana=aduana_nombre,
|
||||
Advalorem='P' if row[39] == 'P' else 'S', # C40
|
||||
TipoExpo='',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[41], # C42
|
||||
NumOperacionVU=row[42], # C43
|
||||
Series=series_info,
|
||||
Marca=StringHelper.clean_text(row[44]), # C45
|
||||
Modelo=StringHelper.clean_text(row[45]), # C46
|
||||
FraccionAmericana=row[46], # C47
|
||||
ECCN=row[47], # C48
|
||||
SimboloEx=simbolo_ex,
|
||||
FechaEmision=parse_yyyymmdd_date(row[50]) if row[50] else None, # C51
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[51], # C52
|
||||
UsuarioAcr=row[52], # C53
|
||||
Transportista=row[53], # C54
|
||||
NumCaja=row[54], # C55
|
||||
Pedimento18=row[55] if len(row) > 55 else '', # C56
|
||||
AduanaCru=row[37], # C38
|
||||
Lote=row[56] if len(row) > 56 else '' # C57
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} detailed definitive import movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed definitive import movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _build_where_clause(self, filters: "ImportDefinitiveFilter") -> str:
|
||||
"""Build WHERE clause for definitive imports query."""
|
||||
where_conditions = []
|
||||
|
||||
# STRICT SEPARATION: Only imports
|
||||
where_conditions.append("ih.operation_type = 'imp'")
|
||||
|
||||
# GOLDEN RULE: If movement_type is ALL, only filter by operation_type
|
||||
# ALWAYS filter by specific invoice_type to avoid duplication with Temporary service
|
||||
where_conditions.append("ih.invoice_type IN ('DEF', 'MATDE', 'EXDEF')")
|
||||
|
||||
# Date range filter
|
||||
if filters.range_type.value == "FF":
|
||||
where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
else:
|
||||
where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
|
||||
# Note: Status filter applied at Python level after CASE WHEN in SELECT
|
||||
# because is_updated doesn't directly represent AC/NA status
|
||||
|
||||
# Provider filter
|
||||
if filters.provider:
|
||||
where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')")
|
||||
|
||||
# Buyer filter
|
||||
if filters.buyer:
|
||||
where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')")
|
||||
|
||||
# Pedimento code filter
|
||||
if filters.pedimento_code:
|
||||
where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'")
|
||||
|
||||
return " AND ".join(where_conditions)
|
||||
|
||||
def _calculate_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple:
|
||||
"""Calculate totals for main partidas only.
|
||||
|
||||
Only sums partidas where is_subpartida is false (equivalent to EsSubpartida = 'P' in Clarion).
|
||||
"""
|
||||
sql = text("""
|
||||
SELECT
|
||||
COALESCE(SUM(lf.value_usd), 0),
|
||||
COALESCE(SUM(lf.value_mxn), 0)
|
||||
FROM a76.item_line_financials lf
|
||||
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
|
||||
WHERE il.invoice_id = :consecutivo
|
||||
AND COALESCE(il.is_subpartida, false) = false
|
||||
""")
|
||||
|
||||
result = db.execute(sql, {"consecutivo": consecutivo}).fetchone()
|
||||
total_me = float(result[0]) if result and result[0] is not None else 0.0
|
||||
total_mn = float(result[1]) if result and result[1] is not None else 0.0
|
||||
|
||||
return total_me, total_mn
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Exchange rate calculation logic for invoice movements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Tuple, Optional
|
||||
from .base import DateHelper
|
||||
from .database_helpers import DatabaseHelper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExchangeRateCalculator:
|
||||
"""Handles exchange rate calculations and commercial value conversions."""
|
||||
|
||||
@staticmethod
|
||||
def calculate_exchange_rate_and_value(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
es_subpartida: str,
|
||||
valor_me: Optional[float],
|
||||
valor_mn_direct: Optional[float],
|
||||
fecha_pago,
|
||||
fecha_inicio,
|
||||
clave_ped: str,
|
||||
tipo_cambio_partida: Optional[float],
|
||||
currency_type: str,
|
||||
exchange_rate_type: str,
|
||||
met_trans: int
|
||||
) -> Tuple[float, Optional[float]]:
|
||||
"""
|
||||
Unified method to calculate exchange rate and commercial value.
|
||||
Eliminates duplicated logic across all import types.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Database name
|
||||
es_subpartida: Subpartida flag ('P' for partida, 'S' for subpartida)
|
||||
valor_me: Value in foreign currency (ME)
|
||||
valor_mn_direct: Direct value in local currency (MN)
|
||||
fecha_pago: Payment date
|
||||
fecha_inicio: Start/entry date
|
||||
clave_ped: Pedimento type code
|
||||
tipo_cambio_partida: Exchange rate from partida record
|
||||
currency_type: "ME" or "MN"
|
||||
exchange_rate_type: "FP" (payment date) or "FT" (transaction date)
|
||||
met_trans: MetTrans configuration value
|
||||
|
||||
Returns:
|
||||
Tuple of (valor_comercial_mn, tipo_cambio_final)
|
||||
"""
|
||||
# Handle subpartidas - always return zero
|
||||
if es_subpartida == 'S':
|
||||
return (0.0, None)
|
||||
|
||||
# Handle foreign currency (ME) case
|
||||
if currency_type == "ME":
|
||||
valor_comercial = valor_me or 0.0
|
||||
tipo_cambio_final = tipo_cambio_partida
|
||||
|
||||
# Try to get exchange rate from GTipoCambio if using payment date
|
||||
if exchange_rate_type == "FP" and fecha_pago:
|
||||
fecha_tc = DateHelper.get_fecha_tipo_cambio(
|
||||
fecha_pago=fecha_pago,
|
||||
fecha_inicio=fecha_inicio,
|
||||
tipo_pedimento=clave_ped,
|
||||
use_transport_method=True,
|
||||
met_trans=met_trans
|
||||
)
|
||||
tc_value = DatabaseHelper.get_exchange_rate(db, db_name, fecha_tc)
|
||||
if tc_value:
|
||||
tipo_cambio_final = tc_value
|
||||
|
||||
# For ME, the value is always in foreign currency (USD)
|
||||
return (valor_comercial, tipo_cambio_final)
|
||||
|
||||
# Handle local currency (MN) case
|
||||
if exchange_rate_type == "FP" and fecha_pago:
|
||||
fecha_tc = DateHelper.get_fecha_tipo_cambio(
|
||||
fecha_pago=fecha_pago,
|
||||
fecha_inicio=fecha_inicio,
|
||||
tipo_pedimento=clave_ped,
|
||||
use_transport_method=True,
|
||||
met_trans=met_trans
|
||||
)
|
||||
tc_value = DatabaseHelper.get_exchange_rate(db, db_name, fecha_tc)
|
||||
|
||||
if tc_value and valor_me is not None:
|
||||
# Calculate MN value from ME * payment date exchange rate
|
||||
return (valor_me * tc_value, tc_value)
|
||||
else:
|
||||
if tc_value is None:
|
||||
logger.warning(f"Exchange rate not found for date {fecha_tc}, using partida values")
|
||||
return (valor_mn_direct or 0.0, tipo_cambio_partida)
|
||||
else:
|
||||
# exchange_rate_type == "FT" (Invoice Date)
|
||||
# Use direct MN value and partida exchange rate
|
||||
return (valor_mn_direct or 0.0, tipo_cambio_partida)
|
||||
|
||||
@staticmethod
|
||||
def calculate_for_aggregated(
|
||||
db: Session,
|
||||
db_name: str,
|
||||
valor_me: float,
|
||||
valor_mn: float,
|
||||
tipo_cambio_db: float,
|
||||
fecha_pago,
|
||||
fecha_inicio,
|
||||
tipo_pedimento: str,
|
||||
currency_type: str,
|
||||
exchange_rate_type: str,
|
||||
is_shelter: bool,
|
||||
use_transport_method: bool,
|
||||
met_trans: int
|
||||
) -> Tuple[float, Optional[float]]:
|
||||
"""
|
||||
Calculate exchange rate and value for aggregated (normal mode) movements.
|
||||
|
||||
This method is used when movements are grouped by invoice rather than
|
||||
showing individual partidas.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_name: Database name
|
||||
valor_me: Aggregated value in foreign currency
|
||||
valor_mn: Aggregated value in local currency
|
||||
tipo_cambio_db: Exchange rate from database
|
||||
fecha_pago: Payment date
|
||||
fecha_inicio: Start date
|
||||
tipo_pedimento: Pedimento type
|
||||
currency_type: "ME" or "MN"
|
||||
exchange_rate_type: "FP" or "FT"
|
||||
is_shelter: Shelter company flag (kept for compatibility)
|
||||
use_transport_method: Use transport method flag
|
||||
met_trans: MetTrans value from config
|
||||
|
||||
Returns:
|
||||
Tuple of (valor_comercial_mn, tipo_cambio)
|
||||
"""
|
||||
# Foreign currency case
|
||||
if currency_type == "ME":
|
||||
valor_comercial = valor_me
|
||||
tipo_cambio = tipo_cambio_db
|
||||
|
||||
# Try to get exchange rate if using payment date
|
||||
if exchange_rate_type == "FP" and fecha_pago:
|
||||
fecha_tc = DateHelper.get_fecha_tipo_cambio(
|
||||
fecha_pago=fecha_pago,
|
||||
fecha_inicio=fecha_inicio,
|
||||
tipo_pedimento=tipo_pedimento,
|
||||
use_transport_method=use_transport_method,
|
||||
met_trans=met_trans
|
||||
)
|
||||
tc_value = DatabaseHelper.get_exchange_rate(
|
||||
db, db_name, fecha_tc,
|
||||
is_shelter=is_shelter,
|
||||
raise_on_missing=is_shelter
|
||||
)
|
||||
|
||||
if tc_value:
|
||||
# For ME, the value is always in foreign currency (USD). Just return the new exchange rate.
|
||||
return (valor_comercial, tc_value)
|
||||
else:
|
||||
logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values")
|
||||
|
||||
return (valor_comercial, tipo_cambio)
|
||||
|
||||
# Local currency case
|
||||
else:
|
||||
if exchange_rate_type == "FP" and fecha_pago:
|
||||
fecha_tc = DateHelper.get_fecha_tipo_cambio(
|
||||
fecha_pago=fecha_pago,
|
||||
fecha_inicio=fecha_inicio,
|
||||
tipo_pedimento=tipo_pedimento,
|
||||
use_transport_method=use_transport_method,
|
||||
met_trans=met_trans
|
||||
)
|
||||
tc_value = DatabaseHelper.get_exchange_rate(
|
||||
db, db_name, fecha_tc,
|
||||
is_shelter=is_shelter,
|
||||
raise_on_missing=is_shelter
|
||||
)
|
||||
|
||||
if tc_value and valor_me is not None:
|
||||
# Calculate MN value from ME * payment date exchange rate
|
||||
return (valor_me * tc_value, tc_value)
|
||||
else:
|
||||
if tc_value is None:
|
||||
logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values")
|
||||
return (valor_mn, tipo_cambio_db)
|
||||
else:
|
||||
return (valor_mn, tipo_cambio_db)
|
||||
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Export service - handles export movements (EXPO DEF).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from ..schemas import ExportFilter, MovementItem, MovementItemDetailed
|
||||
from .base import ConfigHelper, StringHelper
|
||||
from .database_helpers import DatabaseHelper
|
||||
from .exchange_rate import ExchangeRateCalculator
|
||||
from .query_builders import ExportQueries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string in YYYYMMDD format to datetime."""
|
||||
if not date_str or date_str == '':
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, '%Y%m%d')
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class ExportService:
|
||||
"""Service for handling export movements (EXPO DEF)."""
|
||||
|
||||
def get_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportFilter
|
||||
) -> List[MovementItem]:
|
||||
"""
|
||||
Get export movements (normal mode - grouped by invoice).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of movement items grouped by invoice
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching export movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Execute optimized aggregated query for NORMAL mode
|
||||
# Note: discharge_clause not used in aggregated query for exports
|
||||
sql = text(ExportQueries.build_aggregated_query(filters.database_name, where_clause))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} export invoices")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
factura = row[0] # C1 - FacturaExpo
|
||||
tipo_mov = row[14] # C34 - TipoFactura
|
||||
|
||||
# Skip cancelled if not included
|
||||
if not filters.include_cancelled and row[3] != 'AC': # C6 - Estatus
|
||||
continue
|
||||
|
||||
consecutivo = row[15] # C35 - Consecutivo
|
||||
|
||||
# Totals come directly from GROUP BY query (no N+1 problem)
|
||||
def to_float(val):
|
||||
if val is None or val == '': return 0.0
|
||||
try: return float(val)
|
||||
except (ValueError, TypeError): return 0.0
|
||||
|
||||
total_me = to_float(row[23]) # total_me from SUM aggregation
|
||||
total_mn = to_float(row[24]) # total_mn from SUM aggregation
|
||||
sum_value_usd = to_float(row[26])
|
||||
sum_value_mxn = to_float(row[27])
|
||||
|
||||
total_me = sum_value_usd if sum_value_usd > 0 else total_me
|
||||
total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn
|
||||
|
||||
# Calculate exchange rate and value
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
valor_me=total_me,
|
||||
valor_mn=total_mn,
|
||||
tipo_cambio_db=row[18], # C48 - TipoCambio
|
||||
fecha_pago=row[7], # C11 - Fecha_Pago
|
||||
fecha_inicio=row[6], # C10 - Fecha_Inicio (mapped previously to C9)
|
||||
tipo_pedimento='', # Not in aggregated query
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
is_shelter=filters.is_shelter,
|
||||
use_transport_method=filters.use_transport_method,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Get pedimento rectification
|
||||
rectified_pedimento = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C2 - PedimentoExpo
|
||||
row[25], # C54 - PedRectifica
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
driver_badge = self._get_driver_badge(db, filters.database_name, factura)
|
||||
|
||||
# Build movement item
|
||||
movement = MovementItem(
|
||||
Factura=factura,
|
||||
Pedimento=row[1], # C2 - PedimentoExpo
|
||||
FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura
|
||||
Estatus=row[3], # C6 - Estatus
|
||||
ClavePed=row[4], # C7 - ClavePed
|
||||
TipoMovTemDef=tipo_mov,
|
||||
EsCambioRegimen='N',
|
||||
ValorMPTemp=valor_comercial,
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
ValorAgre=to_float(row[28]),
|
||||
TipoExpo='EXPO DEF',
|
||||
PedimentoR1=rectified_pedimento,
|
||||
EDocument=row[16], # C40 - EDocument
|
||||
NumOperacionVU=row[17], # C41 - NumOperacionVU
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=driver_badge,
|
||||
UsuarioCap=row[20], # C50 - UsuarioCap
|
||||
UsuarioAcr=row[21], # C51 - UsuarioAct
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[7]), # C11 - Fecha_Pago
|
||||
NumCaja=row[22], # C53 - Transporte + NumTrasporte
|
||||
Pedimento18='', # Not in aggregated query
|
||||
AduanaCru=row[13], # C33 - Aduana_Cruce
|
||||
Lote='' # Not in aggregated query
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} export movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching export movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""
|
||||
Get export movements (detailed mode - line by line).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of detailed movement items (one per partida)
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching detailed export movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Build discharge filter for main query
|
||||
discharge_clause = ""
|
||||
if filters.discharge_filter == "SiDes":
|
||||
discharge_clause = " AND EqiPex.Descarga = 1"
|
||||
elif filters.discharge_filter == "NoDes":
|
||||
discharge_clause = " AND EqiPex.Descarga = 0"
|
||||
|
||||
# Modify main query to include discharge filter
|
||||
where_with_discharge = where_clause + discharge_clause
|
||||
|
||||
# Execute main query
|
||||
sql = text(ExportQueries.build_main_query(filters.database_name, where_with_discharge))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} detailed export partidas")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
# Skip cancelled if not included
|
||||
if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus
|
||||
continue
|
||||
|
||||
# Get client/supplier information
|
||||
proveedor_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[13], is_supplier=True # C14 - Proveedor
|
||||
)
|
||||
vendido_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[14], is_supplier=False # C15 - VendidoA
|
||||
)
|
||||
|
||||
# Get customs agent information
|
||||
agente_info = DatabaseHelper.get_customs_agent_info(
|
||||
db, filters.database_name, row[15] # C16 - AAduanal
|
||||
)
|
||||
|
||||
# Get customs section name
|
||||
customs_name = DatabaseHelper.get_aduana_seccion_nombre(
|
||||
db, filters.database_name, row[32] # C33 - Aduana_Cruce
|
||||
)
|
||||
|
||||
# Calculate exchange rate and value for this partida
|
||||
valor_mn, tipo_cambio_final = ExchangeRateCalculator.calculate_exchange_rate_and_value(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
es_subpartida=row[37], # C38 - EsSubPartida
|
||||
valor_me=row[36], # C37 - ValorExpoME
|
||||
valor_mn_direct=row[35], # C36 - ValorExpoMN
|
||||
fecha_pago=row[10], # C11 - Fecha_Pago
|
||||
fecha_inicio=row[8], # C9 - Fecha_Inicio
|
||||
clave_ped=row[55], # C56 - TIPOPEDIMENTOTRANSPORTEE
|
||||
tipo_cambio_partida=row[47], # C48 - TipoCambio
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Set peso values (material_type is typically 'PT' or 'MP', not just 'P')
|
||||
peso_neto_final = row[24] if row[37] != 'S' else 0 # C25 - PesoNeto
|
||||
peso_bruto_final = row[25] if row[37] != 'S' else 0 # C26 - PesoBruto
|
||||
|
||||
# Get series information
|
||||
series_info = DatabaseHelper.get_series_info_export(
|
||||
db, filters.database_name, row[34], row[41], filters.is_shelter
|
||||
)
|
||||
|
||||
# Get pedimento rectification
|
||||
rectified_pedimento = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C2 - PedimentoExpo
|
||||
row[38], # C39 - PedRectifica
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
driver_badge = self._get_driver_badge(db, filters.database_name, row[0]) # C1 - FacturaExpo
|
||||
|
||||
# Build detailed movement item
|
||||
movement = MovementItemDetailed(
|
||||
Linea=row[41], # C42 - LineaExpo
|
||||
Factura=row[0], # C1 - FacturaExpo
|
||||
Pedimento=row[1], # C2 - PedimentoExpo
|
||||
FechaFactura=row[2], # C3 - FechaFactura
|
||||
Estatus=row[5], # C6 - Estatus
|
||||
ClavePed=row[4], # C5 - ClavePed
|
||||
TipoMovTemDef=row[31], # C34 - TipoFactura
|
||||
EsCambioRegimen='N',
|
||||
Regimen=row[5], # C6 - Regime (Shared index with Estatus in this query)
|
||||
Fecha_Inicio=parse_yyyymmdd_date(row[6]), # C7 - Fecha_Inicio
|
||||
Fecha_Fin=parse_yyyymmdd_date(row[7]), # C8 - Fecha_Fin
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[8]), # C9 - Fecha_Pago
|
||||
Remesa=row[9], # C12 - Remesa
|
||||
TipoCambio=tipo_cambio_final,
|
||||
Proveedor=proveedor_info.get("name"),
|
||||
RFCProveedor=proveedor_info.get("rfc"),
|
||||
ProveedorTaxID=proveedor_info.get("tax_id"),
|
||||
VendidoA=vendido_info.get("name"),
|
||||
VendidoARFC=vendido_info.get("rfc"),
|
||||
VendidoATaxID=vendido_info.get("tax_id"),
|
||||
AgenteAduanal=agente_info.get("name"),
|
||||
Patente=agente_info.get("license"),
|
||||
NumParte=row[17], # C18 - NumParte
|
||||
DescripcionE=StringHelper.remove_commas(row[18]), # C19 - DescripcionE
|
||||
DescripcionI=StringHelper.remove_commas(row[19]), # C20 - DescripcionI
|
||||
CantidadIE=row[20], # C21 - CantExpo
|
||||
UniMed=row[21], # C22 - UnidadMedida
|
||||
ValorComercialMN=valor_mn,
|
||||
PesoNeto=peso_neto_final,
|
||||
PesoBruto=peso_bruto_final,
|
||||
OrdenCompraVenta=row[26], # C27 - OrdenCompra
|
||||
FraccionArancelaria=row[27], # C28 - FraccionExpo
|
||||
Preferencia=row[28], # C29 - TipoFraccion
|
||||
Sector=row[30], # C31 - Sector
|
||||
PaisOrigen=row[31], # C32 - PaisOrigen
|
||||
Aduana=customs_name,
|
||||
Advalorem=row[29], # C30 - Advalorem
|
||||
TipoExpo='EXPO DEF',
|
||||
PedimentoR1=rectified_pedimento,
|
||||
EDocument=row[39], # C40 - EDocument
|
||||
NumOperacionVU=row[40], # C41 - NumOperacionVU
|
||||
Series=series_info,
|
||||
Marca=StringHelper.clean_text(row[42]), # C43 - Marca
|
||||
Modelo=StringHelper.clean_text(row[43]), # C44 - Modelo
|
||||
FraccionAmericana=row[44], # C45 - FraccionAme
|
||||
ECCN=row[45], # C46 - ECCN
|
||||
FechaEmision=parse_yyyymmdd_date(row[48]) if row[48] else None, # C49 - FechaEmision
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=driver_badge,
|
||||
UsuarioCap=row[49], # C50 - UsuarioCap
|
||||
UsuarioAcr=row[50], # C51 - UsuarioAct
|
||||
Transportista=row[51], # C52 - Carrier ID (derived from log.transport_id)
|
||||
NumCaja=row[52], # C53 - log.transport_id || log.transport_num
|
||||
Pedimento18=row[53], # C54 - empty
|
||||
AduanaCru=row[32], # C33 - Aduana_Cruce
|
||||
Lote=row[54] if len(row) > 54 else '' # C55 - Lote
|
||||
)
|
||||
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} detailed export movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed export movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _build_where_clause(self, filters: ExportFilter) -> str:
|
||||
"""
|
||||
Build WHERE clause for export query.
|
||||
|
||||
IMPORTANT: Returns conditions WITHOUT the WHERE keyword (already in base query)
|
||||
AC (Active) = is_updated = true
|
||||
NA (Not Applicable/Deactivated) = is_updated = false
|
||||
"""
|
||||
conditions = []
|
||||
|
||||
# STRICT SEPARATION: Only exports
|
||||
conditions.append("ih.operation_type = 'exp'")
|
||||
|
||||
# Exclude REP (export reports)
|
||||
conditions.append("ih.invoice_type NOT IN ('REP')")
|
||||
|
||||
# GOLDEN RULE: If movement_type is ALL, only filter by operation_type
|
||||
if filters.movement_type.value != "ALL":
|
||||
# Filter by invoice type for exports
|
||||
conditions.append("ih.invoice_type IN ('EXP', 'EXREP')")
|
||||
|
||||
# CRITICAL VALIDATION: AC/NA status filter
|
||||
# If include_cancelled is False (checkbox unchecked), only show AC invoices
|
||||
# AC (Active) = is_updated = true
|
||||
# NA (Not Applicable/Deactivated) = is_updated = false
|
||||
if not filters.include_cancelled:
|
||||
conditions.append("ih.is_updated = true")
|
||||
logger.debug("Filtering only active invoices (is_updated = true)")
|
||||
else:
|
||||
logger.debug("Including cancelled invoices (include_cancelled = true)")
|
||||
|
||||
# Date range
|
||||
date_field = "ih.invoice_date" if filters.range_type.value == "FF" else "pd.payment_date"
|
||||
conditions.append(f"{date_field} >= TO_DATE('{filters.start_date}', 'YYYYMMDD')")
|
||||
conditions.append(f"{date_field} <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
|
||||
# Optional filters
|
||||
if filters.provider:
|
||||
conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')")
|
||||
if filters.buyer:
|
||||
conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')")
|
||||
if filters.pedimento_code:
|
||||
conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'")
|
||||
|
||||
return " AND ".join(conditions)
|
||||
|
||||
def _build_discharge_clause(self, discharge_filter: str) -> str:
|
||||
"""Build discharge filter clause for totals query."""
|
||||
if discharge_filter == "SiDes":
|
||||
return " AND il.is_discharged = true"
|
||||
elif discharge_filter == "NoDes":
|
||||
return " AND il.is_discharged = false"
|
||||
return ""
|
||||
|
||||
def _calculate_totals(
|
||||
self,
|
||||
db: Session,
|
||||
db_name: str,
|
||||
consecutivo: int,
|
||||
discharge_clause: str
|
||||
) -> tuple:
|
||||
"""Calculate total values for an export invoice."""
|
||||
try:
|
||||
sql = text(ExportQueries.build_totals_query(db_name, discharge_clause))
|
||||
result = db.execute(sql, {"consecutivo": consecutivo}).fetchone()
|
||||
|
||||
if result:
|
||||
return (result[0] or 0, result[1] or 0)
|
||||
return (0, 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating export totals for consecutivo {consecutivo}: {e}")
|
||||
return (0, 0)
|
||||
|
||||
def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> str:
|
||||
"""Get driver's unique badge number for an export invoice."""
|
||||
if not factura:
|
||||
return None
|
||||
|
||||
try:
|
||||
sql = text(ExportQueries.build_driver_badge_query(db_name))
|
||||
result = db.execute(sql, {"factura": factura}).fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching driver badge for export invoice {factura}: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Export repair service - handles EXPO REP movements (repair exports).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from ..schemas import ExportRepairFilter, MovementItem, MovementItemDetailed
|
||||
from .base import ConfigHelper, StringHelper
|
||||
from .database_helpers import DatabaseHelper
|
||||
from .exchange_rate import ExchangeRateCalculator
|
||||
from .query_builders import ExportRepairQueries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string in YYYYMMDD format to datetime."""
|
||||
if not date_str or date_str == '':
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, '%Y%m%d')
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class ExportRepairService:
|
||||
"""Service for handling export repair movements (EXPO REP)."""
|
||||
|
||||
def get_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportRepairFilter
|
||||
) -> List[MovementItem]:
|
||||
"""
|
||||
Get export repair movements (normal mode - grouped by invoice).
|
||||
|
||||
Aggregated query column order (ExportRepairQueries.build_aggregated_query):
|
||||
[0] C1 - invoice_number
|
||||
[1] C2 - pedimento_number
|
||||
[2] C3 - invoice_date
|
||||
[3] C6 - estatus (AC/NA)
|
||||
[4] C7 - pedimento_code
|
||||
[5] C8 - regime
|
||||
[6] C11 - payment_date
|
||||
[7] C12 - remesa
|
||||
[8] C13 - exchange_rate (fecha_pago context)
|
||||
[9] C14 - provider_id
|
||||
[10] C15 - sold_to_id
|
||||
[11] C16 - customs_broker_id
|
||||
[12] C27 - purchase_order
|
||||
[13] C33 - customs_office ← AduanaCru
|
||||
[14] C34 - document_type ← TipoFactura / tipo_mov
|
||||
[15] C35 - id ← consecutivo
|
||||
[16] C40 - edocument ← EDocument
|
||||
[17] C41 - vucem_op_num ← NumOperacionVU
|
||||
[18] C48 - exchange_rate ← TipoCambio
|
||||
[19] C49 - emission_date
|
||||
[20] C50 - capture_user ← UsuarioCap
|
||||
[21] C51 - who_updated ← UsuarioAcr
|
||||
[22] C52 - carrier_id ← Transportista
|
||||
[23] C53 - transport ← NumCaja
|
||||
[24] total_me
|
||||
[25] total_mn
|
||||
[26] C54 - ped_r1 ← PedimentoR1
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of movement items grouped by invoice
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching export repair movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Execute optimized aggregated query for NORMAL mode
|
||||
sql = text(ExportRepairQueries.build_aggregated_query(filters.database_name, where_clause))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} export repair invoices")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
factura = row[0] # C1 - FacturaExpo
|
||||
tipo_mov = row[14] # C34 - TipoFactura
|
||||
estatus = row[3] # C6 - Estatus (AC o NA)
|
||||
|
||||
# Filtrar facturas según include_cancelled
|
||||
if not filters.include_cancelled and estatus != 'AC':
|
||||
continue
|
||||
|
||||
consecutivo = row[15] # C35 - Consecutivo
|
||||
|
||||
# Totals come directly from GROUP BY query (no N+1 problem)
|
||||
def to_float(val):
|
||||
if val is None or val == '': return 0.0
|
||||
try: return float(val)
|
||||
except (ValueError, TypeError): return 0.0
|
||||
|
||||
total_me = to_float(row[24]) # total_me
|
||||
total_mn = to_float(row[25]) # total_mn
|
||||
sum_value_usd = to_float(row[27])
|
||||
sum_value_mxn = to_float(row[28])
|
||||
|
||||
total_me = sum_value_usd if sum_value_usd > 0 else total_me
|
||||
total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn
|
||||
|
||||
# Calculate exchange rate and value
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
valor_me=total_me,
|
||||
valor_mn=total_mn,
|
||||
tipo_cambio_db=row[18], # C48 - TipoCambio
|
||||
fecha_pago=row[6], # C11 - Fecha_Pago
|
||||
fecha_inicio='',
|
||||
tipo_pedimento='',
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
is_shelter=filters.is_shelter,
|
||||
use_transport_method=False,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Get pedimento rectification (already resolved by SQL COALESCE)
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C2 - PedimentoExpo
|
||||
row[26], # C54 - PedRectifica (pre-built by SQL)
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura)
|
||||
|
||||
# Build movement item
|
||||
movement = MovementItem(
|
||||
Factura=factura,
|
||||
Pedimento=row[1], # C2 - PedimentoExpo
|
||||
FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura
|
||||
Estatus=row[3], # C6 - Estatus
|
||||
ClavePed=row[4], # C7 - ClavePed
|
||||
TipoMovTemDef=tipo_mov, # C34 - TipoFactura
|
||||
EsCambioRegimen='N',
|
||||
ValorMPTemp=valor_comercial,
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
ValorAgre=to_float(row[29]),
|
||||
TipoExpo='EXPO REP',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[16], # C40 - EDocument ← FIXED (was 17)
|
||||
NumOperacionVU=row[17], # C41 - NumOperacionVU ← FIXED (was 18)
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[20], # C50 - UsuarioCap ← FIXED (was 21)
|
||||
UsuarioAcr=row[21], # C51 - UsuarioAct ← FIXED (was 22)
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[6]), # C11 - Fecha_Pago ← FIXED (was 8)
|
||||
NumCaja=row[23], # C53 - NumCaja
|
||||
Pedimento18='',
|
||||
AduanaCru=row[13], # C33 - customs_office ← FIXED (was 14)
|
||||
Lote=''
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} export repair movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching export repair movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: ExportRepairFilter
|
||||
) -> List[MovementItemDetailed]:
|
||||
"""
|
||||
Get export repair movements (detailed mode - line by line).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of detailed movement items (one per partida)
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching detailed export repair movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Add discharge filter to WHERE clause
|
||||
discharge_clause = ""
|
||||
if filters.discharge_filter.value == "SiDes":
|
||||
discharge_clause = " AND RepPex.Descarga = 1"
|
||||
elif filters.discharge_filter.value == "NoDes":
|
||||
discharge_clause = " AND RepPex.Descarga = 0"
|
||||
|
||||
where_with_discharge = where_clause + discharge_clause
|
||||
|
||||
# Execute main query
|
||||
sql = text(ExportRepairQueries.build_main_query(filters.database_name, where_with_discharge))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} detailed export repair partidas")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
# Skip cancelled if not included
|
||||
if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus
|
||||
continue
|
||||
|
||||
# Get provider information
|
||||
proveedor_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[13], is_supplier=True # C14 - Proveedor
|
||||
)
|
||||
|
||||
# Get buyer information
|
||||
vendido_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[14], is_supplier=False # C15 - VendidoA
|
||||
)
|
||||
|
||||
# Get customs agent information
|
||||
agente_info = DatabaseHelper.get_customs_agent_info(
|
||||
db, filters.database_name, row[15] # C16 - AAduanal
|
||||
)
|
||||
|
||||
# Get customs section name
|
||||
aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre(
|
||||
db, filters.database_name, row[32] # C33 - Aduana_Cruce
|
||||
)
|
||||
|
||||
# Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S')
|
||||
if row[37] != 'S': # C38 - EsSubPartida
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_partida(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
valor_me=row[36], # C37 - ValorExpoME
|
||||
valor_mn=row[35], # C36 - ValorExpoMN
|
||||
tipo_cambio_db=row[47], # C48 - TipoCambio
|
||||
fecha_pago=row[10], # C11 - Fecha_Pago
|
||||
fecha_inicio=row[8], # C9 - Fecha_Inicio
|
||||
tipo_pedimento=row[55], # C56 - TIPOPEDIMENTOTRANSPORTEE
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
is_shelter=filters.is_shelter,
|
||||
use_transport_method=False,
|
||||
met_trans=met_trans
|
||||
)
|
||||
peso_neto = float(row[24]) if row[24] else 0.0 # C25
|
||||
peso_bruto = float(row[25]) if row[25] else 0.0 # C26
|
||||
else: # Subpartida
|
||||
valor_comercial = 0.0
|
||||
tipo_cambio = 0.0
|
||||
peso_neto = 0.0
|
||||
peso_bruto = 0.0
|
||||
|
||||
# Get series information
|
||||
series_info = DatabaseHelper.get_series_info_export(
|
||||
db, filters.database_name, row[34], row[41], filters.is_shelter # C35, C42
|
||||
)
|
||||
|
||||
# Get part export symbol
|
||||
simbolo_ex = None
|
||||
if row[46]: # C47 - NumParte
|
||||
simbolo_ex = DatabaseHelper.get_part_export_symbol(
|
||||
db, filters.database_name, row[46], filters.is_shelter
|
||||
)
|
||||
|
||||
# Get pedimento rectification
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C2 - PedimentoExpo
|
||||
row[38], # C39 - PedRectifica
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
num_gaf_uni = self._get_driver_badge(db, filters.database_name, row[0])
|
||||
|
||||
# Build detailed movement item
|
||||
movement = MovementItemDetailed(
|
||||
Linea=row[41], # C42 - LineaExpo
|
||||
Factura=row[0], # C1 - FacturaExpo
|
||||
Pedimento=row[1], # C2 - PedimentoExpo
|
||||
FechaFactura=row[2], # C3 - FechaFactura
|
||||
Estatus=row[5], # C6 - Estatus
|
||||
ClavePed=row[6], # C7 - ClavePed
|
||||
TipoMovTemDef=row[33], # C34 - TipoFactura
|
||||
EsCambioRegimen='N',
|
||||
Regimen=row[7], # C8 - Regimen
|
||||
Fecha_Inicio=parse_yyyymmdd_date(row[8]), # C9 - Fecha_Inicio
|
||||
Fecha_Fin=parse_yyyymmdd_date(row[9]), # C10 - Fecha_Fin
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[10]), # C11 - Fecha_Pago
|
||||
Remesa=row[11], # C12 - Remesa
|
||||
Proveedor=proveedor_info.get('name'),
|
||||
RFCProveedor=proveedor_info.get('rfc'),
|
||||
ProveedorTaxID=proveedor_info.get('tax_id'),
|
||||
VendidoA=vendido_info.get('name'),
|
||||
VendidoARFC=vendido_info.get('rfc'),
|
||||
VendidoATaxID=vendido_info.get('tax_id'),
|
||||
AgenteAduanal=agente_info.get('name'),
|
||||
Patente=agente_info.get('license'),
|
||||
NumParte=row[46], # C47 - NumParte
|
||||
DescripcionE=StringHelper.clean_text(row[18]), # C19
|
||||
DescripcionI=StringHelper.clean_text(row[19]), # C20
|
||||
CantidadIE=float(row[20]) if row[20] else 0.0, # C21
|
||||
UniMed=row[21], # C22
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
PesoNeto=peso_neto,
|
||||
PesoBruto=peso_bruto,
|
||||
OrdenCompraVenta=row[26], # C27 - OrdenCompra
|
||||
FraccionArancelaria=row[27], # C28 - FraccionExpo
|
||||
Preferencia=row[28], # C29 - TipoFraccion
|
||||
Sector=row[30], # C31 - Sector
|
||||
PaisOrigen=row[31], # C32 - PaisOrigen
|
||||
Aduana=aduana_nombre,
|
||||
Advalorem=row[37], # C38 - EsSubPartida
|
||||
TipoExpo='EXPO REP',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[39], # C40 - EDocument
|
||||
NumOperacionVU=row[40], # C41 - NumOperacionVU
|
||||
Series=series_info,
|
||||
Marca=StringHelper.clean_text(row[42]), # C43
|
||||
Modelo=StringHelper.clean_text(row[43]), # C44
|
||||
FraccionAmericana=row[44], # C45 - FraccionAme
|
||||
ECCN=row[45], # C46 - ECCN
|
||||
SimboloEx=simbolo_ex,
|
||||
FechaEmision=parse_yyyymmdd_date(row[48]) if row[48] else None, # C49 - FechaFactura
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[49], # C50 - UsuarioCap
|
||||
UsuarioAcr=row[50], # C51 - UsuarioAct
|
||||
Transportista=row[51], # C52 - Transportista
|
||||
NumCaja=row[52], # C53 - Transporte + NumTrasporte
|
||||
Pedimento18=row[53], # C54 - Pedimento18
|
||||
AduanaCru=row[32], # C33 - Aduana_Cruce
|
||||
Lote=row[54] if len(row) > 54 else '' # C55 - Lote
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} detailed export repair movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed export repair movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _build_where_clause(self, filters: ExportRepairFilter) -> str:
|
||||
"""Build WHERE clause for export repair query."""
|
||||
where_conditions = []
|
||||
|
||||
# STRICT SEPARATION: Only exports for repair
|
||||
where_conditions.append("ih.operation_type = 'exp'")
|
||||
# GOLDEN RULE: If movement_type is ALL, only filter by operation_type
|
||||
if filters.movement_type.value == "ALL":
|
||||
pass
|
||||
else:
|
||||
where_conditions.append("ih.invoice_type = 'REPAR'")
|
||||
|
||||
# Date range filter
|
||||
if filters.range_type.value == "FF":
|
||||
where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
else:
|
||||
where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
|
||||
# Provider filter
|
||||
if filters.provider:
|
||||
where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')")
|
||||
|
||||
# Buyer filter
|
||||
if filters.buyer:
|
||||
where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')")
|
||||
|
||||
# Pedimento code filter
|
||||
if filters.pedimento_code:
|
||||
where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'")
|
||||
|
||||
# Movement type filter
|
||||
if filters.movement_type.value == "AFIJO":
|
||||
where_conditions.append("ih.document_type = 'AFIJO'")
|
||||
elif filters.movement_type.value == "NODES":
|
||||
where_conditions.append("ih.document_type = 'NODES'")
|
||||
|
||||
return " AND ".join(where_conditions)
|
||||
|
||||
return total_me, total_mn
|
||||
|
||||
def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> str:
|
||||
"""Get driver badge number for invoice."""
|
||||
# TODO: GConductor table not migrated to PostgreSQL yet
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,877 @@
|
||||
"""
|
||||
SQL Query builders for invoice movement services.
|
||||
Centralizes all SQL query construction logic.
|
||||
"""
|
||||
|
||||
|
||||
class TemporaryImportQueries:
|
||||
"""SQL queries for temporary imports using PostgreSQL tables."""
|
||||
|
||||
@staticmethod
|
||||
def build_aggregated_query(db_name: str, where_str: str) -> str:
|
||||
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
|
||||
# Note: db_name parameter kept for compatibility but not used in PostgreSQL
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1,
|
||||
COALESCE(ped.pedimento_number, '') AS C2,
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4,
|
||||
COALESCE(ped.pedimento_code, '') AS C5,
|
||||
COALESCE(ped.regime, '') AS C10,
|
||||
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11,
|
||||
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12,
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13,
|
||||
COALESCE(cmp.remesa, 0) AS C14,
|
||||
COALESCE(fin.exchange_rate, 0) AS C15,
|
||||
COALESCE(cmp.provider_id::text, '') AS C16,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C17,
|
||||
COALESCE(cmp.customs_broker_id::text, '') AS C18,
|
||||
COALESCE(cmp.aduana, '') AS C38,
|
||||
ih.id AS C39,
|
||||
COALESCE(ped_r1.pedimento_number, '') AS C41,
|
||||
COALESCE(cmp.edocument, '') AS C42,
|
||||
COALESCE(cmp.vucem_operation_num, '') AS C43,
|
||||
COALESCE(fin.exchange_rate, 0) AS C50,
|
||||
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51,
|
||||
COALESCE(ih.capture_user, '') AS C52,
|
||||
COALESCE(ih.who_updated, '') AS C53,
|
||||
COALESCE(log.carrier_id, '') AS C54,
|
||||
COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55,
|
||||
'' AS C56,
|
||||
'' AS C57,
|
||||
'' AS C58,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1
|
||||
LEFT JOIN a76.items i ON i.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = i.id
|
||||
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
WHERE ih.operation_type = 'imp'
|
||||
AND ih.invoice_type = 'TEM'
|
||||
AND {where_str}
|
||||
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
|
||||
log.entry_exit_date, log.delivery_date, log.payment_date, cmp.remesa, fin.exchange_rate,
|
||||
cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, cmp.aduana, ped_r1.pedimento_number,
|
||||
cmp.edocument, cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated,
|
||||
log.carrier_id, log.transport_num, log.license_plate
|
||||
ORDER BY ih.invoice_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_main_query(db_name: str, where_str: str) -> str:
|
||||
"""Build main SQL query for DETAILED mode (all partidas) from PostgreSQL."""
|
||||
# Note: db_name parameter is kept for compatibility but not used in PostgreSQL
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1,
|
||||
COALESCE(ped.pedimento_number, '') AS C2,
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4,
|
||||
COALESCE(ped.pedimento_code, '') AS C5,
|
||||
COALESCE(fin.value_me, 0) AS C6,
|
||||
COALESCE(fin.value_mn, 0) AS C7,
|
||||
COALESCE(cmp.provider_id::text, '') AS C8,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C9,
|
||||
COALESCE(ped.regime, '') AS C10,
|
||||
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11,
|
||||
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12,
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13,
|
||||
COALESCE(cmp.remesa, 0) AS C14,
|
||||
COALESCE(fin.exchange_rate, 0) AS C15,
|
||||
COALESCE(cmp.provider_id::text, '') AS C16,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C17,
|
||||
COALESCE(cmp.customs_broker_id::text, '') AS C18,
|
||||
'' AS C19,
|
||||
COALESCE(il.class_id::text, '') AS C20,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22,
|
||||
COALESCE(lq.quantity, 0) AS C23,
|
||||
COALESCE(il.unit_of_measure::text, '') AS C24,
|
||||
COALESCE(lf.value_mxn, 0) AS C25,
|
||||
COALESCE(lf.customs_value_mxn, 0) AS C26,
|
||||
COALESCE(lf.value_usd, 0) AS C27,
|
||||
COALESCE(lf.customs_value_usd, 0) AS C28,
|
||||
COALESCE(lq.net_weight, 0) AS C29,
|
||||
COALESCE(lq.gross_weight, 0) AS C30,
|
||||
COALESCE(ih.purchase_order, '') AS C31,
|
||||
COALESCE(lc.fraction, '') AS C32,
|
||||
COALESCE(lc.fraction_type, '') AS C33,
|
||||
COALESCE(lc.advalorem_numeric, 0) AS C34,
|
||||
COALESCE(lc.sector, '') AS C35,
|
||||
COALESCE(lf.igi_amount_usd, 0) AS C36,
|
||||
COALESCE(lc.origin_country, '') AS C37,
|
||||
COALESCE(cmp.aduana, '') AS C38,
|
||||
ih.id AS C39,
|
||||
FALSE AS C40,
|
||||
'' AS C41,
|
||||
COALESCE(cmp.edocument, '') AS C42,
|
||||
COALESCE(cmp.vucem_operation_num, '') AS C43,
|
||||
COALESCE(il.line_number, 0) AS C44,
|
||||
'' AS C45,
|
||||
'' AS C46,
|
||||
COALESCE(cls.us_fraction, '') AS C47,
|
||||
COALESCE(prt.eccn, '') AS C48,
|
||||
COALESCE(il.part_number::text, '') AS C49,
|
||||
COALESCE(fin.exchange_rate, 0) AS C50,
|
||||
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51,
|
||||
COALESCE(ih.capture_user, '') AS C52,
|
||||
COALESCE(ih.who_updated, '') AS C53,
|
||||
COALESCE(log.carrier_id, '') AS C54,
|
||||
COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55,
|
||||
'' AS C56,
|
||||
'' AS C57,
|
||||
'' AS C58
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = (
|
||||
SELECT id FROM a76.items WHERE invoice_id = ih.id LIMIT 1
|
||||
)
|
||||
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
|
||||
LEFT JOIN a76.classes cls ON cls.id = il.class_id
|
||||
LEFT JOIN a76.parts prt ON prt.id = il.part_number
|
||||
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
|
||||
WHERE ih.operation_type = 'imp'
|
||||
AND ih.invoice_type = 'TEM'
|
||||
AND {where_str}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_totals_query(db_name: str) -> str:
|
||||
"""Build query to get totals for an invoice."""
|
||||
return f"""
|
||||
SELECT
|
||||
COALESCE(SUM(EqiPim.ValorImpoME), 0),
|
||||
COALESCE(SUM(EqiPim.ValorImpoMN), 0)
|
||||
FROM [{db_name}].dbo.QEqiMaq EqiPim
|
||||
WHERE EqiPim.Consecutivo = :consecutivo
|
||||
AND EqiPim.EsSubpartida = 'P'
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_series_query(db_name: str) -> str:
|
||||
"""Build query to get series information."""
|
||||
return f"""
|
||||
SELECT SerieImpo, ModeloImpo, ParteImpo
|
||||
FROM [{db_name}].dbo.QSeriesImpo
|
||||
WHERE Consecutivo = :consecutivo
|
||||
AND LineaImpo = :linea
|
||||
ORDER BY RenImpo
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_driver_badge_query(db_name: str) -> str:
|
||||
"""Build query to get driver badge number."""
|
||||
return f"""
|
||||
SELECT TOP 1 NUMGAFETEUNICO
|
||||
FROM [{db_name}].dbo.GConductor
|
||||
LEFT JOIN [{db_name}].dbo.QFacImp
|
||||
ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR
|
||||
WHERE FacturaImpo = :factura
|
||||
"""
|
||||
|
||||
|
||||
class DefinitiveImportQueries:
|
||||
"""SQL queries for definitive imports (PostgreSQL schema)."""
|
||||
|
||||
@staticmethod
|
||||
def build_aggregated_query(db_name: str, where_clause: str) -> str:
|
||||
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1,
|
||||
COALESCE(ped.pedimento_number, '') AS C2,
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4,
|
||||
COALESCE(ped.pedimento_code, '') AS C5,
|
||||
COALESCE(ped.regime, '') AS C10,
|
||||
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11,
|
||||
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12,
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13,
|
||||
COALESCE(log.payment_receipt_num, '') AS C14,
|
||||
COALESCE(fin.exchange_rate, 0) AS C15,
|
||||
COALESCE(cmp.provider_id::text, '') AS C16,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C17,
|
||||
COALESCE(cmp.customs_broker_id::text, '') AS C18,
|
||||
COALESCE(ih.purchase_order, '') AS C31,
|
||||
COALESCE(cmp.aduana, '') AS C39,
|
||||
ih.id AS C35,
|
||||
COALESCE(ped_r1.pedimento_number, '') AS C42,
|
||||
COALESCE(cmp.edocument, '') AS C43,
|
||||
COALESCE(cmp.vucem_operation_num, '') AS C44,
|
||||
COALESCE(fin.exchange_rate, 0) AS C51,
|
||||
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C52,
|
||||
COALESCE(ih.capture_user, '') AS C53,
|
||||
COALESCE(ih.who_updated, '') AS C54,
|
||||
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56,
|
||||
'' AS C57,
|
||||
'' AS C58,
|
||||
'' AS C59,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1
|
||||
LEFT JOIN a76.items i ON i.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = i.id
|
||||
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
WHERE ih.operation_type = 'imp'
|
||||
AND ih.invoice_type IN ('DEF', 'EXDEF', 'MATDE')
|
||||
AND {where_clause}
|
||||
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
|
||||
log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num,
|
||||
fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id,
|
||||
ih.purchase_order, cmp.aduana, ped_r1.pedimento_number, cmp.edocument,
|
||||
cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated,
|
||||
log.transport_id, log.transport_num
|
||||
ORDER BY ih.invoice_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_main_query(db_name: str, where_clause: str) -> str:
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1, -- [0]
|
||||
ped.pedimento_number AS C2, -- [1]
|
||||
ih.invoice_date AS C3, -- [2]
|
||||
ped.status AS C4, -- [3]
|
||||
ped.pedimento_code AS C5, -- [4]
|
||||
'' AS C6, '' AS C7, '' AS C8, '' AS C9, -- [5-8]
|
||||
ped.regime AS C10, -- [9]
|
||||
log.entry_exit_date AS C11, -- [10]
|
||||
log.delivery_date AS C12, -- [11]
|
||||
log.payment_date AS C13, -- [12]
|
||||
log.payment_receipt_num AS C14, -- [13]
|
||||
'' AS C15, -- [14]
|
||||
cmp.provider_id AS C16, -- [15]
|
||||
cmp.sold_to_id AS C17, -- [16]
|
||||
cmp.customs_broker_id AS C18, -- [17]
|
||||
'' AS C19, -- [18]
|
||||
prt.part_number AS C20, -- [19]
|
||||
ld.description_spanish AS C21, -- [20]
|
||||
ld.description_english AS C22, -- [21]
|
||||
lq.quantity AS C23, -- [22]
|
||||
um.code AS C24, -- [23]
|
||||
lf.value_mxn AS C25, -- [24]
|
||||
'' AS C26, -- [25]
|
||||
lf.value_usd AS C27, -- [26]
|
||||
'' AS C28, -- [27]
|
||||
lq.net_weight AS C29, -- [28]
|
||||
lq.gross_weight AS C30, -- [29]
|
||||
ih.purchase_order AS C31, -- [30]
|
||||
lc.fraction AS C32, -- [31]
|
||||
'' AS C33, '' AS C34, -- [32-33]
|
||||
ih.id AS C35, -- [34]
|
||||
'' AS C36, '' AS C37, -- [35-36]
|
||||
lc.origin_country AS C38, -- [37]
|
||||
cmp.aduana AS C39, -- [38]
|
||||
il.material_type AS C40, -- [39]
|
||||
il.id AS C41, -- [40]
|
||||
'' AS C42, -- [41] rectification_id
|
||||
cmp.edocument AS C43, -- [42]
|
||||
cmp.vucem_operation_num AS C44, -- [43]
|
||||
il.line_number AS C45, -- [44]
|
||||
ld.brand AS C46, -- [45]
|
||||
ld.model AS C47, -- [46]
|
||||
prt.us_fraction AS C48, -- [47]
|
||||
prt.eccn AS C49, -- [48]
|
||||
prt.id AS C50, -- [49]
|
||||
fin.exchange_rate AS C51, -- [50]
|
||||
ih.emission_date AS C52, -- [51]
|
||||
ih.capture_user AS C53, -- [52]
|
||||
ih.who_updated AS C54, -- [53]
|
||||
'' AS C55, -- [54]
|
||||
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, -- [55]
|
||||
'' AS C57, -- [56] Pedimento18 (row[56])
|
||||
COALESCE(ld.lot, '') AS C58, -- [57] Lote (row[57])
|
||||
'' AS C59, -- [58] TipoPed (row[58])
|
||||
'' AS C60 -- [59] Relleno final
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
|
||||
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
|
||||
LEFT JOIN a76.classes cls ON cls.id = il.class_id
|
||||
LEFT JOIN a76.parts prt ON prt.id = il.part_number
|
||||
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
|
||||
WHERE {where_clause}
|
||||
ORDER BY ih.invoice_number, il.line_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_totals_query(db_name: str) -> str:
|
||||
"""Build query to get totals for a definitive import invoice."""
|
||||
return f"""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0)
|
||||
FROM a76.item_line_financials lf
|
||||
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
|
||||
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
|
||||
INNER JOIN a76.items itm ON itm.id = il.item_id
|
||||
WHERE itm.invoice_id = :consecutivo
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_series_query(db_name: str) -> str:
|
||||
"""Build query to get series information for definitive imports."""
|
||||
# TODO: QSeriesDef table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as serie, '' as modelo, '' as parte
|
||||
WHERE 1=0
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_driver_badge_query(db_name: str) -> str:
|
||||
"""Build query to get driver badge number for definitive imports."""
|
||||
# TODO: GConductor table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as badge
|
||||
WHERE 1=0
|
||||
"""
|
||||
|
||||
|
||||
class RepairImportQueries:
|
||||
"""SQL queries for repair imports (PostgreSQL schema)."""
|
||||
|
||||
@staticmethod
|
||||
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
|
||||
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
|
||||
discharge_filter = "" # Temporarily disabled until schema migration
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C2,
|
||||
COALESCE(ped.pedimento_number, '') AS C3,
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C4,
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C5,
|
||||
COALESCE(ped.pedimento_code, '') AS C6,
|
||||
COALESCE(ped.regime, '') AS C7,
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C9,
|
||||
COALESCE(cmp.remesa::text, '') AS C10,
|
||||
COALESCE(fin.exchange_rate, 0) AS C11,
|
||||
COALESCE(cmp.provider_id::text, '') AS C12,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C13,
|
||||
COALESCE(cmp.customs_broker_id::text, '') AS C14,
|
||||
COALESCE(ih.purchase_order, '') AS C24,
|
||||
COALESCE(ped.customs_office, '') AS C29,
|
||||
ih.id AS C30,
|
||||
COALESCE(cmp.edocument, '') AS C33,
|
||||
COALESCE(cmp.vucem_operation_num, '') AS C34,
|
||||
COALESCE(fin.exchange_rate, 0) AS C40,
|
||||
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C41,
|
||||
COALESCE(ih.capture_user, '') AS C42,
|
||||
COALESCE(ih.who_updated, '') AS C43,
|
||||
COALESCE(log.carrier_id, '') AS C44,
|
||||
COALESCE(log.transport_num, '') AS C45,
|
||||
COALESCE(ped.pedimento_code, '') AS C47,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.items i ON i.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = i.id
|
||||
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
WHERE ih.operation_type = 'imp'
|
||||
AND ih.invoice_type = 'REP'
|
||||
AND COALESCE(cmp.is_regime_change, false) = false
|
||||
{"AND " + where_str if where_str else ""}
|
||||
{discharge_filter}
|
||||
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
|
||||
log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id,
|
||||
cmp.customs_broker_id, ih.purchase_order, ped.customs_office, cmp.edocument,
|
||||
cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated,
|
||||
log.carrier_id, log.transport_num
|
||||
ORDER BY ih.invoice_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
|
||||
"""Build main SQL query for repair import data."""
|
||||
# Note: is_discharged field not yet migrated to PostgreSQL schema
|
||||
# discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
|
||||
discharge_filter = "" # Temporarily disabled until schema migration
|
||||
return f"""
|
||||
SELECT
|
||||
il.line_number,
|
||||
ih.invoice_number,
|
||||
COALESCE(ped.pedimento_number, ''),
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD'),
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END,
|
||||
COALESCE(ped.pedimento_code, ''),
|
||||
COALESCE(ped.regime, ''),
|
||||
'',
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), ''),
|
||||
COALESCE(cmp.remesa::text, ''),
|
||||
COALESCE(fin.exchange_rate, 0),
|
||||
COALESCE(cmp.provider_id::text, ''),
|
||||
COALESCE(cmp.sold_to_id::text, ''),
|
||||
COALESCE(cmp.customs_broker_id::text, ''),
|
||||
COALESCE(il.part_number::text, ''),
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
|
||||
COALESCE(lq.quantity, 0),
|
||||
COALESCE(il.unit_of_measure, 0),
|
||||
COALESCE(lf.value_mxn, 0),
|
||||
COALESCE(lf.value_usd, 0),
|
||||
COALESCE(lq.net_weight, 0),
|
||||
COALESCE(lq.gross_weight, 0),
|
||||
COALESCE(ih.purchase_order, ''),
|
||||
COALESCE(lc.fraction, ''),
|
||||
'',
|
||||
COALESCE(lc.sector, ''),
|
||||
COALESCE(lc.origin_country, ''),
|
||||
COALESCE(ped.customs_office, ''),
|
||||
ih.id,
|
||||
'P',
|
||||
'',
|
||||
COALESCE(cmp.edocument, ''),
|
||||
COALESCE(cmp.vucem_operation_num, ''),
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
|
||||
COALESCE(lc.american_fraction, ''),
|
||||
COALESCE(prt.eccn, ''),
|
||||
COALESCE(fin.exchange_rate, 0),
|
||||
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), ''),
|
||||
COALESCE(ih.capture_user, ''),
|
||||
COALESCE(ih.who_updated, ''),
|
||||
COALESCE(log.carrier_id, ''),
|
||||
COALESCE(log.transport_num, ''),
|
||||
'',
|
||||
COALESCE(ped.pedimento_code, '')
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
|
||||
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
|
||||
LEFT JOIN a76.parts prt ON prt.id = il.part_number
|
||||
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
|
||||
WHERE ih.operation_type = 'imp'
|
||||
AND ih.invoice_type = 'REP'
|
||||
AND COALESCE(cmp.is_regime_change, false) = false
|
||||
{"AND " + where_str if where_str else ""}
|
||||
{discharge_filter}
|
||||
ORDER BY ih.invoice_number, il.line_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
|
||||
"""Build query to get totals for a repair import invoice."""
|
||||
# Note: is_discharged field not yet migrated to PostgreSQL schema
|
||||
# discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
|
||||
discharge_filter = "" # Temporarily disabled until schema migration
|
||||
return f"""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0)
|
||||
FROM a76.item_line_financials lf
|
||||
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
|
||||
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
|
||||
INNER JOIN a76.items itm ON itm.id = il.item_id
|
||||
WHERE itm.invoice_id = :consecutivo
|
||||
|
||||
{discharge_filter}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_series_query(db_name: str) -> str:
|
||||
"""Build query to get series information for repair imports."""
|
||||
# TODO: QSeriesImpoRep table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as serie, '' as modelo, '' as parte
|
||||
WHERE 1=0
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_driver_badge_query(db_name: str) -> str:
|
||||
"""Build query to get driver badge number for repair imports."""
|
||||
# TODO: GConductor table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as badge
|
||||
WHERE 1=0
|
||||
"""
|
||||
|
||||
|
||||
class ExportQueries:
|
||||
"""SQL queries for exports (PostgreSQL schema)."""
|
||||
|
||||
@staticmethod
|
||||
def build_aggregated_query(db_name: str, where_clause: str) -> str:
|
||||
"""
|
||||
Build optimized query for NORMAL mode (grouped by invoice with totals).
|
||||
|
||||
Args:
|
||||
db_name: Database name (not used in PostgreSQL version)
|
||||
where_clause: Additional WHERE conditions (without WHERE keyword)
|
||||
"""
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1,
|
||||
COALESCE(ped.pedimento_number, '') AS C2,
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6,
|
||||
COALESCE(ped.pedimento_code, '') AS C7,
|
||||
COALESCE(ped.regime, '') AS C8,
|
||||
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C9,
|
||||
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C10,
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11,
|
||||
COALESCE(log.payment_receipt_num, '') AS C12,
|
||||
COALESCE(cmp.provider_id::text, '') AS C14,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C15,
|
||||
COALESCE(cmp.customs_broker_id::text, '') AS C16,
|
||||
COALESCE(ih.purchase_order, '') AS C27,
|
||||
COALESCE(cmp.aduana, '') AS C33,
|
||||
COALESCE(ih.invoice_type, '') AS C34,
|
||||
ih.id AS C35,
|
||||
COALESCE(cmp.edocument, '') AS C40,
|
||||
COALESCE(cmp.vucem_operation_num, '') AS C41,
|
||||
COALESCE(fin.exchange_rate, 0) AS C48,
|
||||
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C49,
|
||||
COALESCE(ih.capture_user, '') AS C50,
|
||||
COALESCE(ih.who_updated, '') AS C51,
|
||||
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.items i ON i.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = i.id
|
||||
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
WHERE {where_clause}
|
||||
GROUP BY ih.id, ih.invoice_number, ih.is_updated, ped.pedimento_number, ped.pedimento_code, ped.regime,
|
||||
log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num,
|
||||
cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, ih.purchase_order, cmp.aduana,
|
||||
ih.invoice_type, cmp.edocument, cmp.vucem_operation_num, fin.exchange_rate,
|
||||
ih.emission_date, ih.capture_user, ih.who_updated, log.transport_id, log.transport_num,
|
||||
ih.invoice_date
|
||||
ORDER BY ih.invoice_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_main_query(db_name: str, where_clause: str) -> str:
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1, -- [0]
|
||||
ped.pedimento_number AS C2, -- [1]
|
||||
ih.invoice_date AS C3, -- [2]
|
||||
'' AS C4, -- [3]
|
||||
'' AS C5, -- [4]
|
||||
ped.status AS C6, -- [5]
|
||||
ped.pedimento_code AS C7, -- [6]
|
||||
ped.regime AS C8, -- [7]
|
||||
log.entry_exit_date AS C9, -- [8]
|
||||
log.delivery_date AS C10, -- [9]
|
||||
log.payment_date AS C11, -- [10]
|
||||
log.payment_receipt_num AS C12, -- [11]
|
||||
'' AS C13, -- [12]
|
||||
cmp.provider_id AS C14, -- [13]
|
||||
cmp.sold_to_id AS C15, -- [14]
|
||||
cmp.customs_broker_id AS C16, -- [15]
|
||||
'' AS C17, -- [16]
|
||||
prt.part_number AS C18, -- [17]
|
||||
ld.description_spanish AS C19, -- [18]
|
||||
ld.description_english AS C20, -- [19]
|
||||
lq.quantity AS C21, -- [20]
|
||||
um.code AS C22, -- [21]
|
||||
'' AS C23, -- [22]
|
||||
'' AS C24, -- [23]
|
||||
lq.net_weight AS C25, -- [24]
|
||||
lq.gross_weight AS C26, -- [25]
|
||||
ih.purchase_order AS C27, -- [26]
|
||||
lc.fraction AS C28, -- [27]
|
||||
'' AS C29, -- [28]
|
||||
'' AS C30, -- [29]
|
||||
'' AS C31, -- [30]
|
||||
'' AS C32, -- [31]
|
||||
cmp.aduana AS C33, -- [32]
|
||||
ih.invoice_type AS C34, -- [33]
|
||||
ih.id AS C35, -- [34]
|
||||
lf.value_mxn AS C36, -- [35]
|
||||
lf.value_usd AS C37, -- [36]
|
||||
il.material_type AS C38, -- [37]
|
||||
'' AS C39, -- [38] rectification_id
|
||||
cmp.edocument AS C40, -- [39]
|
||||
cmp.vucem_operation_num AS C41, -- [40]
|
||||
il.line_number AS C42, -- [41]
|
||||
ld.brand AS C43, -- [42]
|
||||
ld.model AS C44, -- [43]
|
||||
prt.us_fraction AS C45, -- [44]
|
||||
prt.eccn AS C46, -- [45]
|
||||
prt.id AS C47, -- [46]
|
||||
fin.exchange_rate AS C48, -- [47]
|
||||
ih.emission_date AS C49, -- [48]
|
||||
ih.capture_user AS C50, -- [49]
|
||||
ih.who_updated AS C51, -- [50]
|
||||
'' AS C52, -- [51]
|
||||
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, -- [52] NumCaja
|
||||
'' AS C54, -- [53] Pedimento18
|
||||
COALESCE(ld.lot, '') AS C55, -- [54] Lote
|
||||
'' AS C56, -- [55] TipoPedimentoTransporte
|
||||
'' AS C57, -- [56]
|
||||
'' AS C58, -- [57]
|
||||
'' AS C59, -- [58]
|
||||
'' AS C60 -- [59] Relleno final
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
|
||||
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
|
||||
LEFT JOIN a76.classes cls ON cls.id = il.class_id
|
||||
LEFT JOIN a76.parts prt ON prt.id = il.part_number
|
||||
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
|
||||
WHERE {where_clause}
|
||||
ORDER BY ih.invoice_number, il.line_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
|
||||
"""Build query to get totals for an export invoice.
|
||||
|
||||
Only sums partidas where is_subitem is false (main partidas, not sub-items).
|
||||
"""
|
||||
discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
|
||||
return f"""
|
||||
SELECT
|
||||
COALESCE(SUM(lf.value_usd), 0),
|
||||
COALESCE(SUM(lf.value_mxn), 0)
|
||||
FROM a76.item_line_financials lf
|
||||
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
|
||||
INNER JOIN a76.items itm ON itm.id = il.item_id
|
||||
WHERE itm.invoice_id = :consecutivo
|
||||
{discharge_filter}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_series_query(db_name: str) -> str:
|
||||
"""Build query to get series information for exports."""
|
||||
# TODO: QSeriesExpo table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as serie, '' as modelo, '' as parte
|
||||
WHERE 1=0
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_driver_badge_query(db_name: str) -> str:
|
||||
"""Build query to get driver badge number for exports."""
|
||||
# TODO: GConductor table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as badge
|
||||
WHERE 1=0
|
||||
"""
|
||||
|
||||
|
||||
class ExportRepairQueries:
|
||||
"""SQL queries for export repairs (PostgreSQL schema)."""
|
||||
|
||||
@staticmethod
|
||||
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
|
||||
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
|
||||
# Note: discharge_clause temporarily disabled until is_discharged field migrated
|
||||
discharge_filter = "" # Will be: " AND il.is_discharged = true/false" when ready
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1,
|
||||
COALESCE(ped.pedimento_number, '') AS C2,
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6,
|
||||
COALESCE(ped.pedimento_code, '') AS C7,
|
||||
COALESCE(ped.regime, '') AS C8,
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11,
|
||||
COALESCE(cmp.remesa::text, '') AS C12,
|
||||
COALESCE(fin.exchange_rate, 0) AS C13,
|
||||
COALESCE(cmp.provider_id::text, '') AS C14,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C15,
|
||||
COALESCE(cmp.customs_broker_id::text, '') AS C16,
|
||||
COALESCE(ih.purchase_order, '') AS C27,
|
||||
COALESCE(ped.customs_office, '') AS C33,
|
||||
COALESCE(ih.document_type, '') AS C34,
|
||||
ih.id AS C35,
|
||||
COALESCE(cmp.edocument, '') AS C40,
|
||||
COALESCE(cmp.vucem_operation_num, '') AS C41,
|
||||
COALESCE(fin.exchange_rate, 0) AS C48,
|
||||
COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49,
|
||||
COALESCE(ih.capture_user, '') AS C50,
|
||||
COALESCE(ih.who_updated, '') AS C51,
|
||||
COALESCE(log.carrier_id, '') AS C52,
|
||||
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.items i ON i.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = i.id
|
||||
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
WHERE ih.operation_type = 'exp'
|
||||
AND ih.invoice_type = 'REP'
|
||||
{"AND " + where_str if where_str else ""}
|
||||
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
|
||||
log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id,
|
||||
cmp.customs_broker_id, ih.purchase_order, ped.customs_office, ih.document_type,
|
||||
cmp.edocument, cmp.vucem_operation_num, ih.invoice_date, ih.capture_user,
|
||||
ih.who_updated, log.carrier_id, log.transport_id, log.transport_num
|
||||
ORDER BY ih.invoice_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_main_query(db_name: str, where_str: str) -> str:
|
||||
"""Build main SQL query for export repair data."""
|
||||
return f"""
|
||||
SELECT
|
||||
ih.invoice_number AS C1,
|
||||
COALESCE(ped.pedimento_number, '') AS C2,
|
||||
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
|
||||
COALESCE(fin.value_me, 0) AS C4,
|
||||
COALESCE(fin.value_mn, 0) AS C5,
|
||||
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6,
|
||||
COALESCE(ped.pedimento_code, '') AS C7,
|
||||
COALESCE(ped.regime, '') AS C8,
|
||||
'' AS C9,
|
||||
'' AS C10,
|
||||
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11,
|
||||
COALESCE(cmp.remesa::text, '') AS C12,
|
||||
COALESCE(fin.exchange_rate, 0) AS C13,
|
||||
COALESCE(cmp.provider_id::text, '') AS C14,
|
||||
COALESCE(cmp.sold_to_id::text, '') AS C15,
|
||||
COALESCE(cmp.customs_broker_id::text, '') AS C16,
|
||||
'' AS C17,
|
||||
COALESCE(cls.class_code, '') AS C18,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cls.description_en, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20,
|
||||
COALESCE(lq.quantity, 0) AS C21,
|
||||
COALESCE(il.unit_of_measure, 0) AS C22,
|
||||
COALESCE(lf.customs_value_mxn, 0) AS C23,
|
||||
COALESCE(lf.customs_value_usd, 0) AS C24,
|
||||
COALESCE(lq.net_weight, 0) AS C25,
|
||||
COALESCE(lq.gross_weight, 0) AS C26,
|
||||
COALESCE(ih.purchase_order, '') AS C27,
|
||||
COALESCE(lc.fraction, '') AS C28,
|
||||
COALESCE(lc.fraction_type, '') AS C29,
|
||||
COALESCE(lc.advalorem_numeric, 0) AS C30,
|
||||
COALESCE(lc.sector, '') AS C31,
|
||||
COALESCE(lc.origin_country, '') AS C32,
|
||||
COALESCE(ped.customs_office, '') AS C33,
|
||||
COALESCE(ih.document_type, '') AS C34,
|
||||
ih.id AS C35,
|
||||
COALESCE(lf.value_mxn, 0) AS C36,
|
||||
COALESCE(lf.value_usd, 0) AS C37,
|
||||
'P' AS C38,
|
||||
'' AS C39,
|
||||
COALESCE(cmp.edocument, '') AS C40,
|
||||
COALESCE(cmp.vucem_operation_num, '') AS C41,
|
||||
COALESCE(il.line_number, 0) AS C42,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44,
|
||||
COALESCE(cls.us_fraction, '') AS C45,
|
||||
COALESCE(prt.eccn, '') AS C46,
|
||||
COALESCE(il.part_number::text, '') AS C47,
|
||||
COALESCE(fin.exchange_rate, 0) AS C48,
|
||||
COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49,
|
||||
COALESCE(ih.capture_user, '') AS C50,
|
||||
COALESCE(ih.who_updated, '') AS C51,
|
||||
COALESCE(log.carrier_id, '') AS C52,
|
||||
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53,
|
||||
'' AS C54,
|
||||
COALESCE(ld.lot, '') AS C55,
|
||||
'' AS C56,
|
||||
'' AS C57,
|
||||
'' AS C58,
|
||||
'' AS C59
|
||||
FROM a76.invoice_header ih
|
||||
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
|
||||
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
|
||||
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
|
||||
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
|
||||
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
|
||||
LEFT JOIN a76.classes cls ON cls.id = il.class_id
|
||||
LEFT JOIN a76.parts prt ON prt.id = il.part_number
|
||||
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
|
||||
WHERE UPPER(ih.operation_type) IN ('EXP', 'TRA', 'RET')
|
||||
AND UPPER(ih.invoice_type) IN ('DEF', 'REP', 'EXDEF', 'MATDE')
|
||||
AND {where_str}
|
||||
ORDER BY ih.invoice_number, il.line_number
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
|
||||
"""Build query to get totals for an export repair invoice."""
|
||||
discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
|
||||
return f"""
|
||||
SELECT
|
||||
COALESCE(SUM(lf.value_usd), 0),
|
||||
COALESCE(SUM(lf.value_mxn), 0)
|
||||
FROM a76.item_line_financials lf
|
||||
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
|
||||
INNER JOIN a76.items itm ON itm.id = il.item_id
|
||||
WHERE itm.invoice_id = :consecutivo
|
||||
|
||||
{discharge_filter}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_series_query(db_name: str) -> str:
|
||||
"""Build query to get series information for export repairs."""
|
||||
# TODO: QSeriesExpoRep table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as serie, '' as modelo, '' as parte
|
||||
WHERE 1=0
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_driver_badge_query(db_name: str) -> str:
|
||||
"""Build query to get driver badge number for export repairs."""
|
||||
# TODO: GConductor table not migrated to PostgreSQL yet
|
||||
return """
|
||||
SELECT '' as badge
|
||||
WHERE 1=0
|
||||
"""
|
||||
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Repair import service - handles IMPRE movements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..schemas import ImportRepairFilter, MovementItem, MovementItemDetailed
|
||||
|
||||
from .base import ConfigHelper, StringHelper
|
||||
from .database_helpers import DatabaseHelper
|
||||
from .exchange_rate import ExchangeRateCalculator
|
||||
from .query_builders import RepairImportQueries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string in YYYYMMDD format to datetime."""
|
||||
if not date_str or date_str == '':
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, '%Y%m%d')
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class RepairImportService:
|
||||
"""Service for handling repair import movements (IMPRE)."""
|
||||
|
||||
def get_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: "ImportRepairFilter"
|
||||
) -> List["MovementItem"]:
|
||||
"""
|
||||
Get repair import movements (normal mode - grouped by invoice).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of movement items grouped by invoice
|
||||
"""
|
||||
from ..schemas import MovementItem
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching repair import movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Execute optimized aggregated query for NORMAL mode
|
||||
sql = text(RepairImportQueries.build_aggregated_query(
|
||||
filters.database_name,
|
||||
where_clause,
|
||||
filters.discharge_filter.value
|
||||
))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} repair import invoices")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
factura = row[0] # C2 - FacturaImpoRep
|
||||
estatus = row[3] # C5 - Estatus (AC o NA)
|
||||
|
||||
# Filtrar facturas según include_cancelled
|
||||
# Si include_cancelled=False, solo mostrar AC (is_updated=true)
|
||||
# Si include_cancelled=True, mostrar todas (AC y NA)
|
||||
if not filters.include_cancelled and estatus != 'AC':
|
||||
continue
|
||||
|
||||
consecutivo = row[14] # C30 - Consecutivo
|
||||
|
||||
# Helper to safely convert to float
|
||||
def to_float(val):
|
||||
if val is None or val == '':
|
||||
return 0.0
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
# Totals come directly from GROUP BY query (no N+1 problem)
|
||||
total_me = to_float(row[24]) # total_me from SUM aggregation
|
||||
total_mn = to_float(row[25]) # total_mn from SUM aggregation
|
||||
sum_value_usd = to_float(row[27])
|
||||
sum_value_mxn = to_float(row[28])
|
||||
|
||||
total_me = sum_value_usd if sum_value_usd > 0 else total_me
|
||||
total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn
|
||||
|
||||
# Calculate exchange rate and value
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
valor_me=total_me,
|
||||
valor_mn=total_mn,
|
||||
tipo_cambio_db=to_float(row[17]), # C40 - TipoCambio
|
||||
fecha_pago=row[6], # C9 - Fecha_Pago
|
||||
fecha_inicio='', # Not available in aggregated query
|
||||
tipo_pedimento=row[23], # C47 - pedimento_code (used as tipo_pedimento)
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
is_shelter=filters.is_shelter,
|
||||
use_transport_method=False,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Get pedimento rectification
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C3 - PedimentoImpoRep
|
||||
row[26], # C48 - PedRectifica
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
num_gaf_uni = DatabaseHelper.get_driver_badge(
|
||||
db, filters.database_name, factura
|
||||
)
|
||||
|
||||
# Build movement item
|
||||
movement = MovementItem(
|
||||
Factura=factura,
|
||||
Pedimento=row[1], # C3 - PedimentoImpoRep
|
||||
FechaFactura=parse_yyyymmdd_date(row[2]), # C4 - FechaFactura
|
||||
Estatus=row[3], # C5 - Estatus
|
||||
ClavePed=row[4], # C6 - ClavePed
|
||||
TipoMovTemDef='IMPRE',
|
||||
EsCambioRegimen='N',
|
||||
ValorMPTemp=valor_comercial,
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
ValorAgre=0.0,
|
||||
TipoExpo='',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[15], # C33 - EDocument
|
||||
NumOperacionVU=row[16], # C34 - NumOperacionVU
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[19], # C42 - UsuarioCap
|
||||
UsuarioAcr=row[20], # C43 - UsuarioAct
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[6]), # C9 - Fecha_Pago
|
||||
NumCaja=row[22], # C45 - Transport num
|
||||
Pedimento18='', # Not in aggregated query
|
||||
AduanaCru=row[13], # C29 - customs_office
|
||||
Lote='' # Not in aggregated query
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} repair import movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching repair import movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: "ImportRepairFilter"
|
||||
) -> List["MovementItemDetailed"]:
|
||||
"""
|
||||
Get repair import movements (detailed mode - line by line).
|
||||
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of detailed movement items (one per partida)
|
||||
"""
|
||||
from ..schemas import MovementItemDetailed
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching detailed repair import movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause with discharge filter
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Add discharge filter to WHERE clause
|
||||
discharge_clause = ""
|
||||
if filters.discharge_filter.value == "SiDes":
|
||||
discharge_clause = " AND RepPim.Descarga = 1"
|
||||
elif filters.discharge_filter.value == "NoDes":
|
||||
discharge_clause = " AND RepPim.Descarga = 0"
|
||||
|
||||
where_with_discharge = where_clause + discharge_clause
|
||||
|
||||
# Execute main query
|
||||
sql = text(RepairImportQueries.build_main_query(filters.database_name, where_with_discharge))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} detailed repair import partidas")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
# Skip cancelled if not included
|
||||
if not filters.include_cancelled and row[4] != 'AC': # C5 - Estatus
|
||||
continue
|
||||
|
||||
# Get all detailed information
|
||||
proveedor_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[11], is_supplier=True
|
||||
)
|
||||
vendido_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[12], is_supplier=False
|
||||
)
|
||||
agente_info = DatabaseHelper.get_customs_agent_info(
|
||||
db, filters.database_name, row[13]
|
||||
)
|
||||
aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre(
|
||||
db, filters.database_name, row[28]
|
||||
)
|
||||
|
||||
# Calculate values using unified method
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
es_subpartida=row[30], # 'P' or 'S'
|
||||
valor_me=row[21],
|
||||
valor_mn_direct=row[20],
|
||||
fecha_pago=row[8],
|
||||
fecha_inicio=row[7],
|
||||
clave_ped=row[45],
|
||||
tipo_cambio_partida=row[38],
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Set peso values based on subpartida flag
|
||||
if row[31] == 'P':
|
||||
peso_neto = float(row[22]) if row[22] else 0.0
|
||||
peso_bruto = float(row[23]) if row[23] else 0.0
|
||||
else:
|
||||
peso_neto = 0.0
|
||||
peso_bruto = 0.0
|
||||
|
||||
series_info = DatabaseHelper.get_series_info(
|
||||
db, filters.database_name, row[29], row[0], filters.is_shelter
|
||||
)
|
||||
|
||||
simbolo_ex = None
|
||||
if row[15]:
|
||||
simbolo_ex = DatabaseHelper.get_part_export_symbol(
|
||||
db, filters.database_name, row[15], filters.is_shelter
|
||||
)
|
||||
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db, row[2], row[32], filters.is_shelter
|
||||
)
|
||||
|
||||
num_gaf_uni = DatabaseHelper.get_driver_badge(
|
||||
db, filters.database_name, row[1]
|
||||
)
|
||||
|
||||
movement = MovementItemDetailed(
|
||||
Linea=row[0],
|
||||
Factura=row[1],
|
||||
Pedimento=row[2],
|
||||
FechaFactura=row[3],
|
||||
Estatus=row[4],
|
||||
ClavePed=row[5],
|
||||
TipoMovTemDef='IMPRE',
|
||||
EsCambioRegimen='N',
|
||||
Regimen=row[6],
|
||||
Fecha_Inicio=parse_yyyymmdd_date(row[7]),
|
||||
Fecha_Fin=parse_yyyymmdd_date(row[8]),
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[9]),
|
||||
Remesa=row[10],
|
||||
Proveedor=proveedor_info.get('name'),
|
||||
RFCProveedor=proveedor_info.get('rfc'),
|
||||
ProveedorTaxID=proveedor_info.get('tax_id'),
|
||||
VendidoA=vendido_info.get('name'),
|
||||
VendidoARFC=vendido_info.get('rfc'),
|
||||
VendidoATaxID=vendido_info.get('tax_id'),
|
||||
AgenteAduanal=agente_info.get('name'),
|
||||
Patente=agente_info.get('license'),
|
||||
NumParte=row[15],
|
||||
DescripcionE=StringHelper.clean_text(row[16]),
|
||||
DescripcionI=StringHelper.clean_text(row[17]),
|
||||
CantidadIE=float(row[18]) if row[18] else 0.0,
|
||||
UniMed=row[19],
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
PesoNeto=peso_neto,
|
||||
PesoBruto=peso_bruto,
|
||||
OrdenCompraVenta=row[24],
|
||||
FraccionArancelaria=row[25],
|
||||
Preferencia=row[26],
|
||||
Sector=row[27],
|
||||
PaisOrigen=row[28],
|
||||
Aduana=aduana_nombre,
|
||||
Advalorem='',
|
||||
TipoExpo='',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[33],
|
||||
NumOperacionVU=row[34],
|
||||
Series=series_info,
|
||||
Marca=StringHelper.clean_text(row[35]),
|
||||
Modelo=StringHelper.clean_text(row[36]),
|
||||
FraccionAmericana=row[37],
|
||||
ECCN=row[38],
|
||||
SimboloEx=simbolo_ex,
|
||||
FechaEmision=parse_yyyymmdd_date(row[40]) if row[40] else None, # C41 - FechaEmision
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[41],
|
||||
UsuarioAcr=row[42],
|
||||
Transportista=row[43],
|
||||
NumCaja=row[44],
|
||||
Pedimento18=row[45],
|
||||
AduanaCru=row[29],
|
||||
Lote=row[54] if len(row) > 54 else '' # Not in query but mapped safely
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} detailed repair import movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed repair import movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _build_where_clause(self, filters: "ImportRepairFilter") -> str:
|
||||
"""Build WHERE clause for repair imports query."""
|
||||
where_conditions = []
|
||||
|
||||
# STRICT SEPARATION: Only imports for repair
|
||||
where_conditions.append("ih.operation_type = 'imp'")
|
||||
# Repair imports are identified by cross-references, not invoice_type
|
||||
where_conditions.append("COALESCE(cmp.is_regime_change, false) = false")
|
||||
|
||||
# Date range filter
|
||||
if filters.range_type.value == "FF":
|
||||
where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
else:
|
||||
where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
|
||||
# Note: Status filter applied at Python level after CASE WHEN in SELECT
|
||||
# because is_updated doesn't directly represent AC/NA status
|
||||
|
||||
# Provider filter
|
||||
if filters.provider:
|
||||
where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')")
|
||||
|
||||
# Buyer filter
|
||||
if filters.buyer:
|
||||
where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')")
|
||||
|
||||
# Pedimento code filter
|
||||
if filters.pedimento_code:
|
||||
where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'")
|
||||
|
||||
return " AND ".join(where_conditions)
|
||||
|
||||
def _calculate_totals(self, db: Session, db_name: str, consecutivo: int, discharge_filter: str) -> tuple:
|
||||
"""Calculate totals for main partidas with discharge filter."""
|
||||
# Build discharge clause
|
||||
# Note: is_discharged field not yet migrated to PostgreSQL schema
|
||||
discharge_clause = ""
|
||||
# Temporarily disabled until schema migration:
|
||||
# if discharge_filter == "SiDes":
|
||||
# discharge_clause = " AND il.is_discharged = true"
|
||||
# elif discharge_filter == "NoDes":
|
||||
# discharge_clause = " AND il.is_discharged = false"
|
||||
|
||||
sql = text(f"""
|
||||
SELECT
|
||||
COALESCE(SUM(lf.value_usd), 0),
|
||||
COALESCE(SUM(lf.value_mxn), 0)
|
||||
FROM a76.item_line_financials lf
|
||||
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
|
||||
WHERE il.invoice_id = :consecutivo
|
||||
AND il.is_subitem = false
|
||||
{discharge_clause}
|
||||
""")
|
||||
|
||||
result = db.execute(sql, {"consecutivo": consecutivo}).fetchone()
|
||||
total_me = float(result[0]) if result and result[0] is not None else 0.0
|
||||
total_mn = float(result[1]) if result and result[1] is not None else 0.0
|
||||
|
||||
return total_me, total_mn
|
||||
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
Temporary import service - handles IMTEM movements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..schemas import ImportTemporaryFilter, MovementItem, MovementItemDetailed
|
||||
|
||||
from .base import ConfigHelper, StringHelper
|
||||
from .database_helpers import DatabaseHelper
|
||||
from .exchange_rate import ExchangeRateCalculator
|
||||
from .query_builders import TemporaryImportQueries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string in YYYYMMDD format to datetime."""
|
||||
if not date_str or date_str == '':
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, '%Y%m%d')
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class TemporaryImportService:
|
||||
"""Service for handling temporary import movements (IMTEM)."""
|
||||
|
||||
def get_movements(
|
||||
self,
|
||||
db: Session,
|
||||
filters: "ImportTemporaryFilter"
|
||||
) -> List["MovementItem"]:
|
||||
"""
|
||||
Get temporary import movements (normal mode - grouped by invoice).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of movement items grouped by invoice
|
||||
"""
|
||||
from ..schemas import MovementItem
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching temporary import movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Execute optimized aggregated query for NORMAL mode (GROUP BY with totals)
|
||||
sql = text(TemporaryImportQueries.build_aggregated_query(filters.database_name, where_clause))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} temporary import invoices")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
factura = row[0] # C1 - FacturaImpo
|
||||
estatus = row[3] # C4 - Estatus (AC o NA)
|
||||
|
||||
# Filtrar facturas según include_cancelled
|
||||
# Si include_cancelled=False, solo mostrar AC (is_updated=true)
|
||||
# Si include_cancelled=True, mostrar todas (AC y NA)
|
||||
if not filters.include_cancelled and estatus != 'AC':
|
||||
continue
|
||||
|
||||
consecutivo = row[15] # C39 - Consecutivo
|
||||
|
||||
# Helper to convert empty strings to None
|
||||
def none_if_empty(val):
|
||||
return None if val == '' else val
|
||||
|
||||
# Helper to safely convert to float
|
||||
def to_float(val):
|
||||
if val is None or val == '':
|
||||
return 0.0
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
# Totals come directly from GROUP BY query (no N+1 problem)
|
||||
# Correct indices based on TemporaryImportQueries.build_aggregated_query
|
||||
total_me = to_float(row[28]) # total_me (index 28)
|
||||
total_mn = to_float(row[29]) # total_mn (index 29)
|
||||
valor_comercial_mn = to_float(row[30]) # valor_comercial_mn from item_line_financials
|
||||
valor_mp_temp_mn = to_float(row[31]) # valor_mp_temp_mn from item_line_financials
|
||||
valor_agre_mn = to_float(row[32]) # valor_agre_mn from item_line_financials
|
||||
valor_mp_temp_usd = to_float(row[33]) # valor_mp_temp_usd from item_line_financials
|
||||
sum_value_usd = to_float(row[34]) # sum_value_usd from item_line_financials (line item sum avoids zero-value header bug)
|
||||
|
||||
# Replace zero values with the computed sums
|
||||
total_me = sum_value_usd if sum_value_usd > 0 else total_me
|
||||
total_mn = valor_comercial_mn if valor_comercial_mn > 0 else total_mn
|
||||
|
||||
# Calculate exchange rate and value
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
valor_me=total_me,
|
||||
valor_mn=total_mn,
|
||||
tipo_cambio_db=to_float(row[19]), # C50 - TipoCambio
|
||||
fecha_pago=row[8], # C13 - Fecha_Pago
|
||||
fecha_inicio=row[6], # C11 - Fecha_Inicio
|
||||
tipo_pedimento=row[4], # C5 - ClavePed (Using correct index)
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
is_shelter=filters.is_shelter,
|
||||
use_transport_method=False, # Not used for temporary imports
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
# Get pedimento rectification
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C2 - PedimentoImpo
|
||||
row[16], # C41 - PedRectifica
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
num_gaf_uni = DatabaseHelper.get_driver_badge(
|
||||
db, filters.database_name, factura
|
||||
)
|
||||
|
||||
# Calculate exchange rate for MPTemp explicitly decoupled from Valor Comercial
|
||||
valor_mp_temp_raw, _ = ExchangeRateCalculator.calculate_for_aggregated(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
valor_me=to_float(row[33]), # sum_value_temp_usd
|
||||
valor_mn=to_float(row[31]), # sum_value_temp_mxn
|
||||
tipo_cambio_db=to_float(row[19]),
|
||||
fecha_pago=row[8],
|
||||
fecha_inicio=row[6],
|
||||
tipo_pedimento=row[4],
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
is_shelter=filters.is_shelter,
|
||||
use_transport_method=False,
|
||||
met_trans=met_trans
|
||||
)
|
||||
valor_mp_temp = float(valor_mp_temp_raw)
|
||||
|
||||
# Build movement item
|
||||
movement = MovementItem(
|
||||
Factura=factura,
|
||||
Pedimento=row[1], # C2 - PedimentoImpo
|
||||
FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura
|
||||
Estatus=row[3], # C4 - Estatus
|
||||
ClavePed=row[4], # C5 - ClavePed
|
||||
TipoMovTemDef='IMTEM',
|
||||
EsCambioRegimen='N',
|
||||
ValorMPTemp=valor_mp_temp,
|
||||
ValorComercialMN=valor_comercial,
|
||||
TipoCambio=tipo_cambio,
|
||||
ValorAgre=valor_agre_mn,
|
||||
TipoExpo='',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[17], # C42 - EDocument
|
||||
NumOperacionVU=row[18], # C43 - NumOperacionVU
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[21], # C52 - UsuarioCap
|
||||
UsuarioAcr=row[22], # C53 - UsuarioAct
|
||||
Fecha_Pago=parse_yyyymmdd_date(none_if_empty(row[8])), # C13 - Fecha_Pago
|
||||
NumCaja=row[24], # C55 - transport_num || license_plate (index 24)
|
||||
Pedimento18=row[25], # C56 - '' empty (index 25)
|
||||
AduanaCru=row[14], # C38 - Aduana_Cruce (index 14)
|
||||
Lote=row[26] # C57 - '' empty (index 26)
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} temporary import movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching temporary import movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_movements_detailed(
|
||||
self,
|
||||
db: Session,
|
||||
filters: "ImportTemporaryFilter"
|
||||
) -> List["MovementItemDetailed"]:
|
||||
"""
|
||||
Get temporary import movements (detailed mode - line by line).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
List of detailed movement items (one per partida)
|
||||
"""
|
||||
from ..schemas import MovementItemDetailed
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching detailed temporary import movements with filters: {filters.model_dump()}")
|
||||
|
||||
# Get MetTrans configuration
|
||||
met_trans = ConfigHelper.get_met_trans_config()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = self._build_where_clause(filters)
|
||||
|
||||
# Execute main query
|
||||
sql = text(TemporaryImportQueries.build_main_query(filters.database_name, where_clause))
|
||||
results = db.execute(sql).fetchall()
|
||||
logger.info(f"Found {len(results)} detailed temporary import partidas")
|
||||
|
||||
movements = []
|
||||
|
||||
for row in results:
|
||||
# Skip cancelled if not included
|
||||
if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus
|
||||
continue
|
||||
|
||||
# Provider and client names now come directly from query (C8, C9)
|
||||
# No need for additional database lookups
|
||||
logger.info(f"Processing invoice {row[0]}: Proveedor='{row[7]}', VendidoA='{row[8]}', CantidadIE={row[22]}, DescripcionE='{row[20][:50] if row[20] else None}'")
|
||||
|
||||
# Get customs agent information
|
||||
agente_info = DatabaseHelper.get_customs_agent_info(
|
||||
db, filters.database_name, row[17] # C18 - AAduanal
|
||||
)
|
||||
|
||||
# Get provider and client details including RFC and TaxID
|
||||
provider_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[15], is_supplier=True
|
||||
) if row[15] else {}
|
||||
|
||||
client_info = DatabaseHelper.get_client_info(
|
||||
db, filters.database_name, row[16], is_supplier=False
|
||||
) if row[16] else {}
|
||||
|
||||
# Get customs section name
|
||||
aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre(
|
||||
db, filters.database_name, row[37] # C38 - Aduana_Cruce
|
||||
)
|
||||
|
||||
# Calculate values (only for main partidas 'P', not subpartidas 'S')
|
||||
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
es_subpartida=row[39], # C40 - EsSubPartida
|
||||
valor_me=row[26], # C27 - ValorImpoME
|
||||
valor_mn_direct=row[24], # C25 - ValorImpoMN
|
||||
fecha_pago=row[12], # C13 - Fecha_Pago
|
||||
fecha_inicio=row[10], # C11 - Fecha_Inicio
|
||||
clave_ped=row[57], # C58 - TIPOPEDIMENTOTRANSPORTEE
|
||||
tipo_cambio_partida=row[49], # C50 - TipoCambio
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
met_trans=met_trans
|
||||
)
|
||||
|
||||
valor_mp_temp_raw, _ = ExchangeRateCalculator.calculate_exchange_rate_and_value(
|
||||
db=db,
|
||||
db_name=filters.database_name,
|
||||
es_subpartida=row[39], # C40 - EsSubPartida
|
||||
valor_me=float(row[59]) if row[59] else 0.0,
|
||||
valor_mn_direct=float(row[58]) if row[58] else 0.0,
|
||||
fecha_pago=row[12], # C13 - Fecha_Pago
|
||||
fecha_inicio=row[10], # C11 - Fecha_Inicio
|
||||
clave_ped=row[57], # C58 - TIPOPEDIMENTOTRANSPORTEE
|
||||
tipo_cambio_partida=row[49], # C50 - TipoCambio
|
||||
currency_type=filters.currency_type.value,
|
||||
exchange_rate_type=filters.exchange_rate_type.value,
|
||||
met_trans=met_trans
|
||||
)
|
||||
valor_mp_temp_mn = float(valor_mp_temp_raw)
|
||||
|
||||
# Assign the properly converted commercial value directly
|
||||
valor_comercial_mn = float(valor_comercial)
|
||||
|
||||
# Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S')
|
||||
if row[39] != 'S': # C40 - EsSubPartida
|
||||
peso_neto = float(row[28]) if row[28] else 0.0 # C29
|
||||
peso_bruto = float(row[29]) if row[29] else 0.0 # C30
|
||||
else:
|
||||
peso_neto = 0.0
|
||||
peso_bruto = 0.0
|
||||
|
||||
# Get series information
|
||||
series_info = DatabaseHelper.get_series_info(
|
||||
db, filters.database_name, row[38], row[43], filters.is_shelter # C39, C44
|
||||
)
|
||||
|
||||
# Get part export symbol
|
||||
simbolo_ex = None
|
||||
if row[48]: # C49 - NumParte
|
||||
simbolo_ex = DatabaseHelper.get_part_export_symbol(
|
||||
db, filters.database_name, row[48], filters.is_shelter
|
||||
)
|
||||
|
||||
# Get pedimento rectification
|
||||
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
|
||||
db,
|
||||
row[1], # C2 - PedimentoImpo
|
||||
row[40], # C41 - PedRectifica
|
||||
filters.is_shelter
|
||||
)
|
||||
|
||||
# Get driver badge
|
||||
num_gaf_uni = DatabaseHelper.get_driver_badge(
|
||||
db, filters.database_name, row[0] # C1 - FacturaImpo
|
||||
)
|
||||
|
||||
# Helper to convert empty strings to None for dates
|
||||
def none_if_empty(val):
|
||||
if val == '' or val is None:
|
||||
return None
|
||||
return val
|
||||
|
||||
# Helper to convert to string (for Remesa, Advalorem)
|
||||
def to_str(val):
|
||||
if val is None or val == '':
|
||||
return None
|
||||
if isinstance(val, bool):
|
||||
return 'P' if val else 'S' # Convert bool to P/S for Advalorem
|
||||
return str(val)
|
||||
|
||||
logger.error(f"DEBUG ROW: NumParte(17)='{row[17]}', Sector(30)='{row[30]}', Partida {row[43]}, Original Part ID(46)='{row[46]}'")
|
||||
|
||||
# Build detailed movement item
|
||||
movement = MovementItemDetailed(
|
||||
Linea=row[43], # C44 - LineaImpo
|
||||
Factura=row[0], # C1 - FacturaImpo
|
||||
Pedimento=row[1], # C2 - PedimentoImpo
|
||||
FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura (convert to datetime)
|
||||
Estatus=row[3], # C4 - Estatus
|
||||
ClavePed=row[4], # C5 - ClavePed
|
||||
TipoMovTemDef='IMTEM',
|
||||
EsCambioRegimen='N',
|
||||
Regimen=row[9], # C10 - Regimen
|
||||
Fecha_Inicio=parse_yyyymmdd_date(row[10]), # C11 - Fecha_Inicio
|
||||
Fecha_Fin=parse_yyyymmdd_date(row[11]), # C12 - Fecha_Fin
|
||||
Fecha_Pago=parse_yyyymmdd_date(row[12]), # C13 - Fecha_Pago
|
||||
Remesa=to_str(row[13]), # C14 - Remesa
|
||||
Proveedor=row[7], # C8 - Provider name (from JOIN)
|
||||
RFCProveedor=provider_info.get('rfc'),
|
||||
ProveedorTaxID=provider_info.get('tax_id'),
|
||||
VendidoA=row[8], # C9 - Client name (from JOIN)
|
||||
VendidoARFC=client_info.get('rfc'),
|
||||
VendidoATaxID=client_info.get('tax_id'),
|
||||
AgenteAduanal=agente_info.get('name'),
|
||||
Patente=agente_info.get('license'),
|
||||
NumParte=row[48], # C49 - Part Number (from JOIN)
|
||||
DescripcionE=StringHelper.clean_text(row[20]), # C21
|
||||
DescripcionI=StringHelper.clean_text(row[21]), # C22
|
||||
CantidadIE=float(row[22]) if row[22] else 0.0, # C23
|
||||
UniMed=row[23], # C24
|
||||
ValorComercialMN=valor_comercial_mn,
|
||||
ValorMPTemp=valor_mp_temp_mn,
|
||||
TipoCambio=tipo_cambio,
|
||||
PesoNeto=peso_neto,
|
||||
PesoBruto=peso_bruto,
|
||||
OrdenCompraVenta=row[30], # C31 - OrdenCompra
|
||||
FraccionArancelaria=row[31], # C32 - Fraccion
|
||||
Preferencia=row[32], # C33 - TipoFraccion
|
||||
Sector=row[34], # C35 - Sector (from COALESCE)
|
||||
PaisOrigen=row[36], # C37 - PaisOrigen
|
||||
Aduana=aduana_nombre,
|
||||
Advalorem=to_str(row[39]), # C40 - EsSubPartida (convert bool to str)
|
||||
TipoExpo='',
|
||||
PedimentoR1=pedimento_r1,
|
||||
EDocument=row[41], # C42 - EDocument
|
||||
NumOperacionVU=row[42], # C43 - NumOperacionVU
|
||||
Series=series_info,
|
||||
Marca=StringHelper.clean_text(row[44]), # C45
|
||||
Modelo=StringHelper.clean_text(row[45]), # C46
|
||||
FraccionAmericana=row[46], # C47 - FraccionAme
|
||||
ECCN=row[47], # C48 - ECCN
|
||||
SimboloEx=simbolo_ex,
|
||||
FechaEmision=parse_yyyymmdd_date(row[50]), # C51 - FechaEmision
|
||||
BaseDeDatos=filters.database_name,
|
||||
NumGafUni=num_gaf_uni,
|
||||
UsuarioCap=row[51], # C52 - UsuarioCap
|
||||
UsuarioAcr=row[52], # C53 - UsuarioAct
|
||||
Transportista=row[53], # C54 - Carrier ID
|
||||
NumCaja=row[54], # C55 - Transport num
|
||||
Pedimento18=row[55], # C56 - Pedimento18
|
||||
AduanaCru=row[37], # C38 - Aduana_Cruce
|
||||
Lote=row[56] # C57 - LOTE
|
||||
)
|
||||
|
||||
movements.append(movement)
|
||||
|
||||
logger.info(f"Successfully processed {len(movements)} detailed temporary import movements")
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed temporary import movements: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _build_where_clause(self, filters: "ImportTemporaryFilter") -> str:
|
||||
"""Build WHERE clause for temporary imports query using PostgreSQL tables."""
|
||||
where_conditions = []
|
||||
|
||||
# STRICT SEPARATION: Only temporary imports
|
||||
where_conditions.append("ih.operation_type = 'imp'")
|
||||
# ALWAYS filter by specific invoice_type to avoid duplication with Definitive service
|
||||
where_conditions.append("ih.invoice_type IN ('TEM', 'MATTEM')")
|
||||
|
||||
# Date range filter
|
||||
if filters.range_type.value == "FF":
|
||||
where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
else:
|
||||
where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')")
|
||||
|
||||
# Note: Status filter applied at Python level after CASE WHEN in SELECT
|
||||
# because is_updated doesn't directly represent AC/NA status
|
||||
|
||||
# Provider filter
|
||||
if filters.provider:
|
||||
where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')")
|
||||
|
||||
# Buyer filter
|
||||
if filters.buyer:
|
||||
where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')")
|
||||
|
||||
# Pedimento code filter
|
||||
if filters.pedimento_code:
|
||||
where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'")
|
||||
|
||||
return " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||
|
||||
def _calculate_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple:
|
||||
"""Calculate totals for main partidas only using PostgreSQL.
|
||||
|
||||
Only sums partidas where is_subpartida is false (equivalent to EsSubpartida = 'P' in Clarion).
|
||||
"""
|
||||
sql = text("""
|
||||
SELECT
|
||||
COALESCE(SUM(lf.value_usd), 0),
|
||||
COALESCE(SUM(lf.value_mxn), 0)
|
||||
FROM a76.item_line_financials lf
|
||||
JOIN a76.item_lines il ON il.id = lf.item_line_id
|
||||
WHERE il.invoice_id = :consecutivo
|
||||
AND COALESCE(il.is_subpartida, false) = false
|
||||
""")
|
||||
|
||||
result = db.execute(sql, {"consecutivo": consecutivo}).fetchone()
|
||||
total_me = float(result[0]) if result and result[0] is not None else 0.0
|
||||
total_mn = float(result[1]) if result and result[1] is not None else 0.0
|
||||
|
||||
return total_me, total_mn
|
||||
File diff suppressed because it is too large
Load Diff
108
backend/api/v1/modules/a76/reports/movements/invoices/tasks.py
Normal file
108
backend/api/v1/modules/a76/reports/movements/invoices/tasks.py
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Dict, Any
|
||||
|
||||
from core.celery_app import celery_app
|
||||
from core.database import CoreSessionLocal
|
||||
from core.email import EmailService
|
||||
from datetime import datetime
|
||||
|
||||
from .movement_service import movement_service
|
||||
from .schemas import AllMovementsFilter
|
||||
from .csv_utils import generate_csv_from_movements
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@celery_app.task(bind=True, name="generate_invoice_movements_async")
|
||||
def generate_invoice_movements_async(self, filter_data: Dict[str, Any], user_email: str = None):
|
||||
"""
|
||||
Async task to generate invoice movements report.
|
||||
FETCHES data -> GENERATES CSV -> SENDS EMAIL (optional) -> RETURNS CSV (base64)
|
||||
"""
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
# 1. Update Progress
|
||||
self.update_state(state='PROCESSING', meta={'current': 10, 'total': 100, 'status': 'Inicializando reporte...'})
|
||||
|
||||
# 2. Reconstruct Filter
|
||||
filters = AllMovementsFilter(**filter_data)
|
||||
|
||||
# 3. Fetch Data
|
||||
self.update_state(state='PROCESSING', meta={'current': 30, 'total': 100, 'status': 'Obteniendo movimientos de base de datos...'})
|
||||
logger.info(f"Async Task: Fetching movements for {filters}")
|
||||
|
||||
movements = movement_service.get_all_movements(db=db, filters=filters)
|
||||
|
||||
self.update_state(state='PROCESSING', meta={'current': 70, 'total': 100, 'status': f'Procesando {len(movements)} registros...'})
|
||||
|
||||
# 4. Generate CSV
|
||||
csv_content = generate_csv_from_movements(
|
||||
movements=movements,
|
||||
filters=filters
|
||||
)
|
||||
|
||||
# 5. Send Email if requested
|
||||
email_sent = False
|
||||
if filters.send_email and user_email:
|
||||
self.update_state(state='PROCESSING', meta={'current': 90, 'total': 100, 'status': 'Enviando correo electrónico...'})
|
||||
try:
|
||||
# Generate filename
|
||||
filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
# Send email (using the new async wrapper or run_until_complete if needed,
|
||||
# but since we are in a sync celery task we might need to be careful with async/await.
|
||||
# Actually EmailService.send_report_email is async.
|
||||
# We need to run it synchronously here or make the task async.
|
||||
# Celery tasks are sync by default. We can use asgiref.sync.async_to_sync
|
||||
|
||||
import asyncio
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
# Helper to run async method
|
||||
result = async_to_sync(EmailService.send_report_email)(
|
||||
recipient_email=user_email,
|
||||
subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}",
|
||||
body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.",
|
||||
csv_content=csv_content,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
if result:
|
||||
email_sent = True
|
||||
logger.info(f"Async Task: Email sent to {user_email}")
|
||||
else:
|
||||
logger.warning(f"Async Task: Failed to send email to {user_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Async Task: Email error: {str(e)}")
|
||||
|
||||
# 6. Encode and Return
|
||||
self.update_state(state='PROCESSING', meta={'current': 95, 'total': 100, 'status': 'Finalizando...'})
|
||||
|
||||
# Convert string csv to bytes then base64
|
||||
pdf_b64 = base64.b64encode(csv_content.encode('utf-8')).decode('utf-8')
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'file_name': f"reporte_facturas_{datetime.now().strftime('%Y%m%d')}.csv",
|
||||
'content': pdf_b64,
|
||||
'media_type': 'text/csv',
|
||||
'email_sent': email_sent,
|
||||
'total_records': len(movements)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_invoice_movements_async: {str(e)}", exc_info=True)
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={
|
||||
'exc_type': type(e).__name__,
|
||||
'exc_message': str(e),
|
||||
'custom': 'Error generating report'
|
||||
}
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
db.close()
|
||||
@@ -35,6 +35,7 @@ from .reports.importacion.facturas.routes import router as invoices_reports_rout
|
||||
from .reports.importacion.consolidados.routes import router as consolidated_reports_router
|
||||
from .reports.importacion.packing_list.routes import router as packing_list_router
|
||||
from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router
|
||||
from .reports.movements.invoices.routes import router as movement_invoices_router
|
||||
from .reports.exportacion.descargo.routes import router as discharge_reports_router
|
||||
from .manifests.manifest.routes import router as manifests_router
|
||||
from .manifests.driver.routes import router as manifest_drivers_router
|
||||
@@ -44,7 +45,6 @@ from .reports.importacion.transmission.temporal.MAINX30.routes import router as
|
||||
from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router
|
||||
|
||||
|
||||
|
||||
# Router principal
|
||||
router = APIRouter()
|
||||
|
||||
@@ -101,6 +101,13 @@ router.include_router(
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
|
||||
movement_invoices_router,
|
||||
prefix="/a76/reports/movements/invoices",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
discharge_reports_router,
|
||||
prefix="/a76/reports/exportacion/descargo",
|
||||
@@ -145,4 +152,4 @@ router.include_router(
|
||||
|
||||
# Registrar router de bitácora
|
||||
from .audit_log.router import router as audit_log_router
|
||||
router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"])
|
||||
router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"])
|
||||
|
||||
@@ -25,6 +25,10 @@ class TrailerService:
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("trailer_number"):
|
||||
query = query.filter(
|
||||
models.Trailer.trailer_number.ilike(f"%{filters['trailer_number']}%")
|
||||
)
|
||||
if filters.get("plate_number"):
|
||||
query = query.filter(
|
||||
models.Trailer.plate_number.ilike(f"%{filters['plate_number']}%")
|
||||
@@ -75,8 +79,8 @@ class TrailerService:
|
||||
db: Session,
|
||||
trailer_number: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
trailer_data: dto.TrailerUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[models.Trailer]:
|
||||
"""Update a trailer"""
|
||||
trailer = TrailerService.get_by_id(db, trailer_number, tenant_id, company_id)
|
||||
|
||||
@@ -9,7 +9,7 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin):
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
transporter_key = Column(String(5), primary_key=True, nullable=False)
|
||||
transporter_key = Column(String(23), primary_key=True, nullable=False)
|
||||
name = Column(String(256), nullable=True)
|
||||
short_name = Column(String(10), nullable=True)
|
||||
responsible = Column(String(100), nullable=True)
|
||||
@@ -27,4 +27,4 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin):
|
||||
ftp_user = Column(String(200), nullable=True)
|
||||
ftp_password = Column(String(100), nullable=True)
|
||||
ftp_directory = Column(String(1000), nullable=True)
|
||||
filler_code = Column(String(4), nullable=True)
|
||||
filler_code = Column(String(20), nullable=True)
|
||||
|
||||
@@ -25,6 +25,10 @@ class TransporterService:
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("transporter_key"):
|
||||
query = query.filter(
|
||||
models.Transporter.transporter_key.ilike(f"%{filters['transporter_key']}%")
|
||||
)
|
||||
if filters.get("name"):
|
||||
query = query.filter(
|
||||
models.Transporter.name.ilike(f"%{filters['name']}%")
|
||||
@@ -75,8 +79,8 @@ class TransporterService:
|
||||
db: Session,
|
||||
transporter_key: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
transporter_data: dto.TransporterUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[models.Transporter]:
|
||||
"""Update a transporter"""
|
||||
transporter = TransporterService.get_by_id(
|
||||
|
||||
@@ -25,6 +25,10 @@ class VehicleService:
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("vehicle_key"):
|
||||
query = query.filter(
|
||||
models.Vehicle.vehicle_key.ilike(f"%{filters['vehicle_key']}%")
|
||||
)
|
||||
if filters.get("plate_number"):
|
||||
query = query.filter(
|
||||
models.Vehicle.plate_number.ilike(f"%{filters['plate_number']}%")
|
||||
@@ -75,8 +79,8 @@ class VehicleService:
|
||||
db: Session,
|
||||
vehicle_key: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
vehicle_data: dto.VehicleUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[models.Vehicle]:
|
||||
"""Update a vehicle"""
|
||||
vehicle = VehicleService.get_by_id(db, vehicle_key, tenant_id, company_id)
|
||||
|
||||
32
backend/api/v1/modules/core/help_center/models.py
Normal file
32
backend/api/v1/modules/core/help_center/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Text, DateTime, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from core.database import Base
|
||||
|
||||
class HelpArticle(Base):
|
||||
"""
|
||||
Modelo para los artículos de ayuda (Base de Conocimientos).
|
||||
Sincronizado entre Servidor Central y Clientes.
|
||||
"""
|
||||
__tablename__ = "help_articles"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
slug = Column(String(255), unique=True, index=True, nullable=False)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
last_editor = Column(String(255), nullable=False)
|
||||
|
||||
# Library Mode Fields
|
||||
category = Column(String(255), nullable=True, default="General")
|
||||
order = Column(Integer, nullable=True, default=0)
|
||||
|
||||
# Removed missing fields to avoid 500 errors (No migration approach)
|
||||
# content_type = Column(String(50), nullable=False, default="article")
|
||||
# file_url = Column(String(512), nullable=True)
|
||||
# file_size = Column(Integer, nullable=True)
|
||||
# mime_type = Column(String(100), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"
|
||||
227
backend/api/v1/modules/core/help_center/routes.py
Normal file
227
backend/api/v1/modules/core/help_center/routes.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import shutil
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.config import settings
|
||||
from core.security import get_current_user, has_role
|
||||
from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||
from .services import HelpCenterService
|
||||
from .tasks import sync_single_article_task
|
||||
|
||||
router = APIRouter(prefix="/help-center", tags=["Help Center"])
|
||||
|
||||
def verify_sync_token(x_sync_token: str = Header(...)):
|
||||
if x_sync_token != settings.SYNC_SECRET_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Sync Token"
|
||||
)
|
||||
|
||||
def trigger_sync_or_broadcast(article_uuid: UUID):
|
||||
"""
|
||||
Helper function to handle synchronization logic.
|
||||
- If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync.
|
||||
- If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}")
|
||||
logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||
|
||||
# 1. Upstream Sync (Client -> Hub)
|
||||
if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""':
|
||||
logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}")
|
||||
sync_single_article_task.delay(str(article_uuid))
|
||||
|
||||
# 2. Downstream Broadcast (Hub -> Spokes)
|
||||
# Only if we are the Hub (no upstream) and have spokes configured.
|
||||
elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS:
|
||||
from .tasks import broadcast_help_update
|
||||
logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}")
|
||||
# origin_client_uuid is None because this change originated on the Hub itself
|
||||
broadcast_help_update.delay(str(article_uuid), None)
|
||||
else:
|
||||
logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True)
|
||||
# We don't re-raise here to avoid returning 500 to the user if the save was successful
|
||||
|
||||
@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)])
|
||||
def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)):
|
||||
"""
|
||||
Endpoint de sincronización inteligente para artículos de ayuda.
|
||||
Requiere X-Sync-Token en los headers.
|
||||
"""
|
||||
result = HelpCenterService.sync_article(db, sync_data)
|
||||
|
||||
# Broadcast to other spokes (Hub logic)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||
|
||||
if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS:
|
||||
# We are the Hub (no central server to push to) and have Spokes configured
|
||||
from .tasks import broadcast_help_update
|
||||
logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}")
|
||||
broadcast_help_update.delay(
|
||||
str(sync_data.article_uuid),
|
||||
str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None
|
||||
)
|
||||
else:
|
||||
logger.info("DEBUG: Broadcast skipped (Condition failed)")
|
||||
|
||||
return result
|
||||
|
||||
@router.post("/upload-image/")
|
||||
def upload_help_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube una imagen para usar en los artículos."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
file_location = f"uploads/help/{new_filename}"
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs("uploads/help", exist_ok=True)
|
||||
|
||||
with open(file_location, "wb+") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
return {"url": f"/api/uploads/help/{new_filename}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/upload-asset/")
|
||||
def upload_help_asset(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
|
||||
# Guardar en una carpeta segun el tipo o general
|
||||
folder = "uploads/help/assets"
|
||||
if file_ext in ['.pdf']:
|
||||
folder = "uploads/help/pdfs"
|
||||
elif file_ext in ['.mp4', '.mov', '.avi']:
|
||||
folder = "uploads/help/videos"
|
||||
|
||||
file_location = f"{folder}/{new_filename}"
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
with open(file_location, "wb+") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_location)
|
||||
|
||||
return {
|
||||
"url": f"/api/{file_location}",
|
||||
"filename": file.filename,
|
||||
"size": file_size,
|
||||
"mime_type": file.content_type
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/articles/", response_model=List[HelpArticleInDB])
|
||||
def list_articles(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Lista todos los artículos de ayuda."""
|
||||
return HelpCenterService.get_all(db)
|
||||
|
||||
@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)])
|
||||
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
|
||||
"""Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token."""
|
||||
return HelpCenterService.get_modifications(db, since)
|
||||
|
||||
@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||
def get_article(
|
||||
article_uuid: UUID,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Obtiene un artículo por UUID."""
|
||||
article = HelpCenterService.get_by_uuid(db, article_uuid)
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
return article
|
||||
|
||||
@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED)
|
||||
def create_article(
|
||||
article: HelpArticleCreate,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Crea un nuevo artículo."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}")
|
||||
|
||||
# Fill last_editor with admin username
|
||||
if current_user.get('preferred_username'):
|
||||
article.last_editor = current_user.get('preferred_username')
|
||||
|
||||
new_article = HelpCenterService.create(db, article)
|
||||
logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}")
|
||||
|
||||
trigger_sync_or_broadcast(new_article.uuid)
|
||||
|
||||
return new_article
|
||||
|
||||
@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||
def update_article(
|
||||
article_uuid: UUID,
|
||||
article_data: HelpArticleUpdate,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Actualiza un artículo."""
|
||||
if current_user.get('preferred_username'):
|
||||
article_data.last_editor = current_user.get('preferred_username')
|
||||
|
||||
article = HelpCenterService.update(db, article_uuid, article_data)
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
trigger_sync_or_broadcast(article.uuid)
|
||||
|
||||
return article
|
||||
|
||||
@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_article(
|
||||
article_uuid: UUID,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Elimina un artículo."""
|
||||
if not HelpCenterService.delete(db, article_uuid):
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
# Broadcast or Sync the deletion?
|
||||
# Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone.
|
||||
# For now, let's at least trigger the logic.
|
||||
# WARNING: sync_single_article_task expects the article to exist to send it.
|
||||
# If we deleted it locally, sync_single_article_task will fail or send nothing.
|
||||
# We need a dedicated 'sync_deletion' task or similar.
|
||||
# Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync
|
||||
# logic right now to avoid breaking things, but I'll add the hook for completeness.
|
||||
# Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs.
|
||||
|
||||
return None
|
||||
66
backend/api/v1/modules/core/help_center/schemas.py
Normal file
66
backend/api/v1/modules/core/help_center/schemas.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class HelpArticleBase(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
content: str
|
||||
last_editor: str
|
||||
category: Optional[str] = "General"
|
||||
order: Optional[int] = 0
|
||||
content_type: str = "article"
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
class HelpArticleCreate(HelpArticleBase):
|
||||
pass
|
||||
|
||||
class HelpArticleUpdate(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
last_editor: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
content_type: Optional[str] = None
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
class HelpArticleInDB(HelpArticleBase):
|
||||
uuid: UUID
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class HelpSyncRequest(BaseModel):
|
||||
article_uuid: UUID
|
||||
client_updated_at: datetime
|
||||
client_content: str
|
||||
client_title: str
|
||||
client_slug: str
|
||||
last_editor: str
|
||||
client_category: Optional[str] = "General"
|
||||
client_order: Optional[int] = 0
|
||||
client_content_type: str = "article"
|
||||
client_file_url: Optional[str] = None
|
||||
client_file_size: Optional[int] = None
|
||||
client_mime_type: Optional[str] = None
|
||||
|
||||
class HelpSyncResponse(BaseModel):
|
||||
status: str
|
||||
server_updated_at: Optional[datetime] = None
|
||||
server_content: Optional[str] = None
|
||||
server_title: Optional[str] = None
|
||||
server_slug: Optional[str] = None
|
||||
server_category: Optional[str] = None
|
||||
server_order: Optional[int] = None
|
||||
server_content_type: Optional[str] = None
|
||||
server_file_url: Optional[str] = None
|
||||
server_file_size: Optional[int] = None
|
||||
server_mime_type: Optional[str] = None
|
||||
message: str
|
||||
240
backend/api/v1/modules/core/help_center/services.py
Normal file
240
backend/api/v1/modules/core/help_center/services.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
from .models import HelpArticle
|
||||
from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||
|
||||
class HelpCenterService:
|
||||
@staticmethod
|
||||
def _inject_metadata(article: HelpArticle) -> HelpArticle:
|
||||
if not article or not article.content:
|
||||
return article
|
||||
|
||||
# Look for <!-- a76_metadata: { ... } -->
|
||||
match = re.search(r'<!-- a76_metadata: (.*?) -->', article.content, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
metadata = json.loads(match.group(1))
|
||||
article.content_type = metadata.get("content_type", "article")
|
||||
article.file_url = metadata.get("file_url")
|
||||
article.file_size = metadata.get("file_size")
|
||||
article.mime_type = metadata.get("mime_type")
|
||||
# Remove metadata from content for clean display if needed,
|
||||
# but usually better to leave it and let parser handle it or hide it here.
|
||||
# For now, we just set the attributes.
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
article.content_type = "article"
|
||||
article.file_url = None
|
||||
article.file_size = None
|
||||
article.mime_type = None
|
||||
|
||||
return article
|
||||
|
||||
@staticmethod
|
||||
def _extract_metadata(content: str, data: dict) -> str:
|
||||
# Remove existing metadata block if any
|
||||
content = re.sub(r'\n\n<!-- a76_metadata: .*? -->', '', content, flags=re.DOTALL)
|
||||
|
||||
metadata = {
|
||||
"content_type": data.get("content_type", "article"),
|
||||
"file_url": data.get("file_url"),
|
||||
"file_size": data.get("file_size"),
|
||||
"mime_type": data.get("mime_type")
|
||||
}
|
||||
|
||||
# Only append if there's something meaningful beyond "article"
|
||||
if metadata["content_type"] != "article" or metadata["file_url"]:
|
||||
content += f"\n\n<!-- a76_metadata: {json.dumps(metadata)} -->"
|
||||
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def get_all(db: Session) -> List[HelpArticle]:
|
||||
articles = db.query(HelpArticle).all()
|
||||
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||
|
||||
@staticmethod
|
||||
def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
return HelpCenterService._inject_metadata(article)
|
||||
|
||||
@staticmethod
|
||||
def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first()
|
||||
return HelpCenterService._inject_metadata(article)
|
||||
|
||||
@staticmethod
|
||||
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
|
||||
# Ensure timezone awareness
|
||||
if since.tzinfo is None:
|
||||
since = since.replace(tzinfo=timezone.utc)
|
||||
articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all()
|
||||
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, article: HelpArticleCreate) -> HelpArticle:
|
||||
data = article.model_dump()
|
||||
# Move metadata into content
|
||||
data["content"] = HelpCenterService._extract_metadata(data["content"], data)
|
||||
# Remove virtual fields from data to avoid SQLAlchemy errors
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
|
||||
for f in virtual_fields:
|
||||
if f in data:
|
||||
del data[f]
|
||||
|
||||
db_article = HelpArticle(**data)
|
||||
db.add(db_article)
|
||||
db.commit()
|
||||
db.refresh(db_article)
|
||||
return HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]:
|
||||
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
if not db_article:
|
||||
return None
|
||||
|
||||
# Inject metadata to existing article to get current virtual fields
|
||||
db_article = HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
update_data = article_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle metadata update
|
||||
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type"]):
|
||||
# Merge existing metadata with new updates
|
||||
current_meta = {
|
||||
"content_type": getattr(db_article, "content_type", "article"),
|
||||
"file_url": getattr(db_article, "file_url", None),
|
||||
"file_size": getattr(db_article, "file_size", None),
|
||||
"mime_type": getattr(db_article, "mime_type", None)
|
||||
}
|
||||
# Update with new data if present
|
||||
for f in ["content_type", "file_url", "file_size", "mime_type"]:
|
||||
if f in update_data:
|
||||
current_meta[f] = update_data[f]
|
||||
|
||||
# Use current content or new content
|
||||
content = update_data.get("content", db_article.content)
|
||||
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
|
||||
|
||||
# Remove virtual fields from data
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
|
||||
for f in virtual_fields:
|
||||
if f in update_data:
|
||||
del update_data[f]
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(db_article, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_article)
|
||||
return HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, article_uuid: UUID) -> bool:
|
||||
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
if not db_article:
|
||||
return False
|
||||
db.delete(db_article)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse:
|
||||
"""
|
||||
Lógica de sincronización "Smart Sync" (Last Write Wins).
|
||||
"""
|
||||
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first()
|
||||
|
||||
client_updated_at = sync_data.client_updated_at
|
||||
if client_updated_at.tzinfo is None:
|
||||
client_updated_at = client_updated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if not db_article:
|
||||
# Caso A: Artículo nuevo desde el cliente
|
||||
# Store metadata in content
|
||||
client_meta = {
|
||||
"content_type": sync_data.client_content_type,
|
||||
"file_url": sync_data.client_file_url,
|
||||
"file_size": sync_data.client_file_size,
|
||||
"mime_type": sync_data.client_mime_type
|
||||
}
|
||||
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||
|
||||
new_article = HelpArticle(
|
||||
uuid=sync_data.article_uuid,
|
||||
slug=sync_data.client_slug,
|
||||
title=sync_data.client_title,
|
||||
content=content_with_meta,
|
||||
updated_at=client_updated_at,
|
||||
last_editor=sync_data.last_editor,
|
||||
category=sync_data.client_category,
|
||||
order=sync_data.client_order
|
||||
)
|
||||
db.add(new_article)
|
||||
db.commit()
|
||||
|
||||
# Download assets if needed (Images in content and main file)
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
if sync_data.client_file_url:
|
||||
download_file_from_hub(sync_data.client_file_url)
|
||||
sync_assets_from_content(sync_data.client_content)
|
||||
|
||||
return HelpSyncResponse(status="OK", message="Article created on server.")
|
||||
|
||||
server_updated_at = db_article.updated_at
|
||||
if server_updated_at.tzinfo is None:
|
||||
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Caso A: Cliente es más nuevo
|
||||
if client_updated_at > server_updated_at:
|
||||
client_meta = {
|
||||
"content_type": sync_data.client_content_type,
|
||||
"file_url": sync_data.client_file_url,
|
||||
"file_size": sync_data.client_file_size,
|
||||
"mime_type": sync_data.client_mime_type
|
||||
}
|
||||
db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||
db_article.title = sync_data.client_title
|
||||
db_article.slug = sync_data.client_slug
|
||||
db_article.updated_at = client_updated_at
|
||||
db_article.last_editor = sync_data.last_editor
|
||||
db_article.category = sync_data.client_category
|
||||
db_article.order = sync_data.client_order
|
||||
db.commit()
|
||||
|
||||
# Download assets if needed (Images in content)
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
if sync_data.client_file_url:
|
||||
download_file_from_hub(sync_data.client_file_url)
|
||||
sync_assets_from_content(sync_data.client_content)
|
||||
|
||||
return HelpSyncResponse(status="OK", message="Server updated with client data.")
|
||||
|
||||
# Caso B: Servidor es más nuevo
|
||||
elif server_updated_at > client_updated_at:
|
||||
# Inject metadata for response
|
||||
db_article = HelpCenterService._inject_metadata(db_article)
|
||||
return HelpSyncResponse(
|
||||
status="UPDATE_REQUIRED",
|
||||
server_updated_at=server_updated_at,
|
||||
server_content=db_article.content,
|
||||
server_title=db_article.title,
|
||||
server_slug=db_article.slug,
|
||||
server_category=db_article.category,
|
||||
server_order=db_article.order,
|
||||
server_content_type=getattr(db_article, "content_type", "article"),
|
||||
server_file_url=getattr(db_article, "file_url", None),
|
||||
server_file_size=getattr(db_article, "file_size", None),
|
||||
server_mime_type=getattr(db_article, "mime_type", None),
|
||||
message="Client is outdated. Update required."
|
||||
)
|
||||
|
||||
# Caso C: Iguales
|
||||
else:
|
||||
return HelpSyncResponse(status="OK", message="Already in sync.")
|
||||
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import logging
|
||||
import httpx
|
||||
from uuid import UUID
|
||||
from celery import shared_task
|
||||
from datetime import datetime, timezone
|
||||
from core.database import CoreSessionLocal
|
||||
from core.config import settings
|
||||
from .models import HelpArticle
|
||||
from .schemas import HelpSyncRequest, HelpSyncResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task(name="sync_all_articles_task")
|
||||
def sync_all_articles_task():
|
||||
"""
|
||||
Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central.
|
||||
Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke).
|
||||
"""
|
||||
if not settings.CENTRAL_SERVER_URL:
|
||||
logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).")
|
||||
return
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
articles = db.query(HelpArticle).all()
|
||||
for article in articles:
|
||||
sync_single_article(article.uuid)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_all_articles_task: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@shared_task(name="sync_single_article_task")
|
||||
def sync_single_article_task(article_uuid_str: str):
|
||||
"""
|
||||
Sincroniza un único artículo inmediatamente después de una edición local.
|
||||
"""
|
||||
sync_single_article(article_uuid_str)
|
||||
|
||||
|
||||
@shared_task(name="broadcast_help_update")
|
||||
def broadcast_help_update(article_uuid_str: str):
|
||||
"""
|
||||
Difunde una actualización de artículo a todos los spokes configurados.
|
||||
"""
|
||||
if not settings.SPOKE_URLS:
|
||||
logger.info("No SPOKE_URLS configured. Skipping broadcast.")
|
||||
return
|
||||
|
||||
spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()]
|
||||
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first()
|
||||
if not article:
|
||||
logger.error(f"Article {article_uuid_str} not found for broadcast.")
|
||||
return
|
||||
|
||||
sync_payload = HelpSyncRequest(
|
||||
article_uuid=article.uuid,
|
||||
client_updated_at=article.updated_at,
|
||||
client_content=article.content,
|
||||
client_title=article.title,
|
||||
client_slug=article.slug,
|
||||
last_editor=article.last_editor,
|
||||
client_category=article.category,
|
||||
client_order=article.order
|
||||
).model_dump(mode='json')
|
||||
|
||||
with httpx.Client() as client:
|
||||
for spoke_url in spokes:
|
||||
# Loop Prevention: Skip if the spoke is the origin
|
||||
try:
|
||||
logger.info(f"Broadcasting update to {spoke_url}")
|
||||
response = client.post(
|
||||
spoke_url,
|
||||
json=sync_payload,
|
||||
headers=headers,
|
||||
timeout=5.0
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting to {spoke_url}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Broadcast error: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def sync_single_article(article_uuid):
|
||||
"""
|
||||
Lógica compartida para sincronizar un artículo con el servidor central.
|
||||
"""
|
||||
logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})")
|
||||
|
||||
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||
# Enhanced check to catch literal empty quotes if they slip through
|
||||
return
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
if not article:
|
||||
return
|
||||
|
||||
sync_data = HelpSyncRequest(
|
||||
article_uuid=article.uuid,
|
||||
client_updated_at=article.updated_at,
|
||||
client_content=article.content,
|
||||
client_title=article.title,
|
||||
client_slug=article.slug,
|
||||
last_editor=article.last_editor,
|
||||
client_category=article.category,
|
||||
client_order=article.order
|
||||
)
|
||||
|
||||
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
settings.CENTRAL_SERVER_URL,
|
||||
json=sync_data.model_dump(mode='json'),
|
||||
headers=headers,
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = HelpSyncResponse(**response.json())
|
||||
if result.status == "UPDATE_REQUIRED":
|
||||
# El servidor tiene una versión más nueva, actualizamos localmente
|
||||
article.content = result.server_content
|
||||
article.title = result.server_title
|
||||
article.slug = result.server_slug
|
||||
article.updated_at = result.server_updated_at
|
||||
db.commit()
|
||||
logger.info(f"Article {article.uuid} updated from server.")
|
||||
|
||||
# Download assets if needed
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
if result.server_file_url:
|
||||
download_file_from_hub(result.server_file_url)
|
||||
sync_assets_from_content(result.server_content)
|
||||
else:
|
||||
logger.info(f"Article {article.uuid} sync OK: {result.message}")
|
||||
else:
|
||||
logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing article {article.uuid}: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
@shared_task(name="sync_from_hub_task")
|
||||
def sync_from_hub_task():
|
||||
"""
|
||||
Tarea de POLLING que el Cliente ejecuta periódicamente.
|
||||
Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde
|
||||
la última actualización local.
|
||||
"""
|
||||
if not settings.CENTRAL_SERVER_URL:
|
||||
return
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
# 1. Obtener la fecha de la última actualización local
|
||||
last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar()
|
||||
if not last_local_update:
|
||||
# Si no hay datos, traer todo desde el principio de los tiempos
|
||||
last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
# Asegurar timezone awareness
|
||||
if last_local_update.tzinfo is None:
|
||||
last_local_update = last_local_update.replace(tzinfo=timezone.utc)
|
||||
|
||||
logger.info(f"Polling Hub for updates since {last_local_update}")
|
||||
|
||||
# 2. Consultar al Hub
|
||||
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||
# CENTRAL_SERVER_URL es ".../help-center/sync/"
|
||||
# Queremos ".../help-center/modifications/"
|
||||
hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/")
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
hub_url,
|
||||
params={"since": last_local_update.isoformat()},
|
||||
headers=headers,
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
articles_data = response.json()
|
||||
if not articles_data:
|
||||
logger.info("No updates found.")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(articles_data)} updates from Hub. Applying...")
|
||||
|
||||
# 3. Aplicar actualizaciones
|
||||
for art_data in articles_data:
|
||||
try:
|
||||
# Logic similar to sync_article but simpler (Force Update from Hub)
|
||||
# We assume Hub is Truth in this Polling flow
|
||||
|
||||
# Try to find by UUID
|
||||
local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first()
|
||||
|
||||
# Fallback: find by Slug if UUID doesn't match
|
||||
if not local_article:
|
||||
local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first()
|
||||
|
||||
server_updated_at = datetime.fromisoformat(art_data['updated_at'])
|
||||
if server_updated_at.tzinfo is None:
|
||||
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if not local_article:
|
||||
new_article = HelpArticle(
|
||||
uuid=art_data['uuid'],
|
||||
slug=art_data['slug'],
|
||||
title=art_data['title'],
|
||||
content=art_data['content'],
|
||||
updated_at=server_updated_at,
|
||||
last_editor=art_data['last_editor'],
|
||||
category=art_data.get('category', "General"),
|
||||
order=art_data.get('order', 0)
|
||||
)
|
||||
db.add(new_article)
|
||||
logger.info(f"Created new article: {art_data['slug']}")
|
||||
else:
|
||||
# Update existing article
|
||||
# If UUID changed in Hub but slug is the same, we update UUID too
|
||||
local_article.uuid = art_data['uuid']
|
||||
local_article.slug = art_data['slug']
|
||||
local_article.title = art_data['title']
|
||||
local_article.content = art_data['content']
|
||||
local_article.updated_at = server_updated_at
|
||||
local_article.last_editor = art_data['last_editor']
|
||||
local_article.category = art_data.get('category', "General")
|
||||
local_article.order = art_data.get('order', 0)
|
||||
logger.info(f"Updated article: {art_data['slug']}")
|
||||
|
||||
db.commit() # Commit each article to avoid bulk failure
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}")
|
||||
|
||||
|
||||
# Download assets after bulk update (Polling)
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
for art_data in articles_data:
|
||||
# art_data contains the virtual fields because it was dumped via HelpArticleInDB
|
||||
if "file_url" in art_data and art_data['file_url']:
|
||||
download_file_from_hub(art_data['file_url'])
|
||||
|
||||
sync_assets_from_content(art_data.get('content', ''))
|
||||
|
||||
logger.info("Polling sync completed successfully.")
|
||||
|
||||
else:
|
||||
logger.error(f"Polling failed: {response.status_code} - {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_from_hub_task: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
76
backend/api/v1/modules/core/help_center/utils.py
Normal file
76
backend/api/v1/modules/core/help_center/utils.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
import re
|
||||
import httpx
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def download_file_from_hub(relative_path: str) -> bool:
|
||||
"""
|
||||
Downloads a file from the Hub to the local storage.
|
||||
relative_path: e.g., 'uploads/help/pdfs/myfile.pdf' or '/api/uploads/help/image.png'
|
||||
"""
|
||||
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||
return False
|
||||
|
||||
# Clean the path
|
||||
clean_path = relative_path.replace("/api/uploads/", "uploads/")
|
||||
if clean_path.startswith("/"):
|
||||
clean_path = clean_path[1:]
|
||||
|
||||
# Check if it starts with uploads
|
||||
if not clean_path.startswith("uploads/"):
|
||||
# If it doesn't start with uploads, it might just be the filename or a subpath
|
||||
# We assume it's relative to /app/
|
||||
pass
|
||||
|
||||
local_path = Path(clean_path)
|
||||
if local_path.exists():
|
||||
logger.info(f"File {clean_path} already exists, skipping download.")
|
||||
return True
|
||||
|
||||
# Ensure directories exist
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Resolve Hub Base URL
|
||||
# CENTRAL_SERVER_URL is usually http://hub:8000/api/v1/core/help-center/sync/
|
||||
# We want http://hub:8000/api/
|
||||
base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0]
|
||||
# The file in backend is served usually under /api/uploads/...
|
||||
# But clean_path is just "uploads/...". So the Hub route is base_url + "/" + clean_path
|
||||
hub_file_url = f"{base_url}/{clean_path}"
|
||||
|
||||
logger.info(f"Downloading asset from Hub: {hub_file_url} -> {local_path}")
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(hub_file_url, timeout=30.0)
|
||||
if response.status_code == 200:
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
logger.info(f"Successfully downloaded {clean_path}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to download {clean_path}: Status {response.status_code} URL: {hub_file_url}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading {clean_path}: {str(e)}")
|
||||
return False
|
||||
|
||||
def sync_assets_from_content(content: str):
|
||||
"""
|
||||
Parses markdown content for image URLs and downloads them if they are local references.
|
||||
Example: 
|
||||
"""
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Regex for markdown images: 
|
||||
image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)'
|
||||
matches = re.findall(image_pattern, content)
|
||||
|
||||
for asset_url in matches:
|
||||
download_file_from_hub(asset_url)
|
||||
@@ -5,6 +5,7 @@ from .tenants.routes import router as tenants_router
|
||||
from .user_tenant.routes import router as user_tenant_router
|
||||
from .users.routes import router as users_router
|
||||
from .dashboard.routes import router as dashboard_router
|
||||
from .help_center.routes import router as help_center_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
@@ -16,3 +17,4 @@ router.include_router(users_router, prefix="/core", tags=["core / users"])
|
||||
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||
router.include_router(permissions_router, prefix="/core", tags=["core / permissions"])
|
||||
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
|
||||
router.include_router(help_center_router, prefix="/core", tags=["core / help-center"])
|
||||
|
||||
Reference in New Issue
Block a user