2545 lines
101 KiB
Python
2545 lines
101 KiB
Python
import traceback
|
|
from typing import Optional, List, Tuple
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import and_, func, or_
|
|
from core.exceptions import ErrorCollector, DuplicateResourceException
|
|
from core.context import get_user_context
|
|
from api.v1.modules.a76.audit_log.models import AuditLog
|
|
from api.v1.modules.a76.audit_log.services.service import AuditService
|
|
from .common.mappers import clean_dict
|
|
from .imports.validators.create import validate_create as validate_create_import
|
|
from .imports.validators.update import validate_update as validate_update_import
|
|
from .exports.validators.create import validate_create as validate_create_export
|
|
from .exports.validators.update import validate_update as validate_update_export
|
|
from .common.common_validators import invoice_exists
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
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_customs.models import LineCustom
|
|
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
|
|
from api.v1.modules.a76.items.line_references.models import LineReference
|
|
from api.v1.modules.a76.items.series.models import Serie
|
|
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
|
|
from . import models, schemas
|
|
|
|
|
|
def _autofill_transport_int_ids(
|
|
db: Session,
|
|
logistics_target,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
) -> None:
|
|
"""
|
|
Ensures invoice logistics internal int IDs are populated when catalog rows exist.
|
|
|
|
String keys (carrier_id, transport_id, trailer_num) are matched to existing catalog
|
|
rows for the tenant/company; no placeholder rows are created. Invalid references must
|
|
be rejected by validate_common before persist.
|
|
"""
|
|
|
|
# Local imports to avoid circular dependencies.
|
|
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
|
|
|
|
def _get(field: str):
|
|
if isinstance(logistics_target, dict):
|
|
return logistics_target.get(field)
|
|
return getattr(logistics_target, field, None)
|
|
|
|
def _set(field: str, value):
|
|
if isinstance(logistics_target, dict):
|
|
logistics_target[field] = value
|
|
else:
|
|
setattr(logistics_target, field, value)
|
|
|
|
carrier_code = _get("carrier_id")
|
|
carrier_int_id = _get("carrier_int_id")
|
|
if carrier_code and carrier_int_id is None:
|
|
transporter_obj = (
|
|
db.query(Transporter)
|
|
.filter(
|
|
Transporter.transporter_key == carrier_code,
|
|
Transporter.tenant_id == tenant_id,
|
|
Transporter.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if transporter_obj:
|
|
_set("carrier_int_id", transporter_obj.transporter_id)
|
|
|
|
transport_code = _get("transport_id")
|
|
transport_int_id = _get("transport_int_id")
|
|
if transport_code and transport_int_id is None:
|
|
vehicle_obj = (
|
|
db.query(Vehicle)
|
|
.filter(
|
|
func.upper(Vehicle.vehicle_key) == str(transport_code).strip().upper(),
|
|
Vehicle.tenant_id == tenant_id,
|
|
Vehicle.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if vehicle_obj:
|
|
_set("transport_int_id", vehicle_obj.vehicle_id)
|
|
|
|
trailer_code = _get("trailer_num")
|
|
trailer_int_id = _get("trailer_int_id")
|
|
if trailer_code and trailer_int_id is None:
|
|
trailer_obj = (
|
|
db.query(Trailer)
|
|
.filter(
|
|
func.upper(Trailer.trailer_number)
|
|
== str(trailer_code).strip().upper(),
|
|
Trailer.tenant_id == tenant_id,
|
|
Trailer.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if trailer_obj:
|
|
_set("trailer_int_id", trailer_obj.trailer_id)
|
|
|
|
# Placa: si no vino valor, tomar del vehículo y si no del remolque (catálogo)
|
|
lp = _get("license_plate")
|
|
if lp is None or str(lp).strip() == "":
|
|
veh = None
|
|
tv_id = _get("transport_int_id")
|
|
tv_code = _get("transport_id")
|
|
if tv_id is not None:
|
|
veh = (
|
|
db.query(Vehicle)
|
|
.filter(
|
|
Vehicle.vehicle_id == tv_id,
|
|
Vehicle.tenant_id == tenant_id,
|
|
Vehicle.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
elif tv_code:
|
|
veh = (
|
|
db.query(Vehicle)
|
|
.filter(
|
|
func.upper(Vehicle.vehicle_key) == str(tv_code).strip().upper(),
|
|
Vehicle.tenant_id == tenant_id,
|
|
Vehicle.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if veh and getattr(veh, "plate_number", None):
|
|
_set("license_plate", veh.plate_number)
|
|
else:
|
|
tr = None
|
|
tr_id = _get("trailer_int_id")
|
|
tr_code = _get("trailer_num")
|
|
if tr_id is not None:
|
|
tr = (
|
|
db.query(Trailer)
|
|
.filter(
|
|
Trailer.trailer_id == tr_id,
|
|
Trailer.tenant_id == tenant_id,
|
|
Trailer.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
elif tr_code:
|
|
tr = (
|
|
db.query(Trailer)
|
|
.filter(
|
|
func.upper(Trailer.trailer_number)
|
|
== str(tr_code).strip().upper(),
|
|
Trailer.tenant_id == tenant_id,
|
|
Trailer.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if tr and getattr(tr, "plate_number", None):
|
|
_set("license_plate", tr.plate_number)
|
|
|
|
|
|
def _get_current_username() -> str:
|
|
"""Helper to get current username from context or fallback to System"""
|
|
try:
|
|
context = get_user_context()
|
|
if context:
|
|
# Token usually has 'preferred_username' or 'name' or 'sub'
|
|
username = (
|
|
context.get("preferred_username")
|
|
or context.get("email")
|
|
or context.get("sub")
|
|
or "System"
|
|
)
|
|
print(f"DEBUG: _get_current_username found context: {username}")
|
|
return username
|
|
except Exception:
|
|
pass
|
|
print("DEBUG: _get_current_username NO context found, using System")
|
|
return "System"
|
|
|
|
|
|
def _autofill_remesa_if_needed(db: Session, invoice_data, tenant_id: int, company_id: int) -> None:
|
|
"""
|
|
Autocalcula remesa cuando hay pedimento consolidado y remesa viene vacía.
|
|
Se hace ANTES de validar para que cumpla reglas de required en validators.
|
|
"""
|
|
try:
|
|
compliance = getattr(invoice_data, "compliance_mx", None)
|
|
if not compliance:
|
|
return
|
|
|
|
pedimento_id = getattr(compliance, "pedimento_id", None)
|
|
remesa = getattr(compliance, "remesa", None)
|
|
|
|
if not pedimento_id or remesa:
|
|
return
|
|
|
|
# No aplicar a MEX (por consistencia con CSV import donde remesa es None para MEX)
|
|
invoice_type = getattr(invoice_data, "invoice_type", None)
|
|
if invoice_type == "MEX":
|
|
return
|
|
|
|
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos, PedimentoType
|
|
|
|
ped = (
|
|
db.query(Pedimentos)
|
|
.filter(
|
|
Pedimentos.id == pedimento_id,
|
|
Pedimentos.tenant_id == tenant_id,
|
|
Pedimentos.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if not ped:
|
|
return
|
|
|
|
if getattr(ped, "pedimento_type", None) != PedimentoType.CONSOLIDATED:
|
|
return
|
|
|
|
max_rem = (
|
|
db.query(func.max(models.InvoiceComplianceMx.remesa))
|
|
.filter(
|
|
models.InvoiceComplianceMx.pedimento_id == pedimento_id,
|
|
models.InvoiceComplianceMx.tenant_id == tenant_id,
|
|
models.InvoiceComplianceMx.company_id == company_id,
|
|
)
|
|
.scalar()
|
|
)
|
|
next_rem = (max_rem or 0) + 1
|
|
compliance.remesa = next_rem
|
|
except Exception:
|
|
# No bloquear guardado por fallo de autocalculo; validación normal aplicará.
|
|
return
|
|
|
|
|
|
def _ensure_create_audit_log(
|
|
db: Session,
|
|
*,
|
|
table_name: str,
|
|
record_id: str,
|
|
record_data: dict,
|
|
username: str,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
) -> bool:
|
|
"""
|
|
Backup audit writer: writes CREATE only when missing.
|
|
Returns True when a backup log row was added.
|
|
"""
|
|
exists = (
|
|
db.query(AuditLog.spec_id)
|
|
.filter(
|
|
AuditLog.table_name == table_name,
|
|
AuditLog.operation_type == "CREATE",
|
|
AuditLog.record_id == record_id,
|
|
AuditLog.tenant_id == tenant_id,
|
|
AuditLog.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if exists:
|
|
return False
|
|
|
|
AuditService.log_crud_operation(
|
|
db=db,
|
|
table_name=table_name,
|
|
operation_type="CREATE",
|
|
record_data=record_data,
|
|
username=username,
|
|
record_id=record_id,
|
|
company_id=company_id,
|
|
tenant_id=tenant_id,
|
|
)
|
|
return True
|
|
|
|
|
|
class InvoiceService:
|
|
"""Service for Invoice Header operations"""
|
|
|
|
@staticmethod
|
|
def get_by_id(
|
|
db: Session, invoice_id: int, tenant_id: int, company_id: int
|
|
) -> Optional[models.InvoiceHeader]:
|
|
"""Get an invoice by ID with tenant/company validation"""
|
|
return (
|
|
db.query(models.InvoiceHeader)
|
|
.filter(
|
|
models.InvoiceHeader.id == invoice_id,
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
@staticmethod
|
|
def get_all(
|
|
db: Session,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
filters: Optional[dict] = None,
|
|
sort_by: Optional[str] = None,
|
|
sort_order: Optional[str] = "asc",
|
|
) -> Tuple[List[models.InvoiceHeader], int]:
|
|
"""Get all invoices for a tenant/company with pagination and optional filters"""
|
|
query = db.query(models.InvoiceHeader).filter(
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
)
|
|
|
|
# Apply filters if provided
|
|
print(f"DEBUG: Invoice Query - Company: {company_id}, Filters: {filters}, Skip: {skip}, Limit: {limit}")
|
|
if filters:
|
|
# Join compliance_mx if needed for filters
|
|
needs_compliance_join = any(k in filters for k in ["pedimento", "manifest_number"])
|
|
if needs_compliance_join:
|
|
query = query.join(models.InvoiceComplianceMx)
|
|
|
|
if filters.get("status") is not None:
|
|
status_val = filters["status"]
|
|
if status_val in [True, "processed", models.InvoiceStatus.PROCESSED]:
|
|
target_status = models.InvoiceStatus.PROCESSED
|
|
elif status_val in [False, "pending", models.InvoiceStatus.PENDING]:
|
|
target_status = models.InvoiceStatus.PENDING
|
|
else:
|
|
target_status = status_val
|
|
query = query.filter(models.InvoiceHeader.status == target_status)
|
|
|
|
if filters.get("operation_type"):
|
|
ot = filters["operation_type"]
|
|
ot_val = ot.value if hasattr(ot, "value") else ot
|
|
query = query.filter(models.InvoiceHeader.operation_type == ot_val)
|
|
|
|
if filters.get("invoice_type"):
|
|
query = query.filter(
|
|
models.InvoiceHeader.invoice_type == filters["invoice_type"]
|
|
)
|
|
|
|
if filters.get("invoice_number"):
|
|
query = query.filter(
|
|
models.InvoiceHeader.invoice_number.ilike(
|
|
f"%{filters['invoice_number']}%"
|
|
)
|
|
)
|
|
|
|
if filters.get("project_number"):
|
|
query = query.filter(
|
|
models.InvoiceHeader.project_number.ilike(
|
|
f"%{filters['project_number']}%"
|
|
)
|
|
)
|
|
|
|
if filters.get("year"):
|
|
try:
|
|
year_val = int(filters["year"])
|
|
query = query.filter(
|
|
func.extract("year", models.InvoiceHeader.invoice_date) == year_val
|
|
)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if filters.get("pedimento"):
|
|
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
|
query = query.join(models.InvoiceComplianceMx.pedimento).filter(
|
|
Pedimentos.pedimento_number.ilike(f"%{filters['pedimento']}%")
|
|
)
|
|
|
|
if filters.get("manifest_number"):
|
|
query = query.filter(
|
|
models.InvoiceComplianceMx.manifest_number.ilike(
|
|
f"%{filters['manifest_number']}%"
|
|
)
|
|
)
|
|
|
|
# Special case for exports: exclude REPAR if no invoice_type specified
|
|
ot_exp = filters.get("operation_type")
|
|
ot_exp_val = ot_exp.value if hasattr(ot_exp, "value") else ot_exp
|
|
if not filters.get("invoice_type") and ot_exp_val == "exp":
|
|
query = query.filter(models.InvoiceHeader.invoice_type != "REPAR")
|
|
|
|
# Filtro por sistema activo (SCAF / SCAII)
|
|
if filters.get("system"):
|
|
query = query.filter(models.InvoiceHeader.system == filters["system"])
|
|
|
|
# Filtro por permisos granulares (allowed_types)
|
|
if "allowed_types" in filters:
|
|
allowed = filters["allowed_types"]
|
|
if allowed is None:
|
|
# Acceso global (admin o view_all) - no filtramos por tipos
|
|
pass
|
|
elif not allowed:
|
|
# Seguridad: Si el usuario NO tiene permisos para ningún tipo específico
|
|
query = query.filter(models.InvoiceHeader.id == -1)
|
|
else:
|
|
conditions = []
|
|
for op, inv in allowed:
|
|
# Aseguramos comparación insensible a mayúsculas para mayor robustez con la DB
|
|
op_str = str(op).lower()
|
|
inv_str = str(inv).lower()
|
|
conditions.append(
|
|
and_(
|
|
func.lower(models.InvoiceHeader.operation_type) == op_str,
|
|
func.lower(models.InvoiceHeader.invoice_type) == inv_str
|
|
)
|
|
)
|
|
if conditions:
|
|
query = query.filter(or_(*conditions))
|
|
else:
|
|
# Seguridad: Si tiene allowed_types pero no generamos condiciones, no debe ver nada
|
|
query = query.filter(models.InvoiceHeader.id == -1)
|
|
|
|
# Apply sorting
|
|
if sort_by:
|
|
# Simple column mapping
|
|
# This can be improved to handle joins if needed
|
|
column = getattr(models.InvoiceHeader, sort_by, None)
|
|
if column:
|
|
if sort_order == "desc":
|
|
query = query.order_by(column.desc())
|
|
else:
|
|
query = query.order_by(column.asc())
|
|
else:
|
|
# Default sorting if none provided
|
|
query = query.order_by(models.InvoiceHeader.id.desc())
|
|
|
|
total = query.count()
|
|
items = query.offset(skip).limit(limit).all()
|
|
|
|
# Keep party_count aligned with the real number of line items.
|
|
# This avoids stale values stored in invoice_header.party_count.
|
|
if items:
|
|
invoice_ids = [inv.id for inv in items]
|
|
counts = (
|
|
db.query(LineItem.invoice_id, func.count(LineItem.id))
|
|
.filter(
|
|
LineItem.invoice_id.in_(invoice_ids),
|
|
LineItem.tenant_id == tenant_id,
|
|
LineItem.company_id == company_id,
|
|
)
|
|
.group_by(LineItem.invoice_id)
|
|
.all()
|
|
)
|
|
count_map = {invoice_id: int(count) for invoice_id, count in counts}
|
|
for inv in items:
|
|
inv.party_count = count_map.get(inv.id, 0)
|
|
|
|
return items, total
|
|
|
|
@staticmethod
|
|
def create(
|
|
db: Session,
|
|
invoice_data: schemas.InvoiceHeaderCreate,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
) -> models.InvoiceHeader:
|
|
"""Create a new invoice with all related data"""
|
|
|
|
# Validaciones con ErrorCollector
|
|
errors = ErrorCollector()
|
|
|
|
# Autocalculo remesa (si aplica) ANTES de validar
|
|
_autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id)
|
|
|
|
# DEBUG: Log payload for analysis
|
|
print(f"DEBUG: Creating invoice {invoice_data.invoice_number} of type {invoice_data.invoice_type}")
|
|
print(f"DEBUG: Payload: {invoice_data.model_dump()}")
|
|
|
|
# Validar si la factura ya existe
|
|
invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors)
|
|
if invoice_data.operation_type == "exp":
|
|
validate_create_export(db, invoice_data, tenant_id, company_id, errors)
|
|
else:
|
|
validate_create_import(db, invoice_data, tenant_id, company_id, errors)
|
|
|
|
# Si hay errores, lanzar excepción ANTES de intentar crear
|
|
errors.raise_if_errors("Error al crear la factura")
|
|
|
|
# Extract nested data
|
|
compliance_data = invoice_data.compliance_mx
|
|
financials_data = invoice_data.financials
|
|
logistics_data = invoice_data.logistics
|
|
details_data = invoice_data.details or []
|
|
collections_data = invoice_data.collections or []
|
|
|
|
try:
|
|
|
|
# Create main invoice header
|
|
raw_invoice_dict = invoice_data.model_dump(
|
|
exclude={
|
|
"compliance_mx",
|
|
"financials",
|
|
"logistics",
|
|
"details",
|
|
"collections",
|
|
}
|
|
)
|
|
invoice_dict = clean_dict(raw_invoice_dict)
|
|
invoice_dict["tenant_id"] = tenant_id
|
|
invoice_dict["company_id"] = company_id
|
|
|
|
# Automatic status and audit fields
|
|
username = _get_current_username()
|
|
invoice_dict["capture_user"] = username
|
|
invoice_dict["who_processed"] = username
|
|
|
|
# Ensure document_type respects DB constraints for MEX/AME invoices (bypass clean_dict)
|
|
if invoice_dict.get("invoice_type") in ["MEX", "AME"] and not invoice_dict.get("document_type"):
|
|
invoice_dict["document_type"] = None
|
|
|
|
new_invoice = models.InvoiceHeader(**invoice_dict)
|
|
|
|
db.add(new_invoice)
|
|
db.flush() # Flush to get the invoice ID
|
|
|
|
# Create compliance_mx if provided
|
|
if compliance_data:
|
|
raw_comp_dict = compliance_data.model_dump()
|
|
compliance_dict = clean_dict(raw_comp_dict)
|
|
|
|
compliance_dict["invoice_id"] = new_invoice.id
|
|
compliance_dict["tenant_id"] = tenant_id
|
|
compliance_dict["company_id"] = company_id
|
|
|
|
new_compliance = models.InvoiceComplianceMx(**compliance_dict)
|
|
db.add(new_compliance)
|
|
|
|
# Create financials if provided
|
|
if financials_data:
|
|
raw_fin_dict = financials_data.model_dump()
|
|
financials_dict = clean_dict(raw_fin_dict)
|
|
|
|
financials_dict["invoice_id"] = new_invoice.id
|
|
financials_dict["tenant_id"] = tenant_id
|
|
financials_dict["company_id"] = company_id
|
|
|
|
new_financials = models.InvoiceFinancials(**financials_dict)
|
|
db.add(new_financials)
|
|
|
|
# Create logistics entries
|
|
if logistics_data:
|
|
raw_log_dict = logistics_data.model_dump()
|
|
logistics_dict = clean_dict(raw_log_dict)
|
|
|
|
logistics_dict["invoice_id"] = new_invoice.id
|
|
logistics_dict["tenant_id"] = tenant_id
|
|
logistics_dict["company_id"] = company_id
|
|
_autofill_transport_int_ids(
|
|
db,
|
|
logistics_dict,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
)
|
|
new_logistics = models.InvoiceLogistics(**logistics_dict)
|
|
db.add(new_logistics)
|
|
|
|
# Create sales details
|
|
for detail_item in details_data:
|
|
raw_det_dict = detail_item.model_dump()
|
|
detail_dict = clean_dict(raw_det_dict)
|
|
|
|
detail_dict["invoice_id"] = new_invoice.id
|
|
detail_dict["tenant_id"] = tenant_id
|
|
detail_dict["company_id"] = company_id
|
|
new_detail = models.InvoiceSalesDetails(**detail_dict)
|
|
db.add(new_detail)
|
|
|
|
# Create collections
|
|
for collection_item in collections_data:
|
|
raw_col_dict = collection_item.model_dump()
|
|
collection_dict = clean_dict(raw_col_dict)
|
|
|
|
collection_dict["invoice_id"] = new_invoice.id
|
|
collection_dict["tenant_id"] = tenant_id
|
|
collection_dict["company_id"] = company_id
|
|
new_collection = models.InvoiceCollections(**collection_dict)
|
|
db.add(new_collection)
|
|
|
|
db.commit()
|
|
db.refresh(new_invoice)
|
|
backup_written = _ensure_create_audit_log(
|
|
db,
|
|
table_name=new_invoice.__tablename__,
|
|
record_id=str(new_invoice.id),
|
|
record_data={
|
|
c.name: getattr(new_invoice, c.name)
|
|
for c in models.InvoiceHeader.__table__.columns
|
|
},
|
|
username=username,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
)
|
|
for detail in new_invoice.details:
|
|
backup_written = (
|
|
_ensure_create_audit_log(
|
|
db,
|
|
table_name=detail.__tablename__,
|
|
record_id=str(detail.id),
|
|
record_data={
|
|
c.name: getattr(detail, c.name)
|
|
for c in models.InvoiceSalesDetails.__table__.columns
|
|
},
|
|
username=username,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
)
|
|
or backup_written
|
|
)
|
|
if backup_written:
|
|
db.commit()
|
|
return new_invoice
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
print("\n\n🔥 ERROR AL GUARDAR FACTURA 🔥")
|
|
print(f"Error: {str(e)}")
|
|
traceback.print_exc() # Esto imprime el error real en la consola
|
|
print("--------------------------------\n")
|
|
raise e
|
|
|
|
@staticmethod
|
|
def update(
|
|
db: Session,
|
|
invoice_id: int,
|
|
tenant_id: int,
|
|
invoice_data: schemas.InvoiceHeaderUpdate,
|
|
company_id: int,
|
|
) -> Optional[models.InvoiceHeader]:
|
|
"""Update an existing invoice with validation"""
|
|
|
|
# DEBUG: Log payload for analysis
|
|
print(f"DEBUG: Updating invoice ID {invoice_id} of type {invoice_data.invoice_type}")
|
|
print(f"DEBUG: Payload: {invoice_data.model_dump(exclude_unset=True)}")
|
|
|
|
# Validaciones con ErrorCollector
|
|
errors = ErrorCollector()
|
|
|
|
# Obtener la factura existente
|
|
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
|
|
if not invoice:
|
|
return None
|
|
|
|
# Si se cambió el número de factura, validar que no exista otra con ese número
|
|
if invoice_data.invoice_number and invoice_data.invoice_number != invoice.invoice_number:
|
|
# Verificar que no exista otra factura con el nuevo número
|
|
existing_invoice = (
|
|
db.query(models.InvoiceHeader.id)
|
|
.filter(
|
|
models.InvoiceHeader.invoice_number == invoice_data.invoice_number,
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
models.InvoiceHeader.id != invoice_id, # Excluir la factura actual
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if existing_invoice:
|
|
errors.add_duplicate_error(
|
|
"invoice_number",
|
|
invoice_data.invoice_number,
|
|
f"Ya existe otra factura con el número '{invoice_data.invoice_number}'",
|
|
)
|
|
|
|
if invoice_data.operation_type == "exp":
|
|
validate_update_export(db, invoice_data, invoice, tenant_id, company_id, errors)
|
|
else:
|
|
# Autocalculo remesa (si aplica) ANTES de validar
|
|
_autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id)
|
|
validate_update_import(db, invoice_data, invoice, tenant_id, company_id, errors)
|
|
|
|
# Si hay errores, lanzar excepción ANTES de actualizar
|
|
errors.raise_if_errors("Error al actualizar la factura")
|
|
|
|
# Update main invoice header fields
|
|
update_dict = invoice_data.model_dump(
|
|
exclude={
|
|
"id",
|
|
"compliance_mx",
|
|
"financials",
|
|
"logistics",
|
|
"details",
|
|
"collections",
|
|
},
|
|
exclude_unset=True,
|
|
)
|
|
for key, value in update_dict.items():
|
|
setattr(invoice, key, value)
|
|
|
|
# Audit update fields
|
|
username = _get_current_username()
|
|
invoice.who_processed = username
|
|
invoice.updated_date = func.now()
|
|
|
|
# Backfill capture_user if missing or previous generic 'System'
|
|
if not invoice.capture_user or invoice.capture_user == "System":
|
|
if username != "System":
|
|
invoice.capture_user = username
|
|
|
|
# Update compliance_mx if provided
|
|
if invoice_data.compliance_mx is not None:
|
|
print(f"DEBUG: 更新 compliance_mx para factura {invoice.id}: {invoice_data.compliance_mx}")
|
|
if invoice.compliance_mx:
|
|
for key, value in invoice_data.compliance_mx.model_dump(
|
|
exclude_unset=True
|
|
).items():
|
|
# Parche rápido para update
|
|
if value == "":
|
|
value = None
|
|
setattr(invoice.compliance_mx, key, value)
|
|
else:
|
|
compliance_dict = invoice_data.compliance_mx.model_dump()
|
|
# Aplicar limpieza manual si es necesario
|
|
if "customs_agent" in compliance_dict:
|
|
compliance_dict["customs_broker_id"] = compliance_dict.pop(
|
|
"customs_agent"
|
|
)
|
|
|
|
compliance_dict["invoice_id"] = invoice.id
|
|
compliance_dict["tenant_id"] = tenant_id
|
|
compliance_dict["company_id"] = company_id
|
|
new_compliance = models.InvoiceComplianceMx(**compliance_dict)
|
|
db.add(new_compliance)
|
|
|
|
# Update financials if provided
|
|
if invoice_data.financials is not None:
|
|
if invoice.financials:
|
|
for key, value in invoice_data.financials.model_dump(
|
|
exclude_unset=True
|
|
).items():
|
|
if value == "":
|
|
value = None
|
|
setattr(invoice.financials, key, value)
|
|
else:
|
|
financials_dict = invoice_data.financials.model_dump()
|
|
financials_dict["invoice_id"] = invoice.id
|
|
financials_dict["tenant_id"] = tenant_id
|
|
financials_dict["company_id"] = company_id
|
|
new_financials = models.InvoiceFinancials(**financials_dict)
|
|
db.add(new_financials)
|
|
|
|
# Update logistics if provided
|
|
if invoice_data.logistics is not None:
|
|
if invoice.logistics:
|
|
for key, value in invoice_data.logistics.model_dump(
|
|
exclude_unset=True
|
|
).items():
|
|
if value == "":
|
|
value = None
|
|
setattr(invoice.logistics, key, value)
|
|
_autofill_transport_int_ids(
|
|
db,
|
|
invoice.logistics,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
)
|
|
else:
|
|
logistics_dict = invoice_data.logistics.model_dump()
|
|
logistics_dict["invoice_id"] = invoice.id
|
|
logistics_dict["tenant_id"] = tenant_id
|
|
logistics_dict["company_id"] = company_id
|
|
_autofill_transport_int_ids(
|
|
db,
|
|
logistics_dict,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
)
|
|
new_logistics = models.InvoiceLogistics(**logistics_dict)
|
|
db.add(new_logistics)
|
|
|
|
db.commit()
|
|
db.refresh(invoice)
|
|
return invoice
|
|
|
|
@staticmethod
|
|
def delete(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> bool:
|
|
"""Delete an invoice and all related data (cascade delete)"""
|
|
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
|
|
if invoice:
|
|
db.delete(invoice)
|
|
db.commit()
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def copy(
|
|
db: Session,
|
|
invoice_id: int,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
new_invoice_number: Optional[str] = None,
|
|
) -> models.InvoiceHeader:
|
|
"""Duplica una factura con todas sus tablas relacionadas, incluyendo partidas.
|
|
|
|
Si se provee new_invoice_number, se usa ese número (falla con 409 si ya existe).
|
|
Si no, genera sufijo '-COPIA' / '-COPIA-N' automáticamente.
|
|
Estado reseteado a 'pending', sin pedimento ni datos de procesamiento.
|
|
"""
|
|
from fastapi import HTTPException
|
|
original = (
|
|
db.query(models.InvoiceHeader)
|
|
.filter(
|
|
models.InvoiceHeader.id == invoice_id,
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
)
|
|
.first()
|
|
)
|
|
if not original:
|
|
raise HTTPException(status_code=404, detail="Factura no encontrada")
|
|
|
|
if new_invoice_number:
|
|
exists = db.query(models.InvoiceHeader).filter(
|
|
models.InvoiceHeader.invoice_number == new_invoice_number,
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
).first()
|
|
if exists:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Ya existe una factura con el número '{new_invoice_number}'"
|
|
)
|
|
else:
|
|
base_number = original.invoice_number or ""
|
|
candidate = f"{base_number}-COPIA"
|
|
counter = 1
|
|
while db.query(models.InvoiceHeader).filter(
|
|
models.InvoiceHeader.invoice_number == candidate,
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
).first():
|
|
counter += 1
|
|
candidate = f"{base_number}-COPIA-{counter}"
|
|
new_invoice_number = candidate
|
|
|
|
username = _get_current_username()
|
|
|
|
# Columnas a excluir en la copia del encabezado
|
|
EXCLUDE_COLS = {
|
|
"id", "invoice_number", "status", "capture_date", "capture_user",
|
|
"who_processed", "processed_date", "process_log", "status_rec",
|
|
"status_rep", "comments_status", "vu_observations", "cfdi_uuid",
|
|
"path_pdf", "path_xml", "created_at", "updated_at",
|
|
}
|
|
|
|
header_data = {
|
|
c.name: getattr(original, c.name)
|
|
for c in models.InvoiceHeader.__table__.columns
|
|
if c.name not in EXCLUDE_COLS
|
|
}
|
|
header_data["invoice_number"] = new_invoice_number
|
|
header_data["status"] = models.InvoiceStatus.PENDING
|
|
header_data["capture_user"] = username
|
|
header_data["who_processed"] = username
|
|
|
|
new_invoice = models.InvoiceHeader(**header_data)
|
|
db.add(new_invoice)
|
|
db.flush()
|
|
|
|
# Copiar compliance_mx (sin pedimento ni datos de procesamiento aduanal)
|
|
comp = db.query(models.InvoiceComplianceMx).filter(
|
|
models.InvoiceComplianceMx.invoice_id == invoice_id
|
|
).first()
|
|
if comp:
|
|
EXCLUDE_COMP = {
|
|
"invoice_id", "vucem_operation_num",
|
|
"electronic_signature", "certificate_number", "niu_number",
|
|
"code_signature", "edocument", "created_at", "updated_at",
|
|
}
|
|
comp_data = {
|
|
c.name: getattr(comp, c.name)
|
|
for c in models.InvoiceComplianceMx.__table__.columns
|
|
if c.name not in EXCLUDE_COMP
|
|
}
|
|
comp_data["invoice_id"] = new_invoice.id
|
|
db.add(models.InvoiceComplianceMx(**comp_data))
|
|
|
|
# Copiar financials
|
|
fin = db.query(models.InvoiceFinancials).filter(
|
|
models.InvoiceFinancials.invoice_id == invoice_id
|
|
).first()
|
|
if fin:
|
|
EXCLUDE_FIN = {"id", "invoice_id", "created_at", "updated_at"}
|
|
fin_data = {
|
|
c.name: getattr(fin, c.name)
|
|
for c in models.InvoiceFinancials.__table__.columns
|
|
if c.name not in EXCLUDE_FIN
|
|
}
|
|
fin_data["invoice_id"] = new_invoice.id
|
|
fin_data["tenant_id"] = tenant_id
|
|
fin_data["company_id"] = company_id
|
|
db.add(models.InvoiceFinancials(**fin_data))
|
|
|
|
# Copiar logistics
|
|
log_entries = db.query(models.InvoiceLogistics).filter(
|
|
models.InvoiceLogistics.invoice_id == invoice_id
|
|
).all()
|
|
for log in log_entries:
|
|
EXCLUDE_LOG = {"id", "invoice_id", "created_at", "updated_at"}
|
|
log_data = {
|
|
c.name: getattr(log, c.name)
|
|
for c in models.InvoiceLogistics.__table__.columns
|
|
if c.name not in EXCLUDE_LOG
|
|
}
|
|
log_data["invoice_id"] = new_invoice.id
|
|
log_data["tenant_id"] = tenant_id
|
|
log_data["company_id"] = company_id
|
|
db.add(models.InvoiceLogistics(**log_data))
|
|
|
|
# Copiar sales details
|
|
details = db.query(models.InvoiceSalesDetails).filter(
|
|
models.InvoiceSalesDetails.invoice_id == invoice_id
|
|
).all()
|
|
for det in details:
|
|
EXCLUDE_DET = {"id", "invoice_id", "created_at", "updated_at"}
|
|
det_data = {
|
|
c.name: getattr(det, c.name)
|
|
for c in models.InvoiceSalesDetails.__table__.columns
|
|
if c.name not in EXCLUDE_DET
|
|
}
|
|
det_data["invoice_id"] = new_invoice.id
|
|
det_data["tenant_id"] = tenant_id
|
|
det_data["company_id"] = company_id
|
|
db.add(models.InvoiceSalesDetails(**det_data))
|
|
|
|
# Copiar collections
|
|
collections = db.query(models.InvoiceCollections).filter(
|
|
models.InvoiceCollections.invoice_id == invoice_id
|
|
).all()
|
|
for col in collections:
|
|
EXCLUDE_COL = {"id", "invoice_id", "created_at", "updated_at"}
|
|
col_data = {
|
|
c.name: getattr(col, c.name)
|
|
for c in models.InvoiceCollections.__table__.columns
|
|
if c.name not in EXCLUDE_COL
|
|
}
|
|
col_data["invoice_id"] = new_invoice.id
|
|
col_data["tenant_id"] = tenant_id
|
|
col_data["company_id"] = company_id
|
|
db.add(models.InvoiceCollections(**col_data))
|
|
|
|
# Copiar partidas (LineItem) con todas sus sub-tablas
|
|
EXCLUDE_LINE = {"id", "invoice_id", "created_at", "updated_at"}
|
|
EXCLUDE_SUB = {"id", "item_line_id", "created_at", "updated_at"}
|
|
EXCLUDE_SERIE = {"id", "line_item_id", "created_at", "updated_at"}
|
|
EXCLUDE_IDENT = {"id", "item_line_id", "created_at", "updated_at"}
|
|
|
|
line_items = db.query(LineItem).filter(
|
|
LineItem.invoice_id == invoice_id,
|
|
LineItem.tenant_id == tenant_id,
|
|
LineItem.company_id == company_id,
|
|
).order_by(LineItem.line_number).all()
|
|
|
|
for item in line_items:
|
|
line_data = {
|
|
c.name: getattr(item, c.name)
|
|
for c in LineItem.__table__.columns
|
|
if c.name not in EXCLUDE_LINE
|
|
}
|
|
line_data["invoice_id"] = new_invoice.id
|
|
new_item = LineItem(**line_data)
|
|
db.add(new_item)
|
|
db.flush()
|
|
|
|
# Copiar series primero para reusar sus IDs en LineReference
|
|
serie_id_map: dict[int, int] = {}
|
|
for serie in db.query(Serie).filter(Serie.line_item_id == item.id).all():
|
|
s_data = {
|
|
c.name: getattr(serie, c.name)
|
|
for c in Serie.__table__.columns
|
|
if c.name not in EXCLUDE_SERIE
|
|
}
|
|
s_data["line_item_id"] = new_item.id
|
|
new_serie = Serie(**s_data)
|
|
db.add(new_serie)
|
|
db.flush()
|
|
serie_id_map[serie.id] = new_serie.id
|
|
|
|
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == item.id).first()
|
|
if fin:
|
|
f_data = {c.name: getattr(fin, c.name) for c in LineFinancial.__table__.columns if c.name not in EXCLUDE_SUB}
|
|
f_data["item_line_id"] = new_item.id
|
|
db.add(LineFinancial(**f_data))
|
|
|
|
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == item.id).first()
|
|
if qty:
|
|
q_data = {c.name: getattr(qty, c.name) for c in LineQuantity.__table__.columns if c.name not in EXCLUDE_SUB}
|
|
q_data["item_line_id"] = new_item.id
|
|
db.add(LineQuantity(**q_data))
|
|
|
|
cus = db.query(LineCustom).filter(LineCustom.item_line_id == item.id).first()
|
|
if cus:
|
|
cu_data = {c.name: getattr(cus, c.name) for c in LineCustom.__table__.columns if c.name not in EXCLUDE_SUB}
|
|
cu_data["item_line_id"] = new_item.id
|
|
db.add(LineCustom(**cu_data))
|
|
|
|
desc = db.query(LineDescription).filter(LineDescription.item_line_id == item.id).first()
|
|
if desc:
|
|
d_data = {c.name: getattr(desc, c.name) for c in LineDescription.__table__.columns if c.name not in EXCLUDE_SUB}
|
|
d_data["item_line_id"] = new_item.id
|
|
db.add(LineDescription(**d_data))
|
|
|
|
ref = db.query(LineReference).filter(LineReference.item_line_id == item.id).first()
|
|
if ref:
|
|
r_data = {
|
|
c.name: getattr(ref, c.name)
|
|
for c in LineReference.__table__.columns
|
|
if c.name not in {"id", "item_line_id", "serie_id", "created_at", "updated_at"}
|
|
}
|
|
r_data["item_line_id"] = new_item.id
|
|
r_data["serie_id"] = serie_id_map.get(ref.serie_id) if ref.serie_id else None
|
|
db.add(LineReference(**r_data))
|
|
|
|
for ident in db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == item.id).all():
|
|
i_data = {c.name: getattr(ident, c.name) for c in IdentifierDetail.__table__.columns if c.name not in EXCLUDE_IDENT}
|
|
i_data["item_line_id"] = new_item.id
|
|
db.add(IdentifierDetail(**i_data))
|
|
|
|
db.commit()
|
|
db.refresh(new_invoice)
|
|
return new_invoice
|
|
|
|
@staticmethod
|
|
def export_items(
|
|
db: Session,
|
|
invoice: "models.InvoiceHeader",
|
|
fmt: str,
|
|
):
|
|
"""Genera StreamingResponse con partidas de la factura.
|
|
|
|
Replica el OF 2 del POPUP legacy (QEqiDef → CSV/TXT/XLSX).
|
|
Formatos: csv (,) | txt (|) | xlsx (openpyxl).
|
|
Incluye sección de series al final si existen.
|
|
"""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
|
|
|
|
def _strip_newlines(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
# --- Query principal: patrón joinedload igual que ItemService.get_all ---
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.financial),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.description),
|
|
joinedload(LineItem.class_info),
|
|
joinedload(LineItem.part_info),
|
|
joinedload(LineItem.unit_of_measure_info),
|
|
joinedload(LineItem.series),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
# --- Lookup bulk de UMCLAVE (TariffFraction.umt por código de fracción) ---
|
|
fractions = {
|
|
item.customs.fraction
|
|
for item in items
|
|
if item.customs and item.customs.fraction
|
|
}
|
|
umt_map: dict = {}
|
|
if fractions:
|
|
umt_map = {
|
|
r.code: r.umt
|
|
for r in db.query(TariffFraction.code, TariffFraction.umt)
|
|
.filter(TariffFraction.code.in_(fractions))
|
|
.all()
|
|
}
|
|
|
|
# --- Delimitadores y media type ---
|
|
fmt = fmt.lower()
|
|
if fmt == 'xlsx':
|
|
return InvoiceService._export_items_xlsx(invoice, items, umt_map)
|
|
|
|
delim = ',' if fmt == 'csv' else '|'
|
|
media_type = 'text/csv; charset=utf-8' if fmt == 'csv' else 'text/plain; charset=utf-8'
|
|
ext = fmt
|
|
|
|
HEADERS = [
|
|
'FACTURA', 'LINEA', 'PROCEDENCIA', 'FACTURA', 'LINEA', 'SI',
|
|
'CANTIDAD', 'UNIMED', 'COSTO UNITARIO',
|
|
'PESO NETO', 'PESO BRUTO', 'CANT BULTOS', 'CLAVE BULTOS',
|
|
'PAIS', 'FRACCION', 'PREFERENCIA', 'SECTOR', 'FRAC AMERICANA',
|
|
'ORDEN COMPRA', 'NUM PARTE',
|
|
'DESCRIP ESP', 'DESCRIP ING', 'MARCA', 'MODELO', 'CLASE', 'UMCLAVE',
|
|
'VALORIMPOMN', 'VALORIMPOME', 'LOTE',
|
|
'DESCRIPCIÓN EXTRA EN ESPAÑOL', 'USUARIO QUE CAPTURO LA FACTURA',
|
|
]
|
|
SER_HEADERS = ['FACTURA', 'LINEA', 'RENGLON', 'SERIE', 'MODELO', 'NUMID']
|
|
|
|
def _item_to_list(item: LineItem):
|
|
q = item.quantity
|
|
f = item.financial
|
|
c = item.customs
|
|
d = item.description
|
|
cls = item.class_info
|
|
part = item.part_info
|
|
uom = item.unit_of_measure_info
|
|
pais = c.origin_country if c else ''
|
|
fraccion = (c.octave_fraction or c.fraction) if c else ''
|
|
umclave = umt_map.get(c.fraction, '') if c and c.fraction else ''
|
|
return [
|
|
invoice.invoice_number or '',
|
|
item.line_number or '',
|
|
'TEM',
|
|
invoice.invoice_number or '',
|
|
item.line_number or '',
|
|
'SI',
|
|
q.quantity if q else '',
|
|
uom.code if uom else '',
|
|
f.unit_cost_capture if f else '',
|
|
q.net_weight if q else '',
|
|
q.gross_weight if q else '',
|
|
q.package_quantity if q else '',
|
|
q.package_id if q else '',
|
|
pais or '',
|
|
fraccion,
|
|
c.fraction_type if c else '',
|
|
c.sector if c else '',
|
|
c.american_fraction if c else '',
|
|
invoice.purchase_order or '',
|
|
part.part_number if part else '',
|
|
_strip_newlines(d.description_spanish if d else None),
|
|
_strip_newlines(d.description_english if d else None),
|
|
d.brand if d else '',
|
|
d.model if d else '',
|
|
cls.class_code if cls else '',
|
|
umclave,
|
|
f.value_mxn if f else '',
|
|
f.value_usd if f else '',
|
|
d.lot if d else '',
|
|
_strip_newlines(d.extra_description if d else None),
|
|
invoice.capture_user or '',
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=delim, lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
buf.seek(0); buf.truncate(0)
|
|
writer.writerow(_item_to_list(item))
|
|
yield buf.getvalue()
|
|
# Sección de series al final
|
|
has_series = any(item.series for item in items)
|
|
if has_series:
|
|
buf.seek(0); buf.truncate(0)
|
|
writer.writerow([])
|
|
writer.writerow(SER_HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
for s in sorted(item.series, key=lambda x: x.row):
|
|
buf.seek(0); buf.truncate(0)
|
|
writer.writerow([
|
|
invoice.invoice_number or '',
|
|
item.line_number or '',
|
|
s.row or '',
|
|
s.serial_numbers or '',
|
|
s.model or '',
|
|
s.number_id or '',
|
|
])
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_export.{ext}"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type=media_type,
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def _export_items_xlsx(invoice, items, umt_map: dict):
|
|
"""Genera el archivo XLSX con openpyxl."""
|
|
import io
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
try:
|
|
import openpyxl
|
|
except ImportError:
|
|
raise ImportError("openpyxl no está instalado. Agrega 'openpyxl' a requirements.txt")
|
|
|
|
def _strip_newlines(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
def _f(v):
|
|
return float(v) if v is not None else ''
|
|
|
|
HEADERS = [
|
|
'FACTURA', 'LINEA', 'PROCEDENCIA', 'FACTURA', 'LINEA', 'SI',
|
|
'CANTIDAD', 'UNIMED', 'COSTO UNITARIO',
|
|
'PESO NETO', 'PESO BRUTO', 'CANT BULTOS', 'CLAVE BULTOS',
|
|
'PAIS', 'FRACCION', 'PREFERENCIA', 'SECTOR', 'FRAC AMERICANA',
|
|
'ORDEN COMPRA', 'NUM PARTE',
|
|
'DESCRIP ESP', 'DESCRIP ING', 'MARCA', 'MODELO', 'CLASE', 'UMCLAVE',
|
|
'VALORIMPOMN', 'VALORIMPOME', 'LOTE',
|
|
'DESCRIPCIÓN EXTRA EN ESPAÑOL', 'USUARIO QUE CAPTURO LA FACTURA',
|
|
]
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Partidas"
|
|
ws.append(HEADERS)
|
|
for item in items:
|
|
q = item.quantity
|
|
f = item.financial
|
|
c = item.customs
|
|
d = item.description
|
|
cls = item.class_info
|
|
part = item.part_info
|
|
uom = item.unit_of_measure_info
|
|
pais = c.origin_country if c else ''
|
|
fraccion = (c.octave_fraction or c.fraction) if c else ''
|
|
umclave = umt_map.get(c.fraction, '') if c and c.fraction else ''
|
|
ws.append([
|
|
invoice.invoice_number or '',
|
|
item.line_number or '',
|
|
'TEM',
|
|
invoice.invoice_number or '',
|
|
item.line_number or '',
|
|
'SI',
|
|
_f(q.quantity if q else None),
|
|
uom.code if uom else '',
|
|
_f(f.unit_cost_capture if f else None),
|
|
_f(q.net_weight if q else None),
|
|
_f(q.gross_weight if q else None),
|
|
_f(q.package_quantity if q else None),
|
|
str(q.package_id or '') if q else '',
|
|
str(pais or ''),
|
|
str(fraccion),
|
|
str(c.fraction_type or '') if c else '',
|
|
str(c.sector or '') if c else '',
|
|
str(c.american_fraction or '') if c else '',
|
|
str(invoice.purchase_order or ''),
|
|
str(part.part_number or '') if part else '',
|
|
_strip_newlines(d.description_spanish if d else None),
|
|
_strip_newlines(d.description_english if d else None),
|
|
str(d.brand or '') if d else '',
|
|
str(d.model or '') if d else '',
|
|
str(cls.class_code or '') if cls else '',
|
|
str(umclave),
|
|
_f(f.value_mxn if f else None),
|
|
_f(f.value_usd if f else None),
|
|
str(d.lot or '') if d else '',
|
|
_strip_newlines(d.extra_description if d else None),
|
|
str(invoice.capture_user or ''),
|
|
])
|
|
has_series = any(item.series for item in items)
|
|
if has_series:
|
|
ws.append([])
|
|
ws.append(['FACTURA', 'LINEA', 'RENGLON', 'SERIE', 'MODELO', 'NUMID'])
|
|
for item in items:
|
|
for s in sorted(item.series, key=lambda x: x.row):
|
|
ws.append([
|
|
invoice.invoice_number or '',
|
|
item.line_number or '',
|
|
s.row or '',
|
|
s.serial_numbers or '',
|
|
s.model or '',
|
|
s.number_id or '',
|
|
])
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
buf.seek(0)
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_export.xlsx"
|
|
return StreamingResponse(
|
|
iter([buf.read()]),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def export_gm_transport(
|
|
db: Session,
|
|
invoice: "models.InvoiceHeader",
|
|
):
|
|
"""Genera CSV para interfaz GM Transport (OF 5 del POPUP legacy).
|
|
|
|
Replica las 21 columnas del formato GM Transport. Pedimento formateado
|
|
como: AÑO ADUANA PATENTE NUMERO con espacios dobles entre campos.
|
|
"""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
|
|
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
|
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
|
|
|
|
def _strip(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
# Datos de pedimento y compliance son por factura (no por partida)
|
|
compliance = (
|
|
db.query(InvoiceComplianceMx)
|
|
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
|
|
.first()
|
|
)
|
|
pedimento = None
|
|
ped_date = None
|
|
if compliance and compliance.pedimento_id:
|
|
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
|
|
if pedimento:
|
|
ped_date_row = (
|
|
db.query(PedimentoDates)
|
|
.filter(PedimentoDates.pedimento_id == pedimento.id)
|
|
.first()
|
|
)
|
|
ped_date = ped_date_row.entry_date if ped_date_row else None
|
|
|
|
def _format_pedimento() -> str:
|
|
if not pedimento or not pedimento.pedimento_number:
|
|
return ''
|
|
return f"{pedimento.year or ''} {pedimento.customs_office or ''} {pedimento.license or ''} {pedimento.pedimento_number or ''}"
|
|
|
|
# Partidas con sus sub-modelos cargados (joinedload — mismo patrón que ItemService)
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.class_info),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
ped_str = _format_pedimento()
|
|
fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else ''
|
|
aduana_str = compliance.aduana if compliance else ''
|
|
|
|
HEADERS = [
|
|
'CANTIDAD', 'ID UNIDAD EMBALAJE', 'DESC. MATERIAL CARGA', 'PESO', 'ID UNIDAD PESO',
|
|
'CODIGO DE PRODUCTO Y SERVICIO', 'CLAVE UNIDAD DE MEDIDA Y EMBALAJE', 'CLAVE UNIDAD',
|
|
'CLAVE FRACCIÓN ARANCELARIA', 'UUID COMERCIO EXTERIOR', 'ES MATERIAL PELIGROSO?',
|
|
'CLAVE MATERIAL PELIGROSO', 'TIPO EMBALAJE', 'DESCRIPCIÓN EMBALAJE',
|
|
'APLICA TARIFA', 'TARIFA', 'IMPORTE', 'IMPORTE BASE',
|
|
'NÚMERO DE PEDIMENTO', 'FECHA', 'ADUANA',
|
|
]
|
|
|
|
def _item_to_list(item: LineItem):
|
|
q = item.quantity
|
|
c = item.customs
|
|
cls = item.class_info
|
|
return [
|
|
q.quantity if q else '',
|
|
'',
|
|
_strip(cls.description_es if cls else None),
|
|
q.gross_weight if q else '',
|
|
'',
|
|
cls.fraction if cls else '',
|
|
'',
|
|
'',
|
|
("'" + c.fraction) if c and c.fraction else '',
|
|
'',
|
|
'NO',
|
|
'', '', '', '', '', '', '',
|
|
ped_str,
|
|
fecha_str,
|
|
aduana_str,
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
buf.seek(0); buf.truncate(0)
|
|
writer.writerow(_item_to_list(item))
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_gm_transport.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def export_carta_porte(db: Session, invoice) -> "StreamingResponse":
|
|
"""
|
|
Genera el CSV de Interfaz Carta Porte (OF-4).
|
|
24 columnas: datos de partida + RFC proveedor/enviado a/importador + pedimento + régimen.
|
|
"""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
|
|
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
|
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
|
|
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
|
|
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
|
|
from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte
|
|
|
|
def _strip(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
# Datos fijos por factura: compliance, pedimento, RFCs
|
|
compliance = (
|
|
db.query(InvoiceComplianceMx)
|
|
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
|
|
.first()
|
|
)
|
|
pedimento = None
|
|
ped_date = None
|
|
regime = ''
|
|
aduana_str = compliance.aduana if compliance else ''
|
|
provider_rfc = shipped_to_rfc = sold_to_rfc = ''
|
|
|
|
if compliance:
|
|
if compliance.pedimento_id:
|
|
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
|
|
if pedimento:
|
|
regime = pedimento.regime or ''
|
|
ped_date_row = (
|
|
db.query(PedimentoDates)
|
|
.filter(PedimentoDates.pedimento_id == pedimento.id)
|
|
.first()
|
|
)
|
|
ped_date = ped_date_row.entry_date if ped_date_row else None
|
|
|
|
def _get_rfc(client_id):
|
|
if not client_id:
|
|
return ''
|
|
row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first()
|
|
return row.rfc if row else ''
|
|
|
|
provider_rfc = _get_rfc(compliance.provider_id)
|
|
shipped_to_rfc = _get_rfc(compliance.shipped_to_id)
|
|
sold_to_rfc = _get_rfc(compliance.sold_to_id)
|
|
|
|
def _format_pedimento() -> str:
|
|
if not pedimento or not pedimento.pedimento_number:
|
|
return ''
|
|
return (
|
|
f"{pedimento.year or ''} {pedimento.customs_office or ''}"
|
|
f" {pedimento.license or ''} {pedimento.pedimento_number or ''}"
|
|
)
|
|
|
|
# Partidas con joinedload (mismo patrón que export_items)
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.financial),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.description),
|
|
joinedload(LineItem.class_info),
|
|
joinedload(LineItem.unit_of_measure_info),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
# Bulk lookups: UMT por fracción aduanera, CartaPorte por fracción de clase
|
|
customs_fractions = {
|
|
item.customs.fraction for item in items if item.customs and item.customs.fraction
|
|
}
|
|
class_fractions = {
|
|
item.class_info.fraction for item in items
|
|
if item.class_info and item.class_info.fraction
|
|
}
|
|
umt_map = (
|
|
{r.code: r.umt for r in db.query(TariffFraction.code, TariffFraction.umt)
|
|
.filter(TariffFraction.code.in_(customs_fractions)).all()}
|
|
if customs_fractions else {}
|
|
)
|
|
cp_map = (
|
|
{r.code: r for r in db.query(CartaPorte)
|
|
.filter(CartaPorte.code.in_(class_fractions)).all()}
|
|
if class_fractions else {}
|
|
)
|
|
|
|
ped_str = _format_pedimento()
|
|
fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else ''
|
|
|
|
HEADERS = [
|
|
'FACTURA', 'CLASE', 'DESCRIPCION CLASE', 'CODIGO DE PRODUCTO Y SERVICIO',
|
|
'DESCRIPCION CÓDIGO', 'CANTIDAD', 'UNIDAD DE MEDIDA', 'UNIDAD DE MEDIDA SAT',
|
|
'PESO NETO', 'PESO BRUTO', 'FRACCION', 'VALOR MN', 'VALOR ME',
|
|
'RFC PROVEEDOR', 'RFC ENVIADO A:', 'PEDIMENTO', 'FECHA INICIO PEDIMENTO',
|
|
'MATERIAL PELIGROSO', 'ADUANA', 'TIPO DE MATERIAL', 'DESCRIPCION DE LA MATERIA',
|
|
'TIPO DE DOCUMENTO', 'RFC IMPORTADOR', 'REGIMEN ADUANERO',
|
|
]
|
|
|
|
def _item_to_list(item: LineItem):
|
|
q = item.quantity
|
|
fin = item.financial
|
|
c = item.customs
|
|
desc = item.description
|
|
cls = item.class_info
|
|
uom = item.unit_of_measure_info
|
|
customs_frac = c.fraction if c else ''
|
|
class_frac = cls.fraction if cls else None
|
|
cp = cp_map.get(class_frac) if class_frac else None
|
|
return [
|
|
invoice.invoice_number or '',
|
|
cls.class_code if cls else '',
|
|
_strip(desc.description_spanish if desc else None),
|
|
cp.code if cp else '',
|
|
cp.description if cp else '',
|
|
q.quantity if q else '',
|
|
uom.code if uom else '',
|
|
umt_map.get(customs_frac, ''),
|
|
q.net_weight if q else '',
|
|
q.gross_weight if q else '',
|
|
customs_frac,
|
|
fin.value_mxn if fin else '',
|
|
fin.value_usd if fin else '',
|
|
provider_rfc,
|
|
shipped_to_rfc,
|
|
ped_str,
|
|
fecha_str,
|
|
'NO',
|
|
aduana_str,
|
|
cls.material_key if cls else '',
|
|
'', # DESCRIPCION DE LA MATERIA — tabla material_types no mapeada
|
|
'01', # TIPO DE DOCUMENTO
|
|
sold_to_rfc,
|
|
regime,
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
buf.seek(0); buf.truncate(0)
|
|
writer.writerow(_item_to_list(item))
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_carta_porte.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def export_tfc(db: Session, invoice) -> "StreamingResponse":
|
|
"""Interfaz TFC (OF-6). 17 columnas. Pedimento desglosado en 4 campos."""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
|
|
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
|
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
|
|
|
|
def _strip(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
compliance = (
|
|
db.query(InvoiceComplianceMx)
|
|
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
|
|
.first()
|
|
)
|
|
pedimento = None
|
|
if compliance and compliance.pedimento_id:
|
|
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
|
|
|
|
ped_year = pedimento.year or '' if pedimento else ''
|
|
ped_office = pedimento.customs_office or '' if pedimento else ''
|
|
ped_license = pedimento.license or '' if pedimento else ''
|
|
ped_number = pedimento.pedimento_number or '' if pedimento else ''
|
|
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.class_info),
|
|
joinedload(LineItem.description),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
HEADERS = [
|
|
'ID DESTINO', 'BIENES TRANSPORTADOS', 'CANTIDAD MERCANCIA', 'CLAVE DE UNIDAD',
|
|
'PESO EN KG', 'DESCRIPCION DE MERCANCIA', 'MATERIAL PELIGROSO', 'CLAVE DE MATERIAL',
|
|
'CLAVE EMBALAJE (SOLO SI ES MATERIAL PELIGROSO)', 'FRACCION ARANCELARIA',
|
|
'UUID DE COMERCIO EXTERIOR', 'DESCRIPCION GUIA DE IDENTIFICACION',
|
|
'PESO KG GUIA DE IDENTIFICACION', 'PEDIMENTO - VALIDACION', 'PEDIMENTO - ADUANA',
|
|
'PEDIMENTO - PATENTE', 'PEDIMENTO NUMERACION PROGRESIVA',
|
|
]
|
|
|
|
def _item_to_list(item: LineItem):
|
|
q = item.quantity
|
|
c = item.customs
|
|
cls = item.class_info
|
|
d = item.description
|
|
return [
|
|
'1',
|
|
cls.fraction if cls else '',
|
|
q.quantity if q else '',
|
|
'',
|
|
q.net_weight if q else '',
|
|
_strip(d.description_spanish if d else None),
|
|
'NO',
|
|
'', '',
|
|
c.fraction if c else '',
|
|
'', '', '',
|
|
ped_year,
|
|
ped_office,
|
|
ped_license,
|
|
ped_number,
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
buf.seek(0); buf.truncate(0)
|
|
writer.writerow(_item_to_list(item))
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_tfc.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def export_carta_porte_consolidada(db: Session, invoice) -> "StreamingResponse":
|
|
"""Interfaz Carta Porte Consolidada (OF-8). 24 cols, igual que OF-4 pero
|
|
partidas agrupadas por clase: SUM de cantidades/pesos/valores."""
|
|
import io
|
|
import csv
|
|
import re
|
|
from decimal import Decimal
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
|
|
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
|
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
|
|
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
|
|
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
|
|
from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte
|
|
|
|
UOM_SAT = {
|
|
'PZA': 'H87', 'KGS': 'KGM', 'MT': 'MTR', 'LT': 'LTR',
|
|
'JGO': 'SET', 'GR': 'GRM', 'M2': 'MTK', 'PAR': 'PR', 'CAJA': 'XBX',
|
|
}
|
|
MAT_DESC = {
|
|
'01': 'Materia prima',
|
|
'02': 'Materia procesada',
|
|
'03': 'Materia terminada(producto terminado)',
|
|
'04': 'Materia para la industria manufacturera',
|
|
'05': 'Otra',
|
|
}
|
|
MATERIAL_EXCLUIDOS = {'TERR', 'INSTA', 'EDIF'}
|
|
|
|
def _strip(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
def _dec(v):
|
|
if v is None:
|
|
return Decimal('0')
|
|
return Decimal(str(v))
|
|
|
|
compliance = (
|
|
db.query(InvoiceComplianceMx)
|
|
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
|
|
.first()
|
|
)
|
|
pedimento = None
|
|
ped_date = None
|
|
regime = ''
|
|
aduana_str = compliance.aduana if compliance else ''
|
|
provider_rfc = shipped_to_rfc = sold_to_rfc = ''
|
|
|
|
if compliance:
|
|
if compliance.pedimento_id:
|
|
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
|
|
if pedimento:
|
|
regime = pedimento.regime or ''
|
|
ped_date_row = (
|
|
db.query(PedimentoDates)
|
|
.filter(PedimentoDates.pedimento_id == pedimento.id)
|
|
.first()
|
|
)
|
|
ped_date = ped_date_row.entry_date if ped_date_row else None
|
|
|
|
def _get_rfc(client_id):
|
|
if not client_id:
|
|
return ''
|
|
row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first()
|
|
return row.rfc if row else ''
|
|
|
|
provider_rfc = _get_rfc(compliance.provider_id)
|
|
shipped_to_rfc = _get_rfc(compliance.shipped_to_id)
|
|
sold_to_rfc = _get_rfc(compliance.sold_to_id)
|
|
|
|
def _format_pedimento() -> str:
|
|
if not pedimento or not pedimento.pedimento_number:
|
|
return ''
|
|
return (
|
|
f"{pedimento.year or ''} {pedimento.customs_office or ''}"
|
|
f" {pedimento.license or ''} {pedimento.pedimento_number or ''}"
|
|
)
|
|
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.financial),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.description),
|
|
joinedload(LineItem.class_info),
|
|
joinedload(LineItem.unit_of_measure_info),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
customs_fractions = {
|
|
item.customs.fraction for item in items if item.customs and item.customs.fraction
|
|
}
|
|
class_fractions = {
|
|
item.class_info.fraction for item in items
|
|
if item.class_info and item.class_info.fraction
|
|
}
|
|
umt_map = (
|
|
{r.code: r.umt for r in db.query(TariffFraction.code, TariffFraction.umt)
|
|
.filter(TariffFraction.code.in_(customs_fractions)).all()}
|
|
if customs_fractions else {}
|
|
)
|
|
cp_map = (
|
|
{r.code: r for r in db.query(CartaPorte)
|
|
.filter(CartaPorte.code.in_(class_fractions)).all()}
|
|
if class_fractions else {}
|
|
)
|
|
|
|
# Agrupación por class_code
|
|
groups: dict = {}
|
|
group_order: list = []
|
|
for item in items:
|
|
key = item.class_info.class_code if item.class_info else ''
|
|
if key not in groups:
|
|
group_order.append(key)
|
|
cls = item.class_info
|
|
c = item.customs
|
|
uom = item.unit_of_measure_info
|
|
class_frac = cls.fraction if cls else None
|
|
cp = cp_map.get(class_frac) if class_frac else None
|
|
mat_key = cls.material_key if cls else ''
|
|
tipo_mat = '' if mat_key in MATERIAL_EXCLUIDOS else '05'
|
|
groups[key] = {
|
|
'class_code': key,
|
|
'description': _strip(item.description.description_spanish if item.description else None),
|
|
'cp_code': cp.code if cp else '',
|
|
'cp_desc': cp.description if cp else '',
|
|
'uom': uom.code if uom else '',
|
|
'uom_sat': UOM_SAT.get(uom.code if uom else '', ''),
|
|
'customs_frac': c.fraction if c else '',
|
|
'tipo_mat': tipo_mat,
|
|
'mat_desc': MAT_DESC.get(tipo_mat, ''),
|
|
'quantity': _dec(item.quantity.quantity if item.quantity else None),
|
|
'net_weight': _dec(item.quantity.net_weight if item.quantity else None),
|
|
'gross_weight': _dec(item.quantity.gross_weight if item.quantity else None),
|
|
'value_mxn': _dec(item.financial.value_mxn if item.financial else None),
|
|
'value_usd': _dec(item.financial.value_usd if item.financial else None),
|
|
}
|
|
else:
|
|
g = groups[key]
|
|
g['quantity'] += _dec(item.quantity.quantity if item.quantity else None)
|
|
g['net_weight'] += _dec(item.quantity.net_weight if item.quantity else None)
|
|
g['gross_weight'] += _dec(item.quantity.gross_weight if item.quantity else None)
|
|
g['value_mxn'] += _dec(item.financial.value_mxn if item.financial else None)
|
|
g['value_usd'] += _dec(item.financial.value_usd if item.financial else None)
|
|
|
|
ped_str = _format_pedimento()
|
|
fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else ''
|
|
|
|
HEADERS = [
|
|
'FACTURA', 'CLASE', 'DESCRIPCION CLASE', 'CODIGO DE PRODUCTO Y SERVICIO',
|
|
'DESCRIPCION CÓDIGO', 'CANTIDAD', 'UNIDAD DE MEDIDA', 'UNIDAD DE MEDIDA SAT',
|
|
'PESO NETO', 'PESO BRUTO', 'FRACCION', 'VALOR MN', 'VALOR ME',
|
|
'RFC PROVEEDOR', 'RFC ENVIADO A:', 'PEDIMENTO', 'FECHA INICIO PEDIMENTO',
|
|
'MATERIAL PELIGROSO', 'ADUANA', 'TIPO DE MATERIAL', 'DESCRIPCION DE LA MATERIA',
|
|
'TIPO DE DOCUMENTO', 'RFC IMPORTADOR', 'REGIMEN ADUANERO',
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for key in group_order:
|
|
g = groups[key]
|
|
buf.seek(0); buf.truncate(0)
|
|
writer.writerow([
|
|
invoice.invoice_number or '',
|
|
g['class_code'],
|
|
g['description'],
|
|
g['cp_code'],
|
|
g['cp_desc'],
|
|
g['quantity'],
|
|
g['uom'],
|
|
g['uom_sat'],
|
|
g['net_weight'],
|
|
g['gross_weight'],
|
|
g['customs_frac'],
|
|
g['value_mxn'],
|
|
g['value_usd'],
|
|
provider_rfc,
|
|
shipped_to_rfc,
|
|
ped_str,
|
|
fecha_str,
|
|
'NO',
|
|
aduana_str,
|
|
g['tipo_mat'],
|
|
g['mat_desc'],
|
|
'01',
|
|
sold_to_rfc,
|
|
regime,
|
|
])
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_carta_porte_consolidada.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def export_aviso_cruce(db: Session, invoice) -> "StreamingResponse":
|
|
"""Interfaz Aviso Cruce (OF-7). 4 columnas: descripción, UMC, cantidad, valor USD."""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
|
|
def _strip(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.description),
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.financial),
|
|
joinedload(LineItem.unit_of_measure_info),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
HEADERS = ['DESCRIPCIÓN', 'UMC', 'CANTIDAD', 'VALOR DÓLARES']
|
|
|
|
def _item_to_list(item: LineItem):
|
|
d = item.description
|
|
q = item.quantity
|
|
f = item.financial
|
|
uom = item.unit_of_measure_info
|
|
return [
|
|
_strip(d.description_spanish if d else None),
|
|
uom.code if uom else '',
|
|
q.quantity if q else '',
|
|
f.value_usd if f else '',
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
buf.seek(0)
|
|
buf.truncate(0)
|
|
writer.writerow(_item_to_list(item))
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_aviso_cruce.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def copy_header_only(
|
|
db: Session,
|
|
invoice_id: int,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
) -> "models.InvoiceHeader":
|
|
"""Copia el encabezado sin partidas, zeroeando totales acumulados.
|
|
Equivale a COPIA_ENC_A_SCAII del legacy Clarion."""
|
|
original = db.query(models.InvoiceHeader).filter(
|
|
models.InvoiceHeader.id == invoice_id,
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
).first()
|
|
if not original:
|
|
raise HTTPException(status_code=404, detail="Factura no encontrada")
|
|
|
|
base_num = original.invoice_number or str(invoice_id)
|
|
candidate = f"{base_num}-ENC"
|
|
count = 1
|
|
while db.query(models.InvoiceHeader).filter(
|
|
models.InvoiceHeader.invoice_number == candidate,
|
|
models.InvoiceHeader.tenant_id == tenant_id,
|
|
models.InvoiceHeader.company_id == company_id,
|
|
).first():
|
|
candidate = f"{base_num}-ENC-{count}"
|
|
count += 1
|
|
|
|
EXCLUDE_COLS = {
|
|
"id", "invoice_number", "status", "capture_date", "capture_user",
|
|
"who_processed", "processed_date", "process_log", "status_rec",
|
|
"status_rep", "comments_status", "vu_observations", "cfdi_uuid",
|
|
"path_pdf", "path_xml", "created_at", "updated_at",
|
|
}
|
|
header_data = {
|
|
c.name: getattr(original, c.name)
|
|
for c in models.InvoiceHeader.__table__.columns
|
|
if c.name not in EXCLUDE_COLS
|
|
}
|
|
header_data["invoice_number"] = candidate
|
|
header_data["status"] = models.InvoiceStatus.PENDING
|
|
header_data["party_count"] = 0
|
|
new_invoice = models.InvoiceHeader(**header_data)
|
|
db.add(new_invoice)
|
|
db.flush()
|
|
|
|
comp = db.query(models.InvoiceComplianceMx).filter(
|
|
models.InvoiceComplianceMx.invoice_id == invoice_id
|
|
).first()
|
|
if comp:
|
|
EXCLUDE_COMP = {
|
|
"id", "invoice_id", "pedimento_id", "pedimento_r1", "pedimento_k1",
|
|
"vucem_operation_num", "electronic_signature", "certificate_number",
|
|
"niu_number", "code_signature", "edocument", "created_at", "updated_at",
|
|
}
|
|
comp_data = {
|
|
c.name: getattr(comp, c.name)
|
|
for c in models.InvoiceComplianceMx.__table__.columns
|
|
if c.name not in EXCLUDE_COMP
|
|
}
|
|
comp_data["invoice_id"] = new_invoice.id
|
|
comp_data["tenant_id"] = tenant_id
|
|
comp_data["company_id"] = company_id
|
|
db.add(models.InvoiceComplianceMx(**comp_data))
|
|
|
|
for log in db.query(models.InvoiceLogistics).filter(
|
|
models.InvoiceLogistics.invoice_id == invoice_id
|
|
).all():
|
|
EXCLUDE_LOG = {"id", "invoice_id", "created_at", "updated_at"}
|
|
log_data = {
|
|
c.name: getattr(log, c.name)
|
|
for c in models.InvoiceLogistics.__table__.columns
|
|
if c.name not in EXCLUDE_LOG
|
|
}
|
|
log_data["invoice_id"] = new_invoice.id
|
|
log_data["tenant_id"] = tenant_id
|
|
log_data["company_id"] = company_id
|
|
db.add(models.InvoiceLogistics(**log_data))
|
|
|
|
fin = db.query(models.InvoiceFinancials).filter(
|
|
models.InvoiceFinancials.invoice_id == invoice_id
|
|
).first()
|
|
if fin:
|
|
EXCLUDE_FIN = {"id", "invoice_id", "created_at", "updated_at"}
|
|
ZERO_FIN = {
|
|
"value_mn", "value_me", "value_mc",
|
|
"customs_value_mn", "customs_value_me",
|
|
"raw_material_value_mn", "raw_material_value_me",
|
|
"aggregate_value_mn", "aggregate_value_me", "aggregate_value_mc",
|
|
"mexican_value_mn", "mexican_value_me", "mexican_value_mc",
|
|
"national_packaging_mn", "national_packaging_me", "national_packaging_mc",
|
|
"iva_mn", "iva_me", "iva_mc", "tax_value_me",
|
|
"total_quantity", "total_packages", "bundle_count",
|
|
"gross_weight", "net_weight",
|
|
}
|
|
fin_data = {}
|
|
for c in models.InvoiceFinancials.__table__.columns:
|
|
if c.name in EXCLUDE_FIN:
|
|
continue
|
|
fin_data[c.name] = 0 if c.name in ZERO_FIN else getattr(fin, c.name)
|
|
fin_data["invoice_id"] = new_invoice.id
|
|
fin_data["tenant_id"] = tenant_id
|
|
fin_data["company_id"] = company_id
|
|
db.add(models.InvoiceFinancials(**fin_data))
|
|
|
|
db.commit()
|
|
db.refresh(new_invoice)
|
|
return new_invoice
|
|
|
|
@staticmethod
|
|
def export_aaduanal_rs(db: Session, invoice) -> "StreamingResponse":
|
|
"""Interfaz AAduanal_RS (OF-10). 16 columnas, headers en inglés."""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
|
|
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
|
|
|
def _strip(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
def _fmt_frac(frac):
|
|
if not frac or len(frac) < 7:
|
|
return frac or ''
|
|
return f"{frac[0:2]}.{frac[2:4]}.{frac[4:6]}.{frac[6:]}"
|
|
|
|
compliance = (
|
|
db.query(InvoiceComplianceMx)
|
|
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
|
|
.first()
|
|
)
|
|
pedimento = None
|
|
if compliance and compliance.pedimento_id:
|
|
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
|
|
|
|
ped_num = ''
|
|
if pedimento:
|
|
ped_num = (
|
|
(pedimento.customs_office or '')
|
|
+ (pedimento.license or '')
|
|
+ (pedimento.pedimento_number or '')
|
|
)
|
|
|
|
inv_date = invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else ''
|
|
edocument = (compliance.edocument or '') if compliance else ''
|
|
vu_obs = re.sub(r'[\t\r\n]+', '', invoice.vu_observations or '') if invoice.vu_observations else ''
|
|
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.description),
|
|
joinedload(LineItem.financial),
|
|
joinedload(LineItem.unit_of_measure_info),
|
|
joinedload(LineItem.part_info),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
HEADERS = [
|
|
'indPedNum', 'ihdInvNum', 'ihdInvDate', 'ihdPartNum', 'ihdQty',
|
|
'ihdPartSKU', 'ihdusunitVal', 'ihdustotalval', 'ihdmexwt', 'ihdPartCtryOrig',
|
|
'ihdPgmCode', 'ihdTariffNum', 'partSpanDesc', 'cpartRegla8', 'COVE', 'e-document',
|
|
]
|
|
|
|
def _row(item: LineItem):
|
|
q = item.quantity
|
|
c = item.customs
|
|
d = item.description
|
|
f = item.financial
|
|
uom = item.unit_of_measure_info
|
|
part = item.part_info
|
|
return [
|
|
ped_num,
|
|
invoice.invoice_number or '',
|
|
inv_date,
|
|
part.part_number if part else '',
|
|
q.quantity if q else '',
|
|
(uom.code or '')[:2] if uom else '',
|
|
f.unit_cost_usd if f else '',
|
|
f.value_usd if f else '',
|
|
q.gross_weight if q else '',
|
|
c.origin_country if c else '',
|
|
'REGLA-8' if (c and c.octave_fraction) else '',
|
|
_fmt_frac(c.fraction if c else None),
|
|
_strip(d.description_spanish if d else None),
|
|
_fmt_frac(c.octave_fraction if c else None),
|
|
edocument,
|
|
vu_obs,
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
buf.seek(0)
|
|
buf.truncate(0)
|
|
writer.writerow(_row(item))
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_aaduanal_rs.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def export_caaarem(db: Session, invoice) -> "StreamingResponse":
|
|
"""Interfaz CAAAREM (OF-9). 47 columnas desnormalizadas: fila 1 = encabezado + partida 1, filas 2+ = 27 vacíos + partida."""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
|
|
def _strip(text):
|
|
if not text:
|
|
return ''
|
|
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
|
|
|
|
def _vinculacion(v):
|
|
# Clarion: CASE 0→'0', 1/2→'1'
|
|
if not v or v == '0':
|
|
return '0'
|
|
return '1'
|
|
|
|
def _currency_to_country(currency):
|
|
# Clarion: USD→'USA', MXP→'MEX', else ''
|
|
if currency == 'USD':
|
|
return 'USA'
|
|
if currency in ('MXP', 'MXN'):
|
|
return 'MEX'
|
|
return ''
|
|
|
|
compliance = (
|
|
db.query(InvoiceComplianceMx)
|
|
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
|
|
.first()
|
|
)
|
|
financials = invoice.financials
|
|
logistics = invoice.logistics
|
|
|
|
company = db.query(Company).filter(
|
|
Company.tenant_id == invoice.tenant_id,
|
|
Company.id == invoice.company_id,
|
|
).first()
|
|
|
|
provider = None
|
|
if compliance and compliance.provider_id:
|
|
provider = (
|
|
db.query(ClientProvider)
|
|
.options(joinedload(ClientProvider.address))
|
|
.filter(
|
|
ClientProvider.id == compliance.provider_id,
|
|
ClientProvider.tenant_id == invoice.tenant_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.description),
|
|
joinedload(LineItem.financial),
|
|
joinedload(LineItem.unit_of_measure_info),
|
|
joinedload(LineItem.part_info),
|
|
joinedload(LineItem.series),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
HEADERS = [
|
|
'TIPO OPERACION', 'CLIENTE', 'PROVEEDOR', 'E-DOCUMENT', 'SUBDIVISION',
|
|
'NUMERO DE FACTURA', 'NUMERO DE EXPORTADOR', 'FECHA FACTURA', 'VALOR TOTAL FACTURA',
|
|
'CERTIFICADO DE ORIGEN', 'MONEDA FACTURACION', 'OBSERVACION', 'VINCULACION FACTURA',
|
|
'INCOTERM', 'PAIS DE FACTURACION', 'PESO TOTAL FACTURA', 'GUIA MASTER', 'GUIA HOUSE',
|
|
'BULTOS TOTAL', 'SEGURO', 'MONEDA SEGURO', 'FLETE', 'MONEDA FLETE',
|
|
'EMBALAJE', 'MONEDA EMBALAJE', 'OTROS', 'MONEDA OTROS',
|
|
'NUMERO DE PARTE', 'FRACCION ARANCELARIA', 'DESCRIPCION PEDIMENTO', 'CANTIDAD',
|
|
'U.M. COMERCIAL', 'VALOR TOTAL', 'CANTIDAD TARIFA', 'U.M. TARIFA',
|
|
'PAIS ORIGEN / DESTINO', 'PAIS COMPRADOR / VENDEDOR', 'VINCULACION', 'VALORACION',
|
|
'PESO', 'BULTOS', 'VALOR AGREGADO', 'MONEDA VALOR AGREGADO',
|
|
'SERIE', 'MARCA', 'MODELO', 'SUBMODELO',
|
|
]
|
|
|
|
def _header_vals():
|
|
currency = financials.currency if financials else ''
|
|
niu = compliance.niu_number if compliance else ''
|
|
is_rail = bool(logistics and logistics.is_rail)
|
|
return [
|
|
'1',
|
|
compliance.sold_to_header or '' if compliance else '',
|
|
compliance.provider_header or '' if compliance else '',
|
|
compliance.edocument or '' if compliance else '',
|
|
'1' if (compliance and compliance.subdivision) else '0',
|
|
invoice.invoice_number or '',
|
|
company.manufacturer_id or '' if company else '',
|
|
invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else '',
|
|
financials.value_me if financials else '',
|
|
'1' if (compliance and compliance.acts_as) else '0',
|
|
currency,
|
|
_strip(invoice.vu_observations),
|
|
_vinculacion(provider.linking if provider else None),
|
|
logistics.incoterm or '' if logistics else '',
|
|
_currency_to_country(currency),
|
|
financials.gross_weight if financials else '',
|
|
niu if is_rail else '',
|
|
niu if is_rail else '',
|
|
financials.total_packages if financials else '',
|
|
financials.insurance if financials else '',
|
|
currency,
|
|
financials.freight if financials else '',
|
|
currency,
|
|
financials.packaging if financials else '',
|
|
currency,
|
|
financials.other_increments if financials else '',
|
|
currency,
|
|
]
|
|
|
|
def _item_vals(item: LineItem):
|
|
q = item.quantity
|
|
c = item.customs
|
|
d = item.description
|
|
f = item.financial
|
|
uom = item.unit_of_measure_info
|
|
part = item.part_info
|
|
first_serie = item.series[0] if item.series else None
|
|
return [
|
|
_strip(part.part_number if part else ''),
|
|
c.fraction if c else '',
|
|
_strip(d.description_spanish if d else ''),
|
|
q.quantity if q else '',
|
|
uom.customs_code if uom else '',
|
|
f.value_usd if f else '',
|
|
q.net_weight if q else '', # CANTIDAD TARIFA simplificado (caso UMClave=1)
|
|
uom.customs_code if uom else '', # U.M. TARIFA simplificado
|
|
c.origin_country if c else '',
|
|
provider.address.country if (provider and provider.address) else '',
|
|
_vinculacion(provider.linking if provider else None),
|
|
item.valuation_method or '',
|
|
q.net_weight if q else '',
|
|
q.package_quantity if q else '',
|
|
'0',
|
|
'USD',
|
|
first_serie.serial_numbers if first_serie else 'S/S',
|
|
d.brand or 'S/M' if d else 'S/M',
|
|
d.model or 'S/M' if d else 'S/M',
|
|
first_serie.sub_model if (first_serie and first_serie.sub_model) else '',
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for idx, item in enumerate(items):
|
|
buf.seek(0)
|
|
buf.truncate(0)
|
|
if idx == 0:
|
|
writer.writerow(_header_vals() + _item_vals(item))
|
|
else:
|
|
writer.writerow([''] * 27 + _item_vals(item))
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_caaarem.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
@staticmethod
|
|
def export_cp_genesis(db: Session, invoice) -> "StreamingResponse":
|
|
"""Interfaz Carta Porte Genesis (OF-11). 31 columnas, una fila por partida."""
|
|
import io
|
|
import csv
|
|
import re
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import joinedload
|
|
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
|
|
from api.v1.modules.a76.items.models import LineItem
|
|
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
|
|
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.classes.models import Class
|
|
|
|
UOM_MAP = {
|
|
'PZA': 'H87', 'KGS': 'KGM', 'MT': 'MTR', 'LT': 'LTR',
|
|
'JGO': 'XKI', 'LB': 'LBR', 'GAL': 'GLL', 'FT': 'LF',
|
|
}
|
|
|
|
def _uom_to_sat(code):
|
|
return UOM_MAP.get(code or '', '')
|
|
|
|
def _rfc_or_taxid(cp):
|
|
# Clarion: TIPOEXTNAC N→RFC, E→TAXID truncado a 9 chars
|
|
if not cp:
|
|
return ''
|
|
if cp.type_nat_foreign == 'E':
|
|
return (cp.rfc or '')[:9]
|
|
return cp.rfc or ''
|
|
|
|
def _proceso(prov):
|
|
# Dirección de tráfico según ciudad del proveedor
|
|
if not prov or not prov.address:
|
|
return ''
|
|
city = (prov.address.city or '').upper()
|
|
if 'JUAREZ' in city:
|
|
return 'EXPORTACION'
|
|
if 'EL PASO' in city or 'ELPASO' in city:
|
|
return 'IMPORTACION'
|
|
return ''
|
|
|
|
def _ped_map(ped):
|
|
# (IDocAdu, ClaveTM, DescripMP, CDocAdu)
|
|
if not ped:
|
|
return ('', '', '', '')
|
|
MAP = {
|
|
'AF': ('ITR', '05', 'Otra', '18'),
|
|
'V1': ('ITR', '05', 'Otra', '18'),
|
|
'A1': ('IMD', '05', 'Otra', '01'),
|
|
'A3': ('IMD', '05', 'Otra', '01'),
|
|
}
|
|
return MAP.get(ped.pedimento_code or '', ('', '', '', ''))
|
|
|
|
def _fmt_ped(ped):
|
|
if not ped:
|
|
return ''
|
|
y = str(ped.year or '').zfill(2)
|
|
co = (ped.customs_office or '')[:2]
|
|
lic = (ped.license or '').zfill(4)
|
|
num = (ped.pedimento_number or '').zfill(7)
|
|
return f"{y} {co} {lic} {num}"
|
|
|
|
compliance = (
|
|
db.query(InvoiceComplianceMx)
|
|
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
|
|
.first()
|
|
)
|
|
financials = invoice.financials
|
|
|
|
company = db.query(Company).filter(
|
|
Company.tenant_id == invoice.tenant_id,
|
|
Company.id == invoice.company_id,
|
|
).first()
|
|
|
|
provider = None
|
|
if compliance and compliance.provider_id:
|
|
provider = (
|
|
db.query(ClientProvider)
|
|
.options(joinedload(ClientProvider.address))
|
|
.filter(
|
|
ClientProvider.id == compliance.provider_id,
|
|
ClientProvider.tenant_id == invoice.tenant_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
shipped_to = None
|
|
if compliance and compliance.shipped_to_id:
|
|
shipped_to = (
|
|
db.query(ClientProvider)
|
|
.options(joinedload(ClientProvider.address))
|
|
.filter(
|
|
ClientProvider.id == compliance.shipped_to_id,
|
|
ClientProvider.tenant_id == invoice.tenant_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
pedimento = None
|
|
if compliance and compliance.pedimento_id:
|
|
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
|
|
|
|
items = (
|
|
db.query(LineItem)
|
|
.options(
|
|
joinedload(LineItem.quantity),
|
|
joinedload(LineItem.customs),
|
|
joinedload(LineItem.financial),
|
|
joinedload(LineItem.class_info).joinedload(Class.unit_of_measure_info),
|
|
)
|
|
.filter(
|
|
LineItem.invoice_id == invoice.id,
|
|
LineItem.tenant_id == invoice.tenant_id,
|
|
LineItem.company_id == invoice.company_id,
|
|
)
|
|
.order_by(LineItem.line_number)
|
|
.all()
|
|
)
|
|
|
|
HEADERS = [
|
|
'ORIGEN', 'NombreRemitente', 'RFC o NumRegIdtrib Remitente', 'ResidenciaFiscal Remitente',
|
|
'DESTINO', 'NombreDestinatario', 'RFC o NumRegIdtrib Destinatario', 'ResidenciaFiscal Destinatario',
|
|
'BienesTransp', 'Descripcion', 'Cantidad', 'ClaveUnidad', 'Unidad',
|
|
'MaterialPeligroso', 'CveMaterialPeligroso', 'Embalaje', 'DescripEmbalaje',
|
|
'PesoEnKg', 'ValorMercancia', 'Moneda', 'TranspInternac',
|
|
'FraccionArancelaria', 'UUIDComercioExt', 'RegimenAduanero', 'TipoMateria',
|
|
'DescripcionMateria', 'TipoDocumento', 'Numero de Pedimento', 'IdentDocAduanero',
|
|
'RFCImpo', 'PaisOrigenDestino',
|
|
]
|
|
|
|
ped_fmt = _fmt_ped(pedimento)
|
|
idoc, clave_tm, descrip_mp, cdoc = _ped_map(pedimento)
|
|
currency = financials.currency if financials else ''
|
|
proceso = _proceso(provider)
|
|
|
|
def _row(item: LineItem):
|
|
q = item.quantity
|
|
c = item.customs
|
|
f = item.financial
|
|
ci = item.class_info
|
|
uom_info = ci.unit_of_measure_info if ci else None
|
|
val_mer = f.value_mc if (f and currency == 'USD') else ''
|
|
return [
|
|
provider.address.city if (provider and provider.address) else '',
|
|
provider.name if provider else '',
|
|
_rfc_or_taxid(provider),
|
|
'USA',
|
|
shipped_to.address.city if (shipped_to and shipped_to.address) else '',
|
|
shipped_to.name if shipped_to else '',
|
|
_rfc_or_taxid(shipped_to),
|
|
'MEX',
|
|
c.fraction if c else '',
|
|
ci.description_es if ci else '',
|
|
q.quantity if q else '',
|
|
_uom_to_sat(ci.unit_of_measure if ci else ''),
|
|
uom_info.description if uom_info else '',
|
|
'', '', # MaterialPeligroso, CveMaterialPeligroso
|
|
'', '', # Embalaje, DescripEmbalaje
|
|
q.net_weight if q else '',
|
|
val_mer,
|
|
currency,
|
|
proceso,
|
|
ci.fraction if ci else '',
|
|
'', # UUIDComercioExt
|
|
idoc,
|
|
clave_tm,
|
|
descrip_mp,
|
|
cdoc,
|
|
ped_fmt,
|
|
ped_fmt,
|
|
company.rfc if company else '',
|
|
provider.address.country if (provider and provider.address) else '',
|
|
]
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
|
|
writer.writerow(HEADERS)
|
|
yield buf.getvalue()
|
|
for item in items:
|
|
buf.seek(0)
|
|
buf.truncate(0)
|
|
writer.writerow(_row(item))
|
|
yield buf.getvalue()
|
|
|
|
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
|
|
filename = f"{safe_num}_cp_genesis.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type='text/csv; charset=utf-8',
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|