Merge pull request 'feature/invoice-def' (#237) from feature/invoice-def into development

Reviewed-on: ADUANASOFT/anexo76#237
This commit is contained in:
2026-03-23 16:42:58 +00:00
14 changed files with 428 additions and 2180 deletions

View File

@@ -6,6 +6,7 @@ from logging.config import fileConfig
from urllib.parse import quote_plus
from alembic import context
from alembic.operations import ops
from core.config import settings
from core.database import Base
from sqlalchemy import engine_from_config, pool
@@ -78,6 +79,125 @@ fileConfig(config.config_file_name)
target_metadata = Base.metadata
def include_object(object_, name, type_, reflected, compare_to):
"""
Keep all objects in autogenerate.
FK noise is cleaned in process_revision_directives.
"""
return True
def _fk_drop_signature(op_):
if not isinstance(op_, ops.DropConstraintOp):
return None
if getattr(op_, "constraint_type", None) != "foreignkey":
return None
return (
getattr(op_, "schema", None),
getattr(op_, "table_name", None),
getattr(op_, "constraint_name", None),
)
def _fk_create_signature(op_):
if not isinstance(op_, ops.CreateForeignKeyOp):
return None
local_cols = tuple(getattr(op_, "local_cols", ()) or ())
remote_cols = tuple(getattr(op_, "remote_cols", ()) or ())
return (
getattr(op_, "source_schema", None),
getattr(op_, "source_table", None),
getattr(op_, "referent_schema", None),
getattr(op_, "referent_table", None),
local_cols,
remote_cols,
)
def _drop_to_create_match(drop_op, create_op):
if not isinstance(drop_op, ops.DropConstraintOp):
return False
if not isinstance(create_op, ops.CreateForeignKeyOp):
return False
if getattr(drop_op, "constraint_type", None) != "foreignkey":
return False
def _normalize_schema(value):
# PostgreSQL reports default schema inconsistently as None/public.
return "public" if value in (None, "") else value
# Prefer structural comparison using Alembic's reverse op when available.
reverse_create = getattr(drop_op, "_reverse", None)
if isinstance(reverse_create, ops.CreateForeignKeyOp):
return (
_normalize_schema(getattr(reverse_create, "source_schema", None))
== _normalize_schema(getattr(create_op, "source_schema", None))
and getattr(reverse_create, "source_table", None) == getattr(create_op, "source_table", None)
and _normalize_schema(getattr(reverse_create, "referent_schema", None))
== _normalize_schema(getattr(create_op, "referent_schema", None))
and getattr(reverse_create, "referent_table", None) == getattr(create_op, "referent_table", None)
and tuple(getattr(reverse_create, "local_cols", ()) or ())
== tuple(getattr(create_op, "local_cols", ()) or ())
and tuple(getattr(reverse_create, "remote_cols", ()) or ())
== tuple(getattr(create_op, "remote_cols", ()) or ())
)
# Fallback for older op payloads: compare source table/schema and name.
return (
_normalize_schema(getattr(drop_op, "schema", None)) == _normalize_schema(getattr(create_op, "source_schema", None))
and getattr(drop_op, "table_name", None) == getattr(create_op, "source_table", None)
and getattr(drop_op, "constraint_name", None) == getattr(create_op, "constraint_name", None)
)
def _prune_fk_churn(container):
if not hasattr(container, "ops"):
return
# First recurse into nested containers.
for op_ in list(container.ops):
_prune_fk_churn(op_)
table_ops = container.ops
kept_ops = []
consumed_indexes = set()
for i, op_i in enumerate(table_ops):
if i in consumed_indexes:
continue
if isinstance(op_i, ops.DropConstraintOp) and getattr(op_i, "constraint_type", None) == "foreignkey":
matched_j = None
for j in range(i + 1, len(table_ops)):
if j in consumed_indexes:
continue
op_j = table_ops[j]
if _drop_to_create_match(op_i, op_j):
matched_j = j
break
if matched_j is not None:
# Drop + recreate same FK detected; remove both.
consumed_indexes.add(i)
consumed_indexes.add(matched_j)
continue
kept_ops.append(op_i)
container.ops = kept_ops
def process_revision_directives(context_, revision, directives):
"""
Remove autogenerate noise where Alembic emits drop/create for equivalent FKs.
Real FK changes are preserved.
"""
if not directives:
return
script = directives[0]
_prune_fk_churn(script.upgrade_ops)
_prune_fk_churn(script.downgrade_ops)
def import_models_from_dir(dir_path: str):
"""Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/"""
import sys
@@ -132,6 +252,10 @@ def run_migrations_offline() -> None:
context.configure(
url=url,
target_metadata=target_metadata,
compare_type=True,
include_schemas=True,
include_object=include_object,
process_revision_directives=process_revision_directives,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
@@ -153,7 +277,14 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
include_schemas=True,
include_object=include_object,
process_revision_directives=process_revision_directives,
)
with context.begin_transaction():
context.run_migrations()

View File

@@ -0,0 +1,45 @@
"""iva_factor
Revision ID: bccb7f8986c7
Revises: 9f3c2d1b7a11
Create Date: 2026-03-23 09:44:02.275257
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bccb7f8986c7'
down_revision: Union[str, Sequence[str], None] = '9f3c2d1b7a11'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_public_carta_porte_code'), table_name='carta_porte_codes')
op.create_index(op.f('ix_public_carta_porte_codes_code'), 'carta_porte_codes', ['code'], unique=False, schema='public')
op.alter_column('invoice_financials', 'iva_factor',
existing_type=sa.VARCHAR(length=10),
type_=sa.Numeric(precision=23, scale=8),
postgresql_using='iva_factor::numeric(23,8)',
existing_nullable=True,
schema='a76')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('invoice_financials', 'iva_factor',
existing_type=sa.Numeric(precision=23, scale=8),
type_=sa.VARCHAR(length=10),
existing_nullable=True,
schema='a76')
op.drop_index(op.f('ix_public_carta_porte_codes_code'), table_name='carta_porte_codes', schema='public')
op.create_index(op.f('ix_public_carta_porte_code'), 'carta_porte_codes', ['code'], unique=False)
# ### end Alembic commands ###

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

@@ -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
@@ -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)
@@ -284,6 +294,7 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id
_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

@@ -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...")
@@ -103,7 +124,8 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id
# ── 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

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

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

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

View File

@@ -110,7 +110,7 @@ export interface InvoiceFinancials {
iva_mn?: number | null;
iva_me?: number | null;
iva_mc?: number | null;
iva_factor?: string | null;
iva_factor?: number | null;
tax_value_me?: number | null;
seal_value_2500?: boolean | null;
total_quantity?: number | null;

View File

@@ -67,7 +67,7 @@
freight: null as number | null,
insurance: null as number | null,
iva_mn: null as number | null,
iva_factor: null as string | null,
iva_factor: null as number | null,
total_quantity: null as number | null,
gross_weight: null as number | null,
net_weight: null as number | null,

View File

@@ -266,7 +266,7 @@
<div>
<p class="text-sm font-medium text-muted-foreground">Factor IVA</p>
<p class="text-base">{invoice.financials.iva_factor || '-'}</p>
<p class="text-base">{formatCurrency(invoice.financials.iva_factor)}</p>
</div>
<div>
@@ -296,59 +296,57 @@
<!-- Logistics Tab -->
<Tabs.Content value="logistics" class="space-y-4">
{#if invoice.logistics && invoice.logistics.length > 0}
<div class="space-y-6">
{#each invoice.logistics as logistics, index}
<div class="border rounded-lg p-4">
<h4 class="font-semibold mb-3">Logística #{index + 1}</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Transportista</p>
<p class="text-base">{logistics.carrier_id || '-'}</p>
</div>
{#if invoice.logistics}
<div class="space-y-6">
<div class="border rounded-lg p-4">
<h4 class="font-semibold mb-3">Logística</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Transportista</p>
<p class="text-base">{invoice.logistics.carrier_id || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Transporte</p>
<p class="text-base">{logistics.transport_type || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Transporte</p>
<p class="text-base">{invoice.logistics.transport_type || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Modo de Transporte</p>
<p class="text-base">{logistics.transport_mode || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Modo de Transporte</p>
<p class="text-base">{invoice.logistics.transport_mode || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Conductor</p>
<p class="text-base">{logistics.driver_name || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Conductor</p>
<p class="text-base">{invoice.logistics.driver_name || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Vehículo</p>
<p class="text-base">{logistics.vehicle_num || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Vehículo</p>
<p class="text-base">{invoice.logistics.vehicle_num || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Placa</p>
<p class="text-base">{logistics.license_plate || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Placa</p>
<p class="text-base">{invoice.logistics.license_plate || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Sello</p>
<p class="text-base">{logistics.seal_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Sello</p>
<p class="text-base">{invoice.logistics.seal_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Guía</p>
<p class="text-base">{logistics.guide_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Guía</p>
<p class="text-base">{invoice.logistics.guide_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Fecha Entrada/Salida</p>
<p class="text-base">{formatDate(logistics.entry_exit_date)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Fecha Entrada/Salida</p>
<p class="text-base">{formatDate(invoice.logistics.entry_exit_date)}</p>
</div>
</div>
{/each}
</div>
</div>
{:else}
<p class="text-muted-foreground">No hay información de logística disponible.</p>

View File

@@ -238,7 +238,7 @@ function buildFinancialsData(generalFormData: any, observationFormData: any, oth
currency_type: generalFormData?.currency_type || (generalFormData?.currency === 'foreign' ? 'USD' : null),
currency: generalFormData?.currency || null,
exchange_rate: generalFormData?.exchange_rate ? Number(generalFormData.exchange_rate) : null,
iva_factor: (InvoiceTopFieldsFormData?.iva_factor || generalFormData?.iva_factor) ? String(InvoiceTopFieldsFormData?.iva_factor || generalFormData.iva_factor) : null,
iva_factor: (InvoiceTopFieldsFormData?.iva_factor || generalFormData?.iva_factor) ? Number(InvoiceTopFieldsFormData?.iva_factor || generalFormData.iva_factor) : null,
// Costs & increments from observationFormData
freight: observationFormData?.freight || null,
insurance: observationFormData?.insurance || null,