Merge branch 'development' into feature/Invoice-movements
This commit is contained in:
@@ -49,7 +49,14 @@ async def get_clients_and_providers(
|
||||
)
|
||||
|
||||
if type is not None:
|
||||
query = query.filter(ClientProvider.client_or_provider == type)
|
||||
# Include 'both' type when filtering by client or provider
|
||||
from sqlalchemy import or_
|
||||
query = query.filter(
|
||||
or_(
|
||||
ClientProvider.client_or_provider == type,
|
||||
ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH
|
||||
)
|
||||
)
|
||||
|
||||
if active is not None:
|
||||
query = query.filter(ClientProvider.is_active == active)
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from .models import ClientOrProviderEnum
|
||||
|
||||
from .dto import (
|
||||
ClientProviderBasicDTO,
|
||||
@@ -56,8 +57,12 @@ class ClientProviderService:
|
||||
)
|
||||
)
|
||||
if filters.get("client_or_provider"):
|
||||
|
||||
query = query.filter(
|
||||
ClientProvider.client_or_provider == filters["client_or_provider"]
|
||||
or_(
|
||||
ClientProvider.client_or_provider == filters["client_or_provider"],
|
||||
ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH
|
||||
)
|
||||
)
|
||||
if filters.get("status"):
|
||||
enabled = 1 if filters["status"] == "enabled" else 0
|
||||
@@ -346,7 +351,10 @@ class ClientProviderService:
|
||||
|
||||
if client_or_provider:
|
||||
query = query.filter(
|
||||
ClientProvider.client_or_provider == client_or_provider
|
||||
or_(
|
||||
ClientProvider.client_or_provider == client_or_provider,
|
||||
ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH
|
||||
)
|
||||
)
|
||||
|
||||
if enabled_only:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Modelo principal de Company
|
||||
"""
|
||||
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import Integer, String, SmallInteger, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -19,39 +20,37 @@ if TYPE_CHECKING:
|
||||
|
||||
class Company(Base, TimestampMixin):
|
||||
"""Información principal de la empresa"""
|
||||
|
||||
|
||||
__tablename__ = "company"
|
||||
__table_args__ = {"schema": "a76", "extend_existing": True}
|
||||
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("core.tenants.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
ForeignKey("core.tenants.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
|
||||
# Información básica
|
||||
name: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
rfc: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
curp: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
main_activity: Mapped[Optional[str]] = mapped_column(String(80))
|
||||
|
||||
|
||||
# Programa
|
||||
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_authorization: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
|
||||
# Sectores
|
||||
sector1: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
sector2: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
sector3: Mapped[Optional[str]] = mapped_column(String(5))
|
||||
|
||||
|
||||
# Identificadores
|
||||
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
|
||||
broker_company: Mapped[Optional[str]] = mapped_column(String(6))
|
||||
|
||||
|
||||
# Responsable
|
||||
responsible: Mapped[Optional[str]] = mapped_column(String(80))
|
||||
responsible_name: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
@@ -59,15 +58,15 @@ class Company(Base, TimestampMixin):
|
||||
responsible_mother_last_name: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
responsible_rfc: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
position: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
|
||||
|
||||
# Configuración básica
|
||||
logo: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
has_express_line: Mapped[Optional[str]] = mapped_column(String(2))
|
||||
has_express_line: Mapped[Optional[str]] = mapped_column(String(2), default="N")
|
||||
order_format_type: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
|
||||
client_name: Mapped[Optional[str]] = mapped_column(String(300))
|
||||
subassembly_mode: Mapped[Optional[str]] = mapped_column(String(7))
|
||||
|
||||
|
||||
# Configuraciones técnicas (flags)
|
||||
previous_code: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
active_labels: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
@@ -79,8 +78,9 @@ class Company(Base, TimestampMixin):
|
||||
parts_replacement: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
activate_facmexame: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
international_firm: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
|
||||
|
||||
# Configuraciones simples
|
||||
ftp_key: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
sifra_path: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
@@ -88,59 +88,59 @@ class Company(Base, TimestampMixin):
|
||||
sql_language: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
balance_operation_mode: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
inter_db_name: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
|
||||
|
||||
# ==================== RELACIONES CON SUBTABLAS ====================
|
||||
|
||||
|
||||
addresses: Mapped[list["CompanyAddress"]] = relationship(
|
||||
"CompanyAddress",
|
||||
back_populates="company",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
certification: Mapped[Optional["CompanyCertification"]] = relationship(
|
||||
"CompanyCertification",
|
||||
back_populates="company",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
digital_certificates: Mapped[list["CompanyDigitalCertificate"]] = relationship(
|
||||
"CompanyDigitalCertificate",
|
||||
back_populates="company",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
ventanilla_unica: Mapped[Optional["CompanyVU"]] = relationship(
|
||||
"CompanyVU",
|
||||
back_populates="company",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
electronic_agent: Mapped[Optional["CompanyElectronicAgent"]] = relationship(
|
||||
"CompanyElectronicAgent",
|
||||
back_populates="company",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
prevalidator: Mapped[Optional["CompanyPrevalidator"]] = relationship(
|
||||
"CompanyPrevalidator",
|
||||
back_populates="company",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
cfdi: Mapped[Optional["CompanyCFDI"]] = relationship(
|
||||
"CompanyCFDI",
|
||||
back_populates="company",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@@ -50,7 +50,8 @@ async def create_company(
|
||||
)
|
||||
|
||||
service = CompanyService(db)
|
||||
return service.create_company_manually(data, tenant_id=tenant_id)
|
||||
new_company = service.create_company_manually(data, tenant_id=tenant_id)
|
||||
return CompanyResponseDTO.model_validate(service.flatten_company_dto(new_company))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -94,7 +95,10 @@ async def list_companies(
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
"items": [CompanyResponseDTO.model_validate(item) for item in items],
|
||||
"items": [
|
||||
CompanyResponseDTO.model_validate(service.flatten_company_dto(item))
|
||||
for item in items
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -122,135 +126,12 @@ async def get_my_companies(
|
||||
service = CompanyService(db)
|
||||
companies = service.get_companies_by_tenant(tenant_id)
|
||||
|
||||
return [CompanyResponseDTO.model_validate(company) for company in companies]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status/exists",
|
||||
response_model=dict,
|
||||
summary="Check if company exists for tenant",
|
||||
)
|
||||
async def check_company_exists(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Check if a company exists for the current tenant"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
service = CompanyService(db)
|
||||
exists = service.exists_company(tenant_id)
|
||||
|
||||
return {"exists": exists}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/info/basic/{company_id}",
|
||||
response_model=dict,
|
||||
summary="Get basic company info",
|
||||
)
|
||||
async def get_basic_info(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get basic information about a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id_from_user = current_user.get("company_id")
|
||||
|
||||
# Validate access
|
||||
validate_access_to_resource(
|
||||
db, tenant_id, company_id_from_user, Company, company_id, "id"
|
||||
)
|
||||
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"id": company.id,
|
||||
"name": company.name,
|
||||
"rfc": company.rfc,
|
||||
"program": company.program,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/info/responsible/{company_id}",
|
||||
response_model=dict,
|
||||
summary="Get responsible person info",
|
||||
)
|
||||
async def get_responsible_info(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get responsible person information for a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id_from_user = current_user.get("company_id")
|
||||
|
||||
# Validate access
|
||||
validate_access_to_resource(
|
||||
db, tenant_id, company_id_from_user, Company, company_id, "id"
|
||||
)
|
||||
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"responsible": company.responsible,
|
||||
"responsible_name": company.responsible_name,
|
||||
"responsible_last_name": company.responsible_last_name,
|
||||
"responsible_mother_last_name": company.responsible_mother_last_name,
|
||||
"responsible_rfc": company.responsible_rfc,
|
||||
"position": company.position,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/info/program/{company_id}",
|
||||
response_model=dict,
|
||||
summary="Get program information",
|
||||
)
|
||||
async def get_program_info(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get program information for a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id_from_user = current_user.get("company_id")
|
||||
|
||||
# Validate access
|
||||
validate_access_to_resource(
|
||||
db, tenant_id, company_id_from_user, Company, company_id, "id"
|
||||
)
|
||||
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"program": company.program,
|
||||
"program_number": company.program_number,
|
||||
"prosec": company.prosec,
|
||||
"prosec_authorization": company.prosec_authorization,
|
||||
}
|
||||
return [
|
||||
CompanyResponseDTO.model_validate(service.flatten_company_dto(company))
|
||||
for company in companies
|
||||
]
|
||||
|
||||
# ... existing code ...
|
||||
|
||||
@router.get(
|
||||
"/{company_id}",
|
||||
@@ -270,6 +151,7 @@ async def get_company(
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
service = CompanyService(db)
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
@@ -277,7 +159,7 @@ async def get_company(
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return CompanyResponseDTO.model_validate(company)
|
||||
return CompanyResponseDTO.model_validate(service.flatten_company_dto(company))
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -299,81 +181,28 @@ async def update_company(
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
updated_company = CompanyService.update(db, company_id, tenant_id, 0, data)
|
||||
service = CompanyService(db)
|
||||
updated_company = service.update(db, company_id, tenant_id, 0, data)
|
||||
if not updated_company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return CompanyResponseDTO.model_validate(updated_company)
|
||||
return CompanyResponseDTO.model_validate(service.flatten_company_dto(updated_company))
|
||||
|
||||
|
||||
return CompanyResponseDTO.model_validate(updated_company)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{company_id}/upload-logo",
|
||||
response_model=dict,
|
||||
summary="Upload company logo",
|
||||
)
|
||||
async def upload_company_logo(
|
||||
company_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Upload logo for a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
# 1. Verify company exists
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
# 2. Define upload path
|
||||
# Use a persistent path: 'app_data/logos/{company_id}'
|
||||
upload_dir = Path(f"app_data/logos/{company_id}")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 3. Save file
|
||||
# Preserve original filename
|
||||
filename = file.filename or "logo.png"
|
||||
file_path = upload_dir / filename
|
||||
|
||||
try:
|
||||
# Check if file exists and remove it to avoid accumulation if needed,
|
||||
# or just overwrite (shutil.copyfileobj overwrites)
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Could not save file: {e}",
|
||||
)
|
||||
|
||||
# 4. Returns the absolute path keys
|
||||
abs_path = str(file_path.absolute())
|
||||
|
||||
return {"path": abs_path}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{company_id}/logo/image",
|
||||
summary="Get company logo image",
|
||||
)
|
||||
@router.get(
|
||||
"/{company_id}/logo/image",
|
||||
summary="Get company logo image",
|
||||
)
|
||||
|
||||
async def get_company_logo_image(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
@@ -401,6 +230,13 @@ async def get_company_logo_image(
|
||||
raise HTTPException(status_code=404, detail="Logo file not found on server")
|
||||
|
||||
return FileResponse(file_path)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{company_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a company",
|
||||
)
|
||||
async def delete_company(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
@@ -497,7 +333,8 @@ async def upload_company_logo(
|
||||
|
||||
# Actualizar la empresa con la ruta del logo
|
||||
update_data = CompanyUpdateDTO(logo=file_path)
|
||||
updated_company = CompanyService.update(db, company_id, tenant_id, 0, update_data)
|
||||
service = CompanyService(db)
|
||||
updated_company = service.update(db, company_id, tenant_id, 0, update_data)
|
||||
|
||||
return {
|
||||
"message": "Logo uploaded successfully",
|
||||
|
||||
@@ -97,8 +97,139 @@ class CompanyService:
|
||||
logger.error(f"Error creating company: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error creating company")
|
||||
|
||||
@staticmethod
|
||||
|
||||
# ==================== HELPERS FOR FIELD MAPPING ====================
|
||||
def _extract_company_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extrae campos que pertenecen a la tabla Company principal"""
|
||||
company_fields = [
|
||||
"name", "rfc", "curp", "main_activity", "program", "program_number",
|
||||
"prosec", "prosec_authorization", "sector1", "sector2", "sector3",
|
||||
"manufacturer_id", "broker_company", "responsible", "responsible_name",
|
||||
"responsible_last_name", "responsible_mother_last_name", "responsible_rfc",
|
||||
"position", "logo", "has_express_line", "order_format_type",
|
||||
"is_service_company", "client_name", "subassembly_mode", "previous_code",
|
||||
"active_labels", "active_fractions", "activate_caat", "trans_interface",
|
||||
"american_costs", "scaf_readonly", "parts_replacement", "activate_facmexame",
|
||||
"part_reference", "international_firm", "ftp_key", "sifra_path",
|
||||
"version_type", "sql_language", "balance_operation_mode", "inter_db_name"
|
||||
]
|
||||
return {k: v for k, v in data.items() if k in company_fields}
|
||||
|
||||
def _extract_certification_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extrae campos que pertenecen a CompanyCertification"""
|
||||
cert_fields = [
|
||||
"is_certified_company", "certified_company_registration",
|
||||
"certified_company_start_date", "certified_company_end_date",
|
||||
"annex31_certification_date", "annex31_certification_number",
|
||||
"annex31_modality", "annex31_company_type", "annex31_renewal_date",
|
||||
"annex31_final_certification_date", "is_oea_company", "ctpat_svi",
|
||||
"trusted_exporter_number", "neec_company"
|
||||
]
|
||||
return {k: v for k, v in data.items() if k in cert_fields}
|
||||
|
||||
def _extract_prevalidator_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extrae campos que pertenecen a CompanyPrevalidator"""
|
||||
# Note: 'prevalidator_key' in DTO maps to 'key' in model
|
||||
fields = {}
|
||||
if "prevalidator_key" in data:
|
||||
fields["key"] = data["prevalidator_key"]
|
||||
|
||||
# Add other fields if present in DTO in the future
|
||||
return fields
|
||||
|
||||
def flatten_company_dto(self, company: Company) -> Dict[str, Any]:
|
||||
"""Flattens Company and its submodels into a single dict for DTO validation"""
|
||||
# 1. Base Company fields
|
||||
result = {
|
||||
k: getattr(company, k)
|
||||
for k in company.__mapper__.c.keys()
|
||||
}
|
||||
# Explicitly ensure logo is present (defensive programming)
|
||||
if hasattr(company, 'logo'):
|
||||
result['logo'] = company.logo
|
||||
|
||||
# Convert has_express_line from String "S"/"N" to Boolean
|
||||
if hasattr(company, 'has_express_line'):
|
||||
val = getattr(company, 'has_express_line', "N")
|
||||
result['has_express_line'] = (val == "S")
|
||||
|
||||
# 2. Certification fields
|
||||
if company.certification:
|
||||
cert_fields = [
|
||||
"is_certified_company", "certified_company_registration",
|
||||
"certified_company_start_date", "certified_company_end_date",
|
||||
"annex31_certification_date", "annex31_certification_number",
|
||||
"annex31_modality", "annex31_company_type", "annex31_renewal_date",
|
||||
"annex31_final_certification_date", "is_oea_company", "ctpat_svi",
|
||||
"trusted_exporter_number", "neec_company"
|
||||
]
|
||||
for field in cert_fields:
|
||||
val = getattr(company.certification, field, None)
|
||||
if val is not None:
|
||||
result[field] = val
|
||||
|
||||
# 3. Prevalidator fields
|
||||
if company.prevalidator:
|
||||
if company.prevalidator.key:
|
||||
result["prevalidator_key"] = company.prevalidator.key
|
||||
|
||||
return result
|
||||
|
||||
# ==================== CRUD METHODS ====================
|
||||
|
||||
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company:
|
||||
from .submodels.certification import CompanyCertification
|
||||
from .submodels.prevalidator import CompanyPrevalidator
|
||||
|
||||
try:
|
||||
# 1. Preparar datos
|
||||
obj_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle boolean flags for Company (Hybrid Approach)
|
||||
# has_express_line is String(2), is_service_company is Boolean
|
||||
if "has_express_line" in obj_data and isinstance(obj_data["has_express_line"], bool):
|
||||
obj_data["has_express_line"] = "S" if obj_data["has_express_line"] else "N"
|
||||
|
||||
# 2. Extract fields for each model
|
||||
company_data = self._extract_company_fields(obj_data)
|
||||
cert_data = self._extract_certification_fields(obj_data)
|
||||
preval_data = self._extract_prevalidator_fields(obj_data)
|
||||
|
||||
# 3. Create Company
|
||||
db_company = Company(**company_data, tenant_id=tenant_id)
|
||||
self.db.add(db_company)
|
||||
self.db.flush() # Generate ID
|
||||
|
||||
# 4. Create Certification if data exists
|
||||
if cert_data:
|
||||
cert = CompanyCertification(**cert_data, company_id=db_company.id)
|
||||
self.db.add(cert)
|
||||
|
||||
# 5. Create Prevalidator if data exists
|
||||
if preval_data:
|
||||
preval = CompanyPrevalidator(**preval_data, company_id=db_company.id)
|
||||
self.db.add(preval)
|
||||
|
||||
# 6. Commit
|
||||
self.db.commit()
|
||||
self.db.refresh(db_company)
|
||||
|
||||
return db_company
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating company manually: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Error de integridad: Es posible que esta empresa ya exista.",
|
||||
)
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating company manually: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}")
|
||||
|
||||
def update(
|
||||
self, # Changed to instance method to use self helper methods
|
||||
db: Session,
|
||||
company_id: int,
|
||||
tenant_id: int,
|
||||
@@ -106,23 +237,57 @@ class CompanyService:
|
||||
company_data: CompanyUpdateDTO,
|
||||
) -> Optional[Company]:
|
||||
"""Update a company"""
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
|
||||
from .submodels.certification import CompanyCertification
|
||||
from .submodels.prevalidator import CompanyPrevalidator
|
||||
|
||||
# Use self.db if db is passed as None, or use passed db (legacy support)
|
||||
session = db if db else self.db
|
||||
|
||||
company = self.get_by_id(session, company_id, tenant_id, company_id_unused)
|
||||
if not company:
|
||||
return None
|
||||
|
||||
# Update only provided fields
|
||||
update_data = company_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
|
||||
# 1. Update Company fields
|
||||
company_fields = self._extract_company_fields(update_data)
|
||||
|
||||
# Hybrid Approach: has_express_line is String, is_service_company is Boolean
|
||||
if "has_express_line" in company_fields and isinstance(company_fields["has_express_line"], bool):
|
||||
company_fields["has_express_line"] = "S" if company_fields["has_express_line"] else "N"
|
||||
|
||||
for field, value in company_fields.items():
|
||||
setattr(company, field, value)
|
||||
|
||||
# 2. Update Certification
|
||||
cert_fields = self._extract_certification_fields(update_data)
|
||||
if cert_fields:
|
||||
if company.certification:
|
||||
for field, value in cert_fields.items():
|
||||
setattr(company.certification, field, value)
|
||||
else:
|
||||
new_cert = CompanyCertification(**cert_fields, company_id=company.id)
|
||||
session.add(new_cert)
|
||||
|
||||
# 3. Update Prevalidator
|
||||
preval_fields = self._extract_prevalidator_fields(update_data)
|
||||
if preval_fields:
|
||||
if company.prevalidator:
|
||||
for field, value in preval_fields.items():
|
||||
setattr(company.prevalidator, field, value)
|
||||
else:
|
||||
new_preval = CompanyPrevalidator(**preval_fields, company_id=company.id)
|
||||
session.add(new_preval)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(company)
|
||||
session.commit()
|
||||
session.refresh(company)
|
||||
return company
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
session.rollback()
|
||||
logger.error(f"Error updating company {company_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating company")
|
||||
raise HTTPException(status_code=500, detail=f"Error al actualizar la empresa: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def delete(
|
||||
@@ -134,19 +299,56 @@ class CompanyService:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Manual cascade delete for submodels to ensure order and avoid FK issues
|
||||
# (Even though cascade="all, delete-orphan" is set, manual deletion is safer for strict DBs)
|
||||
|
||||
# 1. Delete Certification
|
||||
if company.certification:
|
||||
db.delete(company.certification)
|
||||
|
||||
# 2. Delete Prevalidator
|
||||
if company.prevalidator:
|
||||
db.delete(company.prevalidator)
|
||||
|
||||
# 3. Delete Electronic Agent
|
||||
if company.electronic_agent:
|
||||
db.delete(company.electronic_agent)
|
||||
|
||||
# 4. Delete VU
|
||||
if company.ventanilla_unica:
|
||||
db.delete(company.ventanilla_unica)
|
||||
|
||||
# 5. Delete CFDI
|
||||
if company.cfdi:
|
||||
db.delete(company.cfdi)
|
||||
|
||||
# 6. Delete Digital Certificates
|
||||
for cert in company.digital_certificates:
|
||||
db.delete(cert)
|
||||
|
||||
# 7. Delete Addresses
|
||||
for addr in company.addresses:
|
||||
db.delete(addr)
|
||||
|
||||
# Flush to execute submodel deletions first
|
||||
db.flush()
|
||||
|
||||
db.delete(company)
|
||||
db.commit()
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError deleting company {company_id}: {str(e)}")
|
||||
# Check if it's a foreign key constraint
|
||||
if "foreign key constraint" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No se puede eliminar la empresa porque tiene registros relacionados (facturas, conceptos, etc.)"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Error al eliminar la empresa")
|
||||
# Try to get detailed error from psycopg2
|
||||
detail = "No se puede eliminar la empresa porque tiene registros relacionados."
|
||||
if hasattr(e, 'orig') and hasattr(e.orig, 'diag'):
|
||||
if e.orig.diag.message_detail:
|
||||
detail += f" Detalles: {e.orig.diag.message_detail}"
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=detail
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting company {company_id}: {str(e)}")
|
||||
@@ -169,32 +371,4 @@ class CompanyService:
|
||||
.filter(Company.tenant_id == tenant_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company:
|
||||
|
||||
try:
|
||||
# 1. Preparar datos
|
||||
obj_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# 2. Crear objeto SQLAlchemy
|
||||
db_obj = Company(**obj_data, tenant_id=tenant_id)
|
||||
|
||||
# 3. Guardar
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating company manually: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Error de integridad: Es posible que esta empresa ya exista.",
|
||||
)
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating company manually: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}")
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
Modelo de certificaciones de empresa
|
||||
"""
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import Integer, String, SmallInteger, ForeignKey
|
||||
from sqlalchemy import Integer, String, SmallInteger, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from core.database import Base
|
||||
from api.v1.common.base_models import TimestampMixin
|
||||
|
||||
@@ -27,10 +27,25 @@ route_handler = TenantCRUDRoutes(
|
||||
max_page_size=100,
|
||||
)
|
||||
|
||||
router = route_handler.router
|
||||
crud_router = route_handler.router
|
||||
|
||||
# Create a custom router for specific endpoints that must be matched BEFORE generic CRUD routes
|
||||
# We use the same prefix so they are grouped together
|
||||
from fastapi import APIRouter
|
||||
custom_router = APIRouter(prefix="/exchange-rate", tags=[])
|
||||
|
||||
@custom_router.get("/test-ping")
|
||||
async def test_ping():
|
||||
return {"message": "pong"}
|
||||
|
||||
# Master router to export
|
||||
router = APIRouter()
|
||||
# Include custom routes FIRST to avoid shadowing by /{id}
|
||||
router.include_router(custom_router)
|
||||
# router.include_router(crud_router)
|
||||
|
||||
|
||||
@router.get(
|
||||
@custom_router.get(
|
||||
"/",
|
||||
response_model=Dict[str, Any],
|
||||
summary="List Exchange Rates",
|
||||
@@ -66,3 +81,37 @@ async def list_exchange_rates(
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
@custom_router.get(
|
||||
"/dof-search",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Fetch Exchange Rate from DOF",
|
||||
description="Fetches the exchange rate from the Official Journal of the Federation (DOF) for a specific date.",
|
||||
)
|
||||
async def fetch_exchange_rate_dof(
|
||||
date: str = Query(..., description="Date in YYYY-MM-DD format"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
# This endpoint can be public or protected. Assuming protected for now.
|
||||
# No specific tenant validation needed since it's an external query,
|
||||
# but good to ensure user is authenticated.
|
||||
|
||||
try:
|
||||
print(f"DEBUG: Route called with date={date}")
|
||||
rate = ExchangeRateService.fetch_from_dof(date)
|
||||
|
||||
if rate is None:
|
||||
return {"success": False, "message": "No se encontró el tipo de cambio en el DOF para la fecha especificada o el servicio no está disponible.", "value": None}
|
||||
|
||||
return {"success": True, "value": rate}
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Error in route: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "message": f"Error interno: {str(e)}", "value": None}
|
||||
|
||||
# Include routers at the end to ensure all routes are registered
|
||||
# Include custom routes FIRST to avoid shadowing by /{id} of crud_router
|
||||
router.include_router(custom_router)
|
||||
router.include_router(crud_router)
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
from datetime import datetime, time
|
||||
import requests
|
||||
import re
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import cast, Date
|
||||
|
||||
from . import dto, models
|
||||
import urllib3
|
||||
from core.config import settings
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
|
||||
|
||||
class ExchangeRateService:
|
||||
@@ -101,8 +109,8 @@ class ExchangeRateService:
|
||||
def update(
|
||||
db: Session,
|
||||
exchange_rate_id: int,
|
||||
exchange_rate_data: dto.ExchangeRateUpdateDTO,
|
||||
tenant_id: int,
|
||||
exchange_rate_data: dto.ExchangeRateUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[models.ExchangeRate]:
|
||||
"""Update an exchange rate"""
|
||||
@@ -135,3 +143,92 @@ class ExchangeRateService:
|
||||
db.delete(exchange_rate)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _get_sitar_api_token(base_url, username, password) -> Optional[str]:
|
||||
"""Helper to get authentication token from external API"""
|
||||
try:
|
||||
print(base_url)
|
||||
login_url = f"{base_url}/exchange-rate/auth/login"
|
||||
payload = {"username": username, "password": password}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
response = requests.post(login_url, json=payload, headers=headers, timeout=5)
|
||||
if response.status_code not in [200, 201]:
|
||||
print(f"External API Login Failed: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
return data.get("token") or data.get("access_token")
|
||||
except Exception as e:
|
||||
print(f"External API Login Error: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def fetch_from_dof(date_str: str) -> Optional[float]:
|
||||
"""
|
||||
Fetches the exchange rate from an external API (replacing direct DOF scraping).
|
||||
The API handles date logic (holidays, weekends) automatically.
|
||||
|
||||
Args:
|
||||
date_str (str): Date in 'YYYY-MM-DD' format.
|
||||
|
||||
Returns:
|
||||
Optional[float]: The exchange rate value if found, None otherwise.
|
||||
"""
|
||||
# API Credentials
|
||||
API_BASE_URL = settings.SITAR_API_URL
|
||||
API_USER = settings.SITAR_API_USER
|
||||
API_PASS = settings.SITAR_API_PASSWORD
|
||||
|
||||
if not API_USER or not API_PASS:
|
||||
print("ERROR: External API credentials not properly configured in settings")
|
||||
return None
|
||||
|
||||
try:
|
||||
print(f"DEBUG: Fetching External API for date: {date_str}")
|
||||
|
||||
# 1. Get Token
|
||||
token = ExchangeRateService._get_sitar_api_token(API_BASE_URL, API_USER, API_PASS)
|
||||
if not token:
|
||||
print("Failed to obtain external API token")
|
||||
return None
|
||||
|
||||
# 2. Fetch Exchange Rate
|
||||
# The API endpoint is /tipoCambio/{YYYY-MM-DD}
|
||||
tc_endpoint = f"{API_BASE_URL}/exchange-rate/tipoCambio/{date_str}"
|
||||
|
||||
# Auth header: The API expects just the token string in common usage, but we try standard first
|
||||
# based on user feedback/code: 'Authorization:' . $token
|
||||
headers = {
|
||||
"Authorization": token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.get(tc_endpoint, headers=headers, timeout=5)
|
||||
|
||||
# Retry logic as per PHP reference (if 401, maybe formatting issue, but requests handles headers well)
|
||||
if response.status_code == 401:
|
||||
# Try with Bearer prefix just in case, though PHP code suggested raw token
|
||||
print("DEBUG: 401 received, retrying with Bearer prefix...")
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
response = requests.get(tc_endpoint, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"External API TC Error: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
# Expected response: {"Id":..., "Fecha":"...", "TipoCambio":17.452, "Mov":"..."}
|
||||
|
||||
if "TipoCambio" in data:
|
||||
val = float(data["TipoCambio"])
|
||||
print(f"DEBUG: External API returned value: {val}")
|
||||
return val
|
||||
|
||||
print(f"DEBUG: 'TipoCambio' key not found in response: {data}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching from External API: {e}")
|
||||
return None
|
||||
|
||||
@@ -19,7 +19,7 @@ class Package(Base, TenantScopedMixin, TimestampMixin):
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="packages_pkey"),
|
||||
UniqueConstraint("tenant_id", "company_id", "key", name="packages_key_ukey"),
|
||||
{"schema": "a76"},
|
||||
{"schema": "a76", "extend_existing": True},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
@@ -36,14 +36,8 @@ class UnitOfMeasureBase(BaseModel):
|
||||
oma_code: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
|
||||
class UnitOfMeasureGeneralBase(BaseModel):
|
||||
code: str = Field(..., max_length=20, description="Unit Code")
|
||||
description: Optional[str] = Field(None, max_length=100)
|
||||
conversion_factor: Optional[Decimal] = None
|
||||
mexico_unit: Optional[str] = Field(None, max_length=20)
|
||||
american_unit_code: Optional[str] = Field(None, max_length=20)
|
||||
customs_code: Optional[int] = Field(None)
|
||||
ace_code: Optional[str] = Field(None, max_length=20)
|
||||
class UnitOfMeasureGeneralBase(UnitOfMeasureBase):
|
||||
pass
|
||||
|
||||
# --- Create DTOs ---
|
||||
|
||||
@@ -105,14 +99,8 @@ class UnitOfMeasureUpdate(BaseModel):
|
||||
oma_code: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
|
||||
class UnitOfMeasureGeneralUpdate(BaseModel):
|
||||
code: Optional[str] = Field(None, max_length=20)
|
||||
description: Optional[str] = Field(None, max_length=100)
|
||||
conversion_factor: Optional[Decimal] = None
|
||||
mexico_unit: Optional[str] = Field(None, max_length=20)
|
||||
american_unit_code: Optional[str] = Field(None, max_length=20)
|
||||
customs_code: Optional[int] = Field(None)
|
||||
ace_code: Optional[str] = Field(None, max_length=20)
|
||||
class UnitOfMeasureGeneralUpdate(UnitOfMeasureUpdate):
|
||||
pass
|
||||
|
||||
# --- Response DTOs ---
|
||||
|
||||
@@ -142,6 +130,5 @@ class UnitOfMeasureResponse(UnitOfMeasureBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UnitOfMeasureGeneralResponse(UnitOfMeasureGeneralBase):
|
||||
id: int
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
class UnitOfMeasureGeneralResponse(UnitOfMeasureResponse):
|
||||
pass
|
||||
|
||||
@@ -17,6 +17,7 @@ ace_router = TenantCRUDRoutes(
|
||||
id_name="id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
max_page_size=10000,
|
||||
).router
|
||||
router.include_router(ace_router)
|
||||
|
||||
@@ -32,6 +33,7 @@ oma_router = TenantCRUDRoutes(
|
||||
id_name="id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
max_page_size=10000,
|
||||
).router
|
||||
router.include_router(oma_router)
|
||||
|
||||
@@ -47,6 +49,7 @@ american_router = TenantCRUDRoutes(
|
||||
id_name="id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
max_page_size=10000,
|
||||
).router
|
||||
router.include_router(american_router)
|
||||
|
||||
@@ -62,6 +65,7 @@ customs_router = TenantCRUDRoutes(
|
||||
id_name="id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
max_page_size=10000,
|
||||
).router
|
||||
router.include_router(customs_router)
|
||||
|
||||
@@ -77,6 +81,7 @@ general_router = TenantCRUDRoutes(
|
||||
id_name="id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
max_page_size=10000,
|
||||
).router
|
||||
router.include_router(general_router)
|
||||
|
||||
@@ -93,5 +98,6 @@ main_router = TenantCRUDRoutes(
|
||||
id_name="id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
max_page_size=10000,
|
||||
).router
|
||||
router.include_router(main_router)
|
||||
|
||||
@@ -154,7 +154,7 @@ class UnitOfMeasureService(BaseService):
|
||||
|
||||
|
||||
class UnitOfMeasureGeneralService(BaseService):
|
||||
model = UnitOfMeasureGeneral
|
||||
model = UnitOfMeasure
|
||||
|
||||
|
||||
def get_all_uom_general(session: Session, skip: int = 0, limit: int = 100) -> Sequence[UnitOfMeasureGeneral]:
|
||||
|
||||
@@ -12,6 +12,76 @@ from api.v1.modules.public.reference_data.currency_types.models import CurrencyT
|
||||
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
|
||||
from ....models import TransportType, Currency, WeightUnit
|
||||
from core.exceptions import ErrorCollector
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
def validate_required_fields_by_operation(
|
||||
invoice_data: Dict[str, Any],
|
||||
operation_type: str,
|
||||
errors: ErrorCollector
|
||||
) -> None:
|
||||
"""
|
||||
Valida campos obligatorios según tipo de operación.
|
||||
Usar ANTES de guardar en BD.
|
||||
"""
|
||||
|
||||
# PROVEEDOR (SIEMPRE OBLIGATORIO - mensaje dinámico)
|
||||
if not invoice_data.get('provider_id'):
|
||||
# Mensaje dinámico según el header seleccionado
|
||||
provider_labels = {
|
||||
'proveedor': 'Proveedor',
|
||||
'exportador': 'Exportador'
|
||||
}
|
||||
provider_header = invoice_data.get('provider_header') or 'proveedor'
|
||||
field_label = provider_labels.get(provider_header, 'Proveedor')
|
||||
|
||||
errors.add_error(
|
||||
field="provider_id",
|
||||
message=f"Debe seleccionar {field_label}",
|
||||
solution=["Seleccione un proveedor de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
# VENDIDO A / CONSIGNADO A (SIEMPRE OBLIGATORIO - mensaje dinámico)
|
||||
if not invoice_data.get('sold_to_id'):
|
||||
# Mensaje dinámico según el header seleccionado
|
||||
sold_to_labels = {
|
||||
'consignado_a': 'Consignado a',
|
||||
'vendido_a': 'Vendido a',
|
||||
'exportado_a': 'Exportado a',
|
||||
'importador': 'Importador'
|
||||
}
|
||||
sold_to_header = invoice_data.get('sold_to_header') or 'consignado_a'
|
||||
field_label = sold_to_labels.get(sold_to_header, 'Cliente')
|
||||
|
||||
errors.add_error(
|
||||
field="sold_to_id",
|
||||
message=f"Debe seleccionar {field_label}",
|
||||
solution=["Seleccione una opción de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
# ENVIADO A (SIEMPRE OBLIGATORIO - mensaje fijo)
|
||||
if not invoice_data.get('shipped_to_id'):
|
||||
errors.add_error(
|
||||
field="shipped_to_id",
|
||||
message="Debe seleccionar el Destinatario",
|
||||
solution=["Seleccione un destinatario de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
# AGENTE ADUANAL (OBLIGATORIO si hay pedimento)
|
||||
if invoice_data.get('pedimento_id') and not invoice_data.get('customs_broker_id'):
|
||||
errors.add_error(
|
||||
field="customs_broker_id",
|
||||
message="Debe seleccionar un Agente Aduanal",
|
||||
solution=["Seleccione un agente aduanal de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
|
||||
def validate_common(
|
||||
@@ -245,78 +315,87 @@ def validate_common(
|
||||
value=invoice.document_type,
|
||||
)
|
||||
|
||||
provider_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.provider_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not provider_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.provider_id",
|
||||
message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Proveedor", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.provider_id,
|
||||
# Validar proveedor solo si se proporciona
|
||||
if invoice.compliance_mx.provider_id:
|
||||
provider_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.provider_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not provider_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.provider_id",
|
||||
message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Proveedor", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.provider_id,
|
||||
)
|
||||
|
||||
selled_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.sold_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not selled_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.sold_to_id",
|
||||
message="El Cliente no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Cliente", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.sold_to_id,
|
||||
# Validar vendido a solo si se proporciona
|
||||
if invoice.compliance_mx.sold_to_id:
|
||||
selled_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.sold_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not selled_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.sold_to_id",
|
||||
message="El Cliente no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Cliente", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.sold_to_id,
|
||||
)
|
||||
|
||||
shipped_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.shipped_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not shipped_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.shipped_to_id",
|
||||
message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Destinatario", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.shipped_to_id,
|
||||
# Validar destinatario solo si se proporciona
|
||||
if invoice.compliance_mx.shipped_to_id:
|
||||
shipped_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.shipped_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not shipped_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.shipped_to_id",
|
||||
message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Destinatario", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.shipped_to_id,
|
||||
)
|
||||
|
||||
customs_broker_exists = (
|
||||
db.query(CustomsBroker)
|
||||
.filter(
|
||||
CustomsBroker.id == invoice.compliance_mx.customs_broker_id,
|
||||
CustomsBroker.tenant_id == tenant_id,
|
||||
CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not customs_broker_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.customs_broker_id",
|
||||
message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.customs_broker_id,
|
||||
# Validar agente aduanal solo si se proporciona
|
||||
if invoice.compliance_mx.customs_broker_id:
|
||||
customs_broker_exists = (
|
||||
db.query(CustomsBroker)
|
||||
.filter(
|
||||
CustomsBroker.id == invoice.compliance_mx.customs_broker_id,
|
||||
CustomsBroker.tenant_id == tenant_id,
|
||||
CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not customs_broker_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.customs_broker_id",
|
||||
message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.customs_broker_id,
|
||||
)
|
||||
|
||||
# Validar transportista solo si se proporciona
|
||||
if invoice.logistics.carrier_id:
|
||||
carrier_exists = (
|
||||
db.query(ClientProvider)
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
|
||||
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
|
||||
from core.exceptions import ErrorCollector
|
||||
from ....schemas import InvoiceHeaderCreate
|
||||
from .common import validate_common
|
||||
from .common import validate_common, validate_required_fields_by_operation
|
||||
|
||||
def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, company_id: int, errors: ErrorCollector) -> None:
|
||||
""" Valida la creación de una nueva factura de importe temporal """
|
||||
@@ -21,22 +21,31 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c
|
||||
errors.add_required_error("invoice_number")
|
||||
|
||||
if not invoice.invoice_date:
|
||||
errors.add_required_error("invoice_date")
|
||||
|
||||
if not invoice.compliance_mx.provider_id:
|
||||
errors.add_required_error("compliance_mx.provider_id")
|
||||
errors.add_required_error("invoice_date")
|
||||
|
||||
if not invoice.compliance_mx.sold_to_id:
|
||||
errors.add_required_error("compliance_mx.sold_to_id")
|
||||
|
||||
if not invoice.compliance_mx.shipped_to_id:
|
||||
errors.add_required_error("compliance_mx.shipped_to_id")
|
||||
|
||||
if not invoice.compliance_mx.customs_broker_id:
|
||||
errors.add_required_error("compliance_mx.customs_broker_id")
|
||||
|
||||
if errors.has_errors():
|
||||
"""Se retorna por que hay campos obligatiorios para las validaciones que tienen que ser llenados"""
|
||||
"""Se retorna porque hay campos obligatorios básicos que deben ser llenados"""
|
||||
return
|
||||
|
||||
# Validar campos obligatorios según tipo de operación
|
||||
invoice_data = {
|
||||
'provider_header': invoice.compliance_mx.provider_header if invoice.compliance_mx else None,
|
||||
'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else None,
|
||||
'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None,
|
||||
'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else None,
|
||||
'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else None,
|
||||
'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else None,
|
||||
'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else None,
|
||||
}
|
||||
|
||||
validate_required_fields_by_operation(
|
||||
invoice_data=invoice_data,
|
||||
operation_type=invoice.operation_type,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
if errors.has_errors():
|
||||
"""Se retorna porque hay campos obligatorios según el tipo de operación que deben ser llenados"""
|
||||
return
|
||||
|
||||
validate_common(db, invoice, tenant_id, company_id, errors)
|
||||
|
||||
@@ -5,6 +5,7 @@ from decimal import Decimal
|
||||
from core.exceptions import ErrorCollector
|
||||
from ....schemas import InvoiceHeaderUpdate
|
||||
from ....models import InvoiceHeader
|
||||
from .common import validate_required_fields_by_operation
|
||||
|
||||
|
||||
# Helper function para limpiar strings (equivalente a Clip())
|
||||
@@ -33,6 +34,22 @@ def validate_update(
|
||||
None (modifica invoice_data in-place y acumula errores en errors)
|
||||
"""
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
validate_required_fields_by_operation(
|
||||
invoice_data=invoice_dict,
|
||||
operation_type=invoice_data.operation_type or 'IMP',
|
||||
errors=errors
|
||||
)
|
||||
|
||||
# Primero ejecutar validaciones comunes
|
||||
# validate_common(invoice_data, errors)
|
||||
|
||||
|
||||
@@ -113,17 +113,17 @@ class InvoiceComplianceMxBase(BaseModel):
|
||||
manifest_number: Optional[str] = Field(
|
||||
None, max_length=15, description="Manifest number"
|
||||
)
|
||||
provider_header: Optional[str] = Field(None, max_length=20, description="Provider header")
|
||||
provider_id: Optional[int] = Field(None, description="Provider ID")
|
||||
sold_to_header: Optional[str] = Field(None, max_length=20, description="Sold to header")
|
||||
sold_to_id: Optional[int] = Field(None, description="Sold to ID")
|
||||
shipped_to_header: Optional[str] = Field(None, max_length=20, description="Shipped to header")
|
||||
shipped_to_id: Optional[int] = Field(None, description="Shipped to ID")
|
||||
shipped_by_header: Optional[str] = Field(
|
||||
provider_header: str = Field(None, max_length=20, description="Provider header")
|
||||
provider_id: int = Field(None, description="Provider ID")
|
||||
sold_to_header: str = Field(None, max_length=20, description="Sold to header")
|
||||
sold_to_id: int = Field(None, description="Sold to ID")
|
||||
shipped_to_header: str = Field(None, max_length=20, description="Shipped to header")
|
||||
shipped_to_id: int = Field(None, description="Shipped to ID")
|
||||
shipped_by_header: Optional[int] = Field(
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Validaciones para creación de items vía API.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from core.exceptions import ErrorCollector
|
||||
from api.v1.modules.a76.items.line_items.schemas import LineItemCreate
|
||||
@@ -12,11 +13,11 @@ def validate_create(
|
||||
line: LineItemCreate,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
errors: ErrorCollector
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Validaciones para crear LineItems vía API (actualmente en uso).
|
||||
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
line: Datos del line item
|
||||
@@ -30,43 +31,34 @@ def validate_create(
|
||||
field="line_number",
|
||||
message="El número de línea es obligatorio",
|
||||
solution="Proporciona un número de línea válido",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
# 2. Validar part_number_id
|
||||
if not line.part_number_id:
|
||||
errors.add_error(
|
||||
field="part_number_id",
|
||||
message="Part Number ID es obligatorio",
|
||||
solution="Selecciona un número de parte válido del catálogo",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 3. Validar class_id
|
||||
|
||||
# 2. Validar class_id
|
||||
if not line.class_id:
|
||||
errors.add_error(
|
||||
field="class_id",
|
||||
message="Clase (ID) es obligatorio",
|
||||
solution="Selecciona una clasificación válida del catálogo",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
# 4. Validar unit_of_measure
|
||||
if not line.unit_of_measure:
|
||||
errors.add_error(
|
||||
field="unit_of_measure",
|
||||
message="U.M. es obligatorio",
|
||||
solution="Proporciona una unidad de medida válida",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
# 5. Validar quantity.quantity
|
||||
if not line.quantity:
|
||||
errors.add_error(
|
||||
field="quantity",
|
||||
message="Quantity es obligatorio",
|
||||
solution="Proporciona una cantidad válida",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
else:
|
||||
# Validar con nombre amigable
|
||||
@@ -75,44 +67,69 @@ def validate_create(
|
||||
field="quantity.quantity",
|
||||
message="Quantity debe ser mayor a cero",
|
||||
solution="Proporciona una cantidad válida",
|
||||
code="INVALID_VALUE" if line.quantity.quantity is not None else "REQUIRED"
|
||||
code=(
|
||||
"INVALID_VALUE"
|
||||
if line.quantity.quantity is not None
|
||||
else "REQUIRED"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# 6. Validar financial.unit_cost
|
||||
if not line.financial:
|
||||
errors.add_error(
|
||||
field="financial",
|
||||
message="Unit Cost es obligatorio",
|
||||
solution="Proporciona el costo unitario del item",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
else:
|
||||
has_cost = (
|
||||
line.financial.unit_cost_usd or
|
||||
line.financial.unit_cost_mxn or
|
||||
line.financial.unit_cost_capture
|
||||
line.financial.unit_cost_usd
|
||||
or line.financial.unit_cost_mxn
|
||||
or line.financial.unit_cost_capture
|
||||
)
|
||||
if not has_cost:
|
||||
errors.add_error(
|
||||
field="financial.unit_cost",
|
||||
message="Unit Cost es obligatorio",
|
||||
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
# 7. Validar description.description_spanish
|
||||
if line.description:
|
||||
if not line.description.description_spanish or not line.description.description_spanish.strip():
|
||||
if (
|
||||
not line.description.description_spanish
|
||||
or not line.description.description_spanish.strip()
|
||||
):
|
||||
errors.add_error(
|
||||
field="description.description_spanish",
|
||||
message="Description in Spanish es obligatorio",
|
||||
solution="Proporciona una descripción del item en español",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
else:
|
||||
errors.add_error(
|
||||
field="description.description_spanish",
|
||||
message="Description in Spanish es obligatorio",
|
||||
solution="Proporciona una descripción del item en español",
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
# 8. Validar customs.origin_country
|
||||
if not line.customs or not line.customs.origin_country:
|
||||
errors.add_error(
|
||||
field="customs.origin_country",
|
||||
message="País de Origen es obligatorio",
|
||||
solution="Selecciona el país de origen del item",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 9. Validar customs.fraction_type
|
||||
if not line.customs or not line.customs.fraction_type:
|
||||
errors.add_error(
|
||||
field="customs.fraction_type",
|
||||
message="Tipo de Tarifa es obligatorio",
|
||||
solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)",
|
||||
code="REQUIRED"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Validaciones para actualización de items vía API.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from core.exceptions import ErrorCollector
|
||||
from api.v1.modules.a76.items.line_items.schemas import LineItemUpdate
|
||||
@@ -13,12 +14,12 @@ def validate_update(
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
errors: ErrorCollector,
|
||||
invoice_id: int = None
|
||||
invoice_id: int = None,
|
||||
) -> None:
|
||||
"""
|
||||
Validaciones para actualizar LineItems vía API.
|
||||
Incluye todas las validaciones de negocio de Clarion.
|
||||
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
line: Datos del line item a actualizar
|
||||
@@ -33,36 +34,27 @@ def validate_update(
|
||||
field="line_number",
|
||||
message="El número de línea no puede estar vacío",
|
||||
solution="Proporciona un número de línea válido",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
# 2. Validar part_number_id si se proporciona
|
||||
if line.part_number_id is not None and not line.part_number_id:
|
||||
errors.add_error(
|
||||
field="part_number_id",
|
||||
message="Part Number ID no puede estar vacío",
|
||||
solution="Selecciona un número de parte válido del catálogo",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 3. Validar class_id si se proporciona
|
||||
|
||||
# 2. Validar class_id si se proporciona
|
||||
if line.class_id is not None and not line.class_id:
|
||||
errors.add_error(
|
||||
field="class_id",
|
||||
message="Clase (ID) no puede estar vacío",
|
||||
solution="Selecciona una clasificación válida del catálogo",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
# 4. Validar unit_of_measure si se proporciona
|
||||
if line.unit_of_measure is not None and not line.unit_of_measure:
|
||||
errors.add_error(
|
||||
field="unit_of_measure",
|
||||
message="U.M. no puede estar vacío",
|
||||
solution="Proporciona una unidad de medida válida",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
# 5. Validar cantidad si se proporciona
|
||||
if line.quantity:
|
||||
# Si se proporciona el objeto quantity, validar que quantity.quantity sea válido
|
||||
@@ -72,7 +64,7 @@ def validate_update(
|
||||
field="quantity.quantity",
|
||||
message="Quantity debe ser mayor a cero",
|
||||
solution="Proporciona una cantidad válida",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
else:
|
||||
# Si se proporciona quantity pero quantity.quantity es None, es requerido
|
||||
@@ -80,9 +72,9 @@ def validate_update(
|
||||
field="quantity.quantity",
|
||||
message="Quantity es obligatorio",
|
||||
solution="Proporciona una cantidad mayor a 0",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
# 6. Validar peso neto si se proporciona
|
||||
if line.quantity and line.quantity.net_weight is not None:
|
||||
if line.quantity.net_weight <= 0:
|
||||
@@ -90,25 +82,25 @@ def validate_update(
|
||||
field="quantity.net_weight",
|
||||
message="Net Weight debe ser mayor a cero",
|
||||
solution="Proporciona un peso neto válido",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
|
||||
|
||||
# 7. Validar costo unitario si se proporciona financial (excepto subpartidas)
|
||||
if line.financial:
|
||||
is_subitem = line.fa_data and line.fa_data.is_subitem if line.fa_data else False
|
||||
|
||||
|
||||
if not is_subitem:
|
||||
has_cost = (
|
||||
line.financial.unit_cost_usd or
|
||||
line.financial.unit_cost_mxn or
|
||||
line.financial.unit_cost_capture
|
||||
line.financial.unit_cost_usd
|
||||
or line.financial.unit_cost_mxn
|
||||
or line.financial.unit_cost_capture
|
||||
)
|
||||
if not has_cost:
|
||||
errors.add_error(
|
||||
field="financial.unit_cost",
|
||||
message="Unit Cost es obligatorio",
|
||||
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
# Validar que sean positivos
|
||||
if line.financial.unit_cost_usd is not None:
|
||||
@@ -117,7 +109,7 @@ def validate_update(
|
||||
field="financial.unit_cost_usd",
|
||||
message="Unit Cost (USD) debe ser mayor a cero",
|
||||
solution="Proporciona un costo unitario válido",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
if line.financial.unit_cost_mxn is not None:
|
||||
if line.financial.unit_cost_mxn <= 0:
|
||||
@@ -125,7 +117,7 @@ def validate_update(
|
||||
field="financial.unit_cost_mxn",
|
||||
message="Unit Cost (MXN) debe ser mayor a cero",
|
||||
solution="Proporciona un costo unitario válido",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
if line.financial.unit_cost_capture is not None:
|
||||
if line.financial.unit_cost_capture <= 0:
|
||||
@@ -133,19 +125,30 @@ def validate_update(
|
||||
field="financial.unit_cost_capture",
|
||||
message="Unit Cost (Captura) debe ser mayor a cero",
|
||||
solution="Proporciona un costo unitario válido",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
|
||||
|
||||
# 8. Validar datos aduanales si se proporcionan
|
||||
if line.customs:
|
||||
# Validar país de origen
|
||||
if line.customs.origin_country is not None and not line.customs.origin_country:
|
||||
errors.add_error(
|
||||
field="customs.origin_country",
|
||||
message="Origin Country no puede estar vacío",
|
||||
solution="Selecciona el país de origen del item",
|
||||
code="REQUIRED"
|
||||
)
|
||||
# Validar país de origen (OBLIGATORIO)
|
||||
if line.customs.origin_country is not None:
|
||||
if not line.customs.origin_country:
|
||||
errors.add_error(
|
||||
field="customs.origin_country",
|
||||
message="País de Origen es obligatorio",
|
||||
solution="Selecciona el país de origen del item",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# Validar tipo de tarifa (OBLIGATORIO)
|
||||
if line.customs.fraction_type is not None:
|
||||
if not line.customs.fraction_type:
|
||||
errors.add_error(
|
||||
field="customs.fraction_type",
|
||||
message="Tipo de Tarifa es obligatorio",
|
||||
solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# Validar preferencia arancelaria
|
||||
if line.customs.preference is not None and not line.customs.preference:
|
||||
@@ -153,46 +156,53 @@ def validate_update(
|
||||
field="customs.preference",
|
||||
message="La preferencia arancelaria no puede estar vacía",
|
||||
solution="Selecciona la preferencia arancelaria",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
# Validar formato de pago de impuestos
|
||||
if line.customs.tax_paid:
|
||||
val_tax = line.customs.tax_paid.upper()
|
||||
if val_tax not in ['SI', 'NO', 'S', 'N']:
|
||||
if val_tax not in ["SI", "NO", "S", "N"]:
|
||||
errors.add_error(
|
||||
field="customs.tax_paid",
|
||||
message="El valor de pago de impuesto debe ser SI/NO o S/N",
|
||||
solution="Proporciona un valor válido: SI, NO, S o N",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
|
||||
|
||||
# Validar forma de pago si existe
|
||||
if line.customs.payment_form:
|
||||
from api.v1.modules.a76.general_catalogs.forms_of_payment.models import PaymentForm
|
||||
|
||||
payment = db.query(PaymentForm).filter(
|
||||
PaymentForm.code == line.customs.payment_form,
|
||||
PaymentForm.tenant_id == tenant_id
|
||||
).first()
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.forms_of_payment.models import (
|
||||
PaymentForm,
|
||||
)
|
||||
|
||||
payment = (
|
||||
db.query(PaymentForm)
|
||||
.filter(
|
||||
PaymentForm.code == line.customs.payment_form,
|
||||
PaymentForm.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not payment:
|
||||
errors.add_error(
|
||||
field="customs.payment_form",
|
||||
message=f"La forma de pago '{line.customs.payment_form}' no es válida",
|
||||
solution="Selecciona una forma de pago válida del catálogo",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
|
||||
# 9. Validar descripción en español si se proporciona
|
||||
# 9. Validar descripción en español (OBLIGATORIA)
|
||||
if line.description and hasattr(line.description, 'description_spanish'):
|
||||
if line.description.description_spanish is not None and not line.description.description_spanish:
|
||||
errors.add_error(
|
||||
field="description.description_spanish",
|
||||
message="La descripción en español no puede estar vacía",
|
||||
solution="Proporciona una descripción del item en español",
|
||||
code="REQUIRED"
|
||||
)
|
||||
if line.description.description_spanish is not None:
|
||||
if not line.description.description_spanish.strip():
|
||||
errors.add_error(
|
||||
field="description.description_spanish",
|
||||
message="Descripción en Español es obligatoria",
|
||||
solution="Proporciona una descripción del item en español",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 10. Validar subpartidas si se actualizan
|
||||
if line.fa_data and line.fa_data.is_subitem:
|
||||
@@ -202,30 +212,35 @@ def validate_update(
|
||||
field="fa_data.main_line_id",
|
||||
message="La subpartida debe tener asignada una partida principal",
|
||||
solution="Selecciona la partida principal de esta subpartida",
|
||||
code="REQUIRED"
|
||||
code="REQUIRED",
|
||||
)
|
||||
elif invoice_id:
|
||||
# Validar que la partida principal exista en la misma factura
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
parent = db.query(LineItem).join(LineItem.item).filter(
|
||||
LineItem.line_number == line.fa_data.main_line_id,
|
||||
Item.invoice_id == invoice_id,
|
||||
LineItem.company_id == company_id
|
||||
).first()
|
||||
|
||||
|
||||
parent = (
|
||||
db.query(LineItem)
|
||||
.join(LineItem.item)
|
||||
.filter(
|
||||
LineItem.line_number == line.fa_data.main_line_id,
|
||||
Item.invoice_id == invoice_id,
|
||||
LineItem.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not parent:
|
||||
errors.add_error(
|
||||
field="fa_data.main_line_id",
|
||||
message=f"La partida principal {line.fa_data.main_line_id} no existe en esta factura",
|
||||
solution="Verifica el número de la partida principal",
|
||||
code="NOT_FOUND"
|
||||
code="NOT_FOUND",
|
||||
)
|
||||
elif parent.fa_data and parent.fa_data.is_subitem:
|
||||
errors.add_error(
|
||||
field="fa_data.main_line_id",
|
||||
message="La partida principal no puede ser otra subpartida",
|
||||
solution="Selecciona una partida normal como principal",
|
||||
code="INVALID_VALUE"
|
||||
code="INVALID_VALUE",
|
||||
)
|
||||
|
||||
@@ -40,11 +40,11 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
|
||||
|
||||
# Part identification
|
||||
part_number: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("a76.parts.id")
|
||||
part_number: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("a76.parts.id")
|
||||
) # NUMPARTE
|
||||
component_part_number: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("a76.parts.id")
|
||||
component_part_number: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("a76.parts.id")
|
||||
) # NUMPARTECOM
|
||||
class_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("a76.classes.id")
|
||||
|
||||
@@ -44,22 +44,22 @@ from api.v1.modules.a24.fa.fa_item_lines.dto import (
|
||||
class LineItemBase(BaseModel):
|
||||
"""Base schema for line items"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
line_number: int = Field(..., description="Line number")
|
||||
|
||||
# Part identification
|
||||
part_number_id: Optional[int] = Field(None, description="Part number")
|
||||
part_number_id: Optional[int] = Field(
|
||||
None, description="Part number", alias="part_number", serialization_alias="part_number_id"
|
||||
)
|
||||
component_part_number_id: Optional[int] = Field(
|
||||
None, description="Component part number"
|
||||
None, description="Component part number", alias="component_part_number", serialization_alias="component_part_number_id"
|
||||
)
|
||||
class_id: Optional[int] = Field(None, description="Class code")
|
||||
|
||||
# Unit of measure
|
||||
unit_of_measure: Optional[int] = Field(
|
||||
None, description="Unit of measure"
|
||||
)
|
||||
alternate_unit: Optional[int] = Field(
|
||||
None, description="Alternate unit"
|
||||
)
|
||||
unit_of_measure: Optional[int] = Field(None, description="Unit of measure")
|
||||
alternate_unit: Optional[int] = Field(None, description="Alternate unit")
|
||||
uma_key: Optional[str] = Field(None, max_length=2, description="UMA key")
|
||||
auxiliary_unit: Optional[str] = Field(
|
||||
None, max_length=5, description="Auxiliary unit"
|
||||
@@ -257,6 +257,12 @@ class LineItemResponse(LineItemBase):
|
||||
if hasattr(data, key):
|
||||
result[key] = getattr(data, key)
|
||||
|
||||
# Map model field names to schema field names for aliased fields
|
||||
if hasattr(data, "part_number"):
|
||||
result["part_number_id"] = data.part_number
|
||||
if hasattr(data, "component_part_number"):
|
||||
result["component_part_number_id"] = data.component_part_number
|
||||
|
||||
# Extract class info
|
||||
if hasattr(data, "class_info") and data.class_info is not None:
|
||||
result["class_code"] = data.class_info.class_code
|
||||
|
||||
@@ -4,6 +4,8 @@ from sqlalchemy import String, Integer, Numeric, SmallInteger, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from core.database import Base
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.packages.models import Package
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..line_items.models import LineItem
|
||||
|
||||
@@ -34,17 +36,17 @@ class LineQuantity(Base):
|
||||
serial_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_SERIES/CANT_SERIESDEF
|
||||
|
||||
# Weight
|
||||
weight_unit: Mapped[Optional[str]] = mapped_column(String(3)) # 'KG' o 'LB'
|
||||
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESONETO
|
||||
gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO
|
||||
|
||||
|
||||
# Packaging
|
||||
package_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVEBULTOS
|
||||
package_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.packages.id"))
|
||||
package_quantity: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS
|
||||
package_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCBULTOS
|
||||
container_quantity: Mapped[Optional[int]] = mapped_column(SmallInteger) # CANTBULCONT
|
||||
container_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCCONTENEDOR
|
||||
box_count: Mapped[Optional[str]] = mapped_column(String(30)) # NOCAJAS
|
||||
|
||||
# Relationship (one-to-one)
|
||||
line: Mapped["LineItem"] = relationship(back_populates="quantity")
|
||||
line: Mapped["LineItem"] = relationship(back_populates="quantity")
|
||||
package_info: Mapped[Optional["Package"]] = relationship(Package)
|
||||
@@ -22,14 +22,12 @@ class LineQuantityBase(BaseModel):
|
||||
serial_count: Optional[int] = Field(None, description="Serial count (CANT_SERIES/CANT_SERIESDEF)")
|
||||
|
||||
# Weight
|
||||
weight_unit: Optional[str] = Field(None, max_length=3, description="Weight unit ('KG' or 'LB')")
|
||||
net_weight: Optional[Decimal] = Field(None, description="Net weight (PESONETO)")
|
||||
gross_weight: Optional[Decimal] = Field(None, description="Gross weight (PESOBRUTO)")
|
||||
|
||||
# Packaging
|
||||
package_key: Optional[str] = Field(None, max_length=5, description="Package key (CLAVEBULTOS)")
|
||||
package_id: Optional[int] = Field(None, description="Package ID (GBultos)")
|
||||
package_quantity: Optional[int] = Field(None, description="Package quantity (CANTBULTOS)")
|
||||
package_description: Optional[str] = Field(None, max_length=40, description="Package description (DESCBULTOS)")
|
||||
container_quantity: Optional[int] = Field(None, description="Container quantity (CANTBULCONT)")
|
||||
container_description: Optional[str] = Field(None, max_length=40, description="Container description (DESCCONTENEDOR)")
|
||||
box_count: Optional[str] = Field(None, max_length=30, description="Box count (NOCAJAS)")
|
||||
|
||||
@@ -33,7 +33,7 @@ from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
from .models import Item
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -158,79 +158,86 @@ class ItemService:
|
||||
company_id: int,
|
||||
) -> Item:
|
||||
"""Create a new item with all related nested data (multiple lines)"""
|
||||
|
||||
|
||||
# Validaciones con ErrorCollector
|
||||
errors = ErrorCollector()
|
||||
|
||||
|
||||
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
|
||||
invoice = None
|
||||
if item_data.invoice_id:
|
||||
invoice = db.query(InvoiceHeader).filter(
|
||||
InvoiceHeader.id == item_data.invoice_id,
|
||||
InvoiceHeader.tenant_id == tenant_id,
|
||||
InvoiceHeader.company_id == company_id
|
||||
).first()
|
||||
|
||||
invoice = (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.id == item_data.invoice_id,
|
||||
InvoiceHeader.tenant_id == tenant_id,
|
||||
InvoiceHeader.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not invoice:
|
||||
errors.add_error(
|
||||
field="invoice_id",
|
||||
message="La factura especificada no existe",
|
||||
code="NOT_FOUND",
|
||||
value=str(item_data.invoice_id)
|
||||
value=str(item_data.invoice_id),
|
||||
)
|
||||
|
||||
|
||||
# Validar cada line item que se va a crear
|
||||
if item_data.lines:
|
||||
for idx, line_data in enumerate(item_data.lines):
|
||||
# Convertir a LineItemCreate para validar
|
||||
line_create = LineItemCreate(**line_data.model_dump())
|
||||
|
||||
|
||||
validate_create(db, line_create, tenant_id, company_id, errors)
|
||||
|
||||
|
||||
# Validaciones adicionales específicas del negocio
|
||||
|
||||
|
||||
# Validar apóstrofes en número de parte
|
||||
if line_data.part_number_id and "'" in str(line_data.part_number_id):
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].part_number_id",
|
||||
field=f"lines[{idx}].part_number",
|
||||
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
|
||||
code="WARNING_APOSTROPHE"
|
||||
code="WARNING_APOSTROPHE",
|
||||
)
|
||||
|
||||
|
||||
# Validar tipo de partida
|
||||
if hasattr(line_data, 'item_type'):
|
||||
if hasattr(line_data, "item_type"):
|
||||
tipo_partida = line_data.item_type
|
||||
if tipo_partida and tipo_partida not in ['N', 'S']:
|
||||
if tipo_partida and tipo_partida not in ["N", "S"]:
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].item_type",
|
||||
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
|
||||
code="INVALID_ITEM_TYPE",
|
||||
value=str(tipo_partida)
|
||||
value=str(tipo_partida),
|
||||
)
|
||||
|
||||
|
||||
# Si es subpartida (S), debe tener partida principal
|
||||
if tipo_partida == 'S':
|
||||
if not hasattr(line_data, 'main_line_id') or not line_data.main_line_id:
|
||||
if tipo_partida == "S":
|
||||
if (
|
||||
not hasattr(line_data, "main_line_id")
|
||||
or not line_data.main_line_id
|
||||
):
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].main_line_id",
|
||||
message="Las subpartidas (tipo 'S') deben tener una partida principal",
|
||||
code="MISSING_MAIN_LINE"
|
||||
code="MISSING_MAIN_LINE",
|
||||
)
|
||||
|
||||
|
||||
# Validar que el line_number sea consecutivo (si se especifica)
|
||||
if hasattr(line_data, 'line_number') and line_data.line_number:
|
||||
if hasattr(line_data, "line_number") and line_data.line_number:
|
||||
expected_line = idx + 1
|
||||
if line_data.line_number != expected_line:
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].line_number",
|
||||
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
|
||||
code="INVALID_LINE_SEQUENCE",
|
||||
value=str(line_data.line_number)
|
||||
value=str(line_data.line_number),
|
||||
)
|
||||
|
||||
|
||||
# Si hay errores, lanzar excepción ANTES de intentar crear
|
||||
errors.raise_if_errors("Error al crear el item")
|
||||
|
||||
|
||||
try:
|
||||
# Extract lines data
|
||||
lines_data = item_data.lines or []
|
||||
@@ -269,6 +276,12 @@ class ItemService:
|
||||
line_dict["tenant_id"] = tenant_id
|
||||
line_dict["company_id"] = company_id
|
||||
|
||||
# Map schema field names to model field names
|
||||
if "part_number_id" in line_dict:
|
||||
line_dict["part_number"] = line_dict.pop("part_number_id")
|
||||
if "component_part_number_id" in line_dict:
|
||||
line_dict["component_part_number"] = line_dict.pop("component_part_number_id")
|
||||
|
||||
# Create line item
|
||||
db_line = LineItem(**line_dict)
|
||||
db.add(db_line)
|
||||
@@ -311,7 +324,9 @@ class ItemService:
|
||||
|
||||
# Create FA data if provided
|
||||
if fa_data:
|
||||
fa_dict = fa_data.model_dump(exclude={"line_item_id"}) # Exclude line_item_id from DTO
|
||||
fa_dict = fa_data.model_dump(
|
||||
exclude={"line_item_id"}
|
||||
) # Exclude line_item_id from DTO
|
||||
fa_dict["id"] = db_line.id # FA table uses same ID as line item
|
||||
fa_dict["tenant_id"] = tenant_id
|
||||
fa_dict["company_id"] = company_id
|
||||
@@ -343,47 +358,54 @@ class ItemService:
|
||||
company_id: int,
|
||||
) -> Item:
|
||||
"""Update an item and optionally its nested data (multiple lines)"""
|
||||
|
||||
|
||||
# Get existing item
|
||||
db_item = ItemService.get_by_id(db, item_id, tenant_id, company_id)
|
||||
if not db_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
# Validaciones con ErrorCollector
|
||||
errors = ErrorCollector()
|
||||
|
||||
|
||||
# Si se está actualizando el invoice_id, validar la factura
|
||||
invoice = None
|
||||
if item_data.invoice_id:
|
||||
invoice = db.query(InvoiceHeader).filter(
|
||||
InvoiceHeader.id == item_data.invoice_id,
|
||||
InvoiceHeader.tenant_id == tenant_id,
|
||||
InvoiceHeader.company_id == company_id
|
||||
).first()
|
||||
|
||||
invoice = (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.id == item_data.invoice_id,
|
||||
InvoiceHeader.tenant_id == tenant_id,
|
||||
InvoiceHeader.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not invoice:
|
||||
errors.add_error(
|
||||
field="invoice_id",
|
||||
message="La factura especificada no existe",
|
||||
code="NOT_FOUND",
|
||||
value=str(item_data.invoice_id)
|
||||
value=str(item_data.invoice_id),
|
||||
)
|
||||
else:
|
||||
# Si no se está actualizando invoice_id, obtener la factura actual por invoice_id
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
invoice = db.query(InvoiceHeader).filter(
|
||||
InvoiceHeader.id == db_item.invoice_id
|
||||
).first()
|
||||
|
||||
|
||||
invoice = (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(InvoiceHeader.id == db_item.invoice_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Validar cada line item que se va a actualizar
|
||||
if item_data.lines:
|
||||
for idx, line_data in enumerate(item_data.lines):
|
||||
# Si el line tiene ID, es actualización; si no, es creación
|
||||
if hasattr(line_data, 'id') and line_data.id:
|
||||
if hasattr(line_data, "id") and line_data.id:
|
||||
# Buscar el line item existente
|
||||
existing_line = next(
|
||||
(line for line in db_item.lines if line.id == line_data.id),
|
||||
None
|
||||
None,
|
||||
)
|
||||
if existing_line:
|
||||
# Convertir a LineItemUpdate para validar
|
||||
@@ -393,43 +415,46 @@ class ItemService:
|
||||
# Es un nuevo line item, validar como creación
|
||||
line_create = LineItemCreate(**line_data.model_dump())
|
||||
validate_create(db, line_create, tenant_id, company_id, errors)
|
||||
|
||||
|
||||
# Validaciones adicionales específicas del negocio
|
||||
# (Aplican tanto para crear como actualizar)
|
||||
|
||||
|
||||
# Validar apóstrofes en número de parte
|
||||
if line_data.part_number_id and "'" in str(line_data.part_number_id):
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].part_number_id",
|
||||
field=f"lines[{idx}].part_number",
|
||||
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
|
||||
solution=None,
|
||||
code="WARNING_APOSTROPHE"
|
||||
code="WARNING_APOSTROPHE",
|
||||
)
|
||||
|
||||
|
||||
# Validar tipo de partida
|
||||
if hasattr(line_data, 'item_type'):
|
||||
if hasattr(line_data, "item_type"):
|
||||
tipo_partida = line_data.item_type
|
||||
if tipo_partida and tipo_partida not in ['N', 'S']:
|
||||
if tipo_partida and tipo_partida not in ["N", "S"]:
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].item_type",
|
||||
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
|
||||
solution=None,
|
||||
code="INVALID_ITEM_TYPE",
|
||||
value=str(tipo_partida)
|
||||
value=str(tipo_partida),
|
||||
)
|
||||
|
||||
|
||||
# Si es subpartida (S), debe tener partida principal
|
||||
if tipo_partida == 'S':
|
||||
if not hasattr(line_data, 'main_line_id') or not line_data.main_line_id:
|
||||
if tipo_partida == "S":
|
||||
if (
|
||||
not hasattr(line_data, "main_line_id")
|
||||
or not line_data.main_line_id
|
||||
):
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].main_line_id",
|
||||
message="Las subpartidas (tipo 'S') deben tener una partida principal",
|
||||
solution=None,
|
||||
code="MISSING_MAIN_LINE"
|
||||
code="MISSING_MAIN_LINE",
|
||||
)
|
||||
|
||||
|
||||
# Validar que el line_number sea consecutivo (si se especifica)
|
||||
if hasattr(line_data, 'line_number') and line_data.line_number:
|
||||
if hasattr(line_data, "line_number") and line_data.line_number:
|
||||
expected_line = idx + 1
|
||||
if line_data.line_number != expected_line:
|
||||
errors.add_error(
|
||||
@@ -437,12 +462,12 @@ class ItemService:
|
||||
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
|
||||
solution=None,
|
||||
code="INVALID_LINE_SEQUENCE",
|
||||
value=str(line_data.line_number)
|
||||
value=str(line_data.line_number),
|
||||
)
|
||||
|
||||
|
||||
# Si hay errores, lanzar excepción ANTES de actualizar
|
||||
errors.raise_if_errors("Error al actualizar el item")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
# Extract lines data
|
||||
@@ -485,6 +510,12 @@ class ItemService:
|
||||
line_dict["tenant_id"] = tenant_id
|
||||
line_dict["company_id"] = company_id
|
||||
|
||||
# Map schema field names to model field names
|
||||
if "part_number_id" in line_dict:
|
||||
line_dict["part_number"] = line_dict.pop("part_number_id")
|
||||
if "component_part_number_id" in line_dict:
|
||||
line_dict["component_part_number"] = line_dict.pop("component_part_number_id")
|
||||
|
||||
db_line = LineItem(**line_dict)
|
||||
db.add(db_line)
|
||||
db.flush()
|
||||
@@ -519,7 +550,9 @@ class ItemService:
|
||||
|
||||
# Create FA data if provided
|
||||
if fa_data is not None:
|
||||
fa_dict = fa_data.model_dump(exclude_unset=True, exclude={"line_item_id"})
|
||||
fa_dict = fa_data.model_dump(
|
||||
exclude_unset=True, exclude={"line_item_id"}
|
||||
)
|
||||
fa_dict["id"] = db_line.id # FA table uses same ID as line item
|
||||
fa_dict["tenant_id"] = tenant_id
|
||||
fa_dict["company_id"] = company_id
|
||||
|
||||
@@ -16,7 +16,7 @@ router = TenantCRUDRoutes(
|
||||
prefix="", # No prefix here, will be added in main router
|
||||
tags=["a76 / pedimentos"], # Tag for Swagger documentation
|
||||
resource_name="Pedimento",
|
||||
id_name="pedimento_id",
|
||||
id_name="id", # Use standard REST convention
|
||||
enable_list=True, # Enable GET / with pagination
|
||||
enable_filters=True, # Enable status, client_id, year filters
|
||||
default_page_size=50,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from celery.result import AsyncResult
|
||||
from core.celery_app import celery_app
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from .task import generar_pdf_aviso_consolidado_exp_async
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db)
|
||||
):
|
||||
task_result = AsyncResult(task_id, app=celery_app)
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"state": task_result.state,
|
||||
"result": None,
|
||||
"info": None
|
||||
}
|
||||
|
||||
if task_result.state == 'FAILURE':
|
||||
response["result"] = str(task_result.result)
|
||||
elif task_result.state == 'SUCCESS':
|
||||
response["result"] = task_result.result
|
||||
elif task_result.state == 'PROCESSING':
|
||||
response["info"] = task_result.info
|
||||
|
||||
return response
|
||||
|
||||
@router.post("/{invoice_id}/download-async")
|
||||
async def trigger_descarga_aviso_consolidado_exp(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(..., description="ID de la empresa"),
|
||||
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_aviso_consolidado_exp_async.delay(invoice_id, company_id)
|
||||
return {"task_id": task.id, "message": "Generación iniciada"}
|
||||
@@ -0,0 +1,658 @@
|
||||
|
||||
import shutil
|
||||
import base64
|
||||
import pdfkit
|
||||
from pathlib import Path
|
||||
from typing import Tuple, List, Callable, Optional, Dict, Any
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from io import BytesIO
|
||||
import pdf417gen
|
||||
|
||||
# --- MODELOS ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
||||
from api.v1.modules.a76.pedmientos.models.pedimento_validation import PedimentoValidation
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
|
||||
# --- SCHEMAS FOR TEMPLATE CONTEXT ---
|
||||
class EmpresaSchema(BaseModel):
|
||||
rfc: str
|
||||
razon_social: str
|
||||
direccion_completa: str
|
||||
tax_id: Optional[str] = None # Extra info just in case
|
||||
|
||||
class PersonaSchema(BaseModel):
|
||||
nombre: str
|
||||
rfc: str
|
||||
curp: str
|
||||
|
||||
class AvisoSchema(BaseModel):
|
||||
pedimento_completo: str
|
||||
tipo_operacion: str
|
||||
clave_pedimento: str
|
||||
acus_valor: str
|
||||
aduana_seccion: str
|
||||
numero_remesa: str
|
||||
peso_bruto: str
|
||||
codigo_aceptacion: str
|
||||
codigo_barras_b64: Optional[str] = None
|
||||
clave_seccion: str
|
||||
marcas_numeros_bultos: str
|
||||
candados: List[str]
|
||||
vehiculo_placas: str
|
||||
vehiculo_tipo: str
|
||||
observaciones: str
|
||||
numero_certificado: str
|
||||
tipo_documento: str # NEW: Invoice Type
|
||||
firma_electronica: str
|
||||
|
||||
class AvisoConsolidadoContext(BaseModel):
|
||||
aviso: AvisoSchema
|
||||
empresa: EmpresaSchema
|
||||
agente: PersonaSchema
|
||||
mandatario: PersonaSchema
|
||||
|
||||
class AvisoConsolidadoExportacionService:
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
self.template = self.jinja_env.get_template('avcon_exp.html')
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> AvisoConsolidadoContext:
|
||||
try:
|
||||
with open("/tmp/barcode_debug.log", "a") as f: f.write(f"ENTER obtener_datos ID={invoice_id}\n")
|
||||
if progress_callback: progress_callback(10, "Buscando factura...")
|
||||
|
||||
# Fetch minimal real data if possible, or use placeholders as requested
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header:
|
||||
# We can't strictly raise 404 if we want to support testing with non-existent IDs for pure UI check,
|
||||
# but valid workflow requires a real invoice. Raising 404 is better practice.
|
||||
raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
|
||||
if progress_callback: progress_callback(30, "Preparando datos...")
|
||||
|
||||
# --- FETCHING REAL DATA ---
|
||||
|
||||
# 1. Compliance & Pedimento
|
||||
compliance = header.compliance_mx
|
||||
pedimento = None
|
||||
if compliance and compliance.pedimento_id:
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
|
||||
|
||||
# Pedimento Completo Construction
|
||||
pedimento_txt = "S/P"
|
||||
clave_ped = ""
|
||||
if pedimento:
|
||||
# Format: YY OFF LIC NUMBER
|
||||
year = pedimento.year or ""
|
||||
office = pedimento.customs_office or ""
|
||||
lic = pedimento.license or ""
|
||||
num = pedimento.pedimento_number or ""
|
||||
pedimento_txt = f"{year} {office} {lic} {num}"
|
||||
clave_ped = pedimento.pedimento_code or ""
|
||||
|
||||
# 2. Importer/Exporter Data (Clarion 100% Match)
|
||||
# Logic:
|
||||
# IF EqiFex:EsCambioRegimen = 'S' THEN
|
||||
# CliPro:Cliente = EqiFex:VendidoA
|
||||
# ELSE
|
||||
# CliPro:Cliente = EqiFex:Proveedor
|
||||
# END
|
||||
|
||||
target_entity_data = {
|
||||
"rfc": "",
|
||||
"razon_social": "",
|
||||
"direccion_completa": "DOMICILIO NO REGISTRADO"
|
||||
}
|
||||
|
||||
target_client_id = None
|
||||
|
||||
if compliance:
|
||||
if compliance.is_regime_change:
|
||||
target_client_id = compliance.sold_to_id
|
||||
else:
|
||||
target_client_id = compliance.provider_id
|
||||
|
||||
if target_client_id:
|
||||
client_obj = db.query(ClientProvider).filter(ClientProvider.id == target_client_id).first()
|
||||
if client_obj:
|
||||
# Fetch Address
|
||||
c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == target_client_id).first()
|
||||
# Fetch Fiscal Data (RFC)
|
||||
c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == target_client_id).first()
|
||||
|
||||
c_rfc = ""
|
||||
if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id
|
||||
elif hasattr(client_obj, 'rfc'): c_rfc = client_obj.rfc
|
||||
|
||||
c_dir_str = "DOMICILIO NO REGISTRADO"
|
||||
if c_addr:
|
||||
parts_c = []
|
||||
if c_addr.streets: parts_c.append(c_addr.streets)
|
||||
if c_addr.exterior_number: parts_c.append(f"No. {c_addr.exterior_number}")
|
||||
if c_addr.interior_number: parts_c.append(f"Int. {c_addr.interior_number}")
|
||||
if c_addr.neighborhood: parts_c.append(f"Col. {c_addr.neighborhood}")
|
||||
if c_addr.postal_code: parts_c.append(f"CP {c_addr.postal_code}")
|
||||
if c_addr.city: parts_c.append(c_addr.city)
|
||||
if c_addr.state: parts_c.append(c_addr.state)
|
||||
if c_addr.country: parts_c.append(c_addr.country)
|
||||
|
||||
if parts_c:
|
||||
c_dir_str = ", ".join(parts_c).upper()
|
||||
|
||||
target_entity_data = {
|
||||
"rfc": c_rfc or "",
|
||||
"razon_social": client_obj.name or client_obj.short_name or "",
|
||||
"direccion_completa": c_dir_str
|
||||
}
|
||||
|
||||
empresa = EmpresaSchema(
|
||||
rfc=target_entity_data["rfc"],
|
||||
razon_social=target_entity_data["razon_social"],
|
||||
direccion_completa=target_entity_data["direccion_completa"]
|
||||
)
|
||||
|
||||
# Destino/Origen (Clarion: Loc:DestinoOrigen = 'Destino/Origen: '&EqiFex:DestinoOrigenCOVE)
|
||||
destino_origen_str = ""
|
||||
if compliance and compliance.origin_destination_cove:
|
||||
# Assuming enum value or string is what we want.
|
||||
# If it's an Enum object, accessing .value is safer.
|
||||
val = compliance.origin_destination_cove
|
||||
if hasattr(val, 'value'): val = val.value
|
||||
destino_origen_str = f"Destino/Origen: {val}"
|
||||
|
||||
|
||||
|
||||
# 3. Datos Aviso (Invoice/Compliance/Logistics/Financials)
|
||||
financials = header.financials
|
||||
logistics = header.logistics
|
||||
|
||||
# Fetch Items associated with this invoice (MOVED UP FOR WEIGHT CALCULATION)
|
||||
items = db.query(Item).filter(Item.invoice_id == invoice_id).all()
|
||||
|
||||
# Peso Bruto
|
||||
peso_bruto_val = "0.0"
|
||||
calculated_gross_weight = 0.0
|
||||
|
||||
# Calculate sum from items first
|
||||
if items:
|
||||
for item in items:
|
||||
if item.lines:
|
||||
for line in item.lines:
|
||||
if line.quantity and line.quantity.gross_weight:
|
||||
try:
|
||||
calculated_gross_weight += float(line.quantity.gross_weight)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if financials and financials.gross_weight and float(financials.gross_weight) > 0:
|
||||
peso_bruto_val = f"{financials.gross_weight:,.2f}"
|
||||
elif calculated_gross_weight > 0:
|
||||
peso_bruto_val = f"{calculated_gross_weight:,.2f}"
|
||||
elif pedimento and pedimento.gross_weight:
|
||||
peso_bruto_val = f"{pedimento.gross_weight:,.2f}"
|
||||
|
||||
# Candados (Seals)
|
||||
candados_list = []
|
||||
if logistics and logistics.seal_number:
|
||||
# Split by comma or space if multiple
|
||||
candados_list = [s.strip() for s in logistics.seal_number.replace(',', ' ').split() if s.strip()]
|
||||
|
||||
# Vehiculo / Contenedor Logic (Replicating Clarion)
|
||||
# Clarion Logic:
|
||||
# 1. Check for explicit `DatosVehiculo`.
|
||||
# 2. Check for `EsFerrocarril`.
|
||||
# 3. Build string from Trailer + Transport.
|
||||
|
||||
# Since we don't have a direct "DatosVehiculo" text field in Logistics (usually), we construct it.
|
||||
# However, we'll check if `license_plate` is being used as a catch-all or if we should build it.
|
||||
|
||||
vehiculo_str = ""
|
||||
tipo_display = ""
|
||||
|
||||
# Basic Logistics Data
|
||||
l_trailer = logistics.trailer_num.strip() if (logistics and logistics.trailer_num) else ""
|
||||
l_placa = logistics.license_plate.strip() if (logistics and logistics.license_plate) else ""
|
||||
l_trans_type = logistics.transport_type.strip() if (logistics and logistics.transport_type) else ""
|
||||
l_vehicle_num = logistics.vehicle_num.strip() if (logistics and logistics.vehicle_num) else ""
|
||||
l_container_types = logistics.container_types.strip() if (logistics and logistics.container_types) else ""
|
||||
|
||||
# Check for Ferrocarril explicitly
|
||||
is_rail = False
|
||||
if "FERRO" in l_trans_type.upper() or "RAIL" in l_trans_type.upper():
|
||||
is_rail = True
|
||||
|
||||
# --- LOGIC NUMERO / TIPO ---
|
||||
final_numero = ""
|
||||
final_tipo = ""
|
||||
|
||||
# 1. Container Logic (Clarion: ContenedoresTipo parsing)
|
||||
# Format expected: "CONTENEDOR|TIPO,CONTENEDOR2|TIPO2..."
|
||||
if l_container_types:
|
||||
# Take first container
|
||||
first_cont_group = l_container_types.split(',')[0] # Split by comma
|
||||
if '|' in first_cont_group:
|
||||
parts = first_cont_group.split('|')
|
||||
final_numero = parts[0].strip()
|
||||
final_tipo = parts[1].strip()
|
||||
else:
|
||||
# Fallback if no pipe
|
||||
final_numero = first_cont_group.strip()
|
||||
final_tipo = "CONT" # Default?
|
||||
|
||||
# 2. Transport = Container Logic
|
||||
elif l_trans_type.upper() == "CONTENEDOR":
|
||||
# Use trailer num as container num
|
||||
if l_trailer:
|
||||
final_numero = l_trailer
|
||||
# Try to find Type? In Clarion it does a DB lookup into GTrailers.ClaveContenedor
|
||||
# We assume 'CONTENEDOR' or a default if not found in simplified logic
|
||||
final_tipo = "CONT"
|
||||
|
||||
# 3. Trailer/General Logic (Fallback)
|
||||
if not final_numero:
|
||||
# Construct valid string
|
||||
parts_veh = []
|
||||
if l_trailer:
|
||||
parts_veh.append(f"TRAILER: {l_trailer}")
|
||||
if not tipo_display: tipo_display = "TRAILER"
|
||||
|
||||
if l_trans_type and l_trans_type.upper() != "NINGUNO":
|
||||
if is_rail:
|
||||
if l_vehicle_num:
|
||||
parts_veh.append(f"CONTENEDOR: {l_vehicle_num}")
|
||||
tipo_display = "FERROCARRIL"
|
||||
else:
|
||||
segment = l_trans_type
|
||||
if l_vehicle_num: segment += f": {l_vehicle_num}"
|
||||
parts_veh.append(segment)
|
||||
if not tipo_display: tipo_display = l_trans_type
|
||||
|
||||
if not parts_veh and l_placa:
|
||||
parts_veh.append(f"PLACAS: {l_placa}")
|
||||
|
||||
final_numero = ", ".join(parts_veh).upper()
|
||||
final_tipo = tipo_display.upper()
|
||||
|
||||
# Codigo de Aceptacion aka Acuse de Validacion
|
||||
codigo_aceptacion_val = ""
|
||||
if pedimento and pedimento.pedimento_validation:
|
||||
# Assuming relationship "pedimento_validation" exists on Pedimentos model (lazy loaded)
|
||||
# Or we can query it if relationship is scalar 'uselist=False'
|
||||
if pedimento.pedimento_validation.validation_ack:
|
||||
codigo_aceptacion_val = pedimento.pedimento_validation.validation_ack
|
||||
|
||||
aviso = AvisoSchema(
|
||||
pedimento_completo=pedimento_txt,
|
||||
tipo_operacion=header.operation_type.upper() if header.operation_type else "EXP",
|
||||
clave_pedimento=clave_ped,
|
||||
acus_valor=compliance.edocument.upper() if (compliance and compliance.edocument) else "",
|
||||
aduana_seccion=compliance.aduana if (compliance and compliance.aduana) else "",
|
||||
numero_remesa=str(compliance.remesa) if (compliance and compliance.remesa) else "",
|
||||
peso_bruto=peso_bruto_val,
|
||||
codigo_aceptacion=codigo_aceptacion_val,
|
||||
codigo_barras_b64=None,
|
||||
clave_seccion=compliance.aduana if (compliance and compliance.aduana) else "", # Using Aduana as Section Key
|
||||
marcas_numeros_bultos=f"{financials.bundle_count} BULTOS" if (financials and financials.bundle_count) else "1 BULTOS",
|
||||
candados=candados_list,
|
||||
vehiculo_placas=final_numero,
|
||||
vehiculo_tipo=final_tipo,
|
||||
observaciones=(header.observation_es or "") + ("\n" + destino_origen_str if destino_origen_str else ""),
|
||||
numero_certificado=compliance.certificate_number if (compliance and compliance.certificate_number) else "",
|
||||
tipo_documento=header.document_type or "FACTURA", # Default
|
||||
firma_electronica=compliance.electronic_signature if (compliance and compliance.electronic_signature) else ""
|
||||
)
|
||||
|
||||
# --- BARCODE GENERATION (PDF417) ---
|
||||
# Replicating Clarion "LLENADOCODIGODEBARRAS" logic
|
||||
try:
|
||||
# Debug Log
|
||||
debug_log = []
|
||||
debug_log.append(f"Processing Invoice {invoice_id}")
|
||||
|
||||
# 1. Patente (4 Digits) - From Pedimento or Compliance
|
||||
patente_txt = pedimento.license if pedimento and pedimento.license else ""
|
||||
if not patente_txt and pedimento_txt:
|
||||
# Fallback parsing "YY OFF LIC NUMBER" -> LIC is index 2 (0, 1, 2)
|
||||
try:
|
||||
parts = pedimento_txt.split()
|
||||
if len(parts) >= 3: patente_txt = parts[2]
|
||||
except: pass
|
||||
|
||||
# 2. Pedimento Number (7 Digits)
|
||||
pedimento_num = pedimento.pedimento_number if pedimento and pedimento.pedimento_number else ""
|
||||
if not pedimento_num and pedimento_txt:
|
||||
try:
|
||||
parts = pedimento_txt.split()
|
||||
if len(parts) >= 4: pedimento_num = parts[3]
|
||||
except: pass
|
||||
|
||||
# 3. Recinto (3 chars) - Default to 000 if invalid/missing as per Clarion 'ELSE LINEPRINT('000'...'
|
||||
# Clarion: Loc:Recinto = EqiFex:Recinto
|
||||
recinto_txt = "000"
|
||||
if compliance and compliance.enclosure:
|
||||
recinto_txt = compliance.enclosure[:3]
|
||||
if not recinto_txt: recinto_txt = "000"
|
||||
|
||||
# 4. E-Document
|
||||
edoc_txt = aviso.acus_valor # Already uppercased
|
||||
|
||||
# 5. Num Contenedor (Rail) or 000...
|
||||
# Clarion: IF Loc:EsFerrocarril = 'SI' ... LINEPRINT(CLIP(Loc:NumContenedor)) ELSE LINEPRINT('0000000000000')
|
||||
# We reused logic for 'vehiculo_placas' and 'vehiculo_tipo' earlier.
|
||||
# Let's re-evaluate "EsFerrocarril" logic safely
|
||||
is_rail_bar = False
|
||||
if "FERRO" in final_tipo.upper() or "RAIL" in final_tipo.upper():
|
||||
is_rail_bar = True
|
||||
|
||||
field_5 = "0000000000000"
|
||||
if is_rail_bar:
|
||||
# We extracted container into 'parts_veh' earlier but let's grab from raw if possible or from aviso?
|
||||
# In our logic above: "CONTENEDOR: {l_vehicle_num}" was added to textual description.
|
||||
# Let's use compliance.container_ids or logistics.vehicle_num
|
||||
c_num = logistics.vehicle_num if logistics and logistics.vehicle_num else ""
|
||||
if c_num: field_5 = c_num
|
||||
|
||||
# 6. Firma Electronica
|
||||
firma_txt = aviso.firma_electronica
|
||||
|
||||
# 7. Cantidad Comercial (Format @n015.3 -> 15 chars total, 3 decimals?)
|
||||
# We need to sum quantities.
|
||||
cant_total = 0.0
|
||||
|
||||
if items:
|
||||
for item in items:
|
||||
if item.lines:
|
||||
for line in item.lines:
|
||||
# Priority: Quantity (UMA or Standard)
|
||||
q = 0.0
|
||||
if line.quantity:
|
||||
try:
|
||||
if line.quantity.quantity_uma is not None:
|
||||
q = float(line.quantity.quantity_uma)
|
||||
elif line.quantity.quantity is not None:
|
||||
q = float(line.quantity.quantity)
|
||||
except (ValueError, TypeError):
|
||||
q = 0.0
|
||||
cant_total += q
|
||||
|
||||
# Format: 15 chars, 3 decimals? Actually Clarion LINEPRINT usually just prints the text.
|
||||
# Clarion 'CLIP(FORMAT(Loc:CantTotal,@n015.3))' removes spaces.
|
||||
cant_total_str = f"{cant_total:.3f}"
|
||||
|
||||
|
||||
|
||||
# 8. Valor Total Dlls
|
||||
# Clarion: LINEPRINT(FORMAT(Loc:ValorTotalDlls,@n012)) -> Integer? Or just standard?
|
||||
# Clarion @n012 usually means right justified or just specific length?
|
||||
# Code says: Loc:ValorTotalDlls = GSQLFile3.SQL3:C1 + (rounding logic).
|
||||
val_usd = 0.0
|
||||
if financials:
|
||||
try:
|
||||
# Use value_me (Foreign Currency) as primary source for USD amount
|
||||
if financials.value_me is not None:
|
||||
val_usd = float(financials.value_me)
|
||||
elif financials.value_mn is not None:
|
||||
# Fallback to MN if ME is missing (though technically incorrect for USD field, avoids crash)
|
||||
val_usd = float(financials.value_mn)
|
||||
except (ValueError, TypeError):
|
||||
val_usd = 0.0
|
||||
|
||||
# Clarion logic:
|
||||
# IF GSQLFile3.SQL3:C1 > 0 AND GSQLFile3.SQL3:C1 < 1 THEN
|
||||
# Loc:ValorTotalDlls = GSQLFile3.SQL3:C1 + (1 - GSQLFile3.SQL3:C1) (Result is 1.0)
|
||||
# ELSE ... ROUND(...,1) or Raw.
|
||||
|
||||
final_val_usd = val_usd
|
||||
if 0.0 < val_usd < 1.0:
|
||||
final_val_usd = 1.0
|
||||
elif (val_usd - int(val_usd)) > 0 and (val_usd - int(val_usd)) < 0.5:
|
||||
# Clarion: IF Loc:Decimal > 0 AND Loc:Decimal < 0.5 THEN Loc:ValorTotalDlls = ROUND(GSQLFile3.SQL3:C1,1)
|
||||
# Round to 1 decimal place? Or standard round? Python round matches generally.
|
||||
final_val_usd = round(val_usd, 1)
|
||||
|
||||
val_usd_str = f"{final_val_usd:.2f}"
|
||||
|
||||
# 9. Cant Embarques (Rail)
|
||||
field_9 = "000000000000"
|
||||
if is_rail_bar:
|
||||
# Logic for Cant Embarques?
|
||||
# Clarion: EqiFex:CantGuiasEmbarque
|
||||
# usage unknown in current DB. Defaulting to 0.
|
||||
pass
|
||||
|
||||
# 10. NIU / DTA (Rail)
|
||||
field_10 = "0000000000000"
|
||||
if is_rail_bar:
|
||||
# Clarion: EqiFex:NumeroNIU
|
||||
if compliance and compliance.niu:
|
||||
field_10 = compliance.niu
|
||||
|
||||
# 11. Remesa (4 chars)
|
||||
remesa_txt = str(compliance.remesa) if (compliance and compliance.remesa) else "0"
|
||||
|
||||
# 12. Filler
|
||||
field_12 = "00000000.000"
|
||||
|
||||
# Construct Line Prints (Text content for barcode)
|
||||
# Clarion LINEPRINT separates by NewLine? Or is it one long string?
|
||||
# "Glo:GeneraTXT" is a file. LINEPRINT appends a line.
|
||||
# So the Barcode Content is a multi-line string or specific format.
|
||||
# PDF417 normally encodes the full text block.
|
||||
|
||||
# 1. Patente (4 Digits)
|
||||
# Formatted: @P####P -> 4 digits.
|
||||
# Assuming simple string slice or pad.
|
||||
patente_formatted = f"{patente_txt}".strip()[:4]
|
||||
|
||||
# 2. Pedimento (7 Digits)
|
||||
pedimento_formatted = f"{pedimento_num}".strip()[:7]
|
||||
|
||||
# 3. Recinto (3 Digits Zero Padded @n03)
|
||||
# Ensure it's numeric-like for zero padding or just string pad?
|
||||
# Clarion FORMAT(Loc:Recinto,@n03) implies numeric.
|
||||
try:
|
||||
recinto_val = int(recinto_txt)
|
||||
recinto_formatted = f"{recinto_val:03d}"
|
||||
except:
|
||||
recinto_formatted = "000"
|
||||
|
||||
# 4. E-Document (Left aligned, clipped)
|
||||
edoc_formatted = edoc_txt.strip()
|
||||
|
||||
# 5. Container (13 chars?) or Rail Logic
|
||||
# Clarion: IF Rail -> CLIP(Loc:NumContenedor) ELSE '0000000000000'
|
||||
if is_rail_bar and field_5 and len(field_5) > 0:
|
||||
field_5_formatted = field_5.strip()
|
||||
else:
|
||||
field_5_formatted = "0000000000000"
|
||||
|
||||
# 6. Firma (Clipped)
|
||||
firma_formatted = firma_txt.strip()
|
||||
|
||||
# 7. Cantidad (FORMAT(Loc:CantTotal,@n015.3)) -> 15 chars, 3 decimals, Zero Padded?
|
||||
# Python f"{val:015.3f}" produces 15 chars total (including dot) with zero padding.
|
||||
cant_total_formatted = f"{cant_total:015.3f}"
|
||||
|
||||
# 8. Valor USD (FORMAT(Loc:ValorTotalDlls,@n012)) -> 12 chars, Integer?, Zero Padded?
|
||||
# If Clarion @n012 means Integer:
|
||||
# But previously we calculated rounding. If it is integer, we cast to int.
|
||||
# Clarion default doubles formatted with @n012 usually rounds to integer.
|
||||
# Let's assume Integer Zero Padded for now based on @n012 (no decimal part).
|
||||
val_usd_formatted = f"{int(final_val_usd):012d}"
|
||||
|
||||
# 9. Cant Embarques (Rail) (FORMAT(...,@n012))
|
||||
field_9_formatted = "000000000000"
|
||||
if is_rail_bar:
|
||||
# If we had a value... assuming 0 generally.
|
||||
pass
|
||||
|
||||
# 10. NIU (Rail) (@s13 -> String 13 chars?) or DTA
|
||||
# Clarion: LINEPRINT(CLIP(FORMAT(Loc:NumeroNIU,@s13)),Glo:GeneraTXT)
|
||||
# CLIP removes spaces, FORMAT @s13 makes it string 13?
|
||||
# Actually CLIP(FORMAT(...,@s13)) might just mean "The string value".
|
||||
# The ELSE is '0000000000000' (13 chars).
|
||||
field_10_formatted = "0000000000000"
|
||||
if is_rail_bar and field_10 != "0000000000000":
|
||||
field_10_formatted = field_10.strip()
|
||||
|
||||
# 11. Remesa (FORMAT(...,@n04) -> 4 digits zero padded)
|
||||
try:
|
||||
remesa_val = int(remesa_txt)
|
||||
remesa_formatted = f"{remesa_val:04d}"
|
||||
except:
|
||||
remesa_formatted = "0000"
|
||||
|
||||
# 12. Filler / Appendix 17
|
||||
# Clarion Logic:
|
||||
# IF TipoFactura = 'IMPOTEMP'/'EXPO'/'IMPODEF' ... IF Apendice17=1 -> '000000000003' ELSE '00000000.000'
|
||||
# Default '00000000.000'
|
||||
field_12_formatted = "00000000.000"
|
||||
if compliance and compliance.appendix_17 == 1:
|
||||
# Check Doc Type? Assuming broadly for now based on flag.
|
||||
field_12_formatted = "000000000003"
|
||||
|
||||
barcode_lines = [
|
||||
patente_formatted,
|
||||
pedimento_formatted,
|
||||
recinto_formatted,
|
||||
edoc_formatted,
|
||||
field_5_formatted,
|
||||
firma_formatted,
|
||||
cant_total_formatted,
|
||||
val_usd_formatted,
|
||||
field_9_formatted,
|
||||
field_10_formatted,
|
||||
remesa_formatted,
|
||||
field_12_formatted
|
||||
]
|
||||
|
||||
# Join with appropriate separator. Clarion LINEPRINT adds CR/LF (Windows).
|
||||
barcode_content = "\r\n".join(barcode_lines)
|
||||
|
||||
# Generate Image
|
||||
codes = pdf417gen.encode(barcode_content, columns=14)
|
||||
image = pdf417gen.render_image(codes, scale=5, padding=5)
|
||||
|
||||
# Convert to B64
|
||||
buffered = BytesIO()
|
||||
image.save(buffered, format="PNG")
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||
|
||||
aviso.codigo_barras_b64 = f"data:image/png;base64,{img_str}"
|
||||
debug_log.append("SUCCESS: Barcode generated.")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"Error generando codigo de barras InvID={invoice_id}: {str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
debug_log.append(f"ERROR: {error_msg}")
|
||||
aviso.codigo_barras_b64 = None
|
||||
|
||||
# Write Debug Log
|
||||
try:
|
||||
with open("/tmp/barcode_debug.log", "a") as f:
|
||||
f.write("\n".join(debug_log) + "\n--------------------------------\n")
|
||||
except Exception as e_log:
|
||||
print(f"FAILED TO WRITE LOG: {e_log}")
|
||||
|
||||
# 4. Agente Aduanal
|
||||
nombre_agente = ""
|
||||
rfc_agente = ""
|
||||
curp_agente = ""
|
||||
|
||||
if compliance and compliance.customs_broker_id:
|
||||
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
|
||||
if broker:
|
||||
nombre_agente = broker.name or ""
|
||||
rfc_agente = broker.tax_id or ""
|
||||
curp_agente = broker.personal_id or ""
|
||||
|
||||
agente = PersonaSchema(
|
||||
nombre=nombre_agente,
|
||||
rfc=rfc_agente,
|
||||
curp=curp_agente
|
||||
)
|
||||
|
||||
# 5. Mandatario (CustomsBrokerPersonnel)
|
||||
mandatario = PersonaSchema(nombre="", rfc="", curp="")
|
||||
|
||||
if broker:
|
||||
# Try to find personnel associated with this broker
|
||||
# Using direct query to ensure specific order if needed, typically just the first valid one
|
||||
personnel = db.query(CustomsBrokerPersonnel).filter(
|
||||
CustomsBrokerPersonnel.customs_broker_id == broker.id
|
||||
).first()
|
||||
|
||||
if personnel:
|
||||
# Construct name if main field is empty
|
||||
full_name = personnel.name
|
||||
if not full_name:
|
||||
parts = []
|
||||
if personnel.first_name: parts.append(personnel.first_name)
|
||||
if personnel.last_name: parts.append(personnel.last_name)
|
||||
if personnel.middle_name: parts.append(personnel.middle_name)
|
||||
full_name = " ".join(parts)
|
||||
|
||||
mandatario = PersonaSchema(
|
||||
nombre=full_name or "",
|
||||
rfc=personnel.tax_id or "",
|
||||
curp=personnel.personal_id or ""
|
||||
)
|
||||
|
||||
return AvisoConsolidadoContext(
|
||||
aviso=aviso,
|
||||
empresa=empresa,
|
||||
agente=agente,
|
||||
mandatario=mandatario
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Service A76 Export Aviso Consolidado: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def generar_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
|
||||
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
|
||||
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
|
||||
|
||||
if progress_callback: progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
context = datos.model_dump()
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"AvisoConsolidado_Exp_{invoice_id}.pdf"
|
||||
|
||||
if progress_callback: progress_callback(90, "Generando PDF final...")
|
||||
|
||||
options = {
|
||||
'page-size': 'Letter',
|
||||
'margin-top': '0.5in',
|
||||
'margin-right': '0.5in',
|
||||
'margin-bottom': '0.5in',
|
||||
'margin-left': '0.5in',
|
||||
'encoding': "UTF-8",
|
||||
'enable-local-file-access': None
|
||||
}
|
||||
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from core.celery_app import celery_app
|
||||
from core.database import CoreSessionLocal
|
||||
|
||||
from .service import AvisoConsolidadoExportacionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@celery_app.task(name="generar_pdf_aviso_consolidado_exp_async", bind=True)
|
||||
def generar_pdf_aviso_consolidado_exp_async(self, invoice_id: int, company_id: int):
|
||||
# 1. Abrimos conexión a la DB
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
logger.info(f"Worker procesando Aviso Consolidado Exp {invoice_id}...")
|
||||
|
||||
# 2. Instanciamos el servicio
|
||||
service = AvisoConsolidadoExportacionService()
|
||||
|
||||
# Update state to PROCESSING
|
||||
self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'})
|
||||
|
||||
def progress_callback(progress: int, status: str):
|
||||
self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status})
|
||||
|
||||
# 3. Generamos los bytes del PDF
|
||||
pdf_bytes, nombre, media_type = service.generar_pdf(
|
||||
db=db,
|
||||
invoice_id=invoice_id,
|
||||
company_id=company_id,
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
# 4. Codificamos a base64
|
||||
pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8')
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_name": nombre,
|
||||
"content": pdf_base64,
|
||||
"media_type": media_type
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en Celery Worker Aviso Consolidado Exp: {str(e)}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" xml:lang="es">
|
||||
|
||||
<head>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
|
||||
<title>Aviso Consolidado - {{ aviso.pedimento_completo }}</title>
|
||||
<style type="text/css">
|
||||
/* ESTILOS EXACTOS DE SCAPII (Copiados de tu archivo) */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Tahoma, sans-serif;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.titulo {
|
||||
font-size: 14pt;
|
||||
padding: 3pt 0 0 6pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.grande {
|
||||
font-size: 13pt;
|
||||
padding-left: 5pt;
|
||||
}
|
||||
|
||||
.medio-bold {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
padding: 3pt 0 0 3pt;
|
||||
}
|
||||
|
||||
.normal {
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.small-bold {
|
||||
font-size: 8pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tiny-bold {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.mini {
|
||||
font-size: 5pt;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1pt solid black;
|
||||
}
|
||||
|
||||
/* Ajusté a negro puro para que se vea como el formato oficial */
|
||||
|
||||
/* Utilidades de Padding/Margin del original */
|
||||
.p-t-1 {
|
||||
padding-top: 1pt;
|
||||
}
|
||||
|
||||
.p-t-2 {
|
||||
padding-top: 2pt;
|
||||
}
|
||||
|
||||
.p-t-3 {
|
||||
padding-top: 3pt;
|
||||
}
|
||||
|
||||
.p-l-2 {
|
||||
padding-left: 2pt;
|
||||
}
|
||||
|
||||
.p-l-3 {
|
||||
padding-left: 3pt;
|
||||
}
|
||||
|
||||
.p-r-2 {
|
||||
padding-right: 2pt;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 10pt;
|
||||
}
|
||||
|
||||
.h-14 {
|
||||
height: 14pt;
|
||||
}
|
||||
|
||||
/* Estilos específicos para el Grid del Aviso */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.bg-grey {
|
||||
background-color: #E4E4E4;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 8pt;
|
||||
font-weight: bold;
|
||||
background-color: #CCCCCC;
|
||||
text-align: center;
|
||||
border: 1pt solid black;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
border-bottom: 1pt solid black;
|
||||
min-height: 10pt;
|
||||
}
|
||||
|
||||
.cell-pad {
|
||||
padding: 2pt 4pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<table cellspacing="0">
|
||||
<tr>
|
||||
<td class="border bg-grey center" style="width: 80%;">
|
||||
<p class="titulo center">AVISO CONSOLIDADO</p>
|
||||
</td>
|
||||
<td class="border center" style="width: 20%;">
|
||||
<p class="normal">Página <span class="small-bold">1</span> de <span class="small-bold">1</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="border cell-pad" style="width: 40%;">
|
||||
<span class="tiny-bold">NUM. PEDIMENTO: </span>
|
||||
<span class="tiny">{{ aviso.pedimento_completo }}</span>
|
||||
</td>
|
||||
<td class="border cell-pad" style="width: 20%;">
|
||||
<span class="tiny-bold">T. OPER: </span>
|
||||
<span class="tiny">{{ aviso.tipo_operacion }}</span>
|
||||
</td>
|
||||
<td class="border cell-pad" style="width: 20%;">
|
||||
<span class="tiny-bold">CVE. PEDIMENTO: </span>
|
||||
<span class="tiny">{{ aviso.clave_pedimento }}</span>
|
||||
</td>
|
||||
<td class="border bg-grey center" style="width: 20%;">
|
||||
<span class="tiny-bold">CERTIFICACIONES</span>
|
||||
<br>
|
||||
<span class="tiny-bold" style="font-size: 6pt;">TIPO: {{ aviso.tipo_documento }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="border cell-pad" colspan="3">
|
||||
<span class="tiny-bold">NUMERO DE ACUSE DE VALOR: </span>
|
||||
<span class="tiny">{{ aviso.acus_valor }}</span>
|
||||
</td>
|
||||
<td class="border" rowspan="4" style="vertical-align: top;">
|
||||
<p class="mini"> </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border cell-pad" style="width: 25%;">
|
||||
<span class="tiny-bold">ADUANA E/S: </span>
|
||||
<span class="tiny">{{ aviso.aduana_seccion }}</span>
|
||||
</td>
|
||||
<td class="border cell-pad" style="width: 25%;">
|
||||
<span class="tiny-bold">NUM. REMESA: </span>
|
||||
<span class="tiny">{{ aviso.numero_remesa }}</span>
|
||||
</td>
|
||||
<td class="border cell-pad" style="width: 30%;">
|
||||
<span class="tiny-bold">PESO BRUTO: </span>
|
||||
<span class="tiny">{{ aviso.peso_bruto }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="section-header">DATOS DEL IMPORTADOR/EXPORTADOR</td>
|
||||
</tr>
|
||||
<tr style="height: 40pt;">
|
||||
<td colspan="3" class="border cell-pad" style="vertical-align: top;">
|
||||
<div style="float: left; width: 30%;">
|
||||
<p class="tiny-bold">RFC:</p>
|
||||
<p class="tiny">{{ empresa.rfc }}</p>
|
||||
</div>
|
||||
<div style="float: left; width: 70%;">
|
||||
<p class="tiny-bold">NOMBRE, DENOMINACION O RAZON SOCIAL:</p>
|
||||
<p class="tiny">{{ empresa.razon_social }}</p>
|
||||
<p class="mini">{{ empresa.direccion_completa }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr style="height: 60pt;">
|
||||
<td class="border cell-pad" style="width: 25%; vertical-align: top;">
|
||||
<p class="tiny-bold">CODIGO DE ACEPTACION:</p>
|
||||
<p class="normal center p-t-3">{{ aviso.codigo_aceptacion }}</p>
|
||||
</td>
|
||||
<td class="border cell-pad" style="width: 55%; vertical-align: top;">
|
||||
<p class="tiny-bold">CODIGO DE BARRAS</p>
|
||||
<div class="center p-t-2">
|
||||
{% if aviso.codigo_barras_b64 %}
|
||||
<img src="{{ aviso.codigo_barras_b64 }}" style="height: 60pt; max-width: 90%;" />
|
||||
{% else %}
|
||||
<br><br><br>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="border cell-pad" style="width: 20%; vertical-align: top;">
|
||||
<p class="tiny-bold">CLAVE DE LA SECCION ADUANERA DE DESPACHO:</p>
|
||||
<p class="grande center p-t-3">{{ aviso.clave_seccion }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="section-header" style="text-align: left; padding-left: 5pt;">MARCAS, NUMEROS Y TOTAL DE BULTOS:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border cell-pad" style="height: 20pt; vertical-align: top;">
|
||||
<p class="tiny">{{ aviso.marcas_numeros_bultos }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="section-header"
|
||||
style="width: 25%; text-align: left; padding-left: 5pt; background-color: #E4E4E4;">NUMERO DE CANDADO:
|
||||
</td>
|
||||
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[0] if aviso.candados|length > 0 }}</td>
|
||||
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[1] if aviso.candados|length > 1 }}</td>
|
||||
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[2] if aviso.candados|length > 2 }}</td>
|
||||
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[3] if aviso.candados|length > 3 }}</td>
|
||||
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[4] if aviso.candados|length > 4 }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="section-header"
|
||||
style="text-align: left; padding-left: 5pt; background-color: #E4E4E4; border-bottom: 0;">1RA. REVISION
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border h-14"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="section-header"
|
||||
style="text-align: left; padding-left: 5pt; background-color: #E4E4E4; border-bottom: 0;">2DA. REVISION
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border h-14"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="section-header"
|
||||
style="width: 25%; text-align: left; padding-left: 5pt; background-color: #CCCCCC;">NUMERO/TIPO:</td>
|
||||
<td class="border cell-pad tiny" style="width: 25%;">{{ aviso.vehiculo_placas }}</td>
|
||||
<td class="border cell-pad tiny" style="width: 25%;">{{ aviso.vehiculo_tipo }}</td>
|
||||
<td class="border cell-pad tiny" style="width: 25%;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="section-header" style="text-align: center; background-color: #CCCCCC;">OBSERVACIONES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border" style="height: 100pt; vertical-align: top; padding: 5pt;">
|
||||
<p class="tiny">{{ aviso.observaciones }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellspacing="0" style="margin-top: -1pt;">
|
||||
<tr>
|
||||
<td class="border cell-pad" style="vertical-align: top; height: 90pt;">
|
||||
<p class="tiny-bold">AGENTE ADUANAL, APODERADO ADUANAL:</p>
|
||||
|
||||
<div style="margin-top: 5pt;">
|
||||
<span class="tiny-bold">NOMBRE: </span>
|
||||
<span class="tiny">{{ agente.nombre }}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2pt;">
|
||||
<div style="display: inline-block; width: 45%;">
|
||||
<span class="tiny-bold">RFC: </span>
|
||||
<span class="tiny">{{ agente.rfc }}</span>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 50%;">
|
||||
<span class="tiny-bold">CURP: </span>
|
||||
<span class="tiny">{{ agente.curp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10pt;">
|
||||
<span class="tiny-bold">MANDATARIO/PERSONA AUTORIZADA:</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2pt;">
|
||||
<span class="tiny-bold">NOMBRE: </span>
|
||||
<span class="tiny">{{ mandatario.nombre }}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2pt;">
|
||||
<div style="display: inline-block; width: 45%;">
|
||||
<span class="tiny-bold">RFC: </span>
|
||||
<span class="tiny">{{ mandatario.rfc }}</span>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 50%;">
|
||||
<span class="tiny-bold">CURP: </span>
|
||||
<span class="tiny">{{ mandatario.curp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 5pt; border-top: 1pt solid black; padding-top: 2pt;">
|
||||
<span class="tiny-bold">NUMERO DE SERIE DEL CERTIFICADO: </span>
|
||||
<span class="tiny">{{ aviso.numero_certificado }}</span>
|
||||
</div>
|
||||
<div style="margin-top: 2pt;">
|
||||
<span class="tiny-bold">e.firma: </span>
|
||||
<p class="mini" style="word-wrap: break-word;">{{ aviso.firma_electronica }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="mini center p-t-5">*********************************************************************** FIN DE LA
|
||||
IMPRESION ***********************************************************************</p>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,630 @@
|
||||
import shutil
|
||||
import base64
|
||||
import pdfkit
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, List, Callable, Optional
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# --- MODELOS ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx
|
||||
from api.v1.modules.a76.items.line_financials.models import LineFinancial
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
)
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.pedmientos.models import Pedimentos
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
# --- TRANSPORTATION MODELS ---
|
||||
from api.v1.modules.a76.transportation.transporters.models import Transporter
|
||||
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
|
||||
from api.v1.modules.a76.transportation.trailers.models import Trailer
|
||||
from api.v1.modules.a76.transportation.drivers.models import Driver
|
||||
|
||||
# --- MODELO DE FRACCIONES ---
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
|
||||
# --- SCHEMAS ---
|
||||
from .schemas import (
|
||||
ClienteSchema, PartidaSchema, TotalesSchema,
|
||||
FacturaSchema, FacturaImportacionCompleta
|
||||
)
|
||||
|
||||
class ConsolidadoImportacionMexService:
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent.parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
self.template = self.jinja_env.get_template('cons_mex_ver.html')
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
except: return 0.0
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
return fraccion_raw
|
||||
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
|
||||
|
||||
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
|
||||
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
|
||||
if not main:
|
||||
return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX")
|
||||
|
||||
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
|
||||
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
|
||||
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre=(main.name or main.short_name) or "S/N",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
num_exterior=(addr.exterior_number or "") if addr else "",
|
||||
num_interior=(addr.interior_number or "") if addr else "",
|
||||
colonia=(addr.neighborhood or "") if addr else "",
|
||||
codigo_postal=(addr.postal_code or "") if addr else "",
|
||||
ciudad=(addr.city or "") if addr else "",
|
||||
estado=(addr.state or "") if addr else "",
|
||||
pais=(addr.country or "MEX") if addr else "MEX",
|
||||
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
|
||||
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
|
||||
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
|
||||
),
|
||||
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
|
||||
)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
|
||||
try:
|
||||
if progress_callback: progress_callback(10, "Buscando factura...")
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header: raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
compliance = header.compliance_mx
|
||||
logistics = header.logistics if header.logistics else None
|
||||
financials = header.financials if header.financials else None
|
||||
if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...")
|
||||
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
|
||||
|
||||
if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...")
|
||||
proveedor_id = compliance.provider_id if compliance else None
|
||||
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
|
||||
|
||||
nombre_agente = ""
|
||||
if compliance and compliance.customs_broker_id:
|
||||
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
|
||||
if broker: nombre_agente = broker.name
|
||||
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
# Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A)
|
||||
# Default Header (Company)
|
||||
cliente_default = ClienteSchema(
|
||||
header="Importer / Consignee:",
|
||||
nombre=getattr(company, 'name', "Empresa Local"),
|
||||
direccion="DOMICILIO FISCAL",
|
||||
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
|
||||
tax_id=getattr(company, 'rfc', ""),
|
||||
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
|
||||
)
|
||||
|
||||
# Left Side Logic (Consignatario / Sold To)
|
||||
cliente_vendido = cliente_default
|
||||
if compliance and compliance.sold_to_id:
|
||||
# Map known headers or default to Sold To / Vendido a
|
||||
raw = (compliance.sold_to_header or "").upper()
|
||||
if "CONSIGN" in raw:
|
||||
clean_header = "Consignee / Consignatario:"
|
||||
else:
|
||||
clean_header = "Sold To / Vendido a:"
|
||||
|
||||
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
|
||||
|
||||
# Right Side Logic (Enviado A / Shipped To)
|
||||
cliente_enviado = cliente_default
|
||||
if compliance and compliance.shipped_to_id:
|
||||
# Map to Shipped To / Enviado a
|
||||
clean_header_shipped = "Shipped To / Enviado a:"
|
||||
|
||||
# Fetch client data
|
||||
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
|
||||
|
||||
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
||||
|
||||
patente_val = ""
|
||||
if pedimento and pedimento.license:
|
||||
patente_val = pedimento.license
|
||||
elif 'broker' in locals() and broker and broker.license:
|
||||
patente_val = broker.license
|
||||
|
||||
|
||||
# --- Transport Data Fetching ---
|
||||
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
|
||||
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
|
||||
|
||||
# Init values
|
||||
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
|
||||
placas_remolque_val = ""
|
||||
transportista_val = (logistics.carrier_id or "") if logistics else ""
|
||||
caat_val = ""
|
||||
scac_val = ""
|
||||
licencia_cond_val = ""
|
||||
conductor_nombre = ""
|
||||
|
||||
# Block Logic (Clarion Style) for transportista_info
|
||||
transport_lines = []
|
||||
|
||||
if logistics:
|
||||
# 1. Transporter (CAAT / SCAC)
|
||||
if logistics.carrier_id:
|
||||
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
|
||||
if transporter_obj:
|
||||
caat_val = transporter_obj.caat_code or ""
|
||||
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
|
||||
transportista_val = transporter_obj.name or logistics.carrier_id
|
||||
|
||||
# Clarion Logic: Name first
|
||||
# Line 1: Name
|
||||
transport_lines.append(transporter_obj.name or "")
|
||||
|
||||
# Line 2: Streets
|
||||
if transporter_obj.streets:
|
||||
transport_lines.append(transporter_obj.streets)
|
||||
|
||||
# Line 3: City, State, Country
|
||||
loc_line = ""
|
||||
if transporter_obj.city:
|
||||
loc_line = transporter_obj.city
|
||||
if transporter_obj.state:
|
||||
loc_line += f", {transporter_obj.state}, "
|
||||
else:
|
||||
loc_line += ", "
|
||||
else:
|
||||
if transporter_obj.state:
|
||||
loc_line = f"{transporter_obj.state},"
|
||||
|
||||
country_desc = transporter_obj.country or ""
|
||||
if loc_line:
|
||||
loc_line += f" {country_desc}"
|
||||
elif country_desc:
|
||||
loc_line = country_desc
|
||||
|
||||
if loc_line.strip(", "):
|
||||
transport_lines.append(loc_line)
|
||||
|
||||
# 2. Vehicle (Placas Tracto) - Try transport_id first
|
||||
if logistics.transport_id:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
|
||||
# 3. Trailer (Placas Remolque)
|
||||
if logistics.trailer_num:
|
||||
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
|
||||
if trl_obj:
|
||||
placas_remolque_val = trl_obj.plate_number or ""
|
||||
|
||||
# 4. Driver (License)
|
||||
if logistics.carrier_id and logistics.driver_name:
|
||||
conductor_nombre = logistics.driver_name
|
||||
# Attempt to find driver by name + carrier
|
||||
drv_obj = db.query(Driver).filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name
|
||||
).first()
|
||||
if drv_obj:
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
|
||||
# --- Building the rest of the block ---
|
||||
|
||||
# Line 4: Driver
|
||||
if conductor_nombre:
|
||||
transport_lines.append(f"Driver/Conductor: {conductor_nombre}")
|
||||
|
||||
# Line 5: Conveyance / Transporte
|
||||
t_label = "Conveyance / Transporte"
|
||||
t_val = placas_val # Default to Truck Plate
|
||||
|
||||
if logistics.transport_type:
|
||||
ttype = str(logistics.transport_type).lower()
|
||||
if "caja" in ttype or "trailer" in ttype:
|
||||
t_label = "Trailer / Caja"
|
||||
t_val = placas_remolque_val or num_transporte_val
|
||||
elif "placa" in ttype:
|
||||
t_label = "Plates / Placas"
|
||||
elif "camion" in ttype or "truck" in ttype:
|
||||
t_label = "Truck / Camión"
|
||||
|
||||
if t_val:
|
||||
transport_lines.append(f"{t_label}: {t_val}")
|
||||
|
||||
# Line 6: SCAC / CAAT
|
||||
codes_line = ""
|
||||
if scac_val:
|
||||
codes_line = f"SCAC Code/Clave: {scac_val}"
|
||||
if caat_val:
|
||||
if codes_line:
|
||||
codes_line += f", CAAT Code/Clave: {caat_val}"
|
||||
else:
|
||||
codes_line = f"CAAT Code/Clave: {caat_val}"
|
||||
|
||||
if codes_line:
|
||||
transport_lines.append(codes_line)
|
||||
|
||||
# Join with newlines
|
||||
transport_block_str = "\n".join([l for l in transport_lines if l])
|
||||
|
||||
factura_schema = FacturaSchema(
|
||||
numero=header.invoice_number or "S/N",
|
||||
fecha=str(header.invoice_date) if header.invoice_date else "",
|
||||
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
|
||||
moneda=getattr(header, 'currency', "USD") or "USD",
|
||||
incoterm=(logistics.incoterm or "") if logistics else "",
|
||||
observaciones=header.observation_es or header.observation_en or "",
|
||||
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
|
||||
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
||||
regimen=header.document_type or "",
|
||||
patente=patente_val,
|
||||
agente_aduanal=nombre_agente,
|
||||
transporte=transporte_txt,
|
||||
num_transporte=num_transporte_val,
|
||||
placas=placas_val,
|
||||
placas_remolque=placas_remolque_val,
|
||||
transportista=transportista_val,
|
||||
caat=caat_val,
|
||||
scac=scac_val,
|
||||
licencia_conductor=licencia_cond_val,
|
||||
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
|
||||
precinto=(logistics.seal_number or "") if logistics else "",
|
||||
destino=(logistics.destination_goods or "") if logistics else "",
|
||||
remesa=remesa_valor, acuse_electronico=acuse_valor,
|
||||
representante_legal=getattr(company, 'responsible', "") or "",
|
||||
nombre_empresa=getattr(company, 'name', "") or "",
|
||||
transportista_info=transport_block_str
|
||||
)
|
||||
|
||||
if progress_callback: progress_callback(50, "Procesando partidas...")
|
||||
|
||||
# --- Fetch Lines from SINGLE Invoice (Requested Scope Change) ---
|
||||
# User requested to ONLY report items from the specific selected invoice,
|
||||
# NOT consolidating all invoices from the same Pedimento.
|
||||
target_invoice_ids = [header.id]
|
||||
|
||||
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(
|
||||
Item.invoice_id.in_(target_invoice_ids)
|
||||
).all()
|
||||
|
||||
partidas_list = []
|
||||
|
||||
# --- AGGREGATION LOGIC (Refactoring based on Clarion) ---
|
||||
from collections import defaultdict
|
||||
# Key: (us_fraction_code, origin_country)
|
||||
# Value: Object with accumulated fields
|
||||
aggregated_data = defaultdict(lambda: {
|
||||
"qty": 0.0,
|
||||
"net_weight_kgs": 0.0,
|
||||
"gross_weight_kgs": 0.0,
|
||||
"total_value": 0.0,
|
||||
"est_total_value": 0.0,
|
||||
"description": "",
|
||||
"advalorem_txt": "0%",
|
||||
"unit_measure": "PZA", # Placeholder, takes first one found
|
||||
"hts_code_print": "",
|
||||
"part_number_display": "CONSOLIDADO"
|
||||
})
|
||||
|
||||
# Pre-fetch US Tariff Fractions for efficiency if possible, or query inside loop (caching recommended)
|
||||
# For simplicity in this step, we query inside or rely on Part data.
|
||||
# Ideally fetch USTariffFraction from DB based on Part.us_fraction
|
||||
|
||||
# --- Optimización: Cargar Facturas en Memoria ---
|
||||
invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all()
|
||||
invoice_map = {inv.id: inv for inv in invoices_list}
|
||||
|
||||
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
|
||||
|
||||
for line in lines:
|
||||
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
|
||||
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
|
||||
part_master = db.query(Part).filter(Part.id == line.part_number).first()
|
||||
|
||||
# --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) ---
|
||||
us_fraction_raw = ""
|
||||
origin_final = "MEX"
|
||||
|
||||
if part_master:
|
||||
origin_final = part_master.fa_data.origin_country if (part_master.fa_data and part_master.fa_data.origin_country) else "MEX"
|
||||
us_fraction_raw = part_master.us_fraction if part_master.us_fraction else ""
|
||||
|
||||
# Key for aggregation
|
||||
us_frac_clean = us_fraction_raw.strip()
|
||||
agg_key = (us_frac_clean, origin_final)
|
||||
# --- Weights & Qty ---
|
||||
q_line = float(qty.quantity) if (qty and qty.quantity) else 0.0
|
||||
nw_line = float(qty.net_weight) if qty else 0.0
|
||||
gw_line = float(qty.gross_weight) if qty else 0.0
|
||||
|
||||
# --- Multi-Currency Normalization Logic ---
|
||||
# Determine Line Currency context
|
||||
# Use manual lookup instead of specific attribute
|
||||
invoice_id = line.item.invoice_id if line.item else None
|
||||
line_invoice = invoice_map.get(invoice_id) if invoice_id else None
|
||||
|
||||
line_currency_is_mxn = False
|
||||
line_exchange_rate = 1.0
|
||||
|
||||
if line_invoice and line_invoice.financials:
|
||||
# Check explicit currency string AND code
|
||||
curr_desc = str(line_invoice.financials.currency or "").upper()
|
||||
curr_code = str(line_invoice.financials.currency_type or "").upper()
|
||||
|
||||
# Logic: It is MXN if description says PESO/MX or code is MXN/MN
|
||||
is_mx_desc = ("MX" in curr_desc or "PESO" in curr_desc)
|
||||
is_mx_code = ("MXN" in curr_code or "MN" == curr_code)
|
||||
|
||||
# But if code allows clarifying USD, prioritize that
|
||||
is_usd_code = ("USD" in curr_code)
|
||||
|
||||
if is_usd_code:
|
||||
line_currency_is_mxn = False
|
||||
elif is_mx_code or is_mx_desc:
|
||||
line_currency_is_mxn = True
|
||||
else:
|
||||
line_currency_is_mxn = False # Default to Foreign/USD if unsure
|
||||
|
||||
line_exchange_rate = float(line_invoice.financials.exchange_rate or 1.0)
|
||||
|
||||
# Target Report Currency
|
||||
report_is_mxn = (factura_schema.moneda == 'MXN')
|
||||
|
||||
# DEBUG LOGGING
|
||||
if line_invoice:
|
||||
print(f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}")
|
||||
print(f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}")
|
||||
|
||||
# --- Get Financials for Line (Raw) ---
|
||||
v_total_raw = 0.0
|
||||
v_unitario_raw = 0.0
|
||||
|
||||
if fin:
|
||||
# NEW PRIORITY LOGIC (To avoid Inflation from dirty Customs Unit Cost)
|
||||
# Priority 1: Use 'fin.value_usd' if it exists and > 0.
|
||||
# Priority 2: Use 'fin.total_commercial_value' if it exists and > 0.
|
||||
# Priority 3: Calculate using 'fin.unit_cost_commercial_usd' * 'q_line'.
|
||||
# Priority 4: Only use 'fin.unit_cost_usd' * 'q_line' if commercial data is also missing.
|
||||
|
||||
val_usd = float(fin.value_usd or 0.0)
|
||||
total_comm = float(fin.total_commercial_value or 0.0)
|
||||
unit_comm_usd = float(fin.unit_cost_commercial_usd or 0.0)
|
||||
unit_usd = float(fin.unit_cost_usd or 0.0)
|
||||
|
||||
# 1. Direct Total: Custom Value (Best case)
|
||||
if val_usd > 0:
|
||||
v_total_raw = val_usd
|
||||
|
||||
# 2. Direct Total: Commercial Total
|
||||
elif total_comm > 0:
|
||||
# Convert if invoice currency is MXN
|
||||
if line_currency_is_mxn and line_exchange_rate > 0:
|
||||
v_total_raw = total_comm / line_exchange_rate
|
||||
else:
|
||||
v_total_raw = total_comm
|
||||
|
||||
# 3. Calc from Commercial Unit Cost (Safe Fallback)
|
||||
elif unit_comm_usd > 0 and q_line > 0:
|
||||
v_total_raw = unit_comm_usd * q_line
|
||||
|
||||
# 4. Calc from Customs Unit Cost (Unknown Risk - Last Resort)
|
||||
elif unit_usd > 0 and q_line > 0:
|
||||
v_total_raw = unit_usd * q_line
|
||||
|
||||
else:
|
||||
v_total_raw = 0.0
|
||||
|
||||
# NOTE: v_unitario_raw is left as 0.0 here.
|
||||
# It will be calculated in the 'Calculation Gap Fill' block below:
|
||||
# v_unitario_raw = v_total_raw / q_line
|
||||
# This guarantees consistency and avoids the inflated unit cost record (198.00).
|
||||
|
||||
# --- Calculation Gap Fill (Raw) ---
|
||||
if q_line > 0:
|
||||
if v_total_raw == 0 and v_unitario_raw > 0:
|
||||
v_total_raw = v_unitario_raw * q_line
|
||||
if v_unitario_raw == 0 and v_total_raw > 0:
|
||||
v_unitario_raw = v_total_raw / q_line
|
||||
|
||||
# --- Conversion to Report Currency (DISABLED TEMPORARILY) ---
|
||||
# User confirms all are USD. Forcing direct sum to avoid logic errors in detection.
|
||||
v_total_line = v_total_raw
|
||||
v_unitario_line = v_unitario_raw
|
||||
|
||||
# if report_is_mxn and not line_currency_is_mxn:
|
||||
# # USD -> MXN
|
||||
# v_total_line = v_total_raw * line_exchange_rate
|
||||
# v_unitario_line = v_unitario_raw * line_exchange_rate
|
||||
# elif not report_is_mxn and line_currency_is_mxn:
|
||||
# # MXN -> USD
|
||||
# if line_exchange_rate > 0:
|
||||
# v_total_line = v_total_raw / line_exchange_rate
|
||||
# v_unitario_line = v_unitario_raw / line_exchange_rate
|
||||
# else:
|
||||
# v_total_line = 0.0
|
||||
# v_unitario_line = 0.0
|
||||
|
||||
print(f"DEBUG: ValRaw: {v_total_raw} -> ValFinal: {v_total_line}")
|
||||
|
||||
# --- Resolve Fraction Details (Description & Rate) ---
|
||||
# Only if this is the first time we see this key (or overwrite, doesn't matter much as they should be same for same HTS)
|
||||
# We check if we already have description set to avoid re-querying if we want optimization,
|
||||
# but relying on DB query per distinct fraction is safer.
|
||||
|
||||
current_agg = aggregated_data[agg_key]
|
||||
|
||||
if not current_agg["description"]:
|
||||
us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first()
|
||||
if us_frac_db:
|
||||
current_agg["description"] = us_frac_db.description or "Sin Descripción"
|
||||
# Parse AdValorem from DB if available, else 0 ??
|
||||
# Creating logical placeholder. The provided Clarion code used `FraAme.Adv`
|
||||
adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later?
|
||||
# Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`.
|
||||
current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%"
|
||||
else:
|
||||
current_agg["description"] = part_master.description_spanish if part_master else "S/D"
|
||||
|
||||
current_agg["hts_code_print"] = us_frac_clean
|
||||
current_agg["unit_measure"] = qty.weight_unit if qty else "KGS" # Default to first found
|
||||
|
||||
# --- Calculate Estimated Tax for this Line ---
|
||||
rate = 0.0
|
||||
try:
|
||||
clean_adv = current_agg["advalorem_txt"].replace("%", "").strip()
|
||||
rate = float(clean_adv) / 100.0
|
||||
except: rate = 0.0
|
||||
|
||||
v_est_line = v_total_line * rate
|
||||
|
||||
# --- Accumulate ---
|
||||
current_agg["qty"] += q_line
|
||||
current_agg["net_weight_kgs"] += nw_line
|
||||
current_agg["gross_weight_kgs"] += gw_line
|
||||
current_agg["total_value"] += v_total_line
|
||||
current_agg["est_total_value"] += v_est_line
|
||||
|
||||
|
||||
# --- Convert Aggregated Data to Schema List ---
|
||||
partidas_list = []
|
||||
|
||||
for (hts, origin), data in aggregated_data.items():
|
||||
|
||||
# Calculate Unit Price based on Total Value / Total Qty
|
||||
unit_price = 0.0
|
||||
if data["qty"] > 0:
|
||||
unit_price = data["total_value"] / data["qty"]
|
||||
|
||||
partidas_list.append(PartidaSchema(
|
||||
numero_parte="VARIOS", # Or empty
|
||||
descripcion=data["description"],
|
||||
fraccion=data["hts_code_print"],
|
||||
origen=origin,
|
||||
advalorem=data["advalorem_txt"],
|
||||
preferencia="General",
|
||||
cantidad_importacion=self.formatear_numero(data["qty"]),
|
||||
unidad_medida=data["unit_measure"],
|
||||
cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later
|
||||
clave_bultos="",
|
||||
peso_neto=self.formatear_numero(data["net_weight_kgs"]),
|
||||
peso_bruto=self.formatear_numero(data["gross_weight_kgs"]),
|
||||
valor_costo_unitario=self.formatear_numero(unit_price),
|
||||
valor_total=self.formatear_numero(data["total_value"]),
|
||||
valor_estimado=self.formatear_numero(data["est_total_value"])
|
||||
))
|
||||
|
||||
# Sort by Fraction (HTS Code)
|
||||
partidas_list.sort(key=lambda x: x.fraccion)
|
||||
|
||||
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
|
||||
|
||||
return FacturaImportacionCompleta(
|
||||
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado, factura=factura_schema,
|
||||
partidas=partidas_list, totales=totales
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Service A76: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
|
||||
cant = sum(p.cantidad_importacion for p in partidas)
|
||||
valor = sum(p.valor_total for p in partidas)
|
||||
peso_n = sum(p.peso_neto for p in partidas)
|
||||
peso_b = sum(p.peso_bruto for p in partidas)
|
||||
bultos = sum(p.cantidad_bultos for p in partidas)
|
||||
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
|
||||
clave_comun = max(set(claves), key=claves.count) if claves else ""
|
||||
if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
|
||||
v_est = sum(p.valor_estimado for p in partidas if isinstance(p.valor_estimado, (int, float, Decimal)))
|
||||
|
||||
tc = float(tipo_cambio) if tipo_cambio else 1.0
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
|
||||
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0),
|
||||
valor_estimado_total=self.formatear_numero(v_est)
|
||||
)
|
||||
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
|
||||
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
|
||||
|
||||
if progress_callback: progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
# LOGO LOGIC
|
||||
logo_b64 = None
|
||||
try:
|
||||
# Fetch company to get logo path
|
||||
|
||||
comp_logo = db.query(Company).filter(Company.id == company_id).first()
|
||||
if comp_logo and comp_logo.logo:
|
||||
p = Path(comp_logo.logo)
|
||||
|
||||
# Logic robusta de búsqueda (igual que en routes.py)
|
||||
target_path = p
|
||||
if not target_path.exists():
|
||||
# Intentar en la ruta estándar: app_data/logos/{id}/{nombre}
|
||||
# Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió
|
||||
fallback = Path(f"app_data/logos/{company_id}") / p.name
|
||||
if fallback.exists():
|
||||
target_path = fallback
|
||||
|
||||
if target_path.exists():
|
||||
with open(target_path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
# Detect MIME type loosely
|
||||
mime = "image/png"
|
||||
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
|
||||
logo_b64 = f"data:{mime};base64,{encoded_string}"
|
||||
except Exception as e:
|
||||
print(f"Error loading logo: {e}")
|
||||
|
||||
context = {
|
||||
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
|
||||
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
|
||||
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
|
||||
'logo_b64': logo_b64
|
||||
}
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"Consolidado_{datos.factura.numero}.{formato}"
|
||||
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
|
||||
|
||||
if progress_callback: progress_callback(90, "Generando PDF final...")
|
||||
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
@@ -30,6 +30,7 @@ class ClienteSchema(BaseModel):
|
||||
|
||||
class FacturaSchema(BaseModel):
|
||||
numero: str
|
||||
titulo_documento: str = "Factura de Importacion" # Titulo dinámico basado en document_type
|
||||
fecha: str
|
||||
tipo_cambio: float
|
||||
moneda: str
|
||||
|
||||
@@ -10,18 +10,20 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# --- MODELOS ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.items.line_financials.models import LineFinancial
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
ClientProvider,
|
||||
ClientProviderAddress,
|
||||
ClientProviderPrograms,
|
||||
)
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.pedmientos.models import Pedimentos
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
# --- TRANSPORTATION MODELS ---
|
||||
from api.v1.modules.a76.transportation.transporters.models import Transporter
|
||||
@@ -32,21 +34,68 @@ from api.v1.modules.a76.transportation.drivers.models import Driver
|
||||
# --- MODELO DE FRACCIONES ---
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
|
||||
# --- MODELO DE UNIDADES DE MEDIDA ---
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
|
||||
# --- SCHEMAS ---
|
||||
from .schemas import (
|
||||
ClienteSchema, PartidaSchema, TotalesSchema,
|
||||
FacturaSchema, FacturaImportacionCompleta
|
||||
ClienteSchema,
|
||||
PartidaSchema,
|
||||
TotalesSchema,
|
||||
FacturaSchema,
|
||||
FacturaImportacionCompleta,
|
||||
)
|
||||
|
||||
|
||||
class FacturaImportacionMexService:
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent.parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
self.template = self.jinja_env.get_template('factura_mex_ver.html')
|
||||
|
||||
def _get_document_title(self, invoice_type: str, is_american: bool = False) -> str:
|
||||
"""
|
||||
Determina el título del documento basado en el tipo de factura.
|
||||
|
||||
Args:
|
||||
invoice_type: Tipo de factura (TEM, DEF, MEX, CR)
|
||||
is_american: Si es factura americana (True) o mexicana (False)
|
||||
|
||||
Returns:
|
||||
Título formateado para la factura
|
||||
"""
|
||||
# Mapeo para facturas mexicanas
|
||||
mexican_titles = {
|
||||
"MEX": "Factura Importación Compras Mexicanas",
|
||||
"DEF": "Importación Definitiva",
|
||||
"TEM": "Importación Temporal",
|
||||
"CR": "Importación de Cambio de Régimen",
|
||||
}
|
||||
|
||||
# Mapeo para facturas americanas
|
||||
american_titles = {
|
||||
"MEX": "Mexican Purchases Import Invoice",
|
||||
"DEF": "Definitive Importation",
|
||||
"TEM": "Temporary Importation",
|
||||
"CR": "Regime Change Importation",
|
||||
}
|
||||
|
||||
# Seleccionar el mapa correcto
|
||||
titles = american_titles if is_american else mexican_titles
|
||||
|
||||
# Obtener el título (normalizar a mayúsculas)
|
||||
invoice_type_upper = invoice_type.upper() if invoice_type else ""
|
||||
title = titles.get(invoice_type_upper, "")
|
||||
|
||||
# Fallback a genéricos si no se encuentra
|
||||
if not title:
|
||||
return "Commercial Invoice" if is_american else "Factura de Importación"
|
||||
|
||||
return title
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
@@ -54,28 +103,49 @@ class FacturaImportacionMexService:
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return 0.0
|
||||
if valor is None:
|
||||
return 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
except: return 0.0
|
||||
except:
|
||||
return 0.0
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
return fraccion_raw
|
||||
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
|
||||
|
||||
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
|
||||
def _obtener_datos_cliente(
|
||||
self, db: Session, client_id: int, rol: str
|
||||
) -> ClienteSchema:
|
||||
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
|
||||
if not main:
|
||||
return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX")
|
||||
|
||||
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
|
||||
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre="Desconocido",
|
||||
direccion="",
|
||||
tax_id="",
|
||||
codigo_postal="",
|
||||
ciudad="",
|
||||
estado="",
|
||||
pais="MEX",
|
||||
)
|
||||
|
||||
addr = (
|
||||
db.query(ClientProviderAddress)
|
||||
.filter(ClientProviderAddress.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
prog = (
|
||||
db.query(ClientProviderPrograms)
|
||||
.filter(ClientProviderPrograms.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre=(main.name or main.short_name) or "S/N",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
num_exterior=(addr.exterior_number or "") if addr else "",
|
||||
num_interior=(addr.interior_number or "") if addr else "",
|
||||
colonia=(addr.neighborhood or "") if addr else "",
|
||||
@@ -83,47 +153,108 @@ class FacturaImportacionMexService:
|
||||
ciudad=(addr.city or "") if addr else "",
|
||||
estado=(addr.state or "") if addr else "",
|
||||
pais=(addr.country or "MEX") if addr else "MEX",
|
||||
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
|
||||
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
|
||||
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
|
||||
tax_id=(
|
||||
prog.tax_id
|
||||
if (prog and prog.tax_id)
|
||||
else (getattr(main, "rfc", "") or "")
|
||||
),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=(
|
||||
prog.prosec_authorization
|
||||
if (prog and prog.prosec and prog.prosec_authorization)
|
||||
else ""
|
||||
),
|
||||
reg_emp=(
|
||||
prog.val_certified_company_registry
|
||||
if (prog and hasattr(prog, "val_certified_company_registry"))
|
||||
else (
|
||||
prog.certified_company_registry
|
||||
if (prog and prog.certified_company_registry)
|
||||
else ""
|
||||
)
|
||||
),
|
||||
cert=(
|
||||
prog.is_certified_company
|
||||
if (prog and prog.is_certified_company)
|
||||
else ""
|
||||
),
|
||||
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
|
||||
)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta:
|
||||
try:
|
||||
if progress_callback: progress_callback(10, "Buscando factura...")
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header: raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
if progress_callback:
|
||||
progress_callback(10, "Buscando factura...")
|
||||
header = (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.id == invoice_id,
|
||||
InvoiceHeader.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not header:
|
||||
raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
compliance = header.compliance_mx
|
||||
compliance = header.compliance_mx
|
||||
logistics = header.logistics if header.logistics else None
|
||||
financials = header.financials if header.financials else None
|
||||
if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...")
|
||||
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
|
||||
|
||||
if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...")
|
||||
if progress_callback:
|
||||
progress_callback(20, "Obteniendo datos de pedimento...")
|
||||
pedimento_id = (
|
||||
compliance.pedimento_id
|
||||
if (compliance and compliance.pedimento_id)
|
||||
else header.related_doc_id
|
||||
)
|
||||
pedimento = (
|
||||
db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first()
|
||||
if pedimento_id
|
||||
else None
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(30, "Obteniendo cliente y proveedor...")
|
||||
proveedor_id = compliance.provider_id if compliance else None
|
||||
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
|
||||
cliente_proveedor = (
|
||||
self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:")
|
||||
if proveedor_id
|
||||
else ClienteSchema(
|
||||
header="Proveedor",
|
||||
nombre="No Asignado",
|
||||
direccion="",
|
||||
tax_id="",
|
||||
codigo_postal="",
|
||||
ciudad="",
|
||||
estado="",
|
||||
pais="",
|
||||
)
|
||||
)
|
||||
|
||||
nombre_agente = ""
|
||||
if compliance and compliance.customs_broker_id:
|
||||
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
|
||||
if broker: nombre_agente = broker.name
|
||||
broker = (
|
||||
db.query(CustomsBroker)
|
||||
.filter(CustomsBroker.id == compliance.customs_broker_id)
|
||||
.first()
|
||||
)
|
||||
if broker:
|
||||
nombre_agente = broker.name
|
||||
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
# Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A)
|
||||
cliente_default = ClienteSchema(
|
||||
header="Importador / consignatario:",
|
||||
nombre=getattr(company, 'name', "Empresa Local"),
|
||||
nombre=getattr(company, "name", "Empresa Local"),
|
||||
direccion="DOMICILIO FISCAL",
|
||||
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
|
||||
tax_id=getattr(company, 'rfc', ""),
|
||||
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
|
||||
num_exterior="",
|
||||
colonia="",
|
||||
codigo_postal="",
|
||||
ciudad="",
|
||||
estado="",
|
||||
pais="MEX",
|
||||
tax_id=getattr(company, "rfc", ""),
|
||||
programa=getattr(company, "program", "IMMEX"),
|
||||
autorizacion=getattr(company, "program_number", ""),
|
||||
)
|
||||
|
||||
# Left Side Logic (Consignatario / Sold To)
|
||||
@@ -131,34 +262,51 @@ class FacturaImportacionMexService:
|
||||
if compliance and compliance.sold_to_id:
|
||||
raw_header = compliance.sold_to_header or "CONSIGNATARIO"
|
||||
clean_header = raw_header.replace("_", " ").capitalize() + ":"
|
||||
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
|
||||
|
||||
cliente_vendido = self._obtener_datos_cliente(
|
||||
db, compliance.sold_to_id, clean_header
|
||||
)
|
||||
|
||||
# Right Side Logic (Enviado A / Shipped To)
|
||||
cliente_enviado = cliente_default
|
||||
if compliance and compliance.shipped_to_id:
|
||||
# Clean header: "enviado_a" -> "Enviado a:"
|
||||
raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO"
|
||||
clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":"
|
||||
|
||||
# Fetch client data
|
||||
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
|
||||
clean_header_shipped = (
|
||||
raw_header_shipped.replace("_", " ").capitalize() + ":"
|
||||
)
|
||||
|
||||
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
||||
# Fetch client data
|
||||
cliente_enviado = self._obtener_datos_cliente(
|
||||
db, compliance.shipped_to_id, clean_header_shipped
|
||||
)
|
||||
|
||||
remesa_valor = (
|
||||
str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
)
|
||||
acuse_valor = (
|
||||
str(compliance.edocument)
|
||||
if (compliance and compliance.edocument)
|
||||
else "N/A"
|
||||
)
|
||||
|
||||
patente_val = ""
|
||||
if pedimento and pedimento.license:
|
||||
patente_val = pedimento.license
|
||||
elif 'broker' in locals() and broker and broker.license:
|
||||
elif "broker" in locals() and broker and broker.license:
|
||||
patente_val = broker.license
|
||||
|
||||
|
||||
# --- Transport Data Fetching ---
|
||||
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
|
||||
transporte_txt = (
|
||||
str(logistics.transport_type)
|
||||
if (logistics and logistics.transport_type)
|
||||
else ""
|
||||
)
|
||||
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
|
||||
|
||||
|
||||
# Init values
|
||||
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
|
||||
placas_val = (
|
||||
(logistics.license_plate or "") if logistics else ""
|
||||
) # Placas Tracto
|
||||
placas_remolque_val = ""
|
||||
transportista_val = (logistics.carrier_id or "") if logistics else ""
|
||||
caat_val = ""
|
||||
@@ -168,46 +316,82 @@ class FacturaImportacionMexService:
|
||||
if logistics:
|
||||
# 1. Transporter (CAAT / SCAC)
|
||||
if logistics.carrier_id:
|
||||
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
|
||||
transporter_obj = (
|
||||
db.query(Transporter)
|
||||
.filter(Transporter.transporter_key == logistics.carrier_id)
|
||||
.first()
|
||||
)
|
||||
if transporter_obj:
|
||||
caat_val = transporter_obj.caat_code or ""
|
||||
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
|
||||
scac_val = (
|
||||
transporter_obj.transport_code or ""
|
||||
) # Mapping transport_code to SCAC
|
||||
transportista_val = transporter_obj.name or logistics.carrier_id
|
||||
|
||||
# 2. Vehicle (Placas Tracto) - Try transport_id first
|
||||
if logistics.transport_id:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
|
||||
veh_obj = (
|
||||
db.query(Vehicle)
|
||||
.filter(Vehicle.vehicle_key == logistics.transport_id)
|
||||
.first()
|
||||
)
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif (
|
||||
logistics.vehicle_num
|
||||
): # Fallback to vehicle_num if populated and transport_id failed/empty
|
||||
veh_obj = (
|
||||
db.query(Vehicle)
|
||||
.filter(Vehicle.vehicle_key == logistics.vehicle_num)
|
||||
.first()
|
||||
)
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
|
||||
# 3. Trailer (Placas Remolque)
|
||||
if logistics.trailer_num:
|
||||
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
|
||||
trl_obj = (
|
||||
db.query(Trailer)
|
||||
.filter(Trailer.trailer_number == logistics.trailer_num)
|
||||
.first()
|
||||
)
|
||||
if trl_obj:
|
||||
placas_remolque_val = trl_obj.plate_number or ""
|
||||
|
||||
# 4. Driver (License)
|
||||
if logistics.carrier_id and logistics.driver_name:
|
||||
# Attempt to find driver by name + carrier
|
||||
drv_obj = db.query(Driver).filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name
|
||||
).first()
|
||||
drv_obj = (
|
||||
db.query(Driver)
|
||||
.filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if drv_obj:
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
|
||||
# Determine Currency
|
||||
moneda_final = getattr(header, 'currency', "USD") or "USD"
|
||||
if currency_code == 'MXN':
|
||||
moneda_final = 'MXN'
|
||||
elif currency_code == 'USD':
|
||||
moneda_final = 'USD'
|
||||
|
||||
factura_schema = FacturaSchema(
|
||||
numero=header.invoice_number or "S/N",
|
||||
titulo_documento=self._get_document_title(header.invoice_type or "", is_american=False),
|
||||
fecha=str(header.invoice_date) if header.invoice_date else "",
|
||||
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
|
||||
moneda=getattr(header, 'currency', "USD") or "USD",
|
||||
moneda=moneda_final,
|
||||
incoterm=(logistics.incoterm or "") if logistics else "",
|
||||
observaciones=header.observation_es or header.observation_en or "",
|
||||
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
|
||||
pedimento=(
|
||||
f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}"
|
||||
if pedimento
|
||||
else ""
|
||||
),
|
||||
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
||||
regimen=header.document_type or "",
|
||||
patente=patente_val,
|
||||
@@ -220,45 +404,74 @@ class FacturaImportacionMexService:
|
||||
caat=caat_val,
|
||||
scac=scac_val,
|
||||
licencia_conductor=licencia_cond_val,
|
||||
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
|
||||
aduana=(
|
||||
compliance.aduana
|
||||
if (compliance and compliance.aduana)
|
||||
else (
|
||||
pedimento.customs_office[:2]
|
||||
if (pedimento and pedimento.customs_office)
|
||||
else ""
|
||||
)
|
||||
),
|
||||
precinto=(logistics.seal_number or "") if logistics else "",
|
||||
destino=(logistics.destination_goods or "") if logistics else "",
|
||||
remesa=remesa_valor, acuse_electronico=acuse_valor
|
||||
remesa=remesa_valor,
|
||||
acuse_electronico=acuse_valor,
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(50, "Procesando partidas...")
|
||||
lines = (
|
||||
db.query(LineItem)
|
||||
.join(Item, LineItem.item_id == Item.id)
|
||||
.filter(Item.invoice_id == header.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if progress_callback: progress_callback(50, "Procesando partidas...")
|
||||
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
|
||||
partidas_list = []
|
||||
|
||||
|
||||
for line in lines:
|
||||
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
|
||||
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
|
||||
qty = (
|
||||
db.query(LineQuantity)
|
||||
.filter(LineQuantity.item_line_id == line.id)
|
||||
.first()
|
||||
)
|
||||
fin = (
|
||||
db.query(LineFinancial)
|
||||
.filter(LineFinancial.item_line_id == line.id)
|
||||
.first()
|
||||
)
|
||||
part_master = db.query(Part).filter(Part.id == line.part_number).first()
|
||||
|
||||
desc_final = "S/D"
|
||||
num_parte_final = str(line.part_number or "S/N")
|
||||
fraccion_raw = ""
|
||||
fraccion_raw = ""
|
||||
origen_final = "MEX"
|
||||
|
||||
if part_master:
|
||||
desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc."
|
||||
desc_final = (
|
||||
part_master.description_spanish
|
||||
or part_master.description_english
|
||||
or "Sin Desc."
|
||||
)
|
||||
num_parte_final = part_master.part_number
|
||||
fraccion_raw = part_master.fraction if part_master.fraction else ""
|
||||
|
||||
|
||||
# Fetch Origin from Master Catalog (FaPart)
|
||||
if part_master.fa_data and part_master.fa_data.origin_country:
|
||||
origen_final = part_master.fa_data.origin_country
|
||||
|
||||
|
||||
fraccion_limpia = fraccion_raw.replace(".", "").strip()
|
||||
if fraccion_limpia:
|
||||
fraccion_limpia = fraccion_limpia[:8].zfill(8)
|
||||
|
||||
# Consultar tabla tariff_fractions
|
||||
fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first()
|
||||
fraccion_db = (
|
||||
db.query(TariffFraction)
|
||||
.filter(TariffFraction.code == fraccion_limpia)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
preferencia_txt = "General"
|
||||
preferencia_txt = "General"
|
||||
advalorem_txt = "0%"
|
||||
fraccion_imprimir = fraccion_raw
|
||||
|
||||
@@ -268,20 +481,20 @@ class FacturaImportacionMexService:
|
||||
if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]:
|
||||
advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%"
|
||||
else:
|
||||
advalorem_txt = "0%"
|
||||
|
||||
advalorem_txt = "0%"
|
||||
|
||||
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
|
||||
else:
|
||||
|
||||
|
||||
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
|
||||
|
||||
# Logic to determine values - Prioritize Specific Currency Columns
|
||||
v_unitario = 0.0
|
||||
v_total = 0.0
|
||||
|
||||
|
||||
if fin:
|
||||
is_mxn = (factura_schema.moneda == 'MXN')
|
||||
|
||||
is_mxn = factura_schema.moneda == "MXN"
|
||||
|
||||
# 1. Try Specific Currency Columns First
|
||||
if is_mxn:
|
||||
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
|
||||
@@ -292,50 +505,83 @@ class FacturaImportacionMexService:
|
||||
|
||||
# 2. Fallback to Generic independently if Specific is 0
|
||||
if not v_unitario:
|
||||
v_unitario = float(fin.commercial_unit_cost or 0.0)
|
||||
|
||||
v_unitario = float(fin.commercial_unit_cost or 0.0)
|
||||
|
||||
if not v_total:
|
||||
v_total = float(fin.total_commercial_value or 0.0)
|
||||
v_total = float(fin.total_commercial_value or 0.0)
|
||||
|
||||
# 3. Calculate from Quantity if still missing
|
||||
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
|
||||
|
||||
|
||||
if cantidad > 0:
|
||||
if v_unitario > 0 and v_total == 0:
|
||||
v_total = v_unitario * cantidad
|
||||
elif v_total > 0 and v_unitario == 0:
|
||||
v_unitario = v_total / cantidad
|
||||
|
||||
partidas_list.append(PartidaSchema(
|
||||
numero_parte=num_parte_final,
|
||||
descripcion=desc_final,
|
||||
fraccion=fraccion_imprimir,
|
||||
origen=origen_final,
|
||||
advalorem=advalorem_txt,
|
||||
preferencia=preferencia_txt,
|
||||
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
|
||||
unidad_medida=qty.weight_unit if qty else "PZA",
|
||||
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
|
||||
clave_bultos=(qty.package_key or "") if qty else "",
|
||||
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
|
||||
peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0),
|
||||
valor_costo_unitario=self.formatear_numero(v_unitario),
|
||||
valor_total=self.formatear_numero(v_total)
|
||||
))
|
||||
# Obtener descripción de la unidad de medida desde la tabla a76.item_lines
|
||||
unidad_desc = ""
|
||||
if line.unit_of_measure:
|
||||
uom = (
|
||||
db.query(UnitOfMeasure)
|
||||
.filter(
|
||||
UnitOfMeasure.id == line.unit_of_measure,
|
||||
UnitOfMeasure.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if uom:
|
||||
unidad_desc = uom.description or uom.code
|
||||
else:
|
||||
unidad_desc = ""
|
||||
|
||||
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
|
||||
partidas_list.append(
|
||||
PartidaSchema(
|
||||
numero_parte=num_parte_final,
|
||||
descripcion=desc_final,
|
||||
fraccion=fraccion_imprimir,
|
||||
origen=origen_final,
|
||||
advalorem=advalorem_txt,
|
||||
preferencia=preferencia_txt,
|
||||
cantidad_importacion=self.formatear_numero(
|
||||
qty.quantity if qty else 0
|
||||
),
|
||||
unidad_medida=unidad_desc,
|
||||
cantidad_bultos=(
|
||||
int(qty.package_quantity)
|
||||
if qty and qty.package_quantity
|
||||
else 0
|
||||
),
|
||||
clave_bultos=(qty.package_key or "") if qty else "",
|
||||
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
|
||||
peso_bruto=self.formatear_numero(
|
||||
qty.gross_weight if qty else 0
|
||||
),
|
||||
valor_costo_unitario=self.formatear_numero(v_unitario),
|
||||
valor_total=self.formatear_numero(v_total),
|
||||
)
|
||||
)
|
||||
|
||||
totales = self.calcular_totales(
|
||||
partidas_list, Decimal(factura_schema.tipo_cambio)
|
||||
)
|
||||
|
||||
return FacturaImportacionCompleta(
|
||||
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado, factura=factura_schema,
|
||||
partidas=partidas_list, totales=totales
|
||||
cliente_proveedor=cliente_proveedor,
|
||||
cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado,
|
||||
factura=factura_schema,
|
||||
partidas=partidas_list,
|
||||
totales=totales,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Service A76: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
|
||||
def calcular_totales(
|
||||
self, partidas: List[PartidaSchema], tipo_cambio: Decimal
|
||||
) -> TotalesSchema:
|
||||
cant = sum(p.cantidad_importacion for p in partidas)
|
||||
valor = sum(p.valor_total for p in partidas)
|
||||
peso_n = sum(p.peso_neto for p in partidas)
|
||||
@@ -343,17 +589,22 @@ class FacturaImportacionMexService:
|
||||
bultos = sum(p.cantidad_bultos for p in partidas)
|
||||
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
|
||||
clave_comun = max(set(claves), key=claves.count) if claves else ""
|
||||
if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
|
||||
if bultos > 1 and clave_comun and not clave_comun.endswith("S"):
|
||||
clave_comun += "S"
|
||||
tc = float(tipo_cambio) if tipo_cambio else 1.0
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
|
||||
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant),
|
||||
bultos_total=bultos,
|
||||
clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n),
|
||||
peso_bruto_total=self.formatear_numero(peso_b),
|
||||
valor_total_total=self.formatear_numero(valor),
|
||||
valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0),
|
||||
)
|
||||
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]:
|
||||
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code)
|
||||
|
||||
if progress_callback: progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
@@ -365,7 +616,7 @@ class FacturaImportacionMexService:
|
||||
comp_logo = db.query(Company).filter(Company.id == company_id).first()
|
||||
if comp_logo and comp_logo.logo:
|
||||
p = Path(comp_logo.logo)
|
||||
|
||||
|
||||
# Logic robusta de búsqueda (igual que en routes.py)
|
||||
target_path = p
|
||||
if not target_path.exists():
|
||||
@@ -377,27 +628,49 @@ class FacturaImportacionMexService:
|
||||
|
||||
if target_path.exists():
|
||||
with open(target_path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
encoded_string = base64.b64encode(image_file.read()).decode(
|
||||
"utf-8"
|
||||
)
|
||||
# Detect MIME type loosely
|
||||
mime = "image/png"
|
||||
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
|
||||
if target_path.suffix.lower() in [".jpg", ".jpeg"]:
|
||||
mime = "image/jpeg"
|
||||
logo_b64 = f"data:{mime};base64,{encoded_string}"
|
||||
except Exception as e:
|
||||
print(f"Error loading logo: {e}")
|
||||
|
||||
context = {
|
||||
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
|
||||
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
|
||||
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
|
||||
'logo_b64': logo_b64
|
||||
"cliente_proveedor": datos.cliente_proveedor.model_dump(),
|
||||
"cliente_vendido": datos.cliente_vendido.model_dump(),
|
||||
"cliente_enviado": datos.cliente_enviado.model_dump(),
|
||||
"factura": datos.factura.model_dump(),
|
||||
"partidas": [p.model_dump() for p in datos.partidas],
|
||||
"totales": datos.totales.model_dump(),
|
||||
"logo_b64": logo_b64,
|
||||
}
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"Factura_{datos.factura.numero}.{formato}"
|
||||
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
|
||||
|
||||
if progress_callback: progress_callback(90, "Generando PDF final...")
|
||||
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
if formato == "html":
|
||||
return html_content.encode("utf-8"), nombre, "text/html"
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(90, "Generando PDF final...")
|
||||
options = {
|
||||
"page-size": "Letter",
|
||||
"margin-top": "0.5in",
|
||||
"margin-right": "0.5in",
|
||||
"margin-bottom": "0.5in",
|
||||
"margin-left": "0.5in",
|
||||
"encoding": "UTF-8",
|
||||
"enable-local-file-access": None,
|
||||
}
|
||||
pdf = pdfkit.from_string(
|
||||
html_content,
|
||||
False,
|
||||
options=options,
|
||||
configuration=self._get_wkhtmltopdf_config(),
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
|
||||
@@ -40,9 +40,10 @@ async def get_task_status(
|
||||
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'"),
|
||||
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)
|
||||
task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type)
|
||||
return {"task_id": task.id, "message": "Generación iniciada"}
|
||||
@@ -5,35 +5,36 @@ from celery import current_task, states
|
||||
from core.database import CoreSessionLocal
|
||||
|
||||
from .mex.service import FacturaImportacionMexService
|
||||
from .usa.service import FacturaImportacionUsaService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@celery_app.task(name="generar_pdf_factura_async", bind=True)
|
||||
def generar_pdf_factura_async(self, invoice_id: int, company_id: int):
|
||||
def generar_pdf_factura_async(self, invoice_id: int, company_id: int, invoice_type: str = 'mexican', currency_code: str = 'ORIGINAL'):
|
||||
|
||||
# 1. Abrimos conexión a la DB
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
logger.info(f"Worker procesando factura {invoice_id}...")
|
||||
logger.info(f"Worker procesando factura {invoice_id} ({invoice_type}, {currency_code})...")
|
||||
|
||||
# 2. Instanciamos el servicio de reportes
|
||||
service = FacturaImportacionMexService()
|
||||
if invoice_type == 'american':
|
||||
service = FacturaImportacionUsaService()
|
||||
else:
|
||||
service = FacturaImportacionMexService()
|
||||
|
||||
# Update state to PROCESSING
|
||||
self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'})
|
||||
|
||||
def progress_callback(progress: int, status: str):
|
||||
self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status})
|
||||
|
||||
# 3. Generamos los bytes del PDF
|
||||
pdf_bytes, nombre, media_type = service.generar_factura_completa(
|
||||
db=db,
|
||||
invoice_id=invoice_id,
|
||||
company_id=company_id,
|
||||
progress_callback=progress_callback
|
||||
progress_callback=progress_callback,
|
||||
currency_code=currency_code
|
||||
)
|
||||
|
||||
# 4. Codificamos a base64 para que viaje seguro por Valkey
|
||||
|
||||
pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8')
|
||||
|
||||
return {
|
||||
@@ -48,5 +49,5 @@ def generar_pdf_factura_async(self, invoice_id: int, company_id: int):
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
finally:
|
||||
# 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres
|
||||
|
||||
db.close()
|
||||
@@ -273,7 +273,7 @@
|
||||
<header>
|
||||
<div class="flex-container clearfix">
|
||||
<div class="width-48" style="position: relative;">
|
||||
<p class="titulo">Factura de Importacion</p>
|
||||
<p class="titulo">{{ factura.titulo_documento }}</p>
|
||||
<div class="cliente" style="height: 1px;"></div>
|
||||
<p class="cliente p-t-1 p-b-1"></p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xml:lang="en">
|
||||
|
||||
<head>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
|
||||
<title>Commercial Invoice - {{ factura.numero }}</title>
|
||||
<style type="text/css">
|
||||
/* ESTILOS EXACTOS DE SCAPII PARA MANTENER EL FORMATO */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Tahoma, sans-serif;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.titulo {
|
||||
font-size: 14pt;
|
||||
padding: 3pt 0 0 6pt;
|
||||
}
|
||||
|
||||
.grande {
|
||||
font-size: 13pt;
|
||||
padding-left: 5pt;
|
||||
}
|
||||
|
||||
.medio-bold {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
padding: 3pt 0 0 3pt;
|
||||
}
|
||||
|
||||
.normal {
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.small-bold {
|
||||
font-size: 8pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tiny-bold {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.mini {
|
||||
font-size: 5pt;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.cliente {
|
||||
border-bottom: 1pt solid black;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1pt solid #808080;
|
||||
}
|
||||
|
||||
.p-t-1 {
|
||||
padding-top: 1pt;
|
||||
}
|
||||
|
||||
.p-t-2 {
|
||||
padding-top: 2pt;
|
||||
}
|
||||
|
||||
.p-t-3 {
|
||||
padding-top: 3pt;
|
||||
}
|
||||
|
||||
.p-t-4 {
|
||||
padding-top: 4pt;
|
||||
}
|
||||
|
||||
.p-t-5 {
|
||||
padding-top: 5pt;
|
||||
}
|
||||
|
||||
.p-t-8 {
|
||||
padding-top: 8pt;
|
||||
}
|
||||
|
||||
.p-t-9 {
|
||||
padding-top: 9pt;
|
||||
}
|
||||
|
||||
.p-l-2 {
|
||||
padding-left: 2pt;
|
||||
}
|
||||
|
||||
.p-l-3 {
|
||||
padding-left: 3pt;
|
||||
}
|
||||
|
||||
.p-l-5 {
|
||||
padding-left: 5pt;
|
||||
}
|
||||
|
||||
.p-l-6 {
|
||||
padding-left: 6pt;
|
||||
}
|
||||
|
||||
.p-l-7 {
|
||||
padding-left: 7pt;
|
||||
}
|
||||
|
||||
.p-l-8 {
|
||||
padding-left: 8pt;
|
||||
}
|
||||
|
||||
.p-l-9 {
|
||||
padding-left: 9pt;
|
||||
}
|
||||
|
||||
.p-l-10 {
|
||||
padding-left: 10pt;
|
||||
}
|
||||
|
||||
.p-r-1 {
|
||||
padding-right: 1pt;
|
||||
}
|
||||
|
||||
.p-r-2 {
|
||||
padding-right: 2pt;
|
||||
}
|
||||
|
||||
.p-r-4 {
|
||||
padding-right: 4pt;
|
||||
}
|
||||
|
||||
.p-r-5 {
|
||||
padding-right: 5pt;
|
||||
}
|
||||
|
||||
.p-r-9 {
|
||||
padding-right: 9pt;
|
||||
}
|
||||
|
||||
.p-b-1 {
|
||||
padding-bottom: 1pt;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 10pt;
|
||||
}
|
||||
|
||||
.h-11 {
|
||||
height: 11pt;
|
||||
}
|
||||
|
||||
.h-14 {
|
||||
height: 14pt;
|
||||
}
|
||||
|
||||
.h-15 {
|
||||
height: 15pt;
|
||||
}
|
||||
|
||||
.h-18 {
|
||||
height: 18pt;
|
||||
}
|
||||
|
||||
.h-22 {
|
||||
height: 22pt;
|
||||
}
|
||||
|
||||
.h-82 {
|
||||
height: 82pt;
|
||||
}
|
||||
|
||||
.h-384 {
|
||||
height: 384pt;
|
||||
}
|
||||
|
||||
.line-1 {
|
||||
line-height: 1pt;
|
||||
}
|
||||
|
||||
.line-7 {
|
||||
line-height: 7pt;
|
||||
}
|
||||
|
||||
.line-8 {
|
||||
line-height: 8pt;
|
||||
}
|
||||
|
||||
.line-9 {
|
||||
line-height: 9pt;
|
||||
}
|
||||
|
||||
.line-10 {
|
||||
line-height: 10pt;
|
||||
}
|
||||
|
||||
.m-l-3 {
|
||||
margin-left: 3pt;
|
||||
}
|
||||
|
||||
.m-l-5 {
|
||||
margin-left: 5.74pt;
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flex-container-end {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.width-48 {
|
||||
width: 48%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.width-48-right {
|
||||
width: 48%;
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.position-relative-centered {
|
||||
position: relative;
|
||||
width: 48%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.full-width-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clearfix::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
table,
|
||||
tbody {
|
||||
vertical-align: top;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<div class="flex-container clearfix">
|
||||
<div class="width-48" style="position: relative;">
|
||||
<p class="titulo">{{ factura.titulo_documento }}</p>
|
||||
<div class="cliente" style="height: 1px;"></div>
|
||||
<p class="cliente p-t-1 p-b-1"></p>
|
||||
</div>
|
||||
<div class="width-48-right">
|
||||
<p class="p-l-5 line-1"><span></span></p>
|
||||
<p class="p-t-2"><br></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container-end clearfix" style="overflow: visible;">
|
||||
<div class="position-relative-centered">
|
||||
{% if logo_b64 %}
|
||||
<div style="position: absolute; top: 10pt; left: 0;">
|
||||
<img src="{{ logo_b64 }}" style="max-height: 70pt; max-width: 120pt;" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="margin-left: 130pt; padding-top: 25pt; text-align: left;">
|
||||
<h1 class="cliente">{{ cliente_proveedor.header }}</h1>
|
||||
<p>{{ cliente_proveedor.nombre }}</p>
|
||||
<p>{{ cliente_proveedor.direccion }}
|
||||
{% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %}
|
||||
{% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %}
|
||||
</p>
|
||||
<p>{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} Zip Code: {{
|
||||
cliente_proveedor.codigo_postal }}{% endif %}</p>
|
||||
<p>{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}</p>
|
||||
<p>TAX ID: {{ cliente_proveedor.tax_id }}
|
||||
{% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %}
|
||||
{{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><br></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position: relative;">
|
||||
<table cellspacing="0" class="m-l-3" style="float: right; text-align: left;">
|
||||
<tbody>
|
||||
<tr class="h-18">
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
|
||||
<p class="medio-bold">INVOICE:</p>
|
||||
</td>
|
||||
<td class="border" colspan="3" style="width:176pt">
|
||||
<p class="grande">{{ factura.numero }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="h-11">
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
|
||||
<p class="normal p-l-3 line-9">Date:</p>
|
||||
</td>
|
||||
<td class="border" style="width:72pt">
|
||||
<p class="normal p-l-2 line-9">{{ factura.fecha }}</p>
|
||||
</td>
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
|
||||
<p class="normal p-l-2 line-9">Ex. Rate:</p>
|
||||
</td>
|
||||
<td class="border" style="width:52pt">
|
||||
<p class="normal center line-9">{{ factura.tipo_cambio }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="h-11">
|
||||
<td bgcolor="#E4E4E4" class="border">
|
||||
<p class="tiny-bold p-l-3 line-9">INCOTERM:</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="tiny p-l-3 line-9">{{ factura.incoterm or '' }}</p>
|
||||
</td>
|
||||
<td bgcolor="#E4E4E4" class="border">
|
||||
<p class="tiny-bold p-l-3 line-9">Customs:</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="tiny p-l-3 line-9 bottom">{{ factura.aduana }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-container clearfix">
|
||||
<div class="width-48">
|
||||
<h1 class="p-t-5 p-l-5 line-10 cliente">{{ cliente_vendido.header }}</h1>
|
||||
<p class="p-l-5 line-8">{{ cliente_vendido.nombre }}</p>
|
||||
<p class="p-l-5">{{ cliente_vendido.direccion }}
|
||||
{% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %}
|
||||
{% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} Zip Code: {{
|
||||
cliente_vendido.codigo_postal }}{% endif %}</p>
|
||||
<p class="p-l-5">{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }}
|
||||
</p>
|
||||
<p class="p-l-5">Tax ID: {{ cliente_vendido.tax_id }}
|
||||
{% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %}
|
||||
{{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">
|
||||
{% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %}
|
||||
{% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %}
|
||||
{% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="width-48-right" style="text-align: left;">
|
||||
<h1 class="p-t-5 p-l-5 line-10 cliente full-width-block">{{ cliente_enviado.header }}</h1>
|
||||
<p class="p-l-5 line-8">{{ cliente_enviado.nombre }}</p>
|
||||
<p class="p-l-5">{{ cliente_enviado.direccion }}
|
||||
{% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %}
|
||||
{% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} Zip Code: {{
|
||||
cliente_enviado.codigo_postal }}{% endif %}</p>
|
||||
<p class="p-l-5">{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}
|
||||
</p>
|
||||
<p class="p-l-5">Tax ID: {{ cliente_enviado.tax_id }}
|
||||
{% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %}
|
||||
{{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">
|
||||
{% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %}
|
||||
{% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %}
|
||||
{% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="p-t-8"><br /></p>
|
||||
</header>
|
||||
|
||||
<table cellspacing="0" class="m-l-5" style="width: 99%; max-width: 580pt;">
|
||||
<thead>
|
||||
<tr class="h-10">
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:60pt">
|
||||
<p class="tiny line-9">Carrier:</p>
|
||||
</td>
|
||||
<td class="border" colspan="4" style="width:120pt">
|
||||
<p class="tiny">{{ factura.transportista }}</p>
|
||||
</td>
|
||||
<td class="border" style="width:40pt">
|
||||
<p class="tiny">SCAC: {{ factura.scac }}</p>
|
||||
</td>
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:40pt">
|
||||
<p class="tiny">INCOTERM:</p>
|
||||
</td>
|
||||
<td class="border" style="width:40pt">
|
||||
<p class="tiny">{{ factura.incoterm }}</p>
|
||||
</td>
|
||||
<td class="border" style="width:50pt">
|
||||
<p class="tiny line-9">Customs: <span class="mini">{{ factura.aduana }}</span> / Ped: <span
|
||||
class="mini">{{ factura.pedimento }}</span></p>
|
||||
</td>
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:20pt">
|
||||
<p><br /></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="h-10">
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:60pt">
|
||||
<p class="tiny line-9">Transport:</p>
|
||||
</td>
|
||||
<td class="border" colspan="4" style="width:120pt">
|
||||
<p class="tiny">{{ factura.transporte }}: {{ factura.num_transporte }}</p>
|
||||
</td>
|
||||
<td class="border" colspan="3" style="width:80pt">
|
||||
<p class="tiny">CAAT: {{ factura.caat }}</p>
|
||||
</td>
|
||||
<td class="border" colspan="2" style="width:100pt">
|
||||
<p class="tiny p-t-1 line-8">Plates: {{ factura.placas or '' }} / Trl: {{ factura.placas_remolque or
|
||||
'' }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="h-10">
|
||||
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:80pt">
|
||||
<p class="tiny p-l-1 line-9">Driver/Lic:</p>
|
||||
</td>
|
||||
<td class="border" colspan="8">
|
||||
<p class="tiny p-l-3">{{ factura.licencia_conductor or 'N/A' }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="h-10">
|
||||
<td class="border" rowspan="2">
|
||||
<p class="tiny-bold p-t-5 center">Line</p>
|
||||
</td>
|
||||
<td class="border" rowspan="2">
|
||||
<p class="tiny-bold p-l-2 line-10">Part Number</p>
|
||||
<p class="tiny-bold p-l-2 line-10">Description</p>
|
||||
</td>
|
||||
<td class="border" colspan="3">
|
||||
<p class="tiny-bold center line-9">Commercial</p>
|
||||
</td>
|
||||
<td class="border" style="width:50pt">
|
||||
<p class="tiny-bold center line-9">Packaging</p>
|
||||
</td>
|
||||
<td class="border" colspan="2">
|
||||
<p class="tiny-bold center line-9">Weight (KGS)</p>
|
||||
</td>
|
||||
<td class="border" colspan="2">
|
||||
<p class="tiny-bold line-9 center">Values</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="h-11">
|
||||
<td class="border center" colspan="2">
|
||||
<p class="mini p-l-2">Quantity</p>
|
||||
</td>
|
||||
<td class="border center">
|
||||
<p class="mini p-l-1">U.M.</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Type</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Net</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Gross</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Unit</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Total</p>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="h-384" style="width: 100%;">
|
||||
{% for partida in partidas %}
|
||||
<tr>
|
||||
<td class="border">
|
||||
<p class="mini p-t-3 center">{{ loop.index }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-1" style="font-weight: bold;">{{ partida.numero_parte }}</p>
|
||||
<p class="mini">{{ partida.descripcion }}</p>
|
||||
<p class="mini">HTS Code: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}</p>
|
||||
|
||||
</td>
|
||||
<td class="border" colspan="2">
|
||||
<p class="mini p-t-2 center">{{ partida.cantidad_importacion }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 center">{{ partida.unidad_medida }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 center">
|
||||
{% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %}
|
||||
{{ partida.clave_bultos }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-3 center">{{ partida.peso_neto }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-3 center">{{ partida.peso_bruto }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 center">${{ partida.valor_costo_unitario }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 right p-r-2">${{ partida.valor_total }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr class="h-14">
|
||||
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:123pt">
|
||||
<p class="small-bold p-t-2 p-l-3 line-10">
|
||||
<span>Remarks:</span>
|
||||
<span class="small-bold" style="float: right; margin-right: 2pt;">TOTALS</span>
|
||||
</p>
|
||||
</td>
|
||||
<td class="border" colspan="2" style="width:28pt">
|
||||
<p class="tiny p-t-5 p-r-1 line-8 center">{{ totales.cantidad_total }}</p>
|
||||
</td>
|
||||
<td class="border" style="width:22pt"></td>
|
||||
<td class="border" style="width:40pt">
|
||||
<p class="tiny p-t-4 center line-8">
|
||||
{% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %}
|
||||
<span>{{ totales.clave_bultos or '' }}</span>
|
||||
</p>
|
||||
</td>
|
||||
<td class="border" style="width:27pt">
|
||||
<p class="tiny p-t-5 center line-8">{{ totales.peso_neto_total }}</p>
|
||||
</td>
|
||||
<td class="border" style="width:28pt">
|
||||
<p class="tiny p-t-5 center line-8">{{ totales.peso_bruto_total }}</p>
|
||||
</td>
|
||||
<td style="width:30pt"></td>
|
||||
<td class="border" style="width:35pt">
|
||||
<p class="tiny p-t-5 right line-8 p-r-2">${{ totales.valor_total_total }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" class="border" style="height: 100pt; text-align:start; vertical-align: top;">
|
||||
<p class="mini p-l-2 p-t-2">{{ factura.observaciones }}</p>
|
||||
</td>
|
||||
<td colspan="8" style="width:336pt; vertical-align: bottom; height: 100%;">
|
||||
<p style="border-bottom: 1pt solid black; width: 80%; margin: 0 auto 2pt auto;"></p>
|
||||
<p class="normal center" style="margin-bottom: 0;">{{ cliente_proveedor.nombre }}</p>
|
||||
<p style="margin-bottom: 0;"><br /></p>
|
||||
<p class="small-bold center line-7" style="margin-bottom: 0;">Values expressed in: {{ factura.moneda
|
||||
}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" style="width:580pt; vertical-align: top; height: 100%;">
|
||||
<p class="p-t-8"><br /></p>
|
||||
<p class="p-l-5 line-8 tiny-bold"></p>
|
||||
<p class="normal p-l-5 line-9">I declare under penalty of perjury that the information contained in
|
||||
this document is true and correct.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,403 @@
|
||||
import shutil
|
||||
import base64
|
||||
import pdfkit
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, List, Callable, Optional
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# --- MODELOS ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.items.line_financials.models import LineFinancial
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
)
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.pedmientos.models import Pedimentos
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
# --- TRANSPORTATION MODELS ---
|
||||
from api.v1.modules.a76.transportation.transporters.models import Transporter
|
||||
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
|
||||
from api.v1.modules.a76.transportation.trailers.models import Trailer
|
||||
from api.v1.modules.a76.transportation.drivers.models import Driver
|
||||
|
||||
# --- MODELO DE FRACCIONES ---
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
|
||||
# --- SCHEMAS ---
|
||||
from .schemas import (
|
||||
ClienteSchema, PartidaSchema, TotalesSchema,
|
||||
FacturaSchema, FacturaImportacionCompleta
|
||||
)
|
||||
|
||||
class FacturaImportacionMexService:
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent.parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
self.template = self.jinja_env.get_template('factura_mex_ver.html')
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
except: return 0.0
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
return fraccion_raw
|
||||
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
|
||||
|
||||
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
|
||||
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
|
||||
if not main:
|
||||
return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX")
|
||||
|
||||
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
|
||||
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
|
||||
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre=(main.name or main.short_name) or "S/N",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
num_exterior=(addr.exterior_number or "") if addr else "",
|
||||
num_interior=(addr.interior_number or "") if addr else "",
|
||||
colonia=(addr.neighborhood or "") if addr else "",
|
||||
codigo_postal=(addr.postal_code or "") if addr else "",
|
||||
ciudad=(addr.city or "") if addr else "",
|
||||
estado=(addr.state or "") if addr else "",
|
||||
pais=(addr.country or "MEX") if addr else "MEX",
|
||||
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
|
||||
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
|
||||
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
|
||||
),
|
||||
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
|
||||
)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
|
||||
try:
|
||||
if progress_callback: progress_callback(10, "Buscando factura...")
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header: raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
compliance = header.compliance_mx
|
||||
logistics = header.logistics if header.logistics else None
|
||||
financials = header.financials if header.financials else None
|
||||
if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...")
|
||||
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
|
||||
|
||||
if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...")
|
||||
proveedor_id = compliance.provider_id if compliance else None
|
||||
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
|
||||
|
||||
nombre_agente = ""
|
||||
if compliance and compliance.customs_broker_id:
|
||||
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
|
||||
if broker: nombre_agente = broker.name
|
||||
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
# Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A)
|
||||
cliente_default = ClienteSchema(
|
||||
header="Importador / consignatario:",
|
||||
nombre=getattr(company, 'name', "Empresa Local"),
|
||||
direccion="DOMICILIO FISCAL",
|
||||
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
|
||||
tax_id=getattr(company, 'rfc', ""),
|
||||
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
|
||||
)
|
||||
|
||||
# Left Side Logic (Consignatario / Sold To)
|
||||
cliente_vendido = cliente_default
|
||||
if compliance and compliance.sold_to_id:
|
||||
raw_header = compliance.sold_to_header or "CONSIGNATARIO"
|
||||
clean_header = raw_header.replace("_", " ").capitalize() + ":"
|
||||
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
|
||||
|
||||
# Right Side Logic (Enviado A / Shipped To)
|
||||
cliente_enviado = cliente_default
|
||||
if compliance and compliance.shipped_to_id:
|
||||
# Clean header: "enviado_a" -> "Enviado a:"
|
||||
raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO"
|
||||
clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":"
|
||||
|
||||
# Fetch client data
|
||||
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
|
||||
|
||||
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
||||
|
||||
patente_val = ""
|
||||
if pedimento and pedimento.license:
|
||||
patente_val = pedimento.license
|
||||
elif 'broker' in locals() and broker and broker.license:
|
||||
patente_val = broker.license
|
||||
|
||||
|
||||
# --- Transport Data Fetching ---
|
||||
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
|
||||
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
|
||||
|
||||
# Init values
|
||||
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
|
||||
placas_remolque_val = ""
|
||||
transportista_val = (logistics.carrier_id or "") if logistics else ""
|
||||
caat_val = ""
|
||||
scac_val = ""
|
||||
licencia_cond_val = ""
|
||||
|
||||
if logistics:
|
||||
# 1. Transporter (CAAT / SCAC)
|
||||
if logistics.carrier_id:
|
||||
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
|
||||
if transporter_obj:
|
||||
caat_val = transporter_obj.caat_code or ""
|
||||
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
|
||||
transportista_val = transporter_obj.name or logistics.carrier_id
|
||||
|
||||
# 2. Vehicle (Placas Tracto) - Try transport_id first
|
||||
if logistics.transport_id:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
|
||||
# 3. Trailer (Placas Remolque)
|
||||
if logistics.trailer_num:
|
||||
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
|
||||
if trl_obj:
|
||||
placas_remolque_val = trl_obj.plate_number or ""
|
||||
|
||||
# 4. Driver (License)
|
||||
if logistics.carrier_id and logistics.driver_name:
|
||||
# Attempt to find driver by name + carrier
|
||||
drv_obj = db.query(Driver).filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name
|
||||
).first()
|
||||
if drv_obj:
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
|
||||
factura_schema = FacturaSchema(
|
||||
numero=header.invoice_number or "S/N",
|
||||
fecha=str(header.invoice_date) if header.invoice_date else "",
|
||||
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
|
||||
moneda=getattr(header, 'currency', "USD") or "USD",
|
||||
incoterm=(logistics.incoterm or "") if logistics else "",
|
||||
observaciones=header.observation_es or header.observation_en or "",
|
||||
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
|
||||
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
||||
regimen=header.document_type or "",
|
||||
patente=patente_val,
|
||||
agente_aduanal=nombre_agente,
|
||||
transporte=transporte_txt,
|
||||
num_transporte=num_transporte_val,
|
||||
placas=placas_val,
|
||||
placas_remolque=placas_remolque_val,
|
||||
transportista=transportista_val,
|
||||
caat=caat_val,
|
||||
scac=scac_val,
|
||||
licencia_conductor=licencia_cond_val,
|
||||
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
|
||||
precinto=(logistics.seal_number or "") if logistics else "",
|
||||
destino=(logistics.destination_goods or "") if logistics else "",
|
||||
remesa=remesa_valor, acuse_electronico=acuse_valor
|
||||
)
|
||||
|
||||
if progress_callback: progress_callback(50, "Procesando partidas...")
|
||||
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
|
||||
partidas_list = []
|
||||
|
||||
for line in lines:
|
||||
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
|
||||
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
|
||||
part_master = db.query(Part).filter(Part.id == line.part_number).first()
|
||||
|
||||
desc_final = "S/D"
|
||||
num_parte_final = str(line.part_number or "S/N")
|
||||
fraccion_raw = ""
|
||||
origen_final = "MEX"
|
||||
|
||||
if part_master:
|
||||
desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc."
|
||||
num_parte_final = part_master.part_number
|
||||
fraccion_raw = part_master.fraction if part_master.fraction else ""
|
||||
|
||||
# Fetch Origin from Master Catalog (FaPart)
|
||||
if part_master.fa_data and part_master.fa_data.origin_country:
|
||||
origen_final = part_master.fa_data.origin_country
|
||||
|
||||
|
||||
fraccion_limpia = fraccion_raw.replace(".", "").strip()
|
||||
if fraccion_limpia:
|
||||
fraccion_limpia = fraccion_limpia[:8].zfill(8)
|
||||
|
||||
# Consultar tabla tariff_fractions
|
||||
fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first()
|
||||
|
||||
|
||||
preferencia_txt = "General"
|
||||
advalorem_txt = "0%"
|
||||
fraccion_imprimir = fraccion_raw
|
||||
|
||||
if fraccion_db:
|
||||
# Si el valor en BD es None, "0", o vacío, dejarlo como "0%" o "EXENTO"
|
||||
adv_db = fraccion_db.adv_impo
|
||||
if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]:
|
||||
advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%"
|
||||
else:
|
||||
advalorem_txt = "0%"
|
||||
|
||||
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
|
||||
else:
|
||||
|
||||
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
|
||||
|
||||
# Logic to determine values - Prioritize Specific Currency Columns
|
||||
v_unitario = 0.0
|
||||
v_total = 0.0
|
||||
|
||||
if fin:
|
||||
is_mxn = (factura_schema.moneda == 'MXN')
|
||||
|
||||
# 1. Try Specific Currency Columns First
|
||||
if is_mxn:
|
||||
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
|
||||
v_total = float(fin.value_commercial_mxn or 0.0)
|
||||
else:
|
||||
v_unitario = float(fin.unit_cost_commercial_usd or 0.0)
|
||||
v_total = float(fin.value_commercial_usd or 0.0)
|
||||
|
||||
# 2. Fallback to Generic independently if Specific is 0
|
||||
if not v_unitario:
|
||||
v_unitario = float(fin.commercial_unit_cost or 0.0)
|
||||
|
||||
if not v_total:
|
||||
v_total = float(fin.total_commercial_value or 0.0)
|
||||
|
||||
# 3. Calculate from Quantity if still missing
|
||||
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
|
||||
|
||||
if cantidad > 0:
|
||||
if v_unitario > 0 and v_total == 0:
|
||||
v_total = v_unitario * cantidad
|
||||
elif v_total > 0 and v_unitario == 0:
|
||||
v_unitario = v_total / cantidad
|
||||
|
||||
partidas_list.append(PartidaSchema(
|
||||
numero_parte=num_parte_final,
|
||||
descripcion=desc_final,
|
||||
fraccion=fraccion_imprimir,
|
||||
origen=origen_final,
|
||||
advalorem=advalorem_txt,
|
||||
preferencia=preferencia_txt,
|
||||
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
|
||||
unidad_medida=qty.weight_unit if qty else "PZA",
|
||||
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
|
||||
clave_bultos=(qty.package_key or "") if qty else "",
|
||||
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
|
||||
peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0),
|
||||
valor_costo_unitario=self.formatear_numero(v_unitario),
|
||||
valor_total=self.formatear_numero(v_total)
|
||||
))
|
||||
|
||||
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
|
||||
|
||||
return FacturaImportacionCompleta(
|
||||
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado, factura=factura_schema,
|
||||
partidas=partidas_list, totales=totales
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Service A76: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
|
||||
cant = sum(p.cantidad_importacion for p in partidas)
|
||||
valor = sum(p.valor_total for p in partidas)
|
||||
peso_n = sum(p.peso_neto for p in partidas)
|
||||
peso_b = sum(p.peso_bruto for p in partidas)
|
||||
bultos = sum(p.cantidad_bultos for p in partidas)
|
||||
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
|
||||
clave_comun = max(set(claves), key=claves.count) if claves else ""
|
||||
if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
|
||||
tc = float(tipo_cambio) if tipo_cambio else 1.0
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
|
||||
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
|
||||
)
|
||||
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
|
||||
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
|
||||
|
||||
if progress_callback: progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
# LOGO LOGIC
|
||||
logo_b64 = None
|
||||
try:
|
||||
# Fetch company to get logo path
|
||||
|
||||
comp_logo = db.query(Company).filter(Company.id == company_id).first()
|
||||
if comp_logo and comp_logo.logo:
|
||||
p = Path(comp_logo.logo)
|
||||
|
||||
# Logic robusta de búsqueda (igual que en routes.py)
|
||||
target_path = p
|
||||
if not target_path.exists():
|
||||
# Intentar en la ruta estándar: app_data/logos/{id}/{nombre}
|
||||
# Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió
|
||||
fallback = Path(f"app_data/logos/{company_id}") / p.name
|
||||
if fallback.exists():
|
||||
target_path = fallback
|
||||
|
||||
if target_path.exists():
|
||||
with open(target_path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
# Detect MIME type loosely
|
||||
mime = "image/png"
|
||||
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
|
||||
logo_b64 = f"data:{mime};base64,{encoded_string}"
|
||||
except Exception as e:
|
||||
print(f"Error loading logo: {e}")
|
||||
|
||||
context = {
|
||||
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
|
||||
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
|
||||
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
|
||||
'logo_b64': logo_b64
|
||||
}
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"Factura_{datos.factura.numero}.{formato}"
|
||||
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
|
||||
|
||||
if progress_callback: progress_callback(90, "Generando PDF final...")
|
||||
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
@@ -0,0 +1,444 @@
|
||||
import shutil
|
||||
import base64
|
||||
import pdfkit
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, List, Callable, Optional
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# --- MODELOS ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.items.line_financials.models import LineFinancial
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
)
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.pedmientos.models import Pedimentos
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
# --- TRANSPORTATION MODELS ---
|
||||
from api.v1.modules.a76.transportation.transporters.models import Transporter
|
||||
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
|
||||
from api.v1.modules.a76.transportation.trailers.models import Trailer
|
||||
from api.v1.modules.a76.transportation.drivers.models import Driver
|
||||
|
||||
# --- MODELO DE FRACCIONES ---
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
|
||||
# --- SCHEMAS ---
|
||||
# Reuse schemas from neighbor package as they fit the same data structure
|
||||
from ..mex.schemas import (
|
||||
ClienteSchema, PartidaSchema, TotalesSchema,
|
||||
FacturaSchema, FacturaImportacionCompleta
|
||||
)
|
||||
|
||||
class FacturaImportacionUsaService:
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent.parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
self.template = self.jinja_env.get_template('factura_usa_ver.html')
|
||||
|
||||
def _get_document_title(self, invoice_type: str, is_american: bool = True) -> str:
|
||||
"""
|
||||
Determina el título del documento basado en el tipo de factura.
|
||||
|
||||
Args:
|
||||
invoice_type: Tipo de factura (TEM, DEF, MEX, CR)
|
||||
is_american: Si es factura americana (True) o mexicana (False)
|
||||
|
||||
Returns:
|
||||
Título formateado para la factura
|
||||
"""
|
||||
# Mapeo para facturas mexicanas
|
||||
mexican_titles = {
|
||||
"MEX": "Factura Importación Compras Mexicanas",
|
||||
"DEF": "Importación Definitiva",
|
||||
"TEM": "Importación Temporal",
|
||||
"CR": "Importación de Cambio de Régimen",
|
||||
}
|
||||
|
||||
# Mapeo para facturas americanas
|
||||
american_titles = {
|
||||
"MEX": "Mexican Purchases Import Invoice",
|
||||
"DEF": "Definitive Importation",
|
||||
"TEM": "Temporary Importation",
|
||||
"CR": "Regime Change Importation",
|
||||
}
|
||||
|
||||
# Seleccionar el mapa correcto
|
||||
titles = american_titles if is_american else mexican_titles
|
||||
|
||||
# Obtener el título (normalizar a mayúsculas)
|
||||
invoice_type_upper = invoice_type.upper() if invoice_type else ""
|
||||
title = titles.get(invoice_type_upper, "")
|
||||
|
||||
# Fallback a genéricos si no se encuentra
|
||||
if not title:
|
||||
return "Commercial Invoice" if is_american else "Factura de Importación"
|
||||
|
||||
return title
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
except: return 0.0
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
return fraccion_raw
|
||||
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
|
||||
|
||||
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
|
||||
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
|
||||
if not main:
|
||||
return ClienteSchema(header=rol, nombre="Unknown", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="USA")
|
||||
|
||||
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
|
||||
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
|
||||
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre=(main.name or main.short_name) or "N/A",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
num_exterior=(addr.exterior_number or "") if addr else "",
|
||||
num_interior=(addr.interior_number or "") if addr else "",
|
||||
colonia=(addr.neighborhood or "") if addr else "",
|
||||
codigo_postal=(addr.postal_code or "") if addr else "",
|
||||
ciudad=(addr.city or "") if addr else "",
|
||||
estado=(addr.state or "") if addr else "",
|
||||
pais=(addr.country or "USA") if addr else "USA",
|
||||
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
|
||||
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
|
||||
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
|
||||
),
|
||||
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
|
||||
)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta:
|
||||
try:
|
||||
if progress_callback: progress_callback(10, "Searching invoice...")
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header: raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
compliance = header.compliance_mx
|
||||
logistics = header.logistics if header.logistics else None
|
||||
financials = header.financials if header.financials else None
|
||||
if progress_callback: progress_callback(20, "Fetching entry data...")
|
||||
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
|
||||
|
||||
if progress_callback: progress_callback(30, "Fetching client and supplier...")
|
||||
proveedor_id = compliance.provider_id if compliance else None
|
||||
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Supplier:") if proveedor_id else ClienteSchema(header="Supplier", nombre="Unassigned", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
|
||||
|
||||
nombre_agente = ""
|
||||
if compliance and compliance.customs_broker_id:
|
||||
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
|
||||
if broker: nombre_agente = broker.name
|
||||
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
# Datos Default (Company/Importer)
|
||||
cliente_default = ClienteSchema(
|
||||
header="Importer / Consignee:",
|
||||
nombre=getattr(company, 'name', "Local Company"),
|
||||
direccion="FISCAL ADDRESS",
|
||||
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
|
||||
tax_id=getattr(company, 'rfc', ""),
|
||||
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
|
||||
)
|
||||
|
||||
# Left Side Logic (Sold To)
|
||||
cliente_vendido = cliente_default
|
||||
if compliance and compliance.sold_to_id:
|
||||
# Force English header for American Invoice
|
||||
clean_header = "Sold To:"
|
||||
# raw_header = compliance.sold_to_header or "SOLD_TO"
|
||||
# clean_header = raw_header.replace("_", " ").title() + ":"
|
||||
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
|
||||
|
||||
# Right Side Logic (Shipped To)
|
||||
cliente_enviado = cliente_default
|
||||
if compliance and compliance.shipped_to_id:
|
||||
# Force English header for American Invoice
|
||||
clean_header_shipped = "Shipped To:"
|
||||
# raw_header_shipped = compliance.shipped_to_header or "SHIPPED_TO"
|
||||
# clean_header_shipped = raw_header_shipped.replace("_", " ").title() + ":"
|
||||
|
||||
# Fetch client data
|
||||
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
|
||||
|
||||
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
||||
|
||||
patente_val = ""
|
||||
if pedimento and pedimento.license:
|
||||
patente_val = pedimento.license
|
||||
elif 'broker' in locals() and broker and broker.license:
|
||||
patente_val = broker.license
|
||||
|
||||
|
||||
# --- Transport Data Fetching ---
|
||||
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
|
||||
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
|
||||
|
||||
# Init values
|
||||
placas_val = (logistics.license_plate or "") if logistics else "" # Plates
|
||||
placas_remolque_val = ""
|
||||
transportista_val = (logistics.carrier_id or "") if logistics else ""
|
||||
caat_val = ""
|
||||
scac_val = ""
|
||||
licencia_cond_val = ""
|
||||
|
||||
if logistics:
|
||||
# 1. Transporter (CAAT / SCAC)
|
||||
if logistics.carrier_id:
|
||||
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
|
||||
if transporter_obj:
|
||||
caat_val = transporter_obj.caat_code or ""
|
||||
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
|
||||
transportista_val = transporter_obj.name or logistics.carrier_id
|
||||
|
||||
# 2. Vehicle (Plates)
|
||||
if logistics.transport_id:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif logistics.vehicle_num:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
|
||||
# 3. Trailer
|
||||
if logistics.trailer_num:
|
||||
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
|
||||
if trl_obj:
|
||||
placas_remolque_val = trl_obj.plate_number or ""
|
||||
|
||||
# 4. Driver (License)
|
||||
if logistics.carrier_id and logistics.driver_name:
|
||||
drv_obj = db.query(Driver).filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name
|
||||
).first()
|
||||
if drv_obj:
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
|
||||
# Determine Currency
|
||||
moneda_final = getattr(header, 'currency', "USD") or "USD"
|
||||
if currency_code == 'MXN':
|
||||
moneda_final = 'MXN'
|
||||
elif currency_code == 'USD':
|
||||
moneda_final = 'USD'
|
||||
|
||||
factura_schema = FacturaSchema(
|
||||
numero=header.invoice_number or "N/A",
|
||||
titulo_documento=self._get_document_title(header.invoice_type or "", is_american=True),
|
||||
fecha=str(header.invoice_date) if header.invoice_date else "",
|
||||
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
|
||||
moneda=moneda_final,
|
||||
incoterm=(logistics.incoterm or "") if logistics else "",
|
||||
observaciones=header.observation_es or header.observation_en or "",
|
||||
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
|
||||
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
||||
regimen=header.document_type or "",
|
||||
patente=patente_val,
|
||||
agente_aduanal=nombre_agente,
|
||||
transporte=transporte_txt,
|
||||
num_transporte=num_transporte_val,
|
||||
placas=placas_val,
|
||||
placas_remolque=placas_remolque_val,
|
||||
transportista=transportista_val,
|
||||
caat=caat_val,
|
||||
scac=scac_val,
|
||||
licencia_conductor=licencia_cond_val,
|
||||
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
|
||||
precinto=(logistics.seal_number or "") if logistics else "",
|
||||
destino=(logistics.destination_goods or "") if logistics else "",
|
||||
remesa=remesa_valor, acuse_electronico=acuse_valor
|
||||
)
|
||||
|
||||
if progress_callback: progress_callback(50, "Processing items...")
|
||||
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
|
||||
partidas_list = []
|
||||
|
||||
for line in lines:
|
||||
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
|
||||
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
|
||||
part_master = db.query(Part).filter(Part.id == line.part_number).first()
|
||||
|
||||
desc_final = "N/D"
|
||||
num_parte_final = str(line.part_number or "N/A")
|
||||
fraccion_raw = ""
|
||||
origen_final = "MEX"
|
||||
|
||||
if part_master:
|
||||
# Prefer English description if available, else Spanish
|
||||
desc_final = part_master.description_english or part_master.description_spanish or "No Desc."
|
||||
num_parte_final = part_master.part_number
|
||||
# Prefer US Fraction (HTS) if available
|
||||
fraccion_raw = part_master.us_fraction if part_master.us_fraction else ""
|
||||
|
||||
if part_master.fa_data and part_master.fa_data.origin_country:
|
||||
origen_final = part_master.fa_data.origin_country
|
||||
|
||||
|
||||
# FRACTION LOGIC: Use US Fraction (us_fraction) if available, otherwise blank
|
||||
fraccion_imprimir = ""
|
||||
|
||||
# Check part master US fraction
|
||||
if part_master and part_master.us_fraction:
|
||||
fraccion_imprimir = part_master.us_fraction.strip()
|
||||
|
||||
# Optional: Format if needed, but raw is usually fine for US HTS
|
||||
# If valid US fraction logic requires looking up in DB, we could add that here.
|
||||
# For now, per requirement: "Si no tiene, pues de queda en blanco"
|
||||
|
||||
# Default "General" and "0%" if no specific logic for US duties yet
|
||||
preferencia_txt = "General"
|
||||
advalorem_txt = "0%"
|
||||
|
||||
# Prioritize USD for American Invoice logic if available?
|
||||
# Sticking to same logic as Mex for now but could prioritize USD columns.
|
||||
# Actually, duplicate logic from mex service for now to ensure consistency.
|
||||
|
||||
v_unitario = 0.0
|
||||
v_total = 0.0
|
||||
|
||||
if fin:
|
||||
is_mxn = (factura_schema.moneda == 'MXN')
|
||||
|
||||
if is_mxn:
|
||||
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
|
||||
v_total = float(fin.value_commercial_mxn or 0.0)
|
||||
else:
|
||||
v_unitario = float(fin.unit_cost_commercial_usd or 0.0)
|
||||
v_total = float(fin.value_commercial_usd or 0.0)
|
||||
|
||||
if not v_unitario:
|
||||
v_unitario = float(fin.commercial_unit_cost or 0.0)
|
||||
|
||||
if not v_total:
|
||||
v_total = float(fin.total_commercial_value or 0.0)
|
||||
|
||||
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
|
||||
|
||||
if cantidad > 0:
|
||||
if v_unitario > 0 and v_total == 0:
|
||||
v_total = v_unitario * cantidad
|
||||
elif v_total > 0 and v_unitario == 0:
|
||||
v_unitario = v_total / cantidad
|
||||
|
||||
# UOM Mapping for English context
|
||||
uom_raw = qty.weight_unit if qty else "PCS"
|
||||
if uom_raw == "PZA": uom_raw = "PCS"
|
||||
|
||||
partidas_list.append(PartidaSchema(
|
||||
numero_parte=num_parte_final,
|
||||
descripcion=desc_final,
|
||||
fraccion=fraccion_imprimir,
|
||||
origen=origen_final,
|
||||
advalorem=advalorem_txt,
|
||||
preferencia=preferencia_txt,
|
||||
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
|
||||
unidad_medida=uom_raw,
|
||||
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
|
||||
clave_bultos=(qty.package_key or "") if qty else "",
|
||||
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
|
||||
peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0),
|
||||
valor_costo_unitario=self.formatear_numero(v_unitario),
|
||||
valor_total=self.formatear_numero(v_total)
|
||||
))
|
||||
|
||||
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
|
||||
|
||||
return FacturaImportacionCompleta(
|
||||
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado, factura=factura_schema,
|
||||
partidas=partidas_list, totales=totales
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Service A76 USA: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
|
||||
cant = sum(p.cantidad_importacion for p in partidas)
|
||||
valor = sum(p.valor_total for p in partidas)
|
||||
peso_n = sum(p.peso_neto for p in partidas)
|
||||
peso_b = sum(p.peso_bruto for p in partidas)
|
||||
bultos = sum(p.cantidad_bultos for p in partidas)
|
||||
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
|
||||
clave_comun = max(set(claves), key=claves.count) if claves else ""
|
||||
# if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
|
||||
# Don't pluralize strictly in English without logic, kept simple.
|
||||
|
||||
tc = float(tipo_cambio) if tipo_cambio else 1.0
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
|
||||
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
|
||||
)
|
||||
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]:
|
||||
if progress_callback: progress_callback(5, "Starting report service...")
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code)
|
||||
|
||||
if progress_callback: progress_callback(80, "Rendering template...")
|
||||
|
||||
# LOGO LOGIC
|
||||
logo_b64 = None
|
||||
try:
|
||||
comp_logo = db.query(Company).filter(Company.id == company_id).first()
|
||||
if comp_logo and comp_logo.logo:
|
||||
p = Path(comp_logo.logo)
|
||||
target_path = p
|
||||
if not target_path.exists():
|
||||
fallback = Path(f"app_data/logos/{company_id}") / p.name
|
||||
if fallback.exists():
|
||||
target_path = fallback
|
||||
|
||||
if target_path.exists():
|
||||
with open(target_path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
mime = "image/png"
|
||||
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
|
||||
logo_b64 = f"data:{mime};base64,{encoded_string}"
|
||||
except Exception as e:
|
||||
print(f"Error loading logo: {e}")
|
||||
|
||||
context = {
|
||||
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
|
||||
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
|
||||
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
|
||||
'logo_b64': logo_b64
|
||||
}
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"Commercial_Invoice_{datos.factura.numero}.{formato}"
|
||||
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
|
||||
|
||||
if progress_callback: progress_callback(90, "Generating PDF...")
|
||||
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completed")
|
||||
return pdf, nombre, "application/pdf"
|
||||
@@ -0,0 +1,49 @@
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, Query, Response, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from .service import PackingListService
|
||||
|
||||
router = APIRouter()
|
||||
service = PackingListService()
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from core.celery_app import celery_app
|
||||
from .task import generar_packing_list_async
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db)
|
||||
):
|
||||
task_result = AsyncResult(task_id, app=celery_app)
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"state": task_result.state,
|
||||
"result": None,
|
||||
"info": None
|
||||
}
|
||||
|
||||
if task_result.state == 'FAILURE':
|
||||
response["result"] = str(task_result.result)
|
||||
elif task_result.state == 'SUCCESS':
|
||||
response["result"] = task_result.result
|
||||
elif task_result.state == 'PROCESSING':
|
||||
response["info"] = task_result.info
|
||||
|
||||
return response
|
||||
|
||||
@router.post("/{invoice_id}/download-async")
|
||||
async def trigger_download_packing_list(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(..., description="ID de la empresa"),
|
||||
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_packing_list_async.delay(invoice_id, company_id)
|
||||
return {"task_id": task.id, "message": "Generación iniciada"}
|
||||
@@ -0,0 +1,93 @@
|
||||
from typing import List, Optional, Union, Any
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
class ClienteSchema(BaseModel):
|
||||
header: str
|
||||
nombre: str
|
||||
direccion: Optional[str] = ""
|
||||
num_exterior: Optional[str] = ""
|
||||
num_interior: Optional[str] = ""
|
||||
colonia: Optional[str] = ""
|
||||
codigo_postal: Optional[str] = ""
|
||||
ciudad: Optional[str] = ""
|
||||
estado: Optional[str] = ""
|
||||
pais: Optional[str] = ""
|
||||
tax_id: str
|
||||
programa: Optional[str] = ""
|
||||
autorizacion: Optional[str] = ""
|
||||
prosec: Optional[str] = ""
|
||||
reg_emp: Optional[str] = ""
|
||||
cert: Optional[str] = ""
|
||||
|
||||
@field_validator('direccion', 'nombre', mode='before')
|
||||
@classmethod
|
||||
def prevent_none(cls, v):
|
||||
return v or ""
|
||||
|
||||
class FacturaSchema(BaseModel):
|
||||
numero: str
|
||||
fecha: str
|
||||
tipo_cambio: Union[float, str]
|
||||
moneda: str
|
||||
pedimento: str = ""
|
||||
clave_pedimento: str = ""
|
||||
remesa: str = ""
|
||||
acuse_electronico: str = ""
|
||||
agente_aduanal: str = ""
|
||||
patente: str = ""
|
||||
precinto: str = ""
|
||||
regimen: str = ""
|
||||
transportista: str = ""
|
||||
scac: str = ""
|
||||
caat: str = ""
|
||||
incoterm: str = ""
|
||||
transporte: str = ""
|
||||
num_transporte: str = ""
|
||||
placas: str = ""
|
||||
placas_remolque: str = ""
|
||||
licencia_conductor: str = ""
|
||||
aduana: str = ""
|
||||
destino: str = ""
|
||||
observaciones: str = ""
|
||||
|
||||
class PartidaSchema(BaseModel):
|
||||
numero_parte: str
|
||||
descripcion: str
|
||||
fraccion: str
|
||||
fraccion_americana: Optional[str] = ""
|
||||
origen: str
|
||||
|
||||
advalorem:Optional[str] = ""
|
||||
preferencia:Optional[str] = ""
|
||||
|
||||
cantidad_importacion: Union[float, str]
|
||||
unidad_medida: str
|
||||
cantidad_bultos: int
|
||||
clave_bultos: str
|
||||
peso_neto: Union[float, str]
|
||||
peso_bruto: Union[float, str]
|
||||
peso_neto_lbs: Union[float, str] = 0.0
|
||||
peso_bruto_lbs: Union[float, str] = 0.0
|
||||
valor_costo_unitario: Union[float, str] = ""
|
||||
valor_total: Union[float, str] = ""
|
||||
|
||||
class TotalesSchema(BaseModel):
|
||||
cantidad_total: Union[float, str]
|
||||
bultos_total: int
|
||||
clave_bultos: str = ""
|
||||
peso_neto_total: Union[float, str]
|
||||
peso_bruto_total: Union[float, str]
|
||||
peso_neto_total_lbs: Union[float, str] = 0.0
|
||||
peso_bruto_total_lbs: Union[float, str] = 0.0
|
||||
valor_total_total: Union[float, str] = ""
|
||||
valor_total_dolares: Union[float, str] = ""
|
||||
|
||||
class PackingListSchema(BaseModel):
|
||||
cliente_proveedor: ClienteSchema
|
||||
cliente_vendido: ClienteSchema
|
||||
cliente_enviado: ClienteSchema
|
||||
factura: FacturaSchema
|
||||
partidas: List[PartidaSchema]
|
||||
totales: TotalesSchema
|
||||
logo_b64: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import shutil
|
||||
import base64
|
||||
import pdfkit
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, List, Callable, Optional
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# --- MODELOS ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.items.line_customs.models import LineCustom
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
)
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.pedmientos.models import Pedimentos
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
# --- TRANSPORTATION MODELS ---
|
||||
from api.v1.modules.a76.transportation.transporters.models import Transporter
|
||||
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
|
||||
from api.v1.modules.a76.transportation.trailers.models import Trailer
|
||||
from api.v1.modules.a76.transportation.drivers.models import Driver
|
||||
|
||||
# --- MODELO DE FRACCIONES ---
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
|
||||
# --- SCHEMAS ---
|
||||
from .schemas import (
|
||||
ClienteSchema, PartidaSchema, TotalesSchema,
|
||||
FacturaSchema, PackingListSchema
|
||||
)
|
||||
|
||||
class PackingListService:
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
self.template = self.jinja_env.get_template('packing_list.html')
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
# List of possible paths
|
||||
paths = [
|
||||
shutil.which("wkhtmltopdf"),
|
||||
"/usr/local/bin/wkhtmltopdf",
|
||||
"/usr/bin/wkhtmltopdf",
|
||||
"C:\\Program Files\\wkhtmltopdf\\bin\\wkhtmltopdf.exe"
|
||||
]
|
||||
|
||||
path = next((p for p in paths if p and Path(p).exists()), None)
|
||||
|
||||
if not path:
|
||||
if shutil.which("echo"):
|
||||
print("WARNING: wkhtmltopdf not found, PDF generation will fail.")
|
||||
raise RuntimeError(f"wkhtmltopdf binary not found. Searched in: {paths}")
|
||||
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
except: return 0.0
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
return fraccion_raw or ""
|
||||
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
|
||||
|
||||
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
|
||||
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
|
||||
if not main:
|
||||
return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX")
|
||||
|
||||
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
|
||||
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
|
||||
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre=(main.name or main.short_name) or "S/N",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
num_exterior=(addr.exterior_number or "") if addr else "",
|
||||
num_interior=(addr.interior_number or "") if addr else "",
|
||||
colonia=(addr.neighborhood or "") if addr else "",
|
||||
codigo_postal=(addr.postal_code or "") if addr else "",
|
||||
ciudad=(addr.city or "") if addr else "",
|
||||
estado=(addr.state or "") if addr else "",
|
||||
pais=(addr.country or "MEX") if addr else "MEX",
|
||||
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
|
||||
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
|
||||
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
|
||||
),
|
||||
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
|
||||
)
|
||||
|
||||
def get_packing_list_data(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> PackingListSchema:
|
||||
try:
|
||||
if progress_callback: progress_callback(10, "Buscando factura...")
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header: raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
compliance = header.compliance_mx
|
||||
logistics = header.logistics if header.logistics else None
|
||||
financials = header.financials if header.financials else None
|
||||
|
||||
if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...")
|
||||
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
|
||||
|
||||
if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...")
|
||||
proveedor_id = compliance.provider_id if compliance else None
|
||||
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
|
||||
|
||||
nombre_agente = ""
|
||||
if compliance and compliance.customs_broker_id:
|
||||
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
|
||||
if broker: nombre_agente = broker.name
|
||||
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
# Datos Default (Company/Importer)
|
||||
cliente_default = ClienteSchema(
|
||||
header="Importer / Consignee:",
|
||||
nombre=getattr(company, 'name', "Empresa Local"),
|
||||
direccion="DOMICILIO FISCAL",
|
||||
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
|
||||
tax_id=getattr(company, 'rfc', ""),
|
||||
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
|
||||
)
|
||||
|
||||
# Left Side Logic (Consignatario / Sold To)
|
||||
cliente_vendido = cliente_default
|
||||
if compliance and compliance.sold_to_id:
|
||||
raw = (compliance.sold_to_header or "").upper()
|
||||
if "CONSIGN" in raw:
|
||||
clean_header = "Consignee / Consignatario:"
|
||||
else:
|
||||
clean_header = "Sold To / Vendido a:"
|
||||
|
||||
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
|
||||
|
||||
# Right Side Logic (Enviado A / Shipped To)
|
||||
cliente_enviado = cliente_default
|
||||
if compliance and compliance.shipped_to_id:
|
||||
clean_header_shipped = "Shipped To / Enviado a:"
|
||||
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
|
||||
|
||||
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
||||
|
||||
patente_val = ""
|
||||
if pedimento and pedimento.license:
|
||||
patente_val = pedimento.license
|
||||
elif 'broker' in locals() and broker and broker.license:
|
||||
patente_val = broker.license
|
||||
|
||||
# --- Transport Data Fetching ---
|
||||
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
|
||||
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
|
||||
|
||||
placas_val = (logistics.license_plate or "") if logistics else ""
|
||||
placas_remolque_val = ""
|
||||
transportista_val = (logistics.carrier_id or "") if logistics else ""
|
||||
caat_val = ""
|
||||
scac_val = ""
|
||||
licencia_cond_val = ""
|
||||
|
||||
if logistics:
|
||||
# 1. Transporter (CAAT / SCAC)
|
||||
if logistics.carrier_id:
|
||||
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
|
||||
if transporter_obj:
|
||||
caat_val = transporter_obj.caat_code or ""
|
||||
scac_val = transporter_obj.transport_code or ""
|
||||
transportista_val = transporter_obj.name or logistics.carrier_id
|
||||
|
||||
# 2. Vehicle (Placas Tracto)
|
||||
if logistics.transport_id:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif logistics.vehicle_num:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
|
||||
# 3. Trailer (Placas Remolque)
|
||||
if logistics.trailer_num:
|
||||
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
|
||||
if trl_obj:
|
||||
placas_remolque_val = trl_obj.plate_number or ""
|
||||
|
||||
# 4. Driver (License)
|
||||
if logistics.carrier_id and logistics.driver_name:
|
||||
drv_obj = db.query(Driver).filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name
|
||||
).first()
|
||||
if drv_obj:
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
|
||||
factura_schema = FacturaSchema(
|
||||
numero=header.invoice_number or "S/N",
|
||||
fecha=str(header.invoice_date) if header.invoice_date else "",
|
||||
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
|
||||
moneda=getattr(header, 'currency', "USD") or "USD",
|
||||
incoterm=(logistics.incoterm or "") if logistics else "",
|
||||
observaciones=header.observation_es or header.observation_en or "",
|
||||
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
|
||||
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
||||
regimen=header.document_type or "",
|
||||
patente=patente_val,
|
||||
agente_aduanal=nombre_agente,
|
||||
transporte=transporte_txt,
|
||||
num_transporte=num_transporte_val,
|
||||
placas=placas_val,
|
||||
placas_remolque=placas_remolque_val,
|
||||
transportista=transportista_val,
|
||||
caat=caat_val,
|
||||
scac=scac_val,
|
||||
licencia_conductor=licencia_cond_val,
|
||||
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
|
||||
precinto=(logistics.seal_number or "") if logistics else "",
|
||||
destino=(logistics.destination_goods or "") if logistics else "",
|
||||
remesa=remesa_valor, acuse_electronico=acuse_valor
|
||||
)
|
||||
|
||||
if progress_callback: progress_callback(50, "Procesando partidas...")
|
||||
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
|
||||
partidas_list = []
|
||||
|
||||
for line in lines:
|
||||
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
|
||||
|
||||
# --- WEIGHT CALCULATION LOGIC ---
|
||||
peso_neto_kg = 0.0
|
||||
peso_bruto_kg = 0.0
|
||||
peso_neto_lb = 0.0
|
||||
peso_bruto_lb = 0.0
|
||||
|
||||
if qty:
|
||||
raw_net = float(qty.net_weight or 0)
|
||||
raw_gross = float(qty.gross_weight or 0)
|
||||
unit = (qty.weight_unit or "KG").upper()
|
||||
|
||||
if unit == "LB" or unit == "LBS":
|
||||
peso_neto_lb = raw_net
|
||||
peso_bruto_lb = raw_gross
|
||||
peso_neto_kg = raw_net / 2.20462
|
||||
peso_bruto_kg = raw_gross / 2.20462
|
||||
else: # Default KG
|
||||
peso_neto_kg = raw_net
|
||||
peso_bruto_kg = raw_gross
|
||||
peso_neto_lb = raw_net * 2.20462
|
||||
peso_bruto_lb = raw_gross * 2.20462
|
||||
# --------------------------------
|
||||
|
||||
custom_obj = db.query(LineCustom).filter(LineCustom.item_line_id == line.id).first()
|
||||
part_master = db.query(Part).filter(Part.id == line.part_number).first()
|
||||
|
||||
desc_final = "S/D"
|
||||
num_parte_final = str(line.part_number or "S/N")
|
||||
fraccion_raw = ""
|
||||
origen_final = "MEX"
|
||||
uom_comercial = "PZA" # Default UOM
|
||||
|
||||
if part_master:
|
||||
desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc."
|
||||
num_parte_final = part_master.part_number
|
||||
fraccion_raw = part_master.fraction if part_master.fraction else ""
|
||||
# Commercial UOM from Part Master
|
||||
uom_comercial = part_master.unit_of_measure or "PZA"
|
||||
|
||||
if part_master.fa_data and part_master.fa_data.origin_country:
|
||||
origen_final = part_master.fa_data.origin_country
|
||||
|
||||
fraccion_limpia = fraccion_raw.replace(".", "").strip()
|
||||
if fraccion_limpia:
|
||||
fraccion_limpia = fraccion_limpia[:8].zfill(8)
|
||||
|
||||
fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first()
|
||||
|
||||
fraccion_imprimir = fraccion_raw
|
||||
if fraccion_db:
|
||||
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
|
||||
else:
|
||||
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
|
||||
|
||||
# FOR PACKING LIST: FINANCIALS ARE HIDDEN/EMPTY
|
||||
v_unitario = ""
|
||||
v_total = ""
|
||||
|
||||
partidas_list.append(PartidaSchema(
|
||||
numero_parte=num_parte_final,
|
||||
descripcion=desc_final,
|
||||
fraccion=fraccion_imprimir,
|
||||
fraccion_americana=custom_obj.american_fraction if custom_obj and custom_obj.american_fraction else "",
|
||||
origen=origen_final,
|
||||
advalorem="", # Hidden
|
||||
preferencia="", # Hidden
|
||||
cantidad_importacion=qty.quantity if qty else 0,
|
||||
unidad_medida=uom_comercial, # Commercial UOM (PCS, EA)
|
||||
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
|
||||
clave_bultos=(qty.package_info.key if qty and qty.package_info else "") if qty else "",
|
||||
peso_neto=self.formatear_numero(peso_neto_kg),
|
||||
peso_bruto=self.formatear_numero(peso_bruto_kg),
|
||||
peso_neto_lbs=self.formatear_numero(peso_neto_lb),
|
||||
peso_bruto_lbs=self.formatear_numero(peso_bruto_lb),
|
||||
valor_costo_unitario=v_unitario, # Hidden
|
||||
valor_total=v_total # Hidden
|
||||
))
|
||||
|
||||
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
|
||||
|
||||
return PackingListSchema(
|
||||
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado, factura=factura_schema,
|
||||
partidas=partidas_list, totales=totales
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"Validation Error: {e.json()}")
|
||||
raise HTTPException(status_code=500, detail=f"Schema Error: {e}")
|
||||
except Exception as e:
|
||||
print(f"Error Service A76: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
|
||||
cant = sum(float(p.cantidad_importacion) for p in partidas)
|
||||
# Financial totals hidden
|
||||
peso_n = sum(float(p.peso_neto) for p in partidas)
|
||||
peso_b = sum(float(p.peso_bruto) for p in partidas)
|
||||
peso_n_lbs = sum(float(p.peso_neto_lbs) for p in partidas)
|
||||
peso_b_lbs = sum(float(p.peso_bruto_lbs) for p in partidas)
|
||||
|
||||
bultos = sum(p.cantidad_bultos for p in partidas)
|
||||
|
||||
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
|
||||
clave_comun = max(set(claves), key=claves.count) if claves else ""
|
||||
if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
|
||||
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
|
||||
peso_neto_total_lbs=self.formatear_numero(peso_n_lbs), peso_bruto_total_lbs=self.formatear_numero(peso_b_lbs),
|
||||
valor_total_total="", valor_total_dolares=""
|
||||
)
|
||||
|
||||
def generate_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str]:
|
||||
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
|
||||
data = self.get_packing_list_data(db, invoice_id, company_id, progress_callback)
|
||||
|
||||
if progress_callback: progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
# LOGO LOGIC
|
||||
logo_b64 = None
|
||||
try:
|
||||
comp_logo = db.query(Company).filter(Company.id == company_id).first()
|
||||
if comp_logo and comp_logo.logo:
|
||||
p = Path(comp_logo.logo)
|
||||
target_path = p
|
||||
if not target_path.exists():
|
||||
fallback = Path(f"app_data/logos/{company_id}") / p.name
|
||||
if fallback.exists():
|
||||
target_path = fallback
|
||||
|
||||
if target_path.exists():
|
||||
with open(target_path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
mime = "image/png"
|
||||
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
|
||||
logo_b64 = f"data:{mime};base64,{encoded_string}"
|
||||
except Exception as e:
|
||||
print(f"Error loading logo: {e}")
|
||||
|
||||
data.logo_b64 = logo_b64 # Assign logo to schema
|
||||
|
||||
context = data.model_dump()
|
||||
html_content = self.template.render(**context)
|
||||
|
||||
if progress_callback: progress_callback(90, "Generando PDF final...")
|
||||
|
||||
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completado")
|
||||
|
||||
filename = f"PackingList_{data.factura.numero}.pdf"
|
||||
return pdf, filename
|
||||
@@ -0,0 +1,51 @@
|
||||
import base64
|
||||
from core.celery_app import celery_app
|
||||
from core.database import CoreSessionLocal
|
||||
from .service import PackingListService
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def generar_packing_list_async(self, invoice_id: int, company_id: int):
|
||||
"""
|
||||
Tarea asíncrona para generar el Packing List
|
||||
"""
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
service = PackingListService()
|
||||
|
||||
def update_progress(percent, message):
|
||||
self.update_state(
|
||||
state='PROCESSING',
|
||||
meta={
|
||||
'current': percent,
|
||||
'total': 100,
|
||||
'status': message
|
||||
}
|
||||
)
|
||||
|
||||
pdf_bytes, filename = service.generate_pdf(db, invoice_id, company_id, update_progress)
|
||||
|
||||
# Codificar a base64 para enviar por JSON
|
||||
pdf_b64 = base64.b64encode(pdf_bytes).decode('utf-8')
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'file_name': filename,
|
||||
'content': pdf_b64,
|
||||
'media_type': 'application/pdf'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error en tarea Packing List: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={
|
||||
'exc_type': type(e).__name__,
|
||||
'exc_message': str(e),
|
||||
'custom': 'Error generating PDF'
|
||||
}
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,519 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" xml:lang="es">
|
||||
|
||||
<head>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
|
||||
<title>Packing List - {{ factura.numero }}</title>
|
||||
<style type="text/css">
|
||||
/* ESTILOS EXACTOS DE SCAPII PARA MANTENER EL FORMATO */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Tahoma, sans-serif;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.titulo {
|
||||
font-size: 14pt;
|
||||
padding: 3pt 0 0 6pt;
|
||||
}
|
||||
|
||||
.grande {
|
||||
font-size: 13pt;
|
||||
padding-left: 5pt;
|
||||
}
|
||||
|
||||
.medio-bold {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
padding: 3pt 0 0 3pt;
|
||||
}
|
||||
|
||||
.normal {
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.small-bold {
|
||||
font-size: 8pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tiny-bold {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.mini {
|
||||
font-size: 5pt;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.cliente {
|
||||
border-bottom: 1pt solid black;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1pt solid #808080;
|
||||
}
|
||||
|
||||
.p-t-1 {
|
||||
padding-top: 1pt;
|
||||
}
|
||||
|
||||
.p-t-2 {
|
||||
padding-top: 2pt;
|
||||
}
|
||||
|
||||
.p-t-3 {
|
||||
padding-top: 3pt;
|
||||
}
|
||||
|
||||
.p-t-4 {
|
||||
padding-top: 4pt;
|
||||
}
|
||||
|
||||
.p-t-5 {
|
||||
padding-top: 5pt;
|
||||
}
|
||||
|
||||
.p-t-8 {
|
||||
padding-top: 8pt;
|
||||
}
|
||||
|
||||
.p-t-9 {
|
||||
padding-top: 9pt;
|
||||
}
|
||||
|
||||
.p-l-2 {
|
||||
padding-left: 2pt;
|
||||
}
|
||||
|
||||
.p-l-3 {
|
||||
padding-left: 3pt;
|
||||
}
|
||||
|
||||
.p-l-5 {
|
||||
padding-left: 5pt;
|
||||
}
|
||||
|
||||
.p-l-6 {
|
||||
padding-left: 6pt;
|
||||
}
|
||||
|
||||
.p-l-7 {
|
||||
padding-left: 7pt;
|
||||
}
|
||||
|
||||
.p-l-8 {
|
||||
padding-left: 8pt;
|
||||
}
|
||||
|
||||
.p-l-9 {
|
||||
padding-left: 9pt;
|
||||
}
|
||||
|
||||
.p-l-10 {
|
||||
padding-left: 10pt;
|
||||
}
|
||||
|
||||
.p-r-1 {
|
||||
padding-right: 1pt;
|
||||
}
|
||||
|
||||
.p-r-2 {
|
||||
padding-right: 2pt;
|
||||
}
|
||||
|
||||
.p-r-4 {
|
||||
padding-right: 4pt;
|
||||
}
|
||||
|
||||
.p-r-5 {
|
||||
padding-right: 5pt;
|
||||
}
|
||||
|
||||
.p-r-9 {
|
||||
padding-right: 9pt;
|
||||
}
|
||||
|
||||
.p-b-1 {
|
||||
padding-bottom: 1pt;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 10pt;
|
||||
}
|
||||
|
||||
.h-11 {
|
||||
height: 11pt;
|
||||
}
|
||||
|
||||
.h-14 {
|
||||
height: 14pt;
|
||||
}
|
||||
|
||||
.h-15 {
|
||||
height: 15pt;
|
||||
}
|
||||
|
||||
.h-18 {
|
||||
height: 18pt;
|
||||
}
|
||||
|
||||
.h-22 {
|
||||
height: 22pt;
|
||||
}
|
||||
|
||||
.h-82 {
|
||||
height: 82pt;
|
||||
}
|
||||
|
||||
.h-384 {
|
||||
height: 384pt;
|
||||
}
|
||||
|
||||
.line-1 {
|
||||
line-height: 1pt;
|
||||
}
|
||||
|
||||
.line-7 {
|
||||
line-height: 7pt;
|
||||
}
|
||||
|
||||
.line-8 {
|
||||
line-height: 8pt;
|
||||
}
|
||||
|
||||
.line-9 {
|
||||
line-height: 9pt;
|
||||
}
|
||||
|
||||
.line-10 {
|
||||
line-height: 10pt;
|
||||
}
|
||||
|
||||
.m-l-3 {
|
||||
margin-left: 3pt;
|
||||
}
|
||||
|
||||
.m-l-5 {
|
||||
margin-left: 5.74pt;
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flex-container-end {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.width-48 {
|
||||
width: 48%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.width-48-right {
|
||||
width: 48%;
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.position-relative-centered {
|
||||
position: relative;
|
||||
width: 48%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.full-width-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clearfix::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
table,
|
||||
tbody {
|
||||
vertical-align: top;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<div class="flex-container clearfix">
|
||||
<div class="width-48" style="position: relative;">
|
||||
<p class="titulo">PACKING LIST / LISTA DE EMPAQUE</p>
|
||||
<div class="cliente" style="height: 1px;"></div>
|
||||
<p class="cliente p-t-1 p-b-1"></p>
|
||||
</div>
|
||||
<div class="width-48-right">
|
||||
<table cellspacing="0" class="m-l-3" style="float: right; text-align: left;">
|
||||
<tbody>
|
||||
<tr class="h-22">
|
||||
<td bgcolor="#E4E4E4" class="border" style="width:100pt">
|
||||
<p class="medio-bold p-l-3 line-9">PACKING LIST / LISTA DE EMPAQUE:</p>
|
||||
</td>
|
||||
<td class="border" colspan="3" style="width:128pt">
|
||||
<p class="grande p-l-5">{{ factura.numero }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="h-22">
|
||||
<td bgcolor="#E4E4E4" class="border">
|
||||
<p class="small-bold p-l-3 line-9">MX CUSTOM BROKER / AGENTE ADUANAL MEXICANO:</p>
|
||||
</td>
|
||||
<td class="border" colspan="3">
|
||||
<p class="normal p-l-5">{{ factura.agente_aduanal or '' }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container-end clearfix" style="overflow: visible;">
|
||||
<div class="position-relative-centered">
|
||||
{% if logo_b64 %}
|
||||
<div style="position: absolute; top: 10pt; left: 0;">
|
||||
<img src="{{ logo_b64 }}" style="max-height: 70pt; max-width: 120pt;" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="margin-left: 130pt; padding-top: 5pt; text-align: left;">
|
||||
<h1 class="cliente">{{ cliente_proveedor.header }}</h1>
|
||||
<p>{{ cliente_proveedor.nombre }}</p>
|
||||
<p>{{ cliente_proveedor.direccion }}
|
||||
{% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %}
|
||||
{% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %}
|
||||
</p>
|
||||
<p>{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{
|
||||
cliente_proveedor.codigo_postal }}{% endif %}</p>
|
||||
<p>{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}</p>
|
||||
<p>TAX ID: {{ cliente_proveedor.tax_id }}
|
||||
{% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %}
|
||||
{{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><br></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex-container clearfix">
|
||||
<div class="width-48">
|
||||
<h1 class="p-t-5 p-l-5 line-10 cliente">{{ cliente_vendido.header }}</h1>
|
||||
<p class="p-l-5 line-8">{{ cliente_vendido.nombre }}</p>
|
||||
<p class="p-l-5">{{ cliente_vendido.direccion }}
|
||||
{% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %}
|
||||
{% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{
|
||||
cliente_vendido.codigo_postal }}{% endif %}</p>
|
||||
<p class="p-l-5">{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }}
|
||||
</p>
|
||||
<p class="p-l-5">RFC: {{ cliente_vendido.tax_id }}
|
||||
{% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %}
|
||||
{{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">
|
||||
{% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %}
|
||||
{% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %}
|
||||
{% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="width-48-right" style="text-align: left;">
|
||||
<h1 class="p-t-5 p-l-5 line-10 cliente full-width-block">{{ cliente_enviado.header }}</h1>
|
||||
<p class="p-l-5 line-8">{{ cliente_enviado.nombre }}</p>
|
||||
<p class="p-l-5">{{ cliente_enviado.direccion }}
|
||||
{% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %}
|
||||
{% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{
|
||||
cliente_enviado.codigo_postal }}{% endif %}</p>
|
||||
<p class="p-l-5">{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}
|
||||
</p>
|
||||
<p class="p-l-5">RFC: {{ cliente_enviado.tax_id }}
|
||||
{% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %}
|
||||
{{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="p-l-5">
|
||||
{% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %}
|
||||
{% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %}
|
||||
{% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="p-t-8"><br /></p>
|
||||
</header>
|
||||
|
||||
<table cellspacing="0" class="m-l-5" style="width: 99%; max-width: 580pt;">
|
||||
<thead>
|
||||
<!-- TABLA DE TRANSPORTES ELIMINADA POR SOLICITUD DEL USUARIO -->
|
||||
|
||||
<tr class="h-10">
|
||||
<td class="border" rowspan="2">
|
||||
<p class="tiny-bold p-t-5 center">Line / Línea</p>
|
||||
</td>
|
||||
<td class="border" rowspan="2" colspan="2">
|
||||
<p class="tiny-bold p-l-2 line-10">Part Number / Número de Parte</p>
|
||||
<p class="tiny-bold p-l-2 line-10">Description / Descripción</p>
|
||||
</td>
|
||||
<td class="border" colspan="2">
|
||||
<p class="tiny-bold center line-9">Quantity / Cantidad</p>
|
||||
</td>
|
||||
<td class="border" colspan="2" style="width:60pt">
|
||||
<p class="tiny-bold center line-9">Packing / Empaque</p>
|
||||
</td>
|
||||
<td class="border" colspan="2">
|
||||
<p class="tiny-bold center line-9">Weight / Peso (KGS)</p>
|
||||
</td>
|
||||
<!-- COLUMNA VALORES REMOVIDA -->
|
||||
</tr>
|
||||
<tr class="h-11">
|
||||
<!-- Subheaders -->
|
||||
<td class="border center">
|
||||
<p class="mini p-l-2">Qty / Cant.</p>
|
||||
</td>
|
||||
<td class="border center">
|
||||
<p class="mini p-l-1">U.M.</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Qty / Cant.</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Type / Tipo</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Net / Neto</p>
|
||||
<p class="mini center font-bold" style="font-size: 4pt;">(LBS / KGS)</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini center">Gross / Bruto</p>
|
||||
<p class="mini center font-bold" style="font-size: 4pt;">(LBS / KGS)</p>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="h-384" style="width: 100%;">
|
||||
{% for partida in partidas %}
|
||||
<tr>
|
||||
<td class="border" style="width: 25pt;">
|
||||
<p class="mini p-t-3 center">{{ loop.index }}</p>
|
||||
</td>
|
||||
<td class="border" colspan="2">
|
||||
<p class="mini p-t-1" style="font-weight: bold;">{{ partida.numero_parte }}</p>
|
||||
<p class="mini">{{ partida.descripcion }} / {{ partida.fraccion_americana }} / {{ partida.origen }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 center">{{ partida.cantidad_importacion }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 center">{{ partida.unidad_medida }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 center">
|
||||
{% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %}
|
||||
</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-2 center">
|
||||
{{ partida.clave_bultos }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-1 center">{{ partida.peso_neto_lbs }}</p>
|
||||
<p class="mini center font-bold">{{ partida.peso_neto }}</p>
|
||||
</td>
|
||||
<td class="border">
|
||||
<p class="mini p-t-1 center">{{ partida.peso_bruto_lbs }}</p>
|
||||
<p class="mini center font-bold">{{ partida.peso_bruto }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr class="h-14">
|
||||
<td bgcolor="#E4E4E4" class="border" colspan="3" style="width:123pt">
|
||||
<p class="small-bold p-t-2 p-l-3 line-10">
|
||||
<span>Observaciones:</span>
|
||||
<span class="small-bold" style="float: right; margin-right: 2pt;">TOTALES</span>
|
||||
</p>
|
||||
</td>
|
||||
<td class="border" style="width:40pt">
|
||||
<p class="mini p-t-3 p-r-1 line-8 center">{{ totales.cantidad_total }}</p>
|
||||
</td>
|
||||
<td class="border" style="width:22pt"></td>
|
||||
<td class="border" style="width:30pt">
|
||||
<p class="mini p-t-3 center line-8">
|
||||
{% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %}
|
||||
</p>
|
||||
</td>
|
||||
<td class="border" style="width:30pt">
|
||||
<p class="mini p-t-3 center line-8">
|
||||
<span>{{ totales.clave_bultos or '' }}</span>
|
||||
</p>
|
||||
</td>
|
||||
<td class="border" style="width:40pt">
|
||||
<p class="mini p-t-2 center line-8">{{ totales.peso_neto_total_lbs }} LBS</p>
|
||||
<p class="mini center line-8 font-bold">{{ totales.peso_neto_total }} KGS</p>
|
||||
</td>
|
||||
<td class="border" style="width:40pt">
|
||||
<p class="mini p-t-2 center line-8">{{ totales.peso_bruto_total_lbs }} LBS</p>
|
||||
<p class="mini center line-8 font-bold">{{ totales.peso_bruto_total }} KGS</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="border" style="height: 100pt; text-align:start; vertical-align: top;">
|
||||
<p class="mini p-l-2 p-t-2">{{ factura.observaciones }}</p>
|
||||
</td>
|
||||
<td colspan="5" style="width:336pt; vertical-align: bottom; height: 100%;">
|
||||
<p style="border-bottom: 1pt solid black; width: 80%; margin: 0 auto 2pt auto;"></p>
|
||||
<p class="normal center" style="margin-bottom: 0;">{{ cliente_proveedor.nombre }}</p>
|
||||
<p style="margin-bottom: 0;"><br /></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" style="width:580pt; vertical-align: top; height: 100%;">
|
||||
<p class="p-t-8"><br /></p>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -51,7 +51,9 @@ from api.v1.modules.public.reference_data.material_types.routes import router as
|
||||
# --- NUEVO IMPORT PARA REPORTES DE FACTURAS ---
|
||||
from .reports.importacion.facturas.routes import router as invoices_reports_router
|
||||
from .reports.importacion.consolidados.routes import router as consolidated_reports_router
|
||||
from .reports.movements.invoices.routes import router as invoice_movements_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
|
||||
|
||||
|
||||
|
||||
# Router principal
|
||||
@@ -134,7 +136,13 @@ router.include_router(
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
invoice_movements_router,
|
||||
prefix="/a76/reports/movements/invoices",
|
||||
tags=["a76 / reports / movements"]
|
||||
packing_list_router,
|
||||
prefix="/a76/reports/importacion/packing-lists",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
aviso_consolidado_export_router,
|
||||
prefix="/a76/reports/exportacion/aviso_consolidado",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
@@ -12,6 +12,9 @@ def access_token():
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client():
|
||||
from api.v1.modules.public.reference_data.countries.routes import router as countries_router
|
||||
from api.v1.modules.public.reference_data.transport_types.routes import router as transport_types_router
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.include_router(countries_router)
|
||||
app.include_router(transport_types_router)
|
||||
return TestClient(app)
|
||||
|
||||
@@ -15,13 +15,26 @@ router = APIRouter(prefix="/countries")
|
||||
async def list_countries(
|
||||
page: int = Query(1, ge=1, description="Número de página"),
|
||||
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
|
||||
search: str = Query(None, description="Término de búsqueda"),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Endpoint público para obtener lista de países - no requiere autenticación"""
|
||||
skip = (page - 1) * page_size
|
||||
query = db.query(Country)
|
||||
items = query.offset(skip).limit(page_size).all()
|
||||
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Country.m3_key.ilike(search_filter)) |
|
||||
(Country.mex_key.ilike(search_filter)) |
|
||||
(Country.ame_key.ilike(search_filter)) |
|
||||
(Country.description_es.ilike(search_filter)) |
|
||||
(Country.description_en.ilike(search_filter))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"items": [CountryDTO.model_validate(obj) for obj in items],
|
||||
"total": total,
|
||||
|
||||
@@ -9,13 +9,17 @@ client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "access_token")
|
||||
def test_list_countries(client, access_token):
|
||||
def test_list_countries_with_search(client, access_token):
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = client.get("/countries/", headers=headers)
|
||||
# Search for a known country from seed, e.g., "Mexico" or "MEX"
|
||||
response = client.get("/countries/?search=Mexico", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert "items" in response.json()
|
||||
assert "page" in response.json()
|
||||
assert "page_size" in response.json()
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
# Depending on seed data, there should be at least one item if "Mexico" exists
|
||||
if data["items"]:
|
||||
for item in data["items"]:
|
||||
assert "mexico" in item["description_es"].lower() or "mexico" in item["description_en"].lower()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "access_token")
|
||||
|
||||
Reference in New Issue
Block a user