Merge branch 'development' into feature/csv-pruebas

This commit is contained in:
2026-03-24 08:08:32 -06:00
71 changed files with 6956 additions and 2796 deletions

View File

@@ -0,0 +1,57 @@
from pydantic import BaseModel, ConfigDict, field_validator
from typing import Optional, List, Dict, Any
from datetime import date
class AphisCatalogDTO(BaseModel):
id: Optional[int] = None
# --- Pestaña 1: General ---
program_code: Optional[str] = None
processing_code: Optional[str] = None
aphis_type: Optional[str] = None
disclaimer: Optional[str] = None
electronic_image: Optional[str] = None
confidential: Optional[str] = None
global_product_id: Optional[str] = None
intended_use_code: Optional[str] = None
intended_use_description: Optional[str] = None
item_type: Optional[str] = None
product_code: Optional[str] = None
product_code_2: Optional[str] = None
product_code_3: Optional[str] = None
scientific_genus_name: Optional[str] = None
scientific_species_name: Optional[str] = None
scientific_sub_species_name: Optional[str] = None
common_name_specific: Optional[str] = None
common_name_general: Optional[str] = None
signed_doc: Optional[str] = None
signed_doc_date: Optional[date] = None
signed_doc_id: Optional[str] = None
invoice_number: Optional[str] = None
quantity_1: Optional[str] = None
quantity_2: Optional[str] = None
quantity_3: Optional[str] = None
inspection: Optional[str] = None
inspection_date: Optional[date] = None
inspection_loc_date: Optional[date] = None
inspection_location: Optional[str] = None
country_production: Optional[str] = None
country_source: Optional[str] = None
# --- Pestañas 2-7: Detalles (Listas de objetos) ---
characteristics: Optional[List[Dict[str, Any]]] = []
pitems: Optional[List[Dict[str, Any]]] = []
lpcos: Optional[List[Dict[str, Any]]] = []
entities: Optional[List[Dict[str, Any]]] = []
containers: Optional[List[Dict[str, Any]]] = []
routing: Optional[List[Dict[str, Any]]] = []
model_config = ConfigDict(from_attributes=True)
@field_validator("signed_doc_date", "inspection_date", "inspection_loc_date", mode="before")
@classmethod
def empty_to_none(cls, v):
if v == "":
return None
return v

View File

@@ -0,0 +1,61 @@
from typing import Optional, List, Dict, Any
from datetime import date
from sqlalchemy import Integer, String, Date, PrimaryKeyConstraint, JSON
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class AphisCatalog(Base):
"""
Catálogo global de registros APHIS por empresa.
Soporta las 7 pestañas de información (General + 6 detalles via JSON).
"""
__tablename__ = "inv_aphis_catalog"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_catalog_pkey"),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# --- Pestaña 1: General (Campos principales) ---
program_code: Mapped[Optional[str]] = mapped_column(String(10))
processing_code: Mapped[Optional[str]] = mapped_column(String(10))
aphis_type: Mapped[Optional[str]] = mapped_column(String(10))
disclaimer: Mapped[Optional[str]] = mapped_column(String(10))
electronic_image: Mapped[Optional[str]] = mapped_column(String(50))
confidential: Mapped[Optional[str]] = mapped_column(String(1))
global_product_id: Mapped[Optional[str]] = mapped_column(String(100))
intended_use_code: Mapped[Optional[str]] = mapped_column(String(10))
intended_use_description: Mapped[Optional[str]] = mapped_column(String(200))
item_type: Mapped[Optional[str]] = mapped_column(String(20))
product_code: Mapped[Optional[str]] = mapped_column(String(20))
product_code_2: Mapped[Optional[str]] = mapped_column(String(20))
product_code_3: Mapped[Optional[str]] = mapped_column(String(20))
scientific_genus_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_species_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_sub_species_name: Mapped[Optional[str]] = mapped_column(String(100))
common_name_specific: Mapped[Optional[str]] = mapped_column(String(200))
common_name_general: Mapped[Optional[str]] = mapped_column(String(200))
signed_doc: Mapped[Optional[str]] = mapped_column(String(100))
signed_doc_date: Mapped[Optional[date]] = mapped_column(Date)
signed_doc_id: Mapped[Optional[str]] = mapped_column(String(50))
invoice_number: Mapped[Optional[str]] = mapped_column(String(50))
quantity_1: Mapped[Optional[str]] = mapped_column(String(50))
quantity_2: Mapped[Optional[str]] = mapped_column(String(50))
quantity_3: Mapped[Optional[str]] = mapped_column(String(50))
inspection: Mapped[Optional[str]] = mapped_column(String(200))
inspection_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_loc_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_location: Mapped[Optional[str]] = mapped_column(String(200))
country_production: Mapped[Optional[str]] = mapped_column(String(3))
country_source: Mapped[Optional[str]] = mapped_column(String(3))
# --- Pestañas 2-7: Detalles (Almacenados como JSON por flexibilidad) ---
characteristics: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
pitems: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
lpcos: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
entities: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
containers: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
routing: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from core.database import get_core_db
from .models import AphisCatalog
from .dto import AphisCatalogDTO
router = APIRouter(prefix="/aphis-catalog", tags=["APHIS Catalog"])
@router.get("/", response_model=List[AphisCatalogDTO])
def list_aphis_catalog(company_id: int, db: Session = Depends(get_core_db)):
records = (
db.query(AphisCatalog)
.filter(AphisCatalog.company_id == company_id)
.order_by(AphisCatalog.id)
.all()
)
return records
@router.post("/", response_model=AphisCatalogDTO)
def create_aphis_catalog(data: AphisCatalogDTO, company_id: int, db: Session = Depends(get_core_db)):
payload = data.model_dump(exclude={"id"})
record = AphisCatalog(**payload, company_id=company_id)
db.add(record)
db.commit()
db.refresh(record)
return record
@router.put("/{record_id}", response_model=AphisCatalogDTO)
def update_aphis_catalog(
record_id: int, data: AphisCatalogDTO, company_id: int, db: Session = Depends(get_core_db)
):
record = (
db.query(AphisCatalog)
.filter(AphisCatalog.id == record_id, AphisCatalog.company_id == company_id)
.first()
)
if not record:
raise HTTPException(status_code=404, detail="Registro no encontrado")
for field, value in data.model_dump(exclude={"id"}).items():
setattr(record, field, value)
db.commit()
db.refresh(record)
return record
@router.delete("/{record_id}", status_code=204)
def delete_aphis_catalog(record_id: int, company_id: int, db: Session = Depends(get_core_db)):
record = (
db.query(AphisCatalog)
.filter(AphisCatalog.id == record_id, AphisCatalog.company_id == company_id)
.first()
)
if not record:
raise HTTPException(status_code=404, detail="Registro no encontrado")
db.delete(record)
db.commit()

View File

@@ -138,6 +138,7 @@ class InvPart(Base, TenantScopedMixin, TimestampMixin):
# --- NUEVOS CAMPOS EXTENSION ---
agency_code_definition: Mapped[Optional[str]] = mapped_column(String(50)) # Fila 7
carta_porte: Mapped[Optional[str]] = mapped_column(String(100)) # Fila 10
client_part_names: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[]) # Fila 5
part_identifiers: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[]) # Fila 9
substitute_parts: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[]) # Pestaña Continuación

View File

@@ -8,6 +8,10 @@ from fastapi import APIRouter
from .fa.fa_classes.routes import router as fa_classes_router
from .fa.fa_item_lines.routes import router as fa_item_lines_router
from .inv.part_countries.routes import router as part_countries_router
from .inv.inv_aphis.inv_aphis_catalog.router import router as aphis_catalog_router
# Importar modelo para que SQLAlchemy cree la tabla automáticamente
import api.v1.modules.a24.inv.inv_aphis.inv_aphis_catalog.models # noqa: F401
# Router principal de A24
@@ -21,3 +25,5 @@ router.include_router(
# Registrar routers de INV (Inventory)
router.include_router(part_countries_router, prefix="/a24", tags=["a24 / inv / part-countries"])
router.include_router(aphis_catalog_router, prefix="/a24", tags=["a24 / inv / aphis-catalog"])

View File

@@ -0,0 +1,150 @@
# Importar modelos para Audit Log
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails
from api.v1.modules.a76.audit_log.events import register_audit_listeners
# Core Modules
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.general_catalogs.company.models import Company
# Reference Data
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.customs_warehouses.models import (
CustomsWarehouse,
)
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import (
PedimentoTransportCatalog,
)
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import (
RegimenPedimento,
)
from api.v1.modules.public.reference_data.states.models import State
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.public.reference_data.license_exceptions.models import LicenseException
from api.v1.modules.public.reference_data.agency_tariff_codes.models import AgencyTariffCode
from api.v1.modules.public.reference_data.identifiers.models import IdentifierCatalog
from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.classification_concepts.models import (
ClassificationConcept,
)
from api.v1.modules.a76.general_catalogs.concepts.models import Concept
from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import (
CustomsBrokerConcept,
)
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import (
DepreciationCatalog,
)
from api.v1.modules.a76.general_catalogs.doda.models import Doda
from api.v1.modules.a76.general_catalogs.electronic_notices.models import (
ElectronicNotice,
)
from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency
from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
from api.v1.modules.a76.general_catalogs.inpc.models import INPC
from api.v1.modules.a76.general_catalogs.legends.models import Legend
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import (
MultiCurrencyType,
)
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.ports.models import Port
from api.v1.modules.a76.general_catalogs.location.models import Location, FaLocationExt
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
from api.v1.modules.a76.general_catalogs.seal.models import Seal
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import (
TariffFraction,
)
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
# Registrar Listeners de Auditoría
def register_audit():
register_audit_listeners(
[
# Core Transactions
Pedimentos,
InvoiceHeader,
InvoiceSalesDetails,
LineItem,
# Sidebar Core Modules
ClientProvider,
CustomsBroker,
Part,
Company,
# Transportation Modules
Trailer,
Transporter,
Vehicle,
# Reference Data
Country,
CurrencyType,
CustomsSection,
CustomsWarehouse,
Incoterm,
InvoiceType,
MaterialType,
PaymentMethod,
PedimentoTransportCatalog,
PedimentoCode,
RegimenPedimento,
Sector,
State,
TransportMode,
TransportType,
ValuationMethod,
LicenseException,
AgencyTariffCode,
IdentifierCatalog,
CartaPorte,
UnitOfMeasure,
ExchangeRate,
Identifier,
Class,
ClassificationConcept,
Concept,
CustomsBrokerConcept,
DepreciationCatalog,
Doda,
ElectronicNotice,
Equivalency,
ErrorCatalog,
FDACatalog,
INPC,
Legend,
MultiCurrencyType,
Package,
Port,
Prevalidator,
Seal,
Signature,
TariffFraction,
UnitConversion,
USTariffFraction,
]
)

View File

@@ -395,7 +395,7 @@ def validate_common(
value=invoice.document_type,
)
else:
if invoice.document_type == "IMD":
if invoice.document_type == "IMD" and invoice.invoice_type.upper() != "DEF":
errors.add_error(
field="document_type",
message="El Tipo de Documento no puede ser 'IMD' a menos que sea un Cambio de Régimen.",

View File

@@ -277,7 +277,7 @@ def finalize_invoice_with_discharge(
if to_discharge:
# Write BalanceMovement (CONSUMPTION) + DischargeHeader + DischargeDetail
register_discharge_ledger(db, invoice, to_discharge)
# Update quantity_returned / value_returned on the import lines
# Update returned values on the import lines
register_import_discharge(db, invoice, to_discharge)
register_discharge_series(db, invoice, to_discharge)

View File

@@ -167,6 +167,13 @@ def register_discharge_ledger(
# lot_consumed_total == consume for single-lot entries (most cases)
value_me = _proportional_value(consume, consume, lot.value_me)
value_mn = _proportional_value(consume, consume, lot.value_mn)
imp_qty = import_line_obj.quantity if import_line_obj else None
imp_total_qty = imp_qty.quantity if imp_qty else None
net_weight = _proportional_qty(
consume,
imp_qty.net_weight if imp_qty else None,
imp_total_qty,
)
movement = BalanceMovement(
tenant_id=export_invoice.tenant_id,
@@ -178,6 +185,7 @@ def register_discharge_ledger(
quantity=consume,
value_me=value_me,
value_mn=value_mn,
net_weight=net_weight,
source_invoice_id=export_invoice.id,
source_item_line_id=export_line_id,
order_peps=0, # placeholder — set after flush (rule 4)
@@ -194,10 +202,7 @@ def register_discharge_ledger(
# ── 3. DischargeDetail ─────────────────────────────────────────
# Denormalized fields expected by reports:
imp_cust = import_line_obj.customs if import_line_obj else None
imp_qty = import_line_obj.quantity if import_line_obj else None
imp_total_qty = imp_qty.quantity if imp_qty else None
net_weight = _proportional_qty(consume, imp_qty.net_weight if imp_qty else None, imp_total_qty)
# Reuse already computed net_weight for consistency with movement.
gross_weight = _proportional_qty(consume, imp_qty.gross_weight if imp_qty else None, imp_total_qty)
detail = DischargeDetail(

View File

@@ -6,8 +6,8 @@ quantities and values consumed by this export invoice.
For each entry in ``to_discharge`` (QSaldoActual in the legacy) the routine:
· Looks up the source import invoice header (TEM → QFacImp, DEF → QFacImpDef).
· Looks up the corresponding import line item.
· Increments quantity_returned, value_returned_mxn, value_returned_usd on the
import line's quantity/financial sub-records.
· Increments value_returned_mxn, value_returned_usd on the import line's
financial sub-record.
· For TEM invoices, also calculates vat_used_mxn / vat_used_usd when the
import invoice date is on or after 2014-12-31 (Clarion date 78165).
@@ -148,9 +148,7 @@ def register_import_discharge(
returned_mn = qty_used * value_mn / original_qty
returned_usd = qty_used * value_usd / original_qty
# ── Accumulate returned qty and value ─────────────────────────────────
qty_rec.quantity_returned = (qty_rec.quantity_returned or Decimal(0)) + qty_used
# ── Accumulate returned value ──────────────────────────────────────────
fin.value_returned_mxn = (fin.value_returned_mxn or Decimal(0)) + returned_mn
fin.value_returned_usd = (fin.value_returned_usd or Decimal(0)) + returned_usd

View File

@@ -61,7 +61,7 @@ def review_qty_vs_weight(
continue
qty = line.quantity.quantity or Decimal(0)
net_weight = getattr(line.quantity.quantity, None) or Decimal(0)
net_weight = line.quantity.net_weight or Decimal(0)
if net_weight != qty:
errors.add_error(

View File

@@ -0,0 +1,373 @@
import datetime
from decimal import Decimal
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from api.v1.modules.a24.discharges.models import (
DischargeDetail,
DischargeHeader,
DischargeStatus,
)
from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType
from api.v1.modules.a76.invoices.models import (
InvoiceHeader,
InvoiceStatus,
OperationType,
)
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from core.exceptions import ErrorCollector
_VAT_CUTOFF = datetime.date(2014, 12, 31) # Clarion day 78165
def _validate_regime_change_definitive_invoice_exists(
db: Session,
invoice: InvoiceHeader,
errors: ErrorCollector,
) -> None:
"""
Clarion mapping:
If EqiFex:EsCambioRegimen='S' then count QFacImpDef where
FacturaImpoDef = FacturaExpo and ProvImpoDefCR='C'.
Python approximation:
Search an import invoice with same invoice_number and invoice_type='IMD'.
"""
if not (invoice.compliance_mx and invoice.compliance_mx.is_regime_change):
return
if not invoice.invoice_number:
return
exists = (
db.query(InvoiceHeader.id)
.filter(
InvoiceHeader.tenant_id == invoice.tenant_id,
InvoiceHeader.company_id == invoice.company_id,
InvoiceHeader.operation_type == OperationType.IMP,
InvoiceHeader.invoice_type == "DEF", # Importación Definitiva generada
InvoiceHeader.invoice_number == invoice.invoice_number,
)
.first()
is not None
)
if exists:
errors.add_error(
field="invoice_number",
message=(
"Error: Existe una Factura de Importación Definitiva a partir "
"de esta Factura."
),
solution=[
f"Desactualizar y borrar la Factura: {invoice.invoice_number} "
"de Importación Definitiva."
],
code="DEFINITIVE_IMPORT_ALREADY_EXISTS",
value=invoice.invoice_number,
)
def _todo_check_access_lock(invoice: InvoiceHeader) -> None:
# TODO: DO VALIDACION_USO_FACTURA_OTRO_USUARIO
# Clarion block against GAccesosModulos (security lock by terminal/user).
_ = invoice
def _return_discharged_quantities(
db: Session,
export_invoice: InvoiceHeader,
lines: List[LineItem],
) -> None:
"""
REGRESA_CANT_RETORNADAS
Returns discharged quantities/values to their source import invoice lines.
Uses the new specialized discharge tables:
a24.discharge_header + a24.discharge_detail
We only revert details that belong to this export invoice and are applied.
This guarantees parity with the actual discharge ledger (instead of relying
on editable UI references in fa_data).
"""
_ = lines # source of truth is discharge tables
details: List[DischargeDetail] = (
db.query(DischargeDetail)
.join(DischargeHeader, DischargeHeader.id == DischargeDetail.discharge_header_id)
.filter(
DischargeHeader.source_invoice_id == export_invoice.id,
DischargeHeader.status == DischargeStatus.APPLIED,
DischargeDetail.tenant_id == export_invoice.tenant_id,
DischargeDetail.company_id == export_invoice.company_id,
)
.all()
)
for detail in details:
qty_exported = Decimal(str(detail.quantity_discharged or 0))
if qty_exported <= 0:
continue
import_line = db.get(LineItem, detail.import_item_line_id)
if (
import_line is None
or import_line.quantity is None
or import_line.financial is None
):
continue
import_invoice = db.get(InvoiceHeader, import_line.invoice_id)
if import_invoice is None:
continue
qty_rec = import_line.quantity
fin = import_line.financial
original_qty = Decimal(str(qty_rec.quantity or 0))
if original_qty == 0:
continue
value_mxn = Decimal(str(fin.value_mxn or 0))
value_usd = Decimal(str(fin.value_usd or 0))
returned_mxn = qty_exported * value_mxn / original_qty
returned_usd = qty_exported * value_usd / original_qty
# Reverse monetary returned values
# Clarion shows '-' for TEM and '+' for DEF; in the current ledger migration,
# register_import_discharge adds both TEM/DEF, so revert subtracts both.
fin.value_returned_mxn = (fin.value_returned_mxn or Decimal(0)) - returned_mxn
fin.value_returned_usd = (fin.value_returned_usd or Decimal(0)) - returned_usd
# TEM VAT recalculation by header date cutoff
if (detail.procedence or "").upper() == "TEM":
inv_date = import_invoice.invoice_date
if isinstance(inv_date, datetime.datetime):
inv_date = inv_date.date()
if inv_date and inv_date >= _VAT_CUTOFF:
iva_factor = Decimal(0)
if import_invoice.financials and import_invoice.financials.iva_factor:
iva_factor = Decimal(str(import_invoice.financials.iva_factor))
fin.vat_used_mxn = (fin.value_returned_mxn or Decimal(0)) * iva_factor / 100
fin.vat_used_usd = (fin.value_returned_usd or Decimal(0)) * iva_factor / 100
else:
fin.vat_used_mxn = Decimal(0)
fin.vat_used_usd = Decimal(0)
def _unmark_returned_series(
db: Session,
export_invoice: InvoiceHeader,
lines: List[LineItem],
) -> None:
"""
DESMARCA_SERIES_RETORNADAS
Resets import serie discharge marks that were set by this export invoice.
New data-source logic:
- Uses a24.discharge_header/detail to identify which import lines were
consumed by this export invoice.
- Uses a76.item_line_series on export lines (discharge=True) to resolve the
import serie row (serie_row) or by serial number fallback.
"""
line_ids = [ln.id for ln in lines if ln.id is not None]
if not line_ids:
return
# All discharge details belonging to this export invoice, grouped by export line.
details: List[DischargeDetail] = (
db.query(DischargeDetail)
.join(DischargeHeader, DischargeHeader.id == DischargeDetail.discharge_header_id)
.filter(
DischargeHeader.source_invoice_id == export_invoice.id,
DischargeHeader.status == DischargeStatus.APPLIED,
DischargeDetail.export_item_line_id.in_(line_ids),
DischargeDetail.tenant_id == export_invoice.tenant_id,
DischargeDetail.company_id == export_invoice.company_id,
)
.all()
)
import_lines_by_export: dict[int, set[int]] = {}
for d in details:
if d.export_item_line_id is None:
continue
import_lines_by_export.setdefault(d.export_item_line_id, set()).add(d.import_item_line_id)
if not import_lines_by_export:
return
# Export series marked for discharge (equivalent to SerExpo.Marca = 1)
export_series: List[Serie] = (
db.execute(
select(Serie).where(
Serie.line_item_id.in_(line_ids),
Serie.discharge == True, # noqa: E712
)
)
.scalars()
.all()
)
for ex_serie in export_series:
candidate_import_lines = import_lines_by_export.get(ex_serie.line_item_id, set())
if not candidate_import_lines:
continue
import_serie: Optional[Serie] = None
# 1) Prefer explicit mapped row from export serie
if ex_serie.serie_row is not None:
import_serie = db.execute(
select(Serie).where(
Serie.line_item_id.in_(candidate_import_lines),
Serie.row == ex_serie.serie_row,
)
).scalar_one_or_none()
# 2) Fallback by serial number if row is absent
if import_serie is None and ex_serie.serial_numbers:
import_serie = db.execute(
select(Serie).where(
Serie.line_item_id.in_(candidate_import_lines),
Serie.serial_numbers == ex_serie.serial_numbers,
)
).scalar_one_or_none()
if import_serie is None:
continue
# Clarion equivalent: SerImp:SerieExpo = 0 / SerDef:SerieExpo = 0
import_serie.discharge = False
def _cancel_discharge_records(
db: Session,
export_invoice: InvoiceHeader,
cancelled_by: Optional[str] = None,
) -> None:
"""
Cancels discharge records created by this export invoice in the new
specialized tables:
- a24.discharge_header: status -> CANCELLED
- a24.balance_movement: insert RETURN per discharge_detail row
This is the ledger-safe equivalent of undoing "Descarga=1" effects.
"""
headers: List[DischargeHeader] = (
db.query(DischargeHeader)
.filter(
DischargeHeader.source_invoice_id == export_invoice.id,
DischargeHeader.tenant_id == export_invoice.tenant_id,
DischargeHeader.company_id == export_invoice.company_id,
DischargeHeader.status.in_([DischargeStatus.APPLIED, DischargeStatus.PARTIAL]),
)
.all()
)
if not headers:
return
op_date = (
export_invoice.invoice_date.date()
if hasattr(export_invoice.invoice_date, "date")
else export_invoice.invoice_date
)
for header in headers:
details: List[DischargeDetail] = (
db.query(DischargeDetail)
.filter(
DischargeDetail.discharge_header_id == header.id,
DischargeDetail.tenant_id == export_invoice.tenant_id,
DischargeDetail.company_id == export_invoice.company_id,
)
.all()
)
for detail in details:
# Reverse each consumed lot with a RETURN movement (append-only ledger)
ret_mov = BalanceMovement(
tenant_id=export_invoice.tenant_id,
company_id=export_invoice.company_id,
import_invoice_id=detail.import_line.invoice_id if detail.import_line else None,
import_item_line_id=detail.import_item_line_id,
part_number_id=None,
movement_type=MovementType.RETURN,
quantity=detail.quantity_discharged or Decimal(0),
value_me=detail.value_me,
value_mn=detail.value_mn,
net_weight=detail.net_weight,
source_invoice_id=export_invoice.id,
source_item_line_id=detail.export_item_line_id,
order_peps=0, # set post-flush
operation_date=op_date,
notes=f"Reversa descargo factura {export_invoice.invoice_number} (header {header.id})",
)
db.add(ret_mov)
db.flush()
ret_mov.order_peps = ret_mov.id
header.status = DischargeStatus.CANCELLED
header.cancelled_by = cancelled_by
header.cancellation_reason = (
f"Des-actualización de factura de exportación {export_invoice.invoice_number}"
)
def _set_invoice_unprocessed(invoice: InvoiceHeader, line_count: int) -> None:
"""
Clarion mapping:
EqiFex:ComofueProcesada=''
EqiFex:Estatus='NA'
EqiFex:Cant_Partidas = Loc:PartidasExpo
"""
invoice.process_log = None
invoice.status = InvoiceStatus.REVERSED
invoice.party_count = line_count
def revert_process(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
cancelled_by: Optional[str] = None,
) -> list:
"""
Des-actualización de factura de exportación (paridad Clarion).
Notes:
- BEGIN/COMMIT/ROLLBACK SQL explícitos del Clarion se controlan con la
transacción de SQLAlchemy en el task (commit/rollback externo).
- Las rutinas Clarion invocadas con DO se dejan como TODO por ahora.
"""
_ = (db, tenant_id, company_id) # reserved for future TO DO implementations
sql_errors: list = []
# INICIALIZA QUEUES (Python: collector ya llega limpio por tarea)
# TODO: Compartir QSisGen / parámetros globales del Clarion.
# TODO: BEGIN TRAN (managed by SQLAlchemy session in task)
_todo_check_access_lock(invoice)
# VERIFICAR SI HAY PARTIDAS DE EXPORTACION
line_count = len(lines)
# VALIDACION DEL CAMBIO DE REGIMEN
_validate_regime_change_definitive_invoice_exists(db, invoice, errors)
errors.raise_if_errors()
# VALIDACIONES DE PARTIDAS NORMAL <> REPARACION
if (invoice.invoice_type or "").upper() != "NODES":
_return_discharged_quantities(db, invoice, lines)
_unmark_returned_series(db, invoice, lines)
_cancel_discharge_records(db, invoice, cancelled_by)
_set_invoice_unprocessed(invoice, line_count)
# TODO: COMMIT/ROLLBACK TRAN + QueueErrorSQL file handling + GBitacora
return sql_errors

View File

@@ -0,0 +1,42 @@
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def pre_validators(
db: Session,
invoice: InvoiceHeader,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> List[LineItem]:
"""
Validaciones previas a la reversión de una factura de importación temporal.
- Verifica que la factura esté en estatus PROCESSED.
- Carga y retorna las partidas asociadas a la factura.
"""
if invoice.status != InvoiceStatus.PROCESSED:
errors.add_error(
"status",
"La factura no fue procesada y no puede ser revertida",
solution=["Verifique el estatus de la factura antes de intentar deshacer el proceso"],
code="NOT_PROCESSED",
value=invoice.status,
)
lines: List[LineItem] = (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.all()
)
return lines

View File

@@ -0,0 +1,83 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
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 revert_invoice_task
router = APIRouter()
@router.post("/invoices/{invoice_id}/revert")
def trigger_invoice_revert(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Inicia la des-actualización de una factura de importación temporal como
tarea Celery.
Retorna el task_id para hacer polling del progreso.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
cancelled_by = (
current_user.get("username")
or current_user.get("user_name")
or current_user.get("preferred_username")
or current_user.get("email")
or "system"
)
task = revert_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id), str(cancelled_by)]
)
return {"task_id": task.id}
@router.get("/invoices/revert/{task_id}/status")
def get_invoice_revert_status(task_id: str):
"""
Consulta el estado de progreso de una tarea de des-actualización de
factura.
Retorna:
- state: 'PROCESSING' | 'SUCCESS' | 'FAILURE'
- info: { current: int, status: str } (cuando state == 'PROCESSING')
- result: dict (cuando state == 'SUCCESS' o 'FAILURE')
"""
task_result = celery_app.AsyncResult(task_id)
if task_result.state in ("PENDING", "STARTED"):
return {
"state": "PROCESSING",
"info": {"current": 0, "status": "Iniciando..."},
}
if task_result.state == "PROGRESS":
return {
"state": "PROCESSING",
"info": task_result.info or {"current": 0, "status": "Procesando..."},
}
if task_result.state == "SUCCESS":
return {
"state": "SUCCESS",
"result": task_result.result,
}
error_info = task_result.result
if isinstance(error_info, Exception):
error_msg = str(error_info)
else:
error_msg = str(error_info) if error_info else "Error desconocido"
return {
"state": "FAILURE",
"result": error_msg,
}

View File

@@ -0,0 +1,89 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader
from .pre_validators import pre_validators
from .main_process import revert_process
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
@celery_app.task(bind=True, name="revert_export_invoice_task")
def revert_invoice_task(
self: Task,
invoice_id: int,
tenant_id: str,
company_id: str,
cancelled_by: str | None = None,
) -> dict:
"""
Des-actualiza una factura de importación temporal ejecutando todas las
validaciones y reversiones del proceso principal (revert/main_process) con
reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
errors = ErrorCollector()
# ── Paso 2: Pre-validaciones ──────────────────────────────────────────
_progress(self, 10, "Validando estatus de la factura...")
lines = pre_validators(db, invoice, tenant_id, company_id, errors)
if not lines:
errors.add_error(
field="line_items",
message="La factura no contiene partidas para revertir",
solution=["Verifique que la factura tenga partidas antes de intentar revertirla"],
code="NO_LINE_ITEMS",
)
errors.raise_if_errors()
# ── Paso 3: Validar cantidades y ejecutar reversión ───────────────────
_progress(self, 40, "Verificando saldos de partidas...")
sql_errors = revert_process(
db=db,
invoice=invoice,
lines=lines,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
cancelled_by=cancelled_by,
)
# ── Paso 4: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Anulando saldos de inventario y confirmando...")
db.flush()
db.commit()
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()

View File

@@ -80,8 +80,13 @@ def void_balance_entries(
(BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), -1),
else_=1,
)
used_expr = case(
# "used" must be NET of returns:
# + consumption/waste/scrap/destruction
# - return
# so an already reverted discharge does not block import un-processing.
used_net_expr = case(
(BalanceMovement.movement_type.in_(USED_MOVEMENTS), BalanceMovement.quantity),
(BalanceMovement.movement_type == MovementType.RETURN, -BalanceMovement.quantity),
else_=Decimal(0),
)
@@ -90,7 +95,7 @@ def void_balance_entries(
select(
BalanceMovement.import_item_line_id,
func.sum(sign_expr * BalanceMovement.quantity).label("balance"),
func.sum(used_expr).label("used"),
func.sum(used_net_expr).label("used_net"),
)
.where(
BalanceMovement.import_item_line_id.in_(import_line_ids),
@@ -100,7 +105,7 @@ def void_balance_entries(
.all()
)
consumed_lots = [row for row in lot_summary if (row.used or 0) > 0]
consumed_lots = [row for row in lot_summary if (row.used_net or 0) > 0]
if consumed_lots:
lot_ids = ", ".join(str(r.import_item_line_id) for r in consumed_lots)
raise ValueError(
@@ -109,13 +114,18 @@ def void_balance_entries(
f"cancelarse primero (item_line ids: {lot_ids})."
)
# ── 3. Build the ENTRY_VOID map: one void per ENTRY ──────────────────────
# ── 3. Build the ENTRY_VOID map: one void per LOT ────────────────────────
# Map lot_id → open balance (should equal the original ENTRY qty since no
# consumptions exist, but we use the actual net balance to be safe).
balance_map: dict[int, Decimal] = {
row.import_item_line_id: Decimal(str(row.balance or 0))
for row in lot_summary
}
# Pick one representative ENTRY per lot to copy informational fields.
entry_by_lot: dict[int, BalanceMovement] = {}
for e in entries:
if e.import_item_line_id not in entry_by_lot:
entry_by_lot[e.import_item_line_id] = e
operation_date = (
invoice.invoice_date.date()
@@ -124,8 +134,8 @@ def void_balance_entries(
)
voids: List[BalanceMovement] = []
for entry in entries:
open_qty = balance_map.get(entry.import_item_line_id, Decimal(0))
for lot_id, entry in entry_by_lot.items():
open_qty = balance_map.get(lot_id, Decimal(0))
if open_qty <= 0:
continue
@@ -133,7 +143,7 @@ def void_balance_entries(
tenant_id=invoice.tenant_id,
company_id=invoice.company_id,
import_invoice_id=invoice.id,
import_item_line_id=entry.import_item_line_id,
import_item_line_id=lot_id,
part_number_id=entry.part_number_id,
movement_type=MovementType.ENTRY_VOID,
quantity=open_qty,

View File

@@ -25,6 +25,10 @@ from .sub_process.review_rule_octave import (
)
from ...common.process.review_uma import revisa_uma
from .sub_process.assing_values import assign_values_lines, assign_values_invoice
from .sub_process.assing_values_def_mex import (
assign_values_iva_lines,
assign_values_invoice_totals,
)
from ..balance.create_balance_entries import create_balance_entries
@@ -150,7 +154,7 @@ def _validate_sisimp_limits(
pass
def _update_invoice_totals(invoice: InvoiceHeader) -> None:
def _update_invoice_totals(invoice: InvoiceHeader, lines: List[LineItem]) -> None:
"""
Copia los totales calculados de financials/logistics al encabezado de la factura
y calcula IVA, incrementables y valores de aduanas.
@@ -198,7 +202,7 @@ def _update_invoice_totals(invoice: InvoiceHeader) -> None:
# Marcar la factura como procesada
invoice.status = InvoiceStatus.PROCESSED
invoice.party_count = len(invoice.financials.__dict__) # se sobreescribirá con el conteo real
invoice.party_count = len(lines)
# TODO: SSisGen:ActSeguridad = 1 → invoice.updated_by = current_user
# TODO: SSisGen:CalValBaseTCPed = 1 →
@@ -246,8 +250,14 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id
review_weights_lbs(db, lines, tenant_id, company_id, errors)
# Paso 3: Asignación de valores por partida y totalización de factura
assign_values_lines(invoice, lines)
assign_values_invoice(invoice, lines)
# Para IMPO DEF / Compras Mexicanas se usa la versión con IVA por partida.
invoice_type = (invoice.invoice_type or "").strip().upper()
if invoice_type in {"DEF", "MEX"}:
assign_values_iva_lines(invoice, lines)
assign_values_invoice_totals(invoice, lines)
else:
assign_values_lines(invoice, lines)
assign_values_invoice(invoice, lines)
# Paso 4: Validaciones per-línea
octave_desc, octave_available = _validate_lines(db, invoice, lines, tenant_id, company_id, errors)
@@ -281,9 +291,10 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id
)
# Paso 7: Actualizar totales, IVA e incrementables y marcar como procesada
_update_invoice_totals(invoice)
_update_invoice_totals(invoice, lines)
# Paso 8: Generar saldos en a24.balance_movement (una entrada por partida)
create_balance_entries(db, invoice, lines)
if invoice_type not in {"DEF", "MEX"}:
create_balance_entries(db, invoice, lines)
db.flush()

View File

@@ -15,8 +15,8 @@ def assign_values_lines(
Calculates and assigns unit costs and values in all currency types for every
line item, based on the invoice currency and exchange rates.
Also resets inventory counters (quantity_returned, quantity_returned_temp,
quantity_existence) to zero, as done in the legacy ASIGNAVALORES_PARTIDA routine.
Legacy returned/existence counters were removed from item_line_quantities;
balances are now derived from a24.balance_movement and discharges.
Currency mapping (legacy -> current enum):
ME (moneda extranjera / foreign) -> Currency.FOREIGN
@@ -53,11 +53,7 @@ def assign_values_lines(
line.financial.value_mxn = capture * tc_mm * tc * qty
line.financial.value_mc = capture * qty
# Reset inventory counters
if line.quantity is not None:
line.quantity.quantity_returned = Decimal(0)
line.quantity.quantity_returned_temp = Decimal(0)
line.quantity.quantity_existence = Decimal(0)
# NOTE: no legacy returned/existence counters to reset.
def assign_values_invoice(

View File

@@ -0,0 +1,159 @@
from decimal import Decimal
from typing import List
from api.v1.modules.a76.invoices.models import Currency, InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
def assign_values_iva_lines(
invoice: InvoiceHeader,
lines: List[LineItem],
) -> None:
"""
ASIGNAVALORES_IVA_PARTIDA
Asigna valores por partida para IMPO DEFINITIVA / COMPRAS MEXICANAS
calculando subtotal + IVA + total en ME/MN/MC.
Nota:
- Los contadores legacy CantRetornada/CantRetornadaTemp ya no existen.
- La trazabilidad de saldos vive en balance_movement + discharges.
"""
if not invoice.financials:
return
currency = invoice.financials.currency
tc = Decimal(str(invoice.financials.exchange_rate or 0))
tc_mm = Decimal(str(invoice.financials.exchange_rate_mm or 0))
iva_factor = Decimal(str(invoice.financials.iva_factor or 0))
for line in lines:
fin = line.financial
qty_rec = line.quantity
if fin is None or qty_rec is None:
continue
qty = Decimal(str(qty_rec.quantity or 0))
capture = Decimal(str(fin.unit_cost_capture or 0))
if qty <= 0:
continue
if currency == Currency.FOREIGN: # ME
# ME base
fin.unit_cost_usd = capture
fin.sub_import_value_usd = capture * qty
fin.vat_usd = fin.sub_import_value_usd * iva_factor / 100
fin.value_usd = fin.sub_import_value_usd + fin.vat_usd
# MN converted from ME
fin.unit_cost_mxn = capture * tc
fin.sub_import_value_mxn = fin.unit_cost_mxn * qty
fin.vat_mxn = fin.sub_import_value_mxn * iva_factor / 100
fin.value_mxn = fin.sub_import_value_mxn + fin.vat_mxn
# MC mirrors capture currency in legacy
fin.unit_cost_mc = capture
fin.sub_import_value_mc = capture * qty
fin.vat_mc = fin.sub_import_value_mc * iva_factor / 100
fin.value_mc = fin.sub_import_value_mc + fin.vat_mc
elif currency == Currency.LOCAL: # MN
# MN base
fin.unit_cost_mxn = capture
fin.sub_import_value_mxn = capture * qty
fin.vat_mxn = fin.sub_import_value_mxn * iva_factor / 100
fin.value_mxn = fin.sub_import_value_mxn + fin.vat_mxn
# ME converted from MN
fin.unit_cost_usd = (capture / tc) if tc else Decimal(0)
fin.sub_import_value_usd = fin.unit_cost_usd * qty
fin.vat_usd = fin.sub_import_value_usd * iva_factor / 100
fin.value_usd = fin.sub_import_value_usd + fin.vat_usd
# MC mirrors capture currency in legacy
fin.unit_cost_mc = capture
fin.sub_import_value_mc = capture * qty
fin.vat_mc = fin.sub_import_value_mc * iva_factor / 100
fin.value_mc = fin.sub_import_value_mc + fin.vat_mc
elif currency == Currency.MANUAL: # MC
# ME from MC * tc_mm
fin.unit_cost_usd = capture * tc_mm
fin.sub_import_value_usd = fin.unit_cost_usd * qty
fin.vat_usd = fin.sub_import_value_usd * iva_factor / 100
fin.value_usd = fin.sub_import_value_usd + fin.vat_usd
# MN from ME * tc
fin.unit_cost_mxn = fin.unit_cost_usd * tc
fin.sub_import_value_mxn = fin.unit_cost_mxn * qty
fin.vat_mxn = fin.sub_import_value_mxn * iva_factor / 100
fin.value_mxn = fin.sub_import_value_mxn + fin.vat_mxn
# MC base
fin.unit_cost_mc = capture
fin.sub_import_value_mc = capture * qty
fin.vat_mc = fin.sub_import_value_mc * iva_factor / 100
fin.value_mc = fin.sub_import_value_mc + fin.vat_mc
def assign_values_invoice_totals(
invoice: InvoiceHeader,
lines: List[LineItem],
) -> None:
"""
ASIGNAVALORES_FACTURA
Totaliza cantidades, pesos y valores/IVA en encabezado para IMPO DEF/MEX.
"""
if not invoice.financials:
return
total_qty = Decimal(0)
total_net = Decimal(0)
total_gross = Decimal(0)
total_packages = 0
total_val_mn = Decimal(0)
total_val_me = Decimal(0)
total_val_mc = Decimal(0)
total_iva_mn = Decimal(0)
total_iva_me = Decimal(0)
total_iva_mc = Decimal(0)
total_sub_mn = Decimal(0)
total_sub_me = Decimal(0)
total_sub_mc = Decimal(0)
for line in lines:
q = line.quantity
f = line.financial
if q:
total_qty += Decimal(str(q.quantity or 0))
total_net += Decimal(str(q.net_weight or 0))
total_gross += Decimal(str(q.gross_weight or 0))
total_packages += int(q.package_quantity or 0)
if f:
total_val_mn += Decimal(str(f.value_mxn or 0))
total_val_me += Decimal(str(f.value_usd or 0))
total_val_mc += Decimal(str(f.value_mc or 0))
total_iva_mn += Decimal(str(f.vat_mxn or 0))
total_iva_me += Decimal(str(f.vat_usd or 0))
total_iva_mc += Decimal(str(f.vat_mc or 0))
total_sub_mn += Decimal(str(f.sub_import_value_mxn or 0))
total_sub_me += Decimal(str(f.sub_import_value_usd or 0))
total_sub_mc += Decimal(str(f.sub_import_value_mc or 0))
fin = invoice.financials
fin.total_quantity = float(total_qty)
fin.net_weight = float(total_net)
fin.gross_weight = float(total_gross)
fin.total_packages = total_packages
fin.value_mn = float(total_val_mn)
fin.value_me = float(total_val_me)
fin.value_mc = float(total_val_mc)
fin.iva_mn = float(total_iva_mn)
fin.iva_me = float(total_iva_me)
fin.iva_mc = float(total_iva_mc)
# No existe subtotal a nivel encabezado en el modelo actual.
# Se conserva en partidas (sub_import_value_*), de donde se agrega cuando se necesite.
_ = (total_sub_mn, total_sub_me, total_sub_mc)

View File

@@ -1,3 +1,5 @@
import logging
from celery import Task
from core.celery_app import celery_app
@@ -12,9 +14,15 @@ from .sub_process.review_exchange_rate import review_exchange_rate
from .sub_process.review_weights import review_weights_kgs, review_weights_lbs
from .sub_process.review_rule_octave import valida_imp_regla_octava, descuenta_cupo_r_octava
from .sub_process.assing_values import assign_values_lines, assign_values_invoice
from .sub_process.assing_values_def_mex import (
assign_values_iva_lines,
assign_values_invoice_totals,
)
from ..balance.create_balance_entries import create_balance_entries
from .main_process import _validate_sisimp_limits, _update_invoice_totals, _validate_lines
logger = logging.getLogger(__name__)
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
@@ -64,8 +72,21 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id
# ── Paso 4: Asignación de valores ─────────────────────────────────────
_progress(self, 50, "Calculando valores por partida...")
assign_values_lines(invoice, lines)
assign_values_invoice(invoice, lines)
raw_type = invoice.invoice_type
invoice_type = (raw_type or "").strip().upper()
logger.info(
"celery import process invoice_type: invoice_id=%s raw=%r normalized=%r document_type=%r",
invoice.id,
raw_type,
invoice_type,
getattr(invoice, "document_type", None),
)
if invoice_type in {"DEF", "MEX"}:
assign_values_iva_lines(invoice, lines)
assign_values_invoice_totals(invoice, lines)
else:
assign_values_lines(invoice, lines)
assign_values_invoice(invoice, lines)
# ── Paso 5: Validaciones por partida ──────────────────────────────────
_progress(self, 70, "Validando partidas...")
@@ -99,11 +120,12 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id
company_id=company_id,
sql_errors=sql_errors,
)
_update_invoice_totals(invoice)
_update_invoice_totals(invoice, lines)
# ── Paso 8: Generar saldos en a24.balance_movement ───────────────────
_progress(self, 98, "Generando saldos de inventario...")
create_balance_entries(db, invoice, lines)
if invoice_type not in {"DEF", "MEX"}:
create_balance_entries(db, invoice, lines)
db.flush()
db.commit()

View File

@@ -43,22 +43,12 @@ def _validate_returned_quantities(
una factura de exportación procesada y se reporta qué factura debe
desactualizarse primero.
"""
lines_with_balance = [
line for line in lines
if line.quantity is not None and (
(line.quantity.quantity_returned_temp or Decimal(0))
+ (line.quantity.quantity_returned or Decimal(0))
+ (line.quantity.quantity_existence or Decimal(0))
) != Decimal(0)
]
if not lines_with_balance:
return
for line in lines_with_balance:
qty_ret_temp = line.quantity.quantity_returned_temp or Decimal(0)
qty_ret = line.quantity.quantity_returned or Decimal(0)
qty_exist = line.quantity.quantity_existence or Decimal(0)
# Importante:
# Con la migración a tablas especializadas (discharge_* + balance_movement),
# los campos legacy de cantidades retornadas pueden quedar desfasados.
# Para bloquear una des-actualización solo debe considerarse descarga ACTIVA
# real (DischargeHeader.status=APPLIED y factura fuente procesada).
for line in lines:
# Buscar DischargeDetail vinculados a esta partida de importación
# cuya factura de exportación esté activa (PROCESSED).
@@ -110,22 +100,7 @@ def _validate_returned_quantities(
],
code="LINE_HAS_ACTIVE_DISCHARGE",
)
else:
# La partida tiene saldo pero no hay descarga activa rastreable —
# reportar el saldo directamente para que el usuario lo investigue.
errors.add_error(
field=f"line[{line.line_number}].quantities",
message=(
f"La Línea: {line.line_number} tiene saldos pendientes "
f"(retornada: {qty_ret}, retornada temp: {qty_ret_temp}, "
f"existencia: {qty_exist}) y no se puede desactualizar."
),
solution=[
"Verifique las exportaciones que afectan a esta partida "
"y desactualícelas primero."
],
code="LINE_HAS_BALANCE",
)
# Si no hay discharge activo, NO bloquear por contadores legacy.
# ─────────────────────────────────────────────────────────────────────────────
@@ -156,6 +131,7 @@ def _reset_invoice_financials(invoice: InvoiceHeader) -> None:
fin.customs_value_me = 0.0
fin.iva_mn = 0.0
fin.iva_me = 0.0
fin.iva_mc = 0.0
invoice.status = InvoiceStatus.PENDING
invoice.process_method = None
@@ -169,11 +145,6 @@ def _reset_line_quantities(lines: List[LineItem]) -> None:
ValorIVAMNUsado=0, ValorIVAMEUsado=0 (Clarion SCAII).
"""
for line in lines:
if line.quantity is not None:
line.quantity.quantity_returned = Decimal(0)
line.quantity.quantity_returned_temp = Decimal(0)
line.quantity.quantity_existence = Decimal(0)
if line.financial is not None:
line.financial.value_returned_mxn = Decimal(0)
line.financial.value_returned_usd = Decimal(0)

View File

@@ -7,7 +7,9 @@ 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 revert_invoice_task
from api.v1.modules.a76.invoices.models import InvoiceHeader, OperationType
from .task import revert_invoice_task as revert_import_invoice_task
from ...exports.revert.task import revert_invoice_task as revert_export_invoice_task
router = APIRouter()
@@ -20,16 +22,32 @@ def trigger_invoice_revert(
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Inicia la des-actualización de una factura de importación temporal como
tarea Celery.
Retorna el task_id para hacer polling del progreso.
Endpoint unificado para des-actualizar facturas.
- operation_type=imp -> task de importación
- operation_type=exp -> task de exportación
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
task = revert_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id)]
cancelled_by = (
current_user.get("username")
or current_user.get("user_name")
or current_user.get("preferred_username")
or current_user.get("email")
or "system"
)
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
raise HTTPException(status_code=404, detail=f"Factura {invoice_id} no encontrada.")
if invoice.operation_type == OperationType.EXP:
task = revert_export_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id), str(cancelled_by)]
)
else:
task = revert_import_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id), str(cancelled_by)]
)
return {"task_id": task.id}

View File

@@ -13,8 +13,14 @@ def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
@celery_app.task(bind=True, name="revert_invoice_task")
def revert_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict:
@celery_app.task(bind=True, name="revert_import_invoice_task")
def revert_invoice_task(
self: Task,
invoice_id: int,
tenant_id: str,
company_id: str,
cancelled_by: str | None = None,
) -> dict:
"""
Des-actualiza una factura de importación temporal ejecutando todas las
validaciones y reversiones del proceso principal (revert/main_process) con

View File

@@ -517,9 +517,9 @@ class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin):
iva_mc: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0, server_default="0"
) # IVAEXPOMC / IVA en MC
iva_factor: Mapped[Optional[str]] = mapped_column(
String(10)
) # FACTORIVA / Factor IVA (puede ser varchar en imports)
iva_factor: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0, server_default="0"
) # FACTORIVA / Factor IVA
tax_value_me: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0, server_default="0"
) # VALORIMPUESTOME / Valor impuesto ME

View File

@@ -10,6 +10,7 @@ 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 . import models, schemas
@@ -166,6 +167,25 @@ class InvoiceService:
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

View File

@@ -248,18 +248,18 @@ def validate_create(
# Calcular peso neto en kilogramos (estándar interno)
if unit_is_kgs:
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.net_weight = quantity
else: # invoice en libras
line.quantity.net_weight = quantity * Decimal("2.204624")
elif unit_is_lbs:
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.net_weight = quantity / Decimal("2.204624")
else: # invoice en libras
line.quantity.net_weight = quantity
else:
# Otra unidad de medida - usar peso capturado y convertir si es necesario
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
# El peso capturado está en kilos
line.quantity.net_weight = net_weight_input
else:
@@ -289,7 +289,7 @@ def validate_create(
# Si no se proporcionó peso bruto, calcularlo
if not gross_weight_input or gross_weight_input == 0:
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
@@ -299,7 +299,7 @@ def validate_create(
)
else:
# Convertir peso bruto capturado según tipo de factura
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.gross_weight = gross_weight_input
else: # libras
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")

View File

@@ -95,21 +95,19 @@ def validate_update(
# Se proporcionó nuevo peso neto, convertir según tipo
net_weight_input = line.quantity.net_weight
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.net_weight = net_weight_input
else: # libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
else:
# Mantener peso existente
line.quantity.net_weight = existing_line.quantity.net_weight
print(f"After weight conversion: net_weight={line.quantity.net_weight}, gross_weight={line.quantity.gross_weight}, weight_type={invoice_weight_type}")
line.quantity.net_weight = existing_line.quantity.net_weight
# Convertir peso bruto si se proporcionó
if line.quantity.gross_weight is not None:
gross_weight_input = line.quantity.gross_weight
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.gross_weight = gross_weight_input
else: # libras, convertir a kilos
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")

View File

@@ -9,6 +9,9 @@ from ...common.fractions import search_fraction_preference
from ...common.common_validators import item_exists
from ...models import LineItem
from ...line_customs.models import FractionType, LineCustom
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
@@ -22,6 +25,8 @@ from api.v1.modules.public.reference_data.valuation_methods.models import (
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.company.models import Company
import re
def validate_common(
db: Session,
@@ -284,18 +289,75 @@ def validate_common(
)
if line.customs.american_fraction:
american_fraction_exists = db.query(
exists().where(
LineCustom.american_fraction == line.customs.american_fraction
def _normalize_american_fraction_code(raw_code: str) -> list[str]:
"""
Attempts to map user input to the canonical USTariffFraction.code.
The catalog commonly stores dotted HTS codes (e.g. 3802.20.00.00),
but users may paste/enter digits-only or use different separators.
"""
normalized_raw = (raw_code or "").strip()
if not normalized_raw:
return []
digits_only = re.sub(r"[.\s\-]", "", normalized_raw)
candidates: list[str] = []
# 1) Exact input
candidates.append(normalized_raw)
# 2) Canonical with dots if length matches common patterns
if len(digits_only) == 10:
candidates.append(
f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}.{digits_only[8:10]}"
)
elif len(digits_only) == 8:
candidates.append(
f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}"
)
# 3) Digits-only (if catalog stores without dots)
candidates.append(digits_only)
# De-duplicate while preserving order
seen: set[str] = set()
deduped: list[str] = []
for c in candidates:
if not c or c in seen:
continue
seen.add(c)
deduped.append(c)
return deduped
raw_american_fraction = str(line.customs.american_fraction)
candidates = _normalize_american_fraction_code(raw_american_fraction)
us_fraction: USTariffFraction | None = None
for candidate in candidates:
us_fraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == candidate,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
).scalar()
if not american_fraction_exists:
if us_fraction:
break
if not us_fraction:
errors.add_error(
field=f"line[{line_number}].customs.american_fraction",
message="La fracción americana especificada no existe.",
solution=["Proporciona una fracción americana valida."],
code="AMERICAN_FRACTION_NOT_FOUND",
)
else:
# Keep canonical value so downstream validators can use it safely.
line.customs.american_fraction = us_fraction.code
if line.order:
if len(line.order) > 20:

View File

@@ -232,18 +232,18 @@ def validate_create(
# Calcular peso neto en kilogramos (estándar interno)
if unit_is_kgs:
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.net_weight = quantity
else: # invoice en libras
line.quantity.net_weight = quantity * Decimal("2.204624")
elif unit_is_lbs:
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.net_weight = quantity / Decimal("2.204624")
else: # invoice en libras
line.quantity.net_weight = quantity
else:
# Otra unidad de medida - usar peso capturado y convertir si es necesario
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
# El peso capturado está en kilos
line.quantity.net_weight = net_weight_input
else:
@@ -273,7 +273,7 @@ def validate_create(
# Si no se proporcionó peso bruto, calcularlo
if not gross_weight_input or gross_weight_input == 0:
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
@@ -283,7 +283,7 @@ def validate_create(
)
else:
# Convertir peso bruto capturado según tipo de factura
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.gross_weight = gross_weight_input
else: # libras
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")

View File

@@ -94,21 +94,19 @@ def validate_update(
# Se proporcionó nuevo peso neto, convertir según tipo
net_weight_input = line.quantity.net_weight
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.net_weight = net_weight_input
else: # libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
else:
# Mantener peso existente
line.quantity.net_weight = existing_line.quantity.net_weight
print(f"After weight conversion: net_weight={line.quantity.net_weight}, gross_weight={line.quantity.gross_weight}, weight_type={invoice_weight_type}")
line.quantity.net_weight = existing_line.quantity.net_weight
# Convertir peso bruto si se proporcionó
if line.quantity.gross_weight is not None:
gross_weight_input = line.quantity.gross_weight
if invoice_weight_type == "KGS":
if invoice_weight_type.lower() == "kgs":
line.quantity.gross_weight = gross_weight_input
else: # libras, convertir a kilos
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")

View File

@@ -30,9 +30,6 @@ class LineQuantity(Base):
# Quantities - Special (SCAF specific)
quantity_temp_export: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTEXPOTEMP
quantity_existence: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTEXISTENCIA
quantity_returned: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTRETORNADA
quantity_returned_temp: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTRETORNADATEMP
serial_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_SERIES/CANT_SERIESDEF
# Weight

View File

@@ -16,9 +16,6 @@ class LineQuantityBase(BaseModel):
# Quantities - Special (SCAF specific)
quantity_temp_export: Optional[Decimal] = Field(None, description="Temporary export quantity (CANTEXPOTEMP)")
quantity_existence: Optional[Decimal] = Field(None, description="Existence quantity (CANTEXISTENCIA)")
quantity_returned: Optional[Decimal] = Field(None, description="Returned quantity (CANTRETORNADA)")
quantity_returned_temp: Optional[Decimal] = Field(None, description="Returned temporary quantity (CANTRETORNADATEMP)")
serial_count: Optional[int] = Field(None, description="Serial count (CANT_SERIES/CANT_SERIESDEF)")
# Weight

View File

@@ -44,6 +44,7 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS
from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader, DischargeStatus
logger = logging.getLogger(__name__)
@@ -733,12 +734,19 @@ class ItemService:
)
result = []
used_map = ItemService._used_quantities_by_procedure(
db=db,
import_line_ids=[line.id for line in lines],
as_of_date=as_of_date,
)
for line in lines:
available_balance = ItemService._compute_balance(db, line.id, as_of_date)
qty = line.quantity
desc = line.description
fa = line.fa_data
inv = line.invoice
qty_used_temp = used_map.get((line.id, "TEM"), Decimal(0))
qty_used_def = used_map.get((line.id, "DEF"), Decimal(0))
# Count subitems (lines that reference this line as parent via subitem_number)
subitem_count = 0
@@ -770,8 +778,8 @@ class ItemService:
"unit_of_measure_code": line.unit_of_measure_info.code if line.unit_of_measure_info else None,
# Quantities
"quantity": float(qty.quantity) if qty and qty.quantity is not None else None,
"quantity_returned_temp": float(qty.quantity_returned_temp) if qty and qty.quantity_returned_temp is not None else None,
"quantity_returned": float(qty.quantity_returned) if qty and qty.quantity_returned is not None else None,
"quantity_used_temp": float(qty_used_temp),
"quantity_used_def": float(qty_used_def),
# Balance
"available_balance": float(available_balance),
"has_balance": available_balance > Decimal(0),
@@ -811,3 +819,39 @@ class ItemService:
)
).scalar()
return Decimal(str(result or 0))
@staticmethod
def _used_quantities_by_procedure(
db: Session,
import_line_ids: List[int],
as_of_date: Optional[datetime.date],
) -> dict[tuple[int, str], Decimal]:
"""
Returns net used quantity by import line and procedence (TEM/DEF),
based on active discharge records only (new ledger logic).
"""
if not import_line_ids:
return {}
query = (
select(
DischargeDetail.import_item_line_id,
DischargeDetail.procedence,
func.sum(DischargeDetail.quantity_discharged),
)
.join(DischargeHeader, DischargeHeader.id == DischargeDetail.discharge_header_id)
.where(
DischargeDetail.import_item_line_id.in_(import_line_ids),
DischargeHeader.status == DischargeStatus.APPLIED,
DischargeDetail.procedence.in_(["TEM", "DEF"]),
)
.group_by(DischargeDetail.import_item_line_id, DischargeDetail.procedence)
)
if as_of_date is not None:
query = query.where(DischargeHeader.discharge_date <= as_of_date)
rows = db.execute(query).all()
out: dict[tuple[int, str], Decimal] = {}
for line_id, procedence, qty in rows:
out[(int(line_id), str(procedence))] = Decimal(str(qty or 0))
return out

View File

@@ -6,9 +6,9 @@ from api.v1.modules.a24.inv.inv_aphis.dto import InvPartAphisGeneralDTO
# --- SUB-DTO: DATOS ADUANALES (FaData) ---
class FaDataDTO(BaseModel):
origin_country: Optional[str] = Field(default=None, pattern=r"^[A-Z]{3}$")
sector: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9]{1,8}$")
fraction_type: Optional[Literal["GENERAL", "PROSEC", "ALADI", "TLCS"]] = None
origin_country: Optional[str] = None
sector: Optional[str] = None
fraction_type: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -22,8 +22,7 @@ logger = logging.getLogger(__name__)
class PartService:
"""Servicio para gestión de Partes (Anexo 76 + Anexo 24)"""
# Estos campos están en el modelo pero NO en la DB todavía (faltan las migraciones del usuario)
# Los diferimos en SELECT y los filtramos en INSERT/UPDATE para que el sistema no truene.
# Estos campos están en el modelo pero NO en la DB todavía, faltan las migraciones del usuario
MISSING_INV_COLUMNS = []
# Campos que SÍ existen en la DB (Verificados con \d a24.inv_partes)

View File

@@ -202,13 +202,13 @@ BASE_SELECT = """
REPLACE(REPLACE(COALESCE(ild.description_spanish,''),CHR(10),''),CHR(13),' ') AS "C14",
REPLACE(REPLACE(COALESCE(ild.description_english,''),CHR(10),''),CHR(13),' ') AS "C15",
COALESCE(ilc.origin_country,'') AS "C16",
COALESCE(ilq.quantity, 0) AS "C17",
COALESCE(ilq.quantity_returned, 0) AS "C18",
COALESCE(ent.qty_impo, 0) AS "C17",
COALESCE(bal.qty_used, 0) AS "C18",
COALESCE(uom.code,'') AS "C19",
COALESCE(ilf.value_mxn, 0) AS "C20",
COALESCE(ilf.value_returned_mxn, 0) AS "C21",
COALESCE(ilf.value_usd, 0) AS "C22",
COALESCE(ilf.value_returned_usd, 0) AS "C23",
COALESCE(ent.val_mn_impo, 0) AS "C20",
COALESCE(bal.val_mn_used, 0) AS "C21",
COALESCE(ent.val_me_impo, 0) AS "C22",
COALESCE(bal.val_me_used, 0) AS "C23",
COALESCE(ilq.net_weight, 0) AS "C24",
COALESCE(ilc.fraction,'') AS "C26",
COALESCE(ilc.fraction_type,'') AS "C27",
@@ -220,7 +220,7 @@ BASE_SELECT = """
il.id AS "C34",
il.line_number AS "C35",
COALESCE(p.part_number,'') AS "C36",
COALESCE(ilq.quantity_returned_temp, 0) AS "C37",
0 AS "C37",
COALESCE(il.location,'') AS "C38",
'' AS "C39",
COALESCE(icm.edocument,'') AS "C40",
@@ -228,13 +228,15 @@ BASE_SELECT = """
COALESCE(cl.material_key,'') AS "C42",
CONCAT(ped.year,'-',ped.customs_office,'-',ped.license,'-',ped.pedimento_number) AS "C43",
COALESCE(ptc.payment_date_code, 'P') AS "C44",
COALESCE(lm.last_movement_type, '') AS "C_last_movement_type",
COALESCE(ilc.octave_fraction,'') AS "C45",
'' AS "C47",
COALESCE(ped.pedimento_code,'') AS "C48",
COALESCE(ilc.rate,'') AS "C49",
COALESCE(il.iv32_type_key,'') AS "C50",
COALESCE(il.guide_number,'') AS "C_embarque",
COALESCE(c_proj.name, '') AS "C_proyecto"
COALESCE(c_proj.name, '') AS "C_proyecto",
COALESCE(bal.qty_balance, 0) AS "C_balance"
"""
BASE_JOINS = """
@@ -252,6 +254,72 @@ BASE_JOINS = """
LEFT JOIN a76.item_line_descriptions ild ON ild.item_line_id = il.id
LEFT JOIN a76.parts p ON p.id = il.part_number_id
LEFT JOIN a76.units_of_measure uom ON uom.id = il.unit_of_measure
LEFT JOIN (
SELECT DISTINCT ON (import_item_line_id)
import_item_line_id,
movement_type AS last_movement_type
FROM a24.balance_movement
WHERE tenant_id = :tenant_id
ORDER BY import_item_line_id, id DESC
) lm ON lm.import_item_line_id = il.id
LEFT JOIN (
SELECT
import_item_line_id,
SUM(
CASE
WHEN movement_type = 'entry' THEN quantity
WHEN movement_type = 'entry_void' THEN -1 * quantity
ELSE 0
END
) AS qty_impo,
SUM(
CASE
WHEN movement_type = 'entry' THEN COALESCE(value_me, 0)
WHEN movement_type = 'entry_void' THEN -1 * COALESCE(value_me, 0)
ELSE 0
END
) AS val_me_impo,
SUM(
CASE
WHEN movement_type = 'entry' THEN COALESCE(value_mn, 0)
WHEN movement_type = 'entry_void' THEN -1 * COALESCE(value_mn, 0)
ELSE 0
END
) AS val_mn_impo
FROM a24.balance_movement
WHERE tenant_id = :tenant_id
GROUP BY import_item_line_id
) ent ON ent.import_item_line_id = il.id
LEFT JOIN (
SELECT
import_item_line_id,
SUM(
CASE
WHEN movement_type IN ('consumption', 'waste', 'scrap', 'destruction') THEN quantity
WHEN movement_type = 'return' THEN -1 * quantity
ELSE 0
END
) as qty_used,
SUM(
CASE
WHEN movement_type IN ('consumption', 'waste', 'scrap', 'destruction') THEN COALESCE(value_me, 0)
WHEN movement_type = 'return' THEN -1 * COALESCE(value_me, 0)
ELSE 0
END
) as val_me_used,
SUM(
CASE
WHEN movement_type IN ('consumption', 'waste', 'scrap', 'destruction') THEN COALESCE(value_mn, 0)
WHEN movement_type = 'return' THEN -1 * COALESCE(value_mn, 0)
ELSE 0
END
) as val_mn_used,
SUM(CASE WHEN movement_type IN ('consumption', 'waste', 'scrap', 'destruction', 'neg_adj', 'transfer_out', 'expiration', 'regime_chg_out', 'entry_void')
THEN -1 * quantity ELSE quantity END) as qty_balance
FROM a24.balance_movement
WHERE tenant_id = :tenant_id
GROUP BY import_item_line_id
) bal ON bal.import_item_line_id = il.id
"""
# ---------------------------------------------------------------------------
@@ -287,6 +355,7 @@ def _query_ped(filters: SaldosFilter) -> tuple:
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
AND ih.operation_type = 'imp'
{company_filter}
{date_filter}
{level_filter}
@@ -310,6 +379,7 @@ def _query_fpp(filters: SaldosFilter) -> tuple:
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
AND ih.operation_type = 'imp'
{company_filter}
{date_filter}
{level_filter}
@@ -332,6 +402,7 @@ def _query_ffa(filters: SaldosFilter) -> tuple:
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
AND ih.operation_type = 'imp'
{company_filter}
{date_filter}
{level_filter}
@@ -362,6 +433,7 @@ def _query_par(filters: SaldosFilter) -> tuple:
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
AND ih.operation_type = 'imp'
{company_filter}
{id_filter}
{date_filter}
@@ -393,6 +465,7 @@ def _query_cla(filters: SaldosFilter) -> tuple:
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
AND ih.operation_type = 'imp'
{company_filter}
{id_filter}
{date_filter}
@@ -431,15 +504,19 @@ def _build_row(
# CANTIDADES
cant_orig = _d(row.get("C17"))
cant_ret = _d(row.get("C18")) + _d(row.get("C37"))
cant_saldo = cant_orig - cant_ret
cant_used = _d(row.get("C18"))
cant_saldo = _d(row.get("C_balance"))
# Mostrar saldo 0, excepto lotes anulados (último movimiento ENTRY_VOID).
if str(row.get("C_last_movement_type") or "").lower() == "entry_void":
return None
if filters.omit_low_balance and cant_saldo <= Decimal(0):
return None
# PESO
peso_neto = _d(row.get("C30"))
peso_usado = (cant_ret * peso_neto / cant_orig) if cant_orig != 0 else Decimal(0)
peso_usado = (cant_used * peso_neto / cant_orig) if cant_orig != 0 else Decimal(0)
peso_saldo = peso_neto - peso_usado
# TIPO DE CAMBIO:
@@ -479,11 +556,11 @@ def _build_row(
if cant_orig != 0:
if use_mn:
if use_fp and fecha_pago:
valor_usado = cant_ret * _d(row.get("C22")) * tc / cant_orig
valor_usado = cant_used * _d(row.get("C22")) * tc / cant_orig
else:
valor_usado = cant_ret * _d(row.get("C20")) / cant_orig
valor_usado = cant_used * _d(row.get("C20")) / cant_orig
else:
valor_usado = cant_ret * _d(row.get("C22")) / cant_orig
valor_usado = cant_used * _d(row.get("C22")) / cant_orig
else:
valor_usado = Decimal(0)
@@ -566,7 +643,7 @@ def _build_row(
"UM": str(row.get("C19") or ""),
"PesoNeto": _fmt_num(peso_neto),
"ValorOriginal": _fmt_num(valor_orig),
"CantidadUsada": _fmt_num(cant_ret),
"CantidadUsada": _fmt_num(cant_used),
"PesoUsado": _fmt_num(peso_usado),
"ValorUsado": _fmt_num(valor_usado),
"CantidadSaldo": _fmt_num(cant_saldo),

View File

@@ -68,13 +68,13 @@ class DashboardService:
)
# Total del mes anterior para comparación
last_month = datetime.utcnow() - timedelta(days=30)
last_month = (datetime.utcnow() - timedelta(days=30)).date()
previous_total = (
self.db.query(func.count(InvoiceHeader.id))
.filter(
InvoiceHeader.tenant_id == self.tenant_id,
InvoiceHeader.company_id == self.company_id,
InvoiceHeader.created_at < last_month,
InvoiceHeader.invoice_date < last_month,
)
.scalar()
or 0
@@ -91,17 +91,17 @@ class DashboardService:
)
# Facturas por mes (últimos 6 meses)
six_months_ago = datetime.utcnow() - timedelta(days=180)
six_months_ago = (datetime.utcnow() - timedelta(days=180)).date()
monthly_data = (
self.db.query(
func.date_trunc("month", InvoiceHeader.created_at).label("month"),
func.date_trunc("month", InvoiceHeader.invoice_date).label("month"),
func.count(InvoiceHeader.id).label("count"),
)
.filter(
InvoiceHeader.tenant_id == self.tenant_id,
InvoiceHeader.company_id == self.company_id,
InvoiceHeader.created_at >= six_months_ago,
InvoiceHeader.invoice_date >= six_months_ago,
)
.group_by("month")
.order_by("month")

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, mapped_column
class CartaPorte(Base):
__tablename__ = "carta_porte"
__tablename__ = "carta_porte_codes"
__table_args__ = (
{"schema": "public", "extend_existing": True},
)

View File

@@ -39,10 +39,8 @@ def seed_carta_porte(db: Session):
if len(batch) >= batch_size:
db.bulk_save_objects(batch)
db.commit()
batch = []
print(f"Inserted {batch_size} records...")
batch = []
if batch:
db.bulk_save_objects(batch)
db.commit()
print(f"Finished seeding with {len(batch)} remaining records.")
db.commit()

View File

@@ -6,7 +6,7 @@ Agrega todos los módulos de la aplicación
from fastapi import APIRouter
from .agency_tariff_codes.routes import router as agency_tariff_codes_router
from .carta_porte.routes import router as carta_porte_router
from .carta_porte_codes.routes import router as carta_porte_router
from .code_pedimento_regimens.routes import router as code_pedimento_regimens_router
from .containers.routes import router as containers_router
from .countries.routes import router as countries_router
@@ -108,7 +108,7 @@ router.include_router(
router.include_router(
carta_porte_router,
prefix="/reference_data",
tags=["public / reference_data / carta_porte"],
tags=["public / reference_data / carta_porte_codes"],
)
router.include_router(
customs_sections_router,

View File

@@ -107,16 +107,12 @@ class SitarAPIBaseService:
# DEBUG LOGGING for SITAR inspection
if "fracciones" in url:
import logging
logger = logging.getLogger(__name__)
logger.info(f"SITAR API Response Headers for {url}: {dict(response.headers)}")
logger = logging.getLogger(__name__)
try:
data = response.json()
if isinstance(data, dict):
logger.info(f"SITAR API Response Body Keys: {list(data.keys())}")
elif isinstance(data, list) and len(data) > 0:
logger.info(f"SITAR API Response List Item Keys: {list(data[0].keys())}")
data = response.json()
return data
except Exception:
logger.error(f"Error parsing SITAR API Response Body: {response.text}")
pass
return response.json()