Resolviendo conflictos

This commit is contained in:
2026-04-16 12:00:52 -05:00
123 changed files with 5482 additions and 1589 deletions

View File

@@ -0,0 +1,12 @@
"""Errores de validación alineados a reglas CSV / catálogos (HTTP 422)."""
from typing import Any, Dict, List
class CatalogValidationError(Exception):
"""Lista de errores tipo {line, col, msg} como en import CSV."""
def __init__(self, errors: List[Dict[str, Any]]):
self.errors = errors or []
first = self.errors[0].get("msg", "Validación de catálogo") if self.errors else "Validación de catálogo"
super().__init__(first)

View File

@@ -5,6 +5,8 @@ import inspect
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
from api.v1.common.catalog_validation_errors import CatalogValidationError
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -376,6 +378,14 @@ class TenantCRUDRoutes(
try:
resource = self.service.create(db, data, tenant_id, company_id)
return resource
except CatalogValidationError as e:
raise HTTPException(
status_code=422,
detail={
"message": str(e),
"errors": e.errors,
},
)
except ValueError as e:
# Capturar errores de validación (como duplicados)
raise HTTPException(status_code=400, detail=str(e))
@@ -412,6 +422,14 @@ class TenantCRUDRoutes(
try:
resource = self.service.create(db, data, tenant_id, company_id)
return resource
except CatalogValidationError as e:
raise HTTPException(
status_code=422,
detail={
"message": str(e),
"errors": e.errors,
},
)
except ValueError as e:
# Capturar errores de validación (como duplicados)
raise HTTPException(status_code=400, detail=str(e))
@@ -454,6 +472,14 @@ class TenantCRUDRoutes(
resource = self.service.update(
db, parent_id, tenant_id, data, company_id
)
except CatalogValidationError as e:
raise HTTPException(
status_code=422,
detail={
"message": str(e),
"errors": e.errors,
},
)
except ValueError as e:
# Capturar errores de validación (como duplicados)
raise HTTPException(status_code=400, detail=str(e))
@@ -498,6 +524,14 @@ class TenantCRUDRoutes(
resource = self.service.update(
db, resource_id, tenant_id, data, company_id
)
except CatalogValidationError as e:
raise HTTPException(
status_code=422,
detail={
"message": str(e),
"errors": e.errors,
},
)
except ValueError as e:
# Capturar errores de validación (como duplicados)
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -0,0 +1,151 @@
# Anexo76 — Documentación del Módulo de Facturas
> **Sistema:** SCAII · Aduanasoft
> **Versión:** 1.0 · Marzo 2026
> **Módulos documentados:** `imports/` · `exports/`
---
## ¿Qué es este módulo?
El módulo de facturas gestiona el ciclo completo de las operaciones aduaneras de una empresa maquiladora: la **entrada** de materiales al país (importaciones) y la **salida** (exportaciones). Ambos módulos están conectados a través de un ledger de inventario compartido — las importaciones crean saldos, las exportaciones los consumen.
---
## Estructura de la documentación
```
docs/
├── README.md ← estás aquí
├── exports/
│ ├── exports_cu_comprensibles.md ← casos de uso
│ └── exports_cu_diagramas.md ← diagramas de flujo Mermaid
└── imports/
├── imports_cu_comprensibles.md ← casos de uso
└── imports_cu_diagramas.md ← diagramas de flujo Mermaid
```
---
## Módulo de Exportaciones
Gestiona la salida de materiales del país. Cada tipo de factura representa un escenario aduanero diferente.
### Casos de uso
| ID | Caso de uso | Descripción breve | Diagrama |
|----|-------------|-------------------|:--------:|
| [CU-EXP-001](./exports/exports_cu_comprensibles.md#cu-exp-001--procesar-una-factura-nodes) | Procesar NODES | Exportación sin descarga de inventario | [→](./exports/exports_cu_diagramas.md#cu-exp-001--procesar-nodes) |
| [CU-EXP-002](./exports/exports_cu_comprensibles.md#cu-exp-002--procesar-una-factura-donac) | Procesar DONAC | Donación al extranjero con descarga directa | [→](./exports/exports_cu_diagramas.md#cu-exp-002--procesar-con-descarga-de-inventario-donac) |
| [CU-EXP-003](./exports/exports_cu_comprensibles.md#cu-exp-003--procesar-una-factura-afijo) | Procesar AFIJO | Exportación con descarga e IMD opcional | [→](./exports/exports_cu_diagramas.md#cu-exp-003--procesar-afijo-con-cambio-de-régimen) |
| [CU-EXP-004](./exports/exports_cu_comprensibles.md#cu-exp-004--procesar-una-factura-scrap) | Procesar SCRAP | Exportación de desperdicio con descarga | [→](./exports/exports_cu_diagramas.md#cu-exp-004--procesar-scrap) |
| [CU-EXP-005](./exports/exports_cu_comprensibles.md#cu-exp-005--procesar-una-factura-reexp) | Procesar REEXP | Reexportación desde importaciones definitivas | [→](./exports/exports_cu_diagramas.md#cu-exp-005--procesar-reexp) |
| [CU-EXP-006](./exports/exports_cu_comprensibles.md#cu-exp-006--procesar-una-factura-vemex) | Procesar VEMEX | Venta al extranjero desde régimen especial | [→](./exports/exports_cu_diagramas.md#cu-exp-006--procesar-vemex) |
| [CU-EXP-007](./exports/exports_cu_comprensibles.md#cu-exp-007--revertir-una-factura-nodes) | Revertir NODES | Deshacer sin efectos en inventario | [→](./exports/exports_cu_diagramas.md#cu-exp-007--revertir-nodes) |
| [CU-EXP-008](./exports/exports_cu_comprensibles.md#cu-exp-008--revertir-una-factura-con-descarga-afijo-donac-scrap-reexp-vemex) | Revertir con descarga | Devolver saldos y cancelar registros del ledger | [→](./exports/exports_cu_diagramas.md#cu-exp-008--revertir-con-descarga-afijo-donac-scrap-reexp-vemex) |
| [CU-EXP-009](./exports/exports_cu_comprensibles.md#cu-exp-009--reversión-bloqueada-por-exportaciones-activas) | Reversión bloqueada por exportación | Bloqueo cuando otra exportación usa los saldos | [→](./exports/exports_cu_diagramas.md#cu-exp-009--reversión-bloqueada-por-exportaciones-activas) |
| [CU-EXP-010](./exports/exports_cu_comprensibles.md#cu-exp-010--reversión-bloqueada-por-importación-definitiva-existente) | Reversión bloqueada por IMD | Bloqueo cuando existe una IMD generada | [→](./exports/exports_cu_diagramas.md#cu-exp-010--reversión-bloqueada-por-importación-definitiva-existente) |
### Resumen rápido por tipo
| Tipo | Descarga inventario | Genera IMD | Requiere procedencia |
|------|:-------------------:|:----------:|:--------------------:|
| NODES | No | No | — |
| DONAC | Sí | No | — |
| AFIJO | Sí | Solo con cambio de régimen | TEM (si CR) |
| SCRAP | Sí | Solo con cambio de régimen | TEM (si CR) |
| REEXP | Sí | No | DEF obligatoria |
| VEMEX | Sí | No | DEF obligatoria |
---
## Módulo de Importaciones
Gestiona la entrada de materiales al país. Las importaciones temporales (TEM) son la base del inventario que las exportaciones consumen.
### Casos de uso
| ID | Caso de uso | Descripción breve | Diagrama |
|----|-------------|-------------------|:--------:|
| [CU-IMP-001](./imports/imports_cu_comprensibles.md#cu-imp-001--procesar-una-factura-tem-importación-temporal) | Procesar TEM | Entrada temporal, genera saldos de inventario | [→](./imports/imports_cu_diagramas.md#cu-imp-001--procesar-tem-importación-temporal) |
| [CU-IMP-002](./imports/imports_cu_comprensibles.md#cu-imp-002--procesar-una-factura-def-importación-definitiva) | Procesar DEF | Entrada definitiva con IVA, sin saldos | [→](./imports/imports_cu_diagramas.md#cu-imp-002--procesar-def-importación-definitiva) |
| [CU-IMP-003](./imports/imports_cu_comprensibles.md#cu-imp-003--procesar-una-factura-mex-compra-mexicana) | Procesar MEX | Compra nacional con IVA, sin saldos | [→](./imports/imports_cu_diagramas.md#cu-imp-003--procesar-mex-compra-mexicana) |
| [CU-IMP-004](./imports/imports_cu_comprensibles.md#cu-imp-004--procesar-una-factura-tem-con-regla-octava-prosec) | Procesar TEM + PROSEC | TEM con permisos de Regla Octava | [→](./imports/imports_cu_diagramas.md#cu-imp-004--procesar-tem-con-regla-octava-prosec) |
| [CU-IMP-005](./imports/imports_cu_comprensibles.md#cu-imp-005--revertir-una-factura-tem) | Revertir TEM | Anular saldos, restaurar cupos PROSEC | [→](./imports/imports_cu_diagramas.md#cu-imp-005--revertir-tem) |
| [CU-IMP-006](./imports/imports_cu_comprensibles.md#cu-imp-006--revertir-una-factura-def-o-mex) | Revertir DEF o MEX | Deshacer sin efectos en inventario | [→](./imports/imports_cu_diagramas.md#cu-imp-006--revertir-def-o-mex) |
| [CU-IMP-007](./imports/imports_cu_comprensibles.md#cu-imp-007--reversión-bloqueada-por-exportaciones-activas) | Reversión bloqueada | Bloqueo cuando exportaciones usan los saldos | [→](./imports/imports_cu_diagramas.md#cu-imp-007--reversión-bloqueada-por-exportaciones-activas) |
### Resumen rápido por tipo
| Tipo | Genera saldo inventario | Calcula IVA | Regla Octava |
|------|:-----------------------:|:-----------:|:------------:|
| TEM | Sí | No | Opcional |
| DEF | No | Sí | No |
| MEX | No | Sí | No |
---
## Cómo se conectan importaciones y exportaciones
```
Importación TEM procesada
│ crea movimientos ENTRADA en el ledger
│ (un registro por partida principal)
Ledger de inventario (a24.balance_movement)
│ las exportaciones consultan saldos disponibles
│ usando criterio PEPS — más antiguo primero
Exportación procesa (DONAC, AFIJO, SCRAP, REEXP, VEMEX)
│ inserta movimientos CONSUMO
│ reduce el saldo disponible
Si se revierte la exportación → inserta RETORNO
Si se revierte la importación → inserta ANULACIÓN DE ENTRADA
El ledger nunca se borra — solo se agregan registros
```
**Regla de orden para reversiones:**
Siempre deben revertirse primero las exportaciones y luego la importación. El sistema lo garantiza mediante bloqueos automáticos.
---
## Estados de una factura
```
PENDIENTE ──[Procesar]──▶ PROCESADA ──[Revertir]──▶ REVERTIDA
│ si hay bloqueos activos
BLOQUEADA (no es un estado real,
el sistema rechaza la operación
con un mensaje de error)
```
---
## Glosario
| Término | Significado |
|---------|-------------|
| **TEM** | Importación Temporal — material que entra para ser procesado y reexportado |
| **DEF** | Importación Definitiva — material que entra pagando impuestos completos |
| **MEX** | Compra a proveedor mexicano |
| **IMD** | Importación Definitiva generada automáticamente por un cambio de régimen |
| **NODES** | Exportación sin descarga de saldos (No Descarga) |
| **AFIJO** | Exportación de material transformado, con descarga de TEM |
| **DONAC** | Donación al extranjero con descarga de inventario |
| **SCRAP** | Exportación de desperdicio o chatarra |
| **REEXP** | Reexportación de material importado definitivamente |
| **VEMEX** | Venta al extranjero desde régimen especial (IMMEX / zona franca) |
| **PEPS** | Primero en Entrar, Primero en Salir — criterio de consumo de inventario |
| **PROSEC** | Programa de Promoción Sectorial — beneficio arancelario para maquiladoras |
| **Regla Octava** | Mecanismo de la Ley Aduanera que permite importar con arancel preferencial bajo un permiso PROSEC |
| **Ledger a24** | Registro histórico de todos los movimientos de inventario — solo escritura, nunca se borra |
| **Cambio de régimen** | Conversión de material temporal a definitivo, requiere generación de IMD |

View File

@@ -0,0 +1,379 @@
# Casos de Uso — Módulo de Exportaciones
**Sistema:** Anexo76 · SCAII
**Módulo:** `exports/`
**Versión:** 1.0 · Marzo 2026
---
## Contexto general
El módulo de exportaciones gestiona el ciclo completo de una factura de exportación dentro del sistema: desde que el usuario la captura hasta que queda registrada como procesada ante la aduana. También permite revertirla si hubo un error.
Toda factura de exportación pasa por uno de dos momentos: **procesarla** (actualizarla) o **revertirla** (desactualizarla). El sistema hace cosas muy distintas dependiendo del **tipo de factura**, porque cada tipo representa un escenario aduanero diferente con reglas propias.
---
## CU-EXP-001 · Procesar una factura NODES
### Descripción
El usuario procesa una factura de exportación de tipo **NODES**. Este tipo representa mercancía que sale del país pero que **no está vinculada a ninguna importación temporal** registrada en el sistema — puede ser producción propia, material adquirido localmente o cualquier salida sin historial de entrada temporal.
### Por qué existe
No toda la mercancía que exporta una maquiladora fue importada temporalmente. Las empresas también exportan lo que fabrican localmente. El sistema necesita registrar esa salida y calcular su valor en pesos y dólares sin buscar ningún saldo previo en el inventario.
### Quién lo usa
El usuario de captura de exportaciones, desde la pantalla de facturas de exportación, al presionar **"Actualizar Factura"**.
### Qué necesita estar listo antes
- La factura debe estar en estado **Pendiente** — si ya fue procesada, el sistema la rechaza.
- Debe tener al menos una partida capturada.
- Todos los campos del encabezado deben estar completos: fecha, proveedor, destinatario, tipo de cambio, moneda, agente aduanal y pedimento.
- El tipo de cambio del día debe estar registrado en el catálogo.
- Las clases y fracciones arancelarias de cada partida deben existir en el catálogo oficial.
- Cada partida principal debe tener un costo unitario mayor a cero.
### Qué hace el sistema
Primero valida que todos los campos obligatorios del encabezado estén correctos y que las partidas existan. Luego marca explícitamente todas las partidas como "sin descarga" para que el inventario de importaciones temporales no se vea afectado. Valida que cada fracción arancelaria esté vigente, que el tipo de cambio coincida con el catálogo y que ninguna partida tenga costo en cero.
Después calcula los valores monetarios de cada partida multiplicando el costo unitario por la cantidad y el tipo de cambio del día, obteniendo el valor en pesos, en dólares y en moneda de cuenta. Suma todos esos valores para obtener los totales de la factura.
Finalmente guarda los totales calculados y marca la factura como **Procesada**.
### Qué queda guardado
La factura queda en estado **Procesada** con sus valores monetarios calculados. El inventario de importaciones temporales no se modifica — NODES no descarga ni registra ningún movimiento en el ledger de saldos.
### Qué puede fallar
| Situación | Qué hace el sistema |
|-----------|---------------------|
| La factura ya estaba procesada | La rechaza de inmediato sin ejecutar ningún paso más |
| El tipo de cambio capturado no coincide con el catálogo | Muestra el error y detiene el proceso |
| Una fracción arancelaria no existe en el catálogo oficial | Reporta qué partida tiene la fracción inválida |
| Una clase arancelaria está desactivada | Reporta qué partida tiene la clase desactivada |
| Una partida tiene costo unitario en cero | Indica qué partida no tiene precio capturado |
| Una partida tiene series activadas pero no hay series registradas | Indica qué partida le faltan series |
---
## CU-EXP-002 · Procesar una factura DONAC
### Descripción
El usuario procesa una factura de exportación de tipo **DONAC**. Este tipo representa una **donación al extranjero** — la mercancía sale del país y descarga directamente el inventario de importaciones temporales, sin ningún trámite adicional de cambio de régimen.
### Por qué existe
Las empresas maquiladoras a veces donan material a organizaciones en el extranjero. La donación sigue siendo una salida de inventario: el material importado temporalmente sale del país y el sistema debe registrar que ese saldo ya no está disponible. DONAC es la vía más directa de hacerlo.
### Quién lo usa
El usuario de captura de exportaciones al procesar una factura marcada como tipo DONAC.
### Qué necesita estar listo antes
Lo mismo que NODES, más lo siguiente:
- Cada partida que se va a descargar debe tener indicada su **factura de importación origen** (la TEM o DEF de la que proviene el material).
- Esa factura de importación debe estar procesada y tener una fecha anterior a la exportación.
- El saldo disponible en esa importación debe ser suficiente para cubrir la cantidad que se quiere exportar.
- La unidad de medida del material debe coincidir entre la importación y la exportación.
### Qué hace el sistema
Ejecuta las mismas validaciones que NODES (encabezado, clases, fracciones, tipo de cambio, costo unitario) pero además realiza el **descargo de inventario**.
Para el descargo, el sistema construye una lista de todo lo que se quiere exportar y busca de qué lotes de importación proviene cada material. Aplica el criterio **PEPS** (Primero en Entrar, Primero en Salir): si hay varios lotes del mismo material de distintas fechas, consume primero el más antiguo. Verifica que el saldo alcance y distribuye las cantidades entre los lotes disponibles.
Si todo está en orden, registra en el ledger de inventario que esos lotes fueron consumidos, actualiza los valores retornados en las partidas de importación origen y marca la factura como **Procesada**.
### Qué queda guardado
La factura queda **Procesada**. En el ledger de saldos quedan registrados movimientos de tipo **Consumo** por cada lote descargado. Las partidas de importación origen reflejan cuánto valor ya fue exportado.
### Qué puede fallar
| Situación | Qué hace el sistema |
|-----------|---------------------|
| La factura de importación origen no está procesada | Indica qué importación debe procesarse primero |
| La fecha de la importación es posterior a la exportación | Es un error de datos — no se puede exportar antes de importar |
| No hay saldo suficiente en la importación origen | Muestra exactamente cuánto falta por partida |
| La unidad de medida no coincide entre importación y exportación | Indica qué partidas tienen incompatibilidad de unidades |
---
## CU-EXP-003 · Procesar una factura AFIJO
### Descripción
El usuario procesa una factura de exportación de tipo **AFIJO**. Este tipo puede tener dos variantes: **con cambio de régimen** o **sin cambio de régimen**.
En ambos casos se descarga el inventario de importaciones temporales. La diferencia es que cuando hay cambio de régimen, el sistema también genera automáticamente una **Importación Definitiva (IMD)** que ampara la conversión del material.
### Por qué existe
AFIJO es uno de los tipos más comunes en operaciones maquiladoras. Ocurre cuando la empresa exporta material que procesó a partir de insumos importados temporalmente. El "afijo" hace referencia a que el material fue transformado o ensamblado dentro del país antes de salir.
Cuando además hay **cambio de régimen**, significa que una parte del material importado temporalmente no será exportado sino que se quedará en México de forma permanente — por eso el sistema genera la IMD que formaliza ese cambio ante la aduana.
### Quién lo usa
El usuario de captura de exportaciones. Antes de procesar, el usuario ya debe haber indicado en la factura si aplica cambio de régimen (`is_regime_change = True`).
### Qué necesita estar listo antes
Lo mismo que DONAC. Si aplica cambio de régimen, adicionalmente:
- Todas las partidas deben venir de importaciones de tipo **Temporal (TEM)** — si alguna viene de una Definitiva, el sistema lo rechaza.
- No debe existir ya una Importación Definitiva generada anteriormente para esta misma factura (el sistema lo verifica para evitar duplicados).
### Qué hace el sistema
**Sin cambio de régimen:** igual que DONAC — descarga el inventario directamente.
**Con cambio de régimen:** antes del descargo, verifica que todas las partidas tengan procedencia TEM. Luego genera automáticamente una nueva factura de Importación Definitiva con los mismos datos del encabezado y una copia de todas las partidas. Esta IMD queda en estado Pendiente para que el usuario la asocie después a su pedimento. Después ejecuta el descargo de inventario normalmente.
### Qué queda guardado
La factura de exportación queda **Procesada**. Si hubo cambio de régimen, existe una nueva factura IMD en estado **Pendiente** lista para ser tramitada. Los movimientos de consumo quedan registrados en el ledger.
### Qué puede fallar
Todo lo que puede fallar en DONAC, más:
| Situación | Qué hace el sistema |
|-----------|---------------------|
| Una partida tiene procedencia DEF en lugar de TEM (solo en cambio de régimen) | Indica qué partidas tienen la procedencia incorrecta |
| Ya existe una IMD generada para esta factura | El sistema la reutiliza sin crear un duplicado — no es un error |
---
## CU-EXP-004 · Procesar una factura SCRAP
### Descripción
El usuario procesa una factura de exportación de tipo **SCRAP**. Representa la salida de material que se exporta como **desperdicio, chatarra o rezago** del proceso productivo.
Su comportamiento es idéntico al de AFIJO: descarga el inventario y, si aplica, genera una Importación Definitiva por cambio de régimen. La diferencia es conceptual — no técnica: el material no fue transformado ni exportado como producto terminado, sino como subproducto o material sobrante.
### Por qué existe
El rezago y la chatarra son una realidad del proceso productivo maquilador. La regulación aduanera mexicana exige que estos materiales — aunque no sean el producto final — también sean reportados al salir del país. SCRAP les da un tratamiento específico diferenciado del producto terminado.
### Quién lo usa
El usuario de captura de exportaciones para facturas de desperdicios o rezagos.
### Qué necesita estar listo antes
Idéntico a AFIJO.
### Qué hace el sistema
Exactamente igual que AFIJO: valida, descarga inventario y, si hay cambio de régimen, genera la IMD correspondiente.
### Qué puede fallar
Idéntico a AFIJO.
---
## CU-EXP-005 · Procesar una factura REEXP
### Descripción
El usuario procesa una factura de exportación de tipo **REEXP**. Representa la **reexportación** de material que en su momento entró al país como **Importación Definitiva (DEF)** — es decir, material que ya pagó impuestos al entrar y ahora se exporta nuevamente.
### Por qué existe
A veces las empresas importan material definitivamente, lo procesan y luego lo exportan. Como ese material entró de forma definitiva (no temporal), no aplica el régimen de maquila temporal — tiene su propio tratamiento. REEXP permite registrar esa salida correctamente, indicando que la procedencia es DEF y no TEM.
### Quién lo usa
El usuario de captura de exportaciones cuando el material a exportar proviene de importaciones definitivas.
### Qué necesita estar listo antes
Lo mismo que DONAC, con una diferencia clave: **todas las partidas deben tener procedencia DEF**. Si alguna viene de una importación temporal, el sistema la rechaza.
### Qué hace el sistema
Antes de iniciar el descargo, verifica que todas las partidas tengan procedencia DEF. Si alguna tiene TEM u otro tipo, detiene el proceso e indica cuál es la partida con el problema. Después ejecuta el descargo de inventario igual que DONAC.
### Qué queda guardado
Igual que DONAC — la factura queda Procesada y los movimientos de consumo quedan en el ledger.
### Qué puede fallar
Todo lo que puede fallar en DONAC, más:
| Situación | Qué hace el sistema |
|-----------|---------------------|
| Una partida tiene procedencia TEM en lugar de DEF | Indica qué partidas tienen la procedencia incorrecta |
---
## CU-EXP-006 · Procesar una factura VEMEX
### Descripción
El usuario procesa una factura de exportación de tipo **VEMEX**. Representa una **venta al extranjero** bajo un régimen aduanero especial — típicamente empresas en zonas francas o con programas IMMEX que realizan ventas virtuales al exterior.
### Por qué existe
VEMEX tiene un tratamiento similar a REEXP: el material que se "exporta" proviene de importaciones definitivas. La distinción respecto a REEXP es de naturaleza fiscal y operativa — en VEMEX la empresa tiene un régimen especial reconocido por la aduana que le permite registrar estas operaciones de forma diferente. El sistema los separa para respetar esa distinción documental.
### Quién lo usa
Empresas con régimen IMMEX o en zonas francas que realizan ventas al extranjero desde México.
### Qué necesita estar listo antes
Igual que REEXP. A diferencia de otros tipos, VEMEX no requiere `document_type` ni `customs_broker_id` — son opcionales en este tipo de operación porque el trámite aduanero tiene características distintas.
### Qué hace el sistema
Idéntico a REEXP: verifica que todas las partidas tengan procedencia DEF y ejecuta el descargo de inventario.
### Qué puede fallar
Idéntico a REEXP.
---
## CU-EXP-007 · Revertir una factura NODES
### Descripción
El usuario deshace el procesamiento de una factura NODES — generalmente porque hubo un error de captura o necesita modificar los datos antes de reenviarla.
### Por qué existe
Un sistema de control aduanero no puede simplemente borrar registros procesados. La reversión permite corregir errores de forma controlada: el sistema regresa la factura a su estado anterior sin eliminar el historial.
### Qué hace el sistema
Limpia todos los valores calculados (totales de la factura, valores por partida) y regresa el estado a **Revertida**. Como NODES nunca tocó el inventario, no hay ningún saldo que devolver — es la reversión más simple del módulo.
### Qué necesita estar listo antes
La factura debe estar en estado **Procesada**. Si no lo está, el sistema rechaza la reversión.
### Qué queda guardado
La factura queda en estado **Revertida**, con los totales en cero, lista para ser corregida y reprocesada.
### Qué puede fallar
| Situación | Qué hace el sistema |
|-----------|---------------------|
| La factura no estaba procesada | La rechaza con un mensaje indicando que no puede revertirse |
---
## CU-EXP-008 · Revertir una factura con descarga (AFIJO, DONAC, SCRAP, REEXP, VEMEX)
### Descripción
El usuario deshace el procesamiento de una factura que sí descargó inventario. Es la reversión más compleja del módulo porque hay que deshacer múltiples efectos: los saldos descargados, las series marcadas y los registros en el ledger de inventario.
### Por qué existe
Cuando una exportación descargó saldos de importaciones temporales, simplemente cambiar el estado de la factura no es suficiente. Hay que devolver formalmente ese inventario para que pueda ser utilizado por otras exportaciones futuras. El sistema lleva un registro histórico de todos los movimientos (no se borra nada) — la reversión se registra como un nuevo movimiento que neutraliza el efecto del original.
### Qué hace el sistema paso a paso
Primero verifica que ninguna de las partidas de esta importación esté siendo descargada activamente por otra exportación procesada. Si las hay, bloquea la reversión completamente — ver CU-EXP-009.
Si no hay bloqueos, ejecuta cuatro acciones en orden:
**1. Devuelve los valores a las importaciones origen.** Resta de cada partida de importación el valor que fue descargado al procesar. Si el material era temporal y la importación fue posterior al 31 de diciembre de 2014, también recalcula el IVA utilizado.
**2. Desmarca las series.** Al procesar, las series de las partidas de importación quedaron marcadas como "ya exportadas". La reversión las desmarca para que vuelvan a estar disponibles.
**3. Cancela los registros de descarga en el ledger.** El ledger de inventario nunca se modifica ni se borra. En su lugar, el sistema inserta nuevos movimientos de tipo **Retorno** que compensan exactamente los **Consumos** que se generaron al procesar. El saldo neto vuelve a ser el original.
**4. Regresa la factura a Revertida.** Limpia los totales y cambia el estado.
### Qué queda guardado
La factura queda en estado **Revertida**. En el ledger quedan los movimientos originales de Consumo más los nuevos movimientos de Retorno que los neutralizan — el historial queda completo y auditable. Los saldos de las importaciones origen quedan como si la exportación nunca hubiera ocurrido.
### Qué puede fallar
| Situación | Qué hace el sistema |
|-----------|---------------------|
| Una partida está siendo descargada por otra exportación activa | Bloquea la reversión — ver CU-EXP-009 |
| La factura no estaba procesada | La rechaza indicando que no puede revertirse |
---
## CU-EXP-009 · Reversión bloqueada por exportaciones activas
### Descripción
El usuario intenta revertir una factura de exportación con descarga, pero el sistema detecta que alguna de las partidas de importación origen **está siendo consumida actualmente por otra exportación procesada**. El sistema bloquea completamente la reversión.
### Por qué existe este bloqueo
Los saldos de inventario son compartidos entre exportaciones. Si se permitiera revertir una importación mientras otra exportación sigue activa sobre esos mismos saldos, el inventario quedaría en un estado inconsistente — con más saldo del que debería haber. El bloqueo garantiza que siempre se deshagan las exportaciones en orden inverso al que fueron procesadas.
### Qué hace el sistema
Recorre cada partida de la importación origen. Por cada una, busca si existe algún registro de descarga activo (con estado **Aplicado**) vinculado a una factura de exportación que sigue procesada. Si encuentra aunque sea uno, detiene todo el proceso y muestra un mensaje por cada exportación activa que está usando esos saldos, indicando exactamente qué factura y qué línea están involucradas.
### Cómo lo resuelve el usuario
Debe ir a cada factura de exportación indicada en el error y revertirla primero. Una vez que todas las exportaciones que consumían esos saldos estén revertidas, puede revertir la importación sin problemas.
### Qué queda guardado
Nada — el sistema no modifica ningún dato cuando bloquea la reversión. Todo queda exactamente igual que antes de intentar la operación.
---
## CU-EXP-010 · Reversión bloqueada por Importación Definitiva existente
### Descripción
El usuario intenta revertir una factura de exportación AFIJO o SCRAP con cambio de régimen, pero el sistema detecta que **ya existe una Importación Definitiva (IMD) generada** a partir de esa exportación. El sistema bloquea la reversión.
### Por qué existe este bloqueo
La Importación Definitiva es un documento fiscal independiente con validez propia ante la aduana. Si se revierte la exportación sin eliminar primero la IMD, ese documento queda "flotando" sin ningún respaldo — una exportación que ya no existe lo generó. Eso crea una inconsistencia en los registros fiscales.
### Qué hace el sistema
Busca si existe en la base de datos una factura de tipo IMD cuyo número de factura coincida con el de la exportación que se quiere revertir. Si la encuentra, bloquea la reversión y le indica al usuario exactamente cuál es la IMD que debe eliminar primero, con su número de referencia.
### Cómo lo resuelve el usuario
Debe desactualizar y eliminar la Importación Definitiva indicada. Después puede revertir la exportación sin problema.
### Qué queda guardado
Nada — el sistema no modifica ningún dato cuando bloquea la reversión.
---
## Resumen de los diez casos
| # | Caso de uso | Tipo | Descarga inventario | Genera IMD |
|---|-------------|------|:-------------------:|:----------:|
| CU-EXP-001 | Procesar NODES | Proceso | No | No |
| CU-EXP-002 | Procesar DONAC | Proceso | Sí | No |
| CU-EXP-003 | Procesar AFIJO | Proceso | Sí | Solo si hay cambio de régimen |
| CU-EXP-004 | Procesar SCRAP | Proceso | Sí | Solo si hay cambio de régimen |
| CU-EXP-005 | Procesar REEXP | Proceso | Sí (solo desde DEF) | No |
| CU-EXP-006 | Procesar VEMEX | Proceso | Sí (solo desde DEF) | No |
| CU-EXP-007 | Revertir NODES | Reversión | No aplica | No |
| CU-EXP-008 | Revertir con descarga | Reversión | Devuelve saldos | No |
| CU-EXP-009 | Reversión bloqueada por exportación activa | Bloqueo | No (bloqueado) | No |
| CU-EXP-010 | Reversión bloqueada por IMD existente | Bloqueo | No (bloqueado) | No |

View File

@@ -0,0 +1,269 @@
# Diagramas de Flujo — Casos de Uso Exportaciones
> Cada diagrama corresponde a un caso de uso en [`exports_cu_comprensibles.md`](./exports_cu_comprensibles.md).
> Formato: Mermaid — compatible con Notion, GitHub y VSCode.
> **Cómo usar en Notion:** bloque `/code` → lenguaje `mermaid` → pegar el contenido.
---
## CU-EXP-001 · Procesar NODES
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura]) --> CHK1{¿La factura\nestá Pendiente?}
CHK1 -- No --> E1([❌ Error\nYa fue procesada])
CHK1 -- Sí --> V1[Validar encabezado\nfecha · proveedor · destinatario\ntipo de cambio · pedimento]
V1 --> V2{¿Hay errores\nen el encabezado?}
V2 -- Sí --> E2([❌ Error\nCampo obligatorio faltante])
V2 -- No --> V3[Verificar clases y fracciones\narancelarias en catálogo oficial]
V3 --> V4{¿Alguna clase\no fracción inválida?}
V4 -- Sí --> E3([❌ Error\nClase o fracción no existe])
V4 -- No --> V5[Validar tipo de cambio\ncontra el catálogo del día]
V5 --> V6{¿TC de la factura\n== TC del catálogo?}
V6 -- No --> E4([❌ Error\nTipo de cambio incorrecto])
V6 -- Sí --> V7[Marcar todas las partidas\ncomo sin descarga]
V7 --> V8[Calcular valores por partida\nCosto × Cantidad × TC\nen pesos · dólares · moneda cuenta]
V8 --> V9[Validar costo unitario · series · pesos]
V9 --> V10{¿Hay errores\nen partidas?}
V10 -- Sí --> E5([❌ Error\nCosto en cero o series faltantes])
V10 -- No --> V11[Calcular totales de la factura\ncantidad · peso · valor MN · ME]
V11 --> FIN([✅ Factura PROCESADA\nSin cambios en inventario])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style E2 fill:#C55A11,color:#fff,rx:16
style E3 fill:#C55A11,color:#fff,rx:16
style E4 fill:#C55A11,color:#fff,rx:16
style E5 fill:#C55A11,color:#fff,rx:16
```
---
## CU-EXP-002 · Procesar con descarga de inventario (DONAC)
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - DONAC]) --> VALID[Validaciones de encabezado\nclases · fracciones · TC · costos]
VALID --> ERR{¿Errores de\nvalidación?}
ERR -- Sí --> E1([❌ Error\nCorregir antes de continuar])
ERR -- No --> DC1[Construir lista de descarga\n¿Qué se quiere exportar\ny de qué importación viene?]
DC1 --> DC2[Buscar importaciones origen\nVerificar que estén procesadas\ny con fecha anterior a la exportación]
DC2 --> DC3{¿Importación origen\nválida y procesada?}
DC3 -- No --> E2([❌ Error\nImportación no encontrada\no no procesada])
DC3 -- Sí --> DC4[Calcular saldos disponibles\ncon criterio PEPS\nmás antiguo primero]
DC4 --> DC5{¿Hay saldo\nsuficiente?}
DC5 -- No --> E3([❌ Error\nSaldo insuficiente en\nla importación origen])
DC5 -- Sí --> DC6[Distribuir cantidades\nentre lotes disponibles]
DC6 --> DC7[Verificación final\n¿Todo lo que se quiere exportar\nquedó cubierto?]
DC7 --> DC8{¿Alguna partida\nsin cubrir?}
DC8 -- Sí --> E4([❌ Error\nSaldo insuficiente\nen verificación final])
DC8 -- No --> SAVE[Registrar la descarga\nen el ledger de inventario\nmovimiento CONSUMO por lote]
SAVE --> UPD[Actualizar valores retornados\nen las importaciones origen]
UPD --> FIN([✅ Factura PROCESADA\nSaldos de inventario reducidos])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style E2 fill:#C55A11,color:#fff,rx:16
style E3 fill:#C55A11,color:#fff,rx:16
style E4 fill:#C55A11,color:#fff,rx:16
```
---
## CU-EXP-003 · Procesar AFIJO con cambio de régimen
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - AFIJO]) --> CR{¿Tiene cambio\nde régimen?}
CR -- No --> DISC[Ir a descarga\nde inventario normal]
DISC --> FIN_DISC([Ver CU-EXP-002\nflujo de descarga])
CR -- Sí --> PROC[Verificar procedencia\nde todas las partidas]
PROC --> CHK{¿Todas las\npartidas son TEM?}
CHK -- No --> E1([❌ Error\nAlguna partida tiene\nprocedencia DEF u otra])
CHK -- Sí --> GEN{¿Se debe generar\nImportación Definitiva?}
GEN -- No --> DISC2[Ir a descarga normal]
GEN -- Sí --> IMD{¿Ya existe una IMD\npara esta factura?}
IMD -- Sí --> REUSE[Reutilizar la IMD existente\nno se crea duplicado]
IMD -- No --> CREATE[Generar nueva factura IMD\ncon los mismos datos del encabezado\ny copia de todas las partidas\nestado: Pendiente]
REUSE --> DISC3[Descarga de inventario\nver CU-EXP-002]
CREATE --> DISC3
DISC2 --> DISC3
DISC3 --> FIN([✅ Factura PROCESADA\nIMD generada en estado Pendiente\nSaldos de inventario reducidos])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style FIN_DISC fill:#2E75B6,color:#fff,rx:16
style DISC2 fill:#BDD7EE,rx:8
style DISC3 fill:#BDD7EE,rx:8
```
---
## CU-EXP-004 · Procesar SCRAP
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - SCRAP]) --> NOTE[Comportamiento idéntico a AFIJO\nSolo cambia la naturaleza del material:\nmaterial exportado como desperdicio o chatarra]
NOTE --> CR{¿Tiene cambio\nde régimen?}
CR -- No --> DISC[Descarga de inventario\nver CU-EXP-002]
CR -- Sí --> AFIJO[Flujo completo\nver CU-EXP-003\nVerifica TEM · Genera IMD · Descarga]
DISC --> FIN([✅ Factura PROCESADA])
AFIJO --> FIN
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style AFIJO fill:#BDD7EE,rx:8
style DISC fill:#BDD7EE,rx:8
```
---
## CU-EXP-005 · Procesar REEXP
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - REEXP]) --> PROC[Verificar procedencia\nde todas las partidas]
PROC --> CHK{¿Todas las\npartidas son DEF?}
CHK -- No --> E1([❌ Error\nAlguna partida tiene\nprocedencia TEM u otra\nREEXP requiere procedencia DEF])
CHK -- Sí --> DISC[Descarga de inventario\nver CU-EXP-002\nSaldos de importaciones DEF]
DISC --> FIN([✅ Factura PROCESADA\nSaldos de importaciones DEF reducidos])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style DISC fill:#BDD7EE,rx:8
```
---
## CU-EXP-006 · Procesar VEMEX
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - VEMEX]) --> NOTE[Empresa con régimen especial\nIMMEX o zona franca\nVenta virtual al extranjero]
NOTE --> PROC[Verificar procedencia\nde todas las partidas]
PROC --> CHK{¿Todas las\npartidas son DEF?}
CHK -- No --> E1([❌ Error\nVEMEX requiere procedencia DEF\nigual que REEXP])
CHK -- Sí --> DISC[Descarga de inventario\nver CU-EXP-002]
DISC --> FIN([✅ Factura PROCESADA\nNo requiere document_type\nni agente aduanal])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style DISC fill:#BDD7EE,rx:8
```
---
## CU-EXP-007 · Revertir NODES
```mermaid
flowchart TD
START([Usuario presiona\nDesactualizar Factura - NODES]) --> CHK{¿La factura\nestá Procesada?}
CHK -- No --> E1([❌ Error\nNo se puede revertir\nlo que no fue procesado])
CHK -- Sí --> CLEAN[Limpiar todos los valores calculados\ntotales MN · ME · cantidad · peso]
CLEAN --> STATUS[Regresar factura a estado\nRevertida]
STATUS --> FIN([✅ Factura REVERTIDA\nSin efectos en inventario\nNODES nunca tocó el ledger])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
```
---
## CU-EXP-008 · Revertir con descarga (AFIJO, DONAC, SCRAP, REEXP, VEMEX)
```mermaid
flowchart TD
START([Usuario presiona\nDesactualizar Factura]) --> CHK1{¿La factura\nestá Procesada?}
CHK1 -- No --> E1([❌ Error\nNo está procesada])
CHK1 -- Sí --> BLOCK[Verificar si alguna partida\nestá siendo usada por\nuna exportación activa]
BLOCK --> CHK2{¿Hay exportaciones\nactivas usando\nestos saldos?}
CHK2 -- Sí --> E2([❌ Bloqueado\nver CU-EXP-009\nRevertir exportaciones primero])
CHK2 -- No --> R1[1 · Devolver valores\na las importaciones origen\nrestar value_returned y recalcular IVA si aplica]
R1 --> R2[2 · Desmarcar series\nlas series de importación vuelven a estar\ndisponibles para futuras exportaciones]
R2 --> R3[3 · Cancelar registros de descarga\ninsertar movimientos RETORNO en el ledger\nel historial queda intacto]
R3 --> R4[4 · Regresar factura a Revertida\nlimpiar totales]
R4 --> FIN([✅ Factura REVERTIDA\nInventario restaurado\nHistorial completo y auditable])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style E2 fill:#C55A11,color:#fff,rx:16
```
---
## CU-EXP-009 · Reversión bloqueada por exportaciones activas
```mermaid
flowchart TD
START([Usuario intenta\nDesactualizar Factura]) --> SCAN[Recorrer cada partida\nde la importación origen]
SCAN --> FIND[Buscar registros de descarga\ncon estado APLICADO\nvinculados a exportaciones procesadas]
FIND --> CHK{¿Hay descargas\nactivas?}
CHK -- No --> OK([Reversión permitida\ncontinúa con CU-EXP-008])
CHK -- Sí --> MSG[Mostrar mensaje de error\npor cada exportación activa\nindicando número de factura y línea]
MSG --> BLOCK([❌ Reversión BLOQUEADA\nEl usuario debe revertir primero\ncada exportación indicada])
style OK fill:#1D6B3C,color:#fff,rx:16
style BLOCK fill:#C55A11,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
```
---
## CU-EXP-010 · Reversión bloqueada por Importación Definitiva existente
```mermaid
flowchart TD
START([Usuario intenta\nDesactualizar Factura AFIJO o SCRAP\ncon cambio de régimen]) --> SEARCH[Buscar en la base de datos\nuna factura IMD con el mismo\nnúmero que la exportación]
SEARCH --> CHK{¿Existe\nuna IMD vinculada?}
CHK -- No --> OK([Reversión permitida\ncontinúa con CU-EXP-008])
CHK -- Sí --> MSG[Mostrar mensaje de error\ncon el número de la IMD\nque debe eliminarse primero]
MSG --> BLOCK([❌ Reversión BLOQUEADA\nEliminar la IMD indicada\nluego intentar de nuevo])
style OK fill:#1D6B3C,color:#fff,rx:16
style BLOCK fill:#C55A11,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
```
---
## Visión general — todos los tipos de exportación
```mermaid
flowchart LR
subgraph PROCESAR ["⬆️ PROCESAR"]
NODES[NODES\nSin descarga]
DONAC[DONAC\nDescarga directa]
AFIJO[AFIJO\nDescarga + IMD opcional]
SCRAP[SCRAP\nDescarga + IMD opcional]
REEXP[REEXP\nProcedencia DEF]
VEMEX[VEMEX\nProcedencia DEF]
end
subgraph REVERTIR ["⬇️ REVERTIR"]
RNODES[Revertir NODES\nSolo limpia valores]
RDESC[Revertir con descarga\nDevuelve saldos + cancela ledger]
end
subgraph BLOQUEOS ["🚫 BLOQUEOS"]
BEXP[Bloqueada por\nexportación activa]
BIMD[Bloqueada por\nIMD existente]
end
NODES --> RNODES
DONAC & AFIJO & SCRAP & REEXP & VEMEX --> RDESC
RDESC --> BEXP
AFIJO & SCRAP --> BIMD
style PROCESAR fill:#E2EFDA,rx:8
style REVERTIR fill:#DEEAF1,rx:8
style BLOQUEOS fill:#FCE4D6,rx:8
```

View File

@@ -0,0 +1,315 @@
# Casos de Uso — Módulo de Importaciones
**Sistema:** Anexo76 · SCAII
**Módulo:** `imports/`
**Versión:** 1.0 · Marzo 2026
---
## Contexto general
El módulo de importaciones gestiona el ciclo completo de una factura de importación dentro del sistema: desde que el usuario la captura hasta que queda registrada como procesada. También permite revertirla si hubo un error.
La importación es el punto de partida de todo el inventario de materiales. Cuando una empresa maquiladora importa insumos, el sistema registra cuánto entró, con qué valor y bajo qué régimen. Ese registro es lo que después permite a las exportaciones descargar saldos. Sin importaciones procesadas, no hay inventario que exportar.
Toda factura de importación pasa por uno de dos momentos: **procesarla** (actualizarla) o **revertirla** (desactualizarla).
---
## Los tipos de factura de importación
Cada tipo representa un escenario aduanero diferente con reglas y efectos distintos sobre el inventario:
| Tipo | ¿Qué representa en la práctica? | ¿Genera saldo de inventario? | ¿Calcula IVA por partida? |
|------|--------------------------------|:----------------------------:|:-------------------------:|
| **TEM** | Material que entra al país temporalmente para ser procesado y exportado | Sí | No |
| **DEF** | Material que entra al país de forma definitiva, pagando impuestos completos | No | Sí |
| **MEX** | Compra de material a proveedores mexicanos | No | Sí |
La distinción más importante del módulo: **solo las importaciones temporales (TEM) generan saldo de inventario**. Las definitivas y las compras mexicanas simplemente registran la entrada y calculan el IVA, pero no alimentan el ledger que las exportaciones van a consumir.
---
## CU-IMP-001 · Procesar una factura TEM (Importación Temporal)
### Descripción
El usuario procesa una factura de importación temporal. El material registrado en esta factura entra al país sin pagar impuestos definitivos, bajo el compromiso de que será exportado después de ser transformado o ensamblado.
### Por qué existe
El régimen de importación temporal es el corazón de la operación maquiladora. Las empresas necesitan registrar exactamente qué entró, cuánto y con qué valor, porque ese registro es el inventario del que después se nutren las exportaciones. Sin este caso de uso, no existirían los saldos que el módulo de exportaciones descarga.
### Quién lo usa
El usuario de captura de importaciones, al presionar **"Actualizar Factura"** en una factura de tipo TEM.
### Qué necesita estar listo antes
- La factura debe estar en estado **Pendiente**.
- Debe tener al menos una partida capturada.
- Todos los campos del encabezado deben estar completos: fecha, proveedor, destinatario, agente aduanal, tipo de cambio, moneda y pedimento.
- El tipo de cambio del día debe estar registrado en el catálogo — a diferencia de exportaciones, aquí es obligatorio que exista; si no existe, el sistema lanza un error (no solo una advertencia).
- Las clases y fracciones arancelarias de cada partida deben ser válidas.
- Cada partida principal debe tener costo unitario mayor a cero.
- Si la empresa tiene programa PROSEC y alguna partida usa Regla Octava, el permiso correspondiente debe estar activo y con cupo disponible.
### Qué hace el sistema
Primero valida los campos del encabezado y carga las partidas. Luego verifica que las clases y fracciones arancelarias sean correctas, que el tipo de cambio coincida con el catálogo y que los pesos declarados cuadren con las cantidades.
Después calcula los valores monetarios de cada partida: costo unitario en pesos, en dólares y en moneda de cuenta, usando el tipo de cambio del día. Para TEM **no se calcula IVA** — ese cálculo solo aplica a DEF y MEX.
Por cada partida también valida que la unidad de medida tenga una equivalencia con la unidad de aduana mexicana, para poder registrar la cantidad correcta ante la autoridad aduanera.
Si la empresa tiene programa PROSEC y alguna partida tiene permiso de Regla Octava, el sistema verifica que ese permiso exista, que esté vigente, que la fracción arancelaria coincida y que haya cupo suficiente. Si todo está bien, descuenta ese cupo del permiso.
Finalmente calcula los totales de la factura, agrega los incrementables (flete, seguro, embalaje) y genera en el ledger de inventario **un registro de entrada por cada partida principal**. Esos registros son los saldos que las exportaciones futuras van a consumir.
### Qué queda guardado
La factura queda en estado **Procesada** con sus totales calculados. En el ledger de saldos quedan registrados **movimientos de entrada** por cada partida, listos para ser consumidos por exportaciones. Si había Regla Octava, los cupos del permiso quedan descontados y el saldo queda registrado en la tabla de saldos PROSEC.
### Qué puede fallar
| Situación | Qué hace el sistema |
|-----------|---------------------|
| La factura ya estaba procesada | La rechaza de inmediato |
| El tipo de cambio del día no está en el catálogo | Error — a diferencia de exportaciones, aquí no es opcional |
| El tipo de cambio capturado difiere del catálogo | Error indicando la diferencia |
| Una fracción arancelaria no existe en el catálogo oficial | Reporta qué partida tiene la fracción inválida |
| Una clase arancelaria está desactivada | Reporta qué partida está afectada |
| Un número de parte está desactivado | Reporta qué partida está afectada |
| La cantidad y el peso neto no coinciden (en partidas KGS o LBS) | Indica qué partida tiene la diferencia |
| Una partida tiene costo en cero | Indica qué partida no tiene precio |
| La partida tiene series activadas pero no hay series registradas | Indica qué partida le faltan series |
| La partida usa Regla Octava pero la empresa no tiene PROSEC | Rechaza el uso del permiso |
| El permiso de Regla Octava no existe o está vencido | Indica qué permiso tiene el problema |
| El cupo del permiso de Regla Octava ya está agotado | Indica que no hay cupo disponible |
| La unidad de medida no tiene equivalencia con la unidad de aduana | Indica qué partida tiene el problema de unidades |
---
## CU-IMP-002 · Procesar una factura DEF (Importación Definitiva)
### Descripción
El usuario procesa una factura de importación definitiva. El material entra al país pagando todos los impuestos correspondientes — es una compra permanente, no temporal.
### Por qué existe
No todos los materiales que usan las maquiladoras entran al país de forma temporal. Algunos insumos, herramientas o componentes se adquieren de forma definitiva. El sistema necesita registrar esa entrada, calcular el IVA que corresponde y dejar constancia del valor total de la importación para efectos contables y aduaneros.
### Quién lo usa
El usuario de captura de importaciones para facturas de importación definitiva.
### Qué necesita estar listo antes
Igual que TEM, con la diferencia de que el `iva_factor` debe estar configurado en la factura para que el sistema pueda calcular el IVA por partida.
### Qué hace el sistema
Ejecuta las mismas validaciones que TEM (encabezado, clases, fracciones, tipo de cambio, pesos, costo unitario, series, UMA). La diferencia está en el cálculo de valores: para DEF el sistema calcula **subtotal + IVA = total** por cada partida, en pesos, dólares y moneda de cuenta. El IVA se calcula como porcentaje del valor de cada partida.
Al terminar, actualiza los totales del encabezado incluyendo los totales de IVA.
**La diferencia crítica con TEM:** no genera ningún movimiento en el ledger de inventario. La importación definitiva no alimenta los saldos que las exportaciones consumen — ese material llegó para quedarse en México, no para ser reexportado bajo régimen temporal.
### Qué queda guardado
La factura queda **Procesada** con sus valores y totales de IVA calculados. El ledger de inventario no se modifica.
### Qué puede fallar
Las mismas situaciones que TEM, excepto lo relacionado con Regla Octava (que no aplica para DEF).
---
## CU-IMP-003 · Procesar una factura MEX (Compra Mexicana)
### Descripción
El usuario procesa una factura de compra a un proveedor mexicano. El material proviene de dentro del país — no hay una importación aduanera real — pero el sistema igualmente registra la entrada y calcula el IVA correspondiente.
### Por qué existe
Las maquiladoras también compran materiales a proveedores locales mexicanos. Aunque no hay un trámite aduanero formal, la empresa igualmente necesita registrar ese costo, el IVA pagado y el valor de lo que adquirió, por razones contables y de control interno.
### Quién lo usa
El usuario de captura de importaciones para facturas de compras nacionales.
### Qué necesita estar listo antes
Igual que DEF. A diferencia de otros tipos, MEX no requiere `document_type` — al ser una compra nacional no hay régimen aduanero que declarar.
### Qué hace el sistema
Idéntico a DEF: valida el encabezado, calcula valores con IVA por partida y actualiza totales. No genera saldos en el ledger.
### Qué queda guardado
Igual que DEF — la factura queda **Procesada** con valores e IVA calculados, sin movimientos en el ledger de inventario.
### Qué puede fallar
Las mismas situaciones que DEF.
---
## CU-IMP-004 · Procesar una factura TEM con Regla Octava (PROSEC)
### Descripción
Es una variante del caso TEM. Ocurre cuando la empresa tiene un **programa PROSEC** activo y alguna o todas sus partidas están amparadas bajo un **permiso de Regla Octava**, que le permite importar ciertos materiales con arancel preferencial.
### Por qué existe
La Regla Octava es un beneficio arancelario del gobierno mexicano para empresas del sector productivo. Las empresas PROSEC pueden importar materias primas con aranceles reducidos, pero a cambio deben consumir esos materiales dentro de cuotas autorizadas. El sistema lleva la cuenta de cuánto cupo ha sido usado y cuánto queda disponible por permiso.
### Quién lo usa
Empresas con programa PROSEC activo, al procesar facturas TEM donde alguna partida tiene un permiso de Regla Octava capturado.
### Qué necesita estar listo antes
Todo lo de TEM, más:
- La empresa debe tener `prosec = True` en su configuración.
- El permiso de Regla Octava debe existir en el catálogo, estar vigente (dentro de las fechas del permiso) y tener cupo disponible.
- La fracción arancelaria de la partida debe coincidir con la fracción registrada en el permiso — o debe existir en el historial de fracciones anteriores si el permiso es previo a mayo de 2010.
- El país de origen de la partida debe estar registrado dentro del permiso.
- La unidad de medida del permiso debe ser compatible con la de la partida.
### Qué hace el sistema
Igual que TEM en todo, más un bloque adicional de validación y descuento de cupos:
Para cada partida con permiso de Regla Octava, el sistema arma dos listas: una con lo que se quiere importar (cantidades y valores por permiso y línea) y otra con el cupo disponible por permiso. Luego cruza ambas listas para verificar que el cupo alcance. Si alcanza, descuenta ese cupo del permiso y registra en la tabla de saldos PROSEC cuánto se consumió de cada permiso por factura.
### Qué queda guardado
Igual que TEM, más: el cupo del permiso de Regla Octava queda reducido en la cantidad importada, y existe un registro en la tabla de saldos PROSEC vinculando esta factura con el permiso utilizado. Ese registro es el que se elimina si la factura se revierte.
### Qué puede fallar
Todo lo de TEM, más las situaciones específicas de Regla Octava descritas en ese caso.
---
## CU-IMP-005 · Revertir una factura TEM
### Descripción
El usuario deshace el procesamiento de una factura de importación temporal — porque hubo un error de captura, los datos cambiaron, o necesita corregirla.
### Por qué existe
Una factura TEM procesada tiene efectos en el inventario: generó saldos que las exportaciones pueden estar usando. Revertirla no es solo cambiar un estado — hay que deshacer todos esos efectos de forma controlada para que el inventario quede consistente.
### Qué hace el sistema
Primero verifica que ninguna exportación activa esté consumiendo los saldos de esta importación. Si las hay, bloquea completamente la reversión — ver CU-IMP-007.
Si no hay bloqueos, ejecuta cuatro acciones en orden:
**1. Revierte los cupos de Regla Octava.** Si la factura usó permisos PROSEC, devuelve el cupo consumido a cada permiso y elimina los registros de saldo PROSEC que se habían creado al procesar.
**2. Reinicia los totales del encabezado.** Pone todos los valores financieros en cero (cantidad, peso, valor en pesos, en dólares, IVA, incrementables) y regresa la factura a estado **Pendiente**.
**3. Reinicia los contadores de cada partida.** Limpia los valores de retorno y de IVA utilizado que se habían calculado en las partidas.
**4. Anula los saldos en el ledger de inventario.** El ledger nunca se borra. En su lugar, el sistema inserta nuevos movimientos de tipo **Anulación de Entrada** que compensan exactamente las **Entradas** originales. El saldo neto de cada lote queda en cero — las exportaciones ya no pueden consumirlo.
### Qué necesita estar listo antes
La factura debe estar en estado **Procesada**. Si no lo está, el sistema rechaza la reversión.
### Qué queda guardado
La factura queda en estado **Pendiente** con todos los valores en cero. En el ledger quedan los movimientos originales de Entrada más los nuevos de Anulación que los neutralizan — el historial queda completo y auditable. Si había Regla Octava, los cupos quedan restaurados y los registros de saldo PROSEC quedan eliminados.
### Qué puede fallar
| Situación | Qué hace el sistema |
|-----------|---------------------|
| Una partida está siendo descargada por una exportación activa | Bloquea toda la reversión — ver CU-IMP-007 |
| La factura no estaba procesada | La rechaza indicando que no puede revertirse |
| Error al actualizar el permiso de Regla Octava | Registra el error como no bloqueante y continúa — la reversión avanza de todas formas |
---
## CU-IMP-006 · Revertir una factura DEF o MEX
### Descripción
El usuario deshace el procesamiento de una factura de importación definitiva o de una compra mexicana.
### Por qué existe
Aunque DEF y MEX no generan saldos de inventario, sí tienen totales calculados y estado procesado que pueden necesitar corrección. La reversión les permite volver a estado pendiente para ser corregidas.
### Qué hace el sistema
Es la más simple de las reversiones de importación porque DEF y MEX nunca generaron saldos en el ledger. El sistema verifica que no haya exportaciones activas usando esa importación (por consistencia, aunque en la práctica DEF y MEX no alimentan el ledger que exportaciones consume), reinicia los totales del encabezado a cero y regresa la factura a **Pendiente**.
No hay cupos de Regla Octava que restaurar ni entradas de ledger que anular.
### Qué queda guardado
La factura queda en estado **Pendiente** con todos los valores en cero, lista para ser corregida y reprocesada.
### Qué puede fallar
| Situación | Qué hace el sistema |
|-----------|---------------------|
| La factura no estaba procesada | La rechaza indicando que no puede revertirse |
---
## CU-IMP-007 · Reversión bloqueada por exportaciones activas
### Descripción
El usuario intenta revertir una factura de importación TEM, pero el sistema detecta que alguna de sus partidas está siendo **consumida actualmente por una exportación que sigue procesada**. El sistema bloquea completamente la reversión.
### Por qué existe este bloqueo
Los saldos de inventario de una importación temporal pueden estar siendo usados por varias exportaciones al mismo tiempo. Si se permitiera revertir la importación mientras esas exportaciones siguen activas, el inventario quedaría en un estado imposible: habría exportaciones que dicen haber consumido saldos de una importación que ya no existe. El bloqueo garantiza que el orden sea siempre el correcto: primero se deshacen las exportaciones y luego la importación.
### Qué hace el sistema
Recorre cada partida de la factura. Por cada una, busca si existe algún registro de descarga activo vinculado a una exportación procesada. Si encuentra aunque sea uno, detiene todo el proceso y muestra un mensaje por cada exportación activa involucrada, indicando exactamente qué factura de exportación y qué línea está usando el saldo.
### Cómo lo resuelve el usuario
Debe ir a cada factura de exportación indicada en el mensaje de error y revertirla primero. Una vez que todas las exportaciones que consumían esos saldos estén revertidas, puede revertir la importación sin problemas.
### Qué queda guardado
Nada — el sistema no modifica ningún dato cuando bloquea la reversión. Todo queda exactamente igual que antes del intento.
---
## Resumen de los siete casos
| # | Caso de uso | Tipo | Genera saldo inventario | Calcula IVA | Regla Octava |
|---|-------------|------|:-----------------------:|:-----------:|:------------:|
| CU-IMP-001 | Procesar TEM | Proceso | Sí | No | Opcional |
| CU-IMP-002 | Procesar DEF | Proceso | No | Sí | No |
| CU-IMP-003 | Procesar MEX | Proceso | No | Sí | No |
| CU-IMP-004 | Procesar TEM con PROSEC | Proceso | Sí | No | Sí — descuenta cupo |
| CU-IMP-005 | Revertir TEM | Reversión | Anula saldos | No | Restaura cupo |
| CU-IMP-006 | Revertir DEF o MEX | Reversión | No aplica | No | No |
| CU-IMP-007 | Reversión bloqueada por exportaciones activas | Bloqueo | No (bloqueado) | No | No |
---
## Relación con el módulo de exportaciones
Las importaciones y exportaciones están directamente conectadas a través del ledger de inventario:
Una factura TEM procesada **crea** saldos. Esos saldos son los que las exportaciones AFIJO, DONAC, SCRAP, REEXP y VEMEX **consumen** al procesarse. Si una exportación consume saldos de una TEM y después esa TEM necesita revertirse, primero hay que revertir la exportación — de ahí viene el bloqueo del CU-IMP-007.
Las facturas DEF y MEX no participan en este ciclo desde el lado de la importación, pero sí pueden ser el origen del material en una exportación de tipo REEXP o VEMEX, donde la procedencia requerida es justamente DEF.

View File

@@ -0,0 +1,222 @@
# Diagramas de Flujo — Casos de Uso Importaciones
> Cada diagrama corresponde a un caso de uso en [`imports_cu_comprensibles.md`](./imports_cu_comprensibles.md).
> Formato: Mermaid — compatible con Notion, GitHub y VSCode.
> **Cómo usar en Notion:** bloque `/code` → lenguaje `mermaid` → pegar el contenido.
---
## CU-IMP-001 · Procesar TEM (Importación Temporal)
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - TEM]) --> CHK1{¿La factura\nestá Pendiente?}
CHK1 -- No --> E1([❌ Error\nYa fue procesada])
CHK1 -- Sí --> V1[Validar encabezado\nfecha · proveedor · destinatario\nTC · moneda · pedimento]
V1 --> TC{¿Existe el TC\ndel día en el catálogo?}
TC -- No --> E2([❌ Error\nTC obligatorio para TEM\nno es opcional como en exports])
TC -- Sí --> TC2{¿TC de la factura\n== TC del catálogo?}
TC2 -- No --> E3([❌ Error\nTipo de cambio\nno coincide])
TC2 -- Sí --> V2[Validar clases · fracciones\nnúmeros de parte · Regla 3.1.21\nrevisión física si aplica]
V2 --> V3{¿Errores en\nclases o fracciones?}
V3 -- Sí --> E4([❌ Error\nClase o fracción inválida])
V3 -- No --> V4[Validar pesos por partida\nKGS o LBS según configuración]
V4 --> CALC[Calcular valores sin IVA\nCosto × Cantidad × TC\npesos · dólares · moneda cuenta]
CALC --> VL[Validar por cada partida\ncosto > 0 · clase activa\nparte activa · series · UMA]
VL --> VL2{¿Errores en\npartidas?}
VL2 -- Sí --> E5([❌ Error\nCosto cero · clase desactivada\nparte desactivada · series faltantes])
VL2 -- No --> OCT{¿Alguna partida\ntiene permiso PROSEC?}
OCT -- Sí --> OCTVAL[Validar permisos\nde Regla Octava\nver CU-IMP-004]
OCT -- No --> TOTALS
OCTVAL --> OCTCHK{¿Permisos\nválidos y con cupo?}
OCTCHK -- No --> E6([❌ Error\nPermiso vencido\no cupo agotado])
OCTCHK -- Sí --> TOTALS[Calcular totales de la factura\nsumar IVA si fecha ≥ 2014-12-31\nagregar incrementables]
TOTALS --> LEDGER[Generar entrada en el ledger\nun movimiento ENTRADA por partida principal\nbase del inventario futuro]
LEDGER --> FIN([✅ Factura PROCESADA\nSaldos de inventario creados\nListos para ser exportados])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style E2 fill:#C55A11,color:#fff,rx:16
style E3 fill:#C55A11,color:#fff,rx:16
style E4 fill:#C55A11,color:#fff,rx:16
style E5 fill:#C55A11,color:#fff,rx:16
style E6 fill:#C55A11,color:#fff,rx:16
```
---
## CU-IMP-002 · Procesar DEF (Importación Definitiva)
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - DEF]) --> V1[Validaciones de encabezado\nigual que TEM]
V1 --> CALC[Calcular valores CON IVA\nSubtotal + IVA = Total\npor cada partida\nen pesos · dólares · moneda cuenta]
CALC --> VL[Validar partidas\ncosto · clase · parte · series · UMA]
VL --> CHK{¿Errores?}
CHK -- Sí --> E1([❌ Error\nCorregir partidas])
CHK -- No --> TOTALS[Calcular totales incluyendo\ntotales de IVA]
TOTALS --> NOTE[No se generan saldos\nen el ledger de inventario\neste material llegó para\nquedarse en México]
NOTE --> FIN([✅ Factura PROCESADA\nSin movimientos en inventario])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style NOTE fill:#FFF3E0,rx:8
```
---
## CU-IMP-003 · Procesar MEX (Compra Mexicana)
```mermaid
flowchart TD
START([Usuario presiona\nActualizar Factura - MEX]) --> NOTE[Compra a proveedor mexicano\nNo hay trámite aduanero real\ndocument_type no es obligatorio]
NOTE --> V1[Validaciones de encabezado\nigual que DEF]
V1 --> CALC[Calcular valores CON IVA\nidéntico a DEF]
CALC --> TOTALS[Calcular totales con IVA]
TOTALS --> FIN([✅ Factura PROCESADA\nSin movimientos en inventario])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style NOTE fill:#FFF3E0,rx:8
```
---
## CU-IMP-004 · Procesar TEM con Regla Octava (PROSEC)
```mermaid
flowchart TD
START([Partida con permiso\nde Regla Octava detectada]) --> PROSEC{¿La empresa\ntiene PROSEC activo?}
PROSEC -- No --> E1([❌ Error\nNo se puede usar Regla Octava\nsin programa PROSEC])
PROSEC -- Sí --> P1[Buscar el permiso\nen el catálogo]
P1 --> P2{¿Permiso\nexiste?}
P2 -- No --> E2([❌ Error\nPermiso no dado de alta])
P2 -- Sí --> P3{¿Fecha de la factura\ndentro del rango\ndel permiso?}
P3 -- No --> E3([❌ Error\nFecha fuera del\nrango del permiso])
P3 -- Sí --> P4{¿Fracción de la partida\ncoincide con la del permiso?}
P4 -- No --> P4B{¿Permiso anterior\na mayo 2010 y fracción\nen historial?}
P4B -- No --> E4([❌ Error\nFracción no corresponde\nal permiso])
P4B -- Sí --> P5
P4 -- Sí --> P5{¿Cupo del permiso\n> 0?}
P5 -- No --> E5([❌ Error\nCupo agotado])
P5 -- Sí --> P6{¿País de origen\nen el permiso?}
P6 -- No --> E6([❌ Error\nPaís de origen no\nampara el permiso])
P6 -- Sí --> P7[Calcular cantidad y valor\na descontar del cupo\ncon equivalencia de unidades si aplica]
P7 --> P8[Verificar que el cupo\nalcance para todas\nlas partidas del lote]
P8 --> P9{¿Cupo\nsuficiente?}
P9 -- No --> E7([❌ Error\nCupo insuficiente para\ncubrir la importación])
P9 -- Sí --> SAVE[Descontar cupo del permiso\nRegistrar saldo PROSEC\nvinculado a esta factura]
SAVE --> FIN([✅ Permisos PROSEC descontados\nContinúa con flujo TEM normal\nver CU-IMP-001])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#2E75B6,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style E2 fill:#C55A11,color:#fff,rx:16
style E3 fill:#C55A11,color:#fff,rx:16
style E4 fill:#C55A11,color:#fff,rx:16
style E5 fill:#C55A11,color:#fff,rx:16
style E6 fill:#C55A11,color:#fff,rx:16
style E7 fill:#C55A11,color:#fff,rx:16
```
---
## CU-IMP-005 · Revertir TEM
```mermaid
flowchart TD
START([Usuario presiona\nDesactualizar Factura - TEM]) --> CHK1{¿La factura\nestá Procesada?}
CHK1 -- No --> E1([❌ Error\nNo se puede revertir])
CHK1 -- Sí --> BLOCK[Verificar si alguna partida\nestá siendo consumida por\nuna exportación activa]
BLOCK --> CHK2{¿Hay exportaciones\nactivas usando\nestos saldos?}
CHK2 -- Sí --> E2([❌ Bloqueado\nver CU-IMP-007\nRevertir exportaciones primero])
CHK2 -- No --> R1[1 · Revertir Regla Octava si aplica\nDevolver cupos a los permisos\nEliminar registros de saldo PROSEC]
R1 --> R2[2 · Reiniciar totales del encabezado\ntodos los valores a cero\nstatus a Pendiente]
R2 --> R3[3 · Reiniciar contadores\nde cada partida a cero]
R3 --> R4[4 · Anular saldos en el ledger\ninsertar movimientos ANULACIÓN DE ENTRADA\nque neutralizan las ENTRADAS originales]
R4 --> FIN([✅ Factura REVERTIDA\nInventario neutralizado\nHistorial completo y auditable])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style E2 fill:#C55A11,color:#fff,rx:16
```
---
## CU-IMP-006 · Revertir DEF o MEX
```mermaid
flowchart TD
START([Usuario presiona\nDesactualizar Factura - DEF o MEX]) --> CHK1{¿La factura\nestá Procesada?}
CHK1 -- No --> E1([❌ Error\nNo se puede revertir])
CHK1 -- Sí --> NOTE[DEF y MEX nunca generaron\nsaldos en el ledger\nni cupos de Regla Octava]
NOTE --> CLEAN[Reiniciar todos los valores\ndel encabezado a cero\nstatus a Pendiente]
CLEAN --> FIN([✅ Factura REVERTIDA\nSin efectos adicionales])
style FIN fill:#1D6B3C,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
style E1 fill:#C55A11,color:#fff,rx:16
style NOTE fill:#FFF3E0,rx:8
```
---
## CU-IMP-007 · Reversión bloqueada por exportaciones activas
```mermaid
flowchart TD
START([Usuario intenta\nDesactualizar Factura TEM]) --> SCAN[Recorrer cada partida\nde la factura]
SCAN --> FIND[Buscar registros de descarga\ncon estado APLICADO\nvinculados a exportaciones procesadas]
FIND --> CHK{¿Hay descargas\nactivas?}
CHK -- No --> OK([Reversión permitida\ncontinúa con CU-IMP-005])
CHK -- Sí --> MSG[Mostrar un mensaje por cada\nexportación activa involucrada\nfactura · línea · cantidad descargada]
MSG --> BLOCK([❌ Reversión BLOQUEADA\nEl usuario debe revertir primero\ncada exportación indicada])
style OK fill:#1D6B3C,color:#fff,rx:16
style BLOCK fill:#C55A11,color:#fff,rx:16
style START fill:#1F4E79,color:#fff,rx:16
```
---
## Visión general — todos los tipos de importación
```mermaid
flowchart LR
subgraph PROCESAR ["⬆️ PROCESAR"]
TEM[TEM\nGenera saldos PEPS\nsin IVA]
TEMO[TEM + PROSEC\nGenera saldos\ndescuenta cupo RO]
DEF[DEF\nSin saldos\ncon IVA]
MEX[MEX\nSin saldos\ncon IVA]
end
subgraph REVERTIR ["⬇️ REVERTIR"]
RTEM[Revertir TEM\nAnula saldos · restaura cupo\nledger ANULACIÓN]
RDEF[Revertir DEF o MEX\nSolo limpia totales]
end
subgraph BLOQUEOS ["🚫 BLOQUEOS"]
BEXP[Bloqueada por\nexportaciones activas\nque usan los saldos]
end
subgraph RELACION ["🔗 Relación con Exports"]
EXP[Exportaciones AFIJO\nDONAC · SCRAP · REEXP · VEMEX\nconsumen saldos generados por TEM]
end
TEM -->|crea saldos| EXP
TEMO -->|crea saldos| EXP
TEM --> RTEM
TEMO --> RTEM
DEF --> RDEF
MEX --> RDEF
RTEM --> BEXP
EXP -->|si está activa bloquea| BEXP
style PROCESAR fill:#E2EFDA,rx:8
style REVERTIR fill:#DEEAF1,rx:8
style BLOQUEOS fill:#FCE4D6,rx:8
style RELACION fill:#EAE3F0,rx:8
```

View File

@@ -33,6 +33,12 @@ CLS_IMPORT_ERROR_LINES_PREFIX = "cls_import_error_lines:"
CLS_IMPORT_REDIS_TTL = common_storage.IMPORT_REDIS_TTL
def _read_plan_for_classes(fieldnames):
if fieldnames:
return common_csv.CsvReadPlan(header_mode="headerless", fieldnames=fieldnames)
return common_csv.CsvReadPlan(header_mode="header")
@celery_app.task(bind=True)
def scan_file(self, job_id: str, config: str = None):
logger.info("Classes import: starting scan for job %s", job_id)
@@ -45,8 +51,9 @@ def scan_file(self, job_id: str, config: str = None):
error_path = common_storage.error_path_for_job(JOB_TYPE, job_id)
fieldnames, has_header = detect_headers_or_data(file_path, common_normalize.normalize_header)
read_plan = _read_plan_for_classes(fieldnames)
try:
total_rows = common_csv.count_csv_rows(file_path, has_header=has_header)
total_rows = common_csv.count_csv_rows(file_path, has_header=has_header, read_plan=read_plan)
except Exception as e:
return {"status": "failed", "error": str(e)}
@@ -83,7 +90,7 @@ def scan_file(self, job_id: str, config: str = None):
try:
with open(error_path, "w", encoding="utf-8") as f_err:
for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames):
for i, row in common_csv.iter_csv_rows_with_plan(file_path, read_plan=read_plan):
self.update_state(
state="PROGRESS",
meta={"current": i, "total": total_rows, "errors": error_count},
@@ -171,6 +178,7 @@ def insert_valid_rows(self, job_id: str):
meta_path = common_meta.get_meta_path(file_path)
fieldnames, _ = detect_headers_or_data(file_path, common_normalize.normalize_header)
read_plan = _read_plan_for_classes(fieldnames)
try:
with CoreSessionLocal() as session:
@@ -183,7 +191,7 @@ def insert_valid_rows(self, job_id: str):
if key:
existing_by_code[key] = c
for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames):
for i, row in common_csv.iter_csv_rows_with_plan(file_path, read_plan=read_plan):
if i in error_lines:
continue

View File

@@ -7,28 +7,12 @@ import io
from typing import Dict, List, Any, Optional, Tuple
from ..common.cell_value import cell_to_str
from ..common import csv_reader as common_csv_reader
# Valores que indican que la primera fila es cabecera (primera columna normalizada)
FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE")
_ENCODING_FALLBACKS: Tuple[str, ...] = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
def _read_text_sample(file_path: str, sample_bytes: int = 2048) -> str:
with open(file_path, "rb") as f:
raw = f.read(sample_bytes)
last_err: Optional[Exception] = None
for enc in _ENCODING_FALLBACKS:
try:
return raw.decode(enc)
except Exception as e:
last_err = e
if last_err:
raise last_err
return ""
def detect_headers_or_data(
file_path: str,
normalize_header_fn,
@@ -42,24 +26,27 @@ def detect_headers_or_data(
- Si no -> has_header=False, fieldnames=TEMPLATE_DOWNLOAD_HEADERS (la primera fila es dato).
"""
try:
# `encoding` se mantiene por compatibilidad; si falla, hacemos fallback para CSVs tipo Excel (cp1252/latin-1).
if encoding and encoding.lower() not in ("auto", "detect"):
try:
with open(file_path, "r", encoding=encoding) as f:
sample = f.read(2048)
except Exception:
sample = _read_text_sample(file_path, sample_bytes=2048)
else:
sample = _read_text_sample(file_path, sample_bytes=2048)
sample, _ = common_csv_reader.read_text_sample(
file_path,
requested_encoding=encoding,
sample_chars=2048,
)
except Exception:
try:
sample, _ = common_csv_reader.read_text_sample(
file_path,
requested_encoding="auto",
sample_chars=2048,
)
except Exception:
return None, True
if not sample:
return None, True
lines = sample.splitlines()
if not lines:
return None, True
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = csv.excel
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
reader = csv.reader(io.StringIO(lines[0]), dialect=dialect)
first_row = next(reader, None)
if not first_row:
@@ -124,10 +111,15 @@ def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any
if key_norm in lookup:
out[lookup[key_norm]] = cell_to_str(value)
elif key_norm.startswith("CLAVE CLASE"):
# CSV leído con delimitador incorrecto: primera columna es "CLAVE CLASE,..." -> usar primer valor como CLASE
# CSV leído con delimitador incorrecto: primera columna puede venir colapsada.
if "CLASE" not in out and value:
val_str = cell_to_str(value)
first_val = (val_str.split(",")[0] if "," in val_str else val_str).strip()
first_val = val_str
for delimiter in (",", ";", "\t"):
if delimiter in first_val:
first_val = first_val.split(delimiter)[0]
break
first_val = first_val.lstrip("\ufeff").strip()
if first_val:
out["CLASE"] = first_val
return out

View File

@@ -5,7 +5,9 @@ Si se pasa headerless_first_cell_values, se detecta si la primera fila es cabece
"""
import csv
import io
from typing import Iterator, Tuple, Dict, Any, Optional, List, Set, Sequence
import codecs
from dataclasses import dataclass
from typing import Iterator, Tuple, Dict, Any, Optional, List, Set, Sequence, Literal
def _normalize_empty_headers(headers: List[str]) -> List[str]:
@@ -21,10 +23,72 @@ def _normalize_empty_headers(headers: List[str]) -> List[str]:
return result
def dedupe_duplicate_headers(headers: List[str], fallback_name: str = "COL") -> List[str]:
"""
Hace únicos headers repetidos agregando sufijo incremental.
"""
counts: Dict[str, int] = {}
unique: List[str] = []
for header in headers:
name = str(header or "").strip() or fallback_name
count = counts.get(name, 0) + 1
counts[name] = count
unique.append(name if count == 1 else f"{name} {count}")
return unique
_ENCODING_FALLBACKS: Sequence[str] = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
def _detect_text_encoding(
@dataclass(frozen=True)
class CsvReadPlan:
"""
Contrato declarativo para lectura de CSV.
- header_mode=header: primera fila siempre cabecera.
- header_mode=headerless: primera fila siempre dato.
- header_mode=auto: decide con headerless_first_cell_values.
"""
header_mode: Literal["header", "headerless", "auto"] = "header"
fieldnames: Optional[List[str]] = None
headerless_first_cell_values: Optional[Set[str]] = None
encoding: Optional[str] = "auto"
delimiters: str = ",;\t"
sample_chars: int = 2048
@dataclass(frozen=True)
class CsvReadMetadata:
encoding: str
dialect: Any
has_header: bool
fieldnames: Optional[List[str]]
def _sample_decodes_with_encoding(raw: bytes, encoding: str) -> bool:
"""
Valida si un sample binario puede decodificarse con `encoding`.
Para UTF-8/UTF-8-SIG tolera corte al final de un multibyte (sample truncado).
"""
try:
raw.decode(encoding)
return True
except UnicodeDecodeError as err:
if encoding not in ("utf-8", "utf-8-sig"):
return False
# Si el error es por sample truncado al final del buffer, validar con decodificador incremental.
if err.end != len(raw):
return False
try:
decoder = codecs.getincrementaldecoder(encoding)(errors="strict")
decoder.decode(raw, final=False)
return True
except Exception:
return False
except Exception:
return False
def detect_text_encoding(
file_path: str,
encodings: Sequence[str] = _ENCODING_FALLBACKS,
sample_bytes: int = 8192,
@@ -38,8 +102,8 @@ def _detect_text_encoding(
last_err: Optional[Exception] = None
for enc in encodings:
try:
raw.decode(enc)
return enc
if _sample_decodes_with_encoding(raw, enc):
return enc
except Exception as e:
last_err = e
if last_err:
@@ -47,6 +111,132 @@ def _detect_text_encoding(
return "utf-8-sig"
def _detect_text_encoding(
file_path: str,
encodings: Sequence[str] = _ENCODING_FALLBACKS,
sample_bytes: int = 8192,
) -> str:
"""
Compatibilidad retroactiva para imports internos antiguos.
"""
return detect_text_encoding(file_path, encodings=encodings, sample_bytes=sample_bytes)
def resolve_read_encoding(file_path: str, requested_encoding: Optional[str] = "auto") -> str:
if requested_encoding and requested_encoding.lower() not in ("auto", "detect"):
return requested_encoding
return detect_text_encoding(file_path)
def read_text_sample(
file_path: str,
requested_encoding: Optional[str] = "auto",
sample_chars: int = 2048,
) -> Tuple[str, str]:
"""
Lee muestra de texto para detectar delimitador/primera fila.
Retorna (sample_text, resolved_encoding).
"""
encoding = resolve_read_encoding(file_path, requested_encoding)
with open(file_path, "r", encoding=encoding) as f:
return f.read(sample_chars), encoding
def detect_csv_dialect(sample: str, delimiters: str = ",;\t") -> Any:
try:
return csv.Sniffer().sniff(sample, delimiters=delimiters)
except Exception:
return "excel"
def inspect_csv(file_path: str, read_plan: Optional[CsvReadPlan] = None) -> CsvReadMetadata:
plan = read_plan or CsvReadPlan()
sample, encoding = read_text_sample(
file_path,
requested_encoding=plan.encoding,
sample_chars=plan.sample_chars,
)
dialect = detect_csv_dialect(sample, delimiters=plan.delimiters)
has_header = True
fieldnames: Optional[List[str]] = None
if plan.header_mode == "headerless":
has_header = False
fieldnames = list(plan.fieldnames or [])
elif plan.header_mode == "auto" and plan.fieldnames and plan.headerless_first_cell_values is not None:
first_line = sample.splitlines()[0] if sample.splitlines() else ""
if first_line:
row_reader = csv.reader(io.StringIO(first_line), dialect=dialect)
first_cells = next(row_reader, None)
first_cell_clean = ((first_cells or [""])[0] or "").lstrip("\ufeff").strip().upper()
if first_cell_clean in plan.headerless_first_cell_values:
has_header = False
fieldnames = list(plan.fieldnames)
return CsvReadMetadata(
encoding=encoding,
dialect=dialect,
has_header=has_header,
fieldnames=fieldnames,
)
def iter_csv_rows_with_plan(
file_path: str,
read_plan: Optional[CsvReadPlan] = None,
) -> Iterator[Tuple[int, Dict[str, Any]]]:
"""
Iterador común de filas usando un ReadPlan.
"""
plan = read_plan or CsvReadPlan()
metadata = inspect_csv(file_path, plan)
with open(file_path, "r", encoding=metadata.encoding) as f:
if metadata.has_header:
first_line = f.readline()
if not first_line:
return
row_reader = csv.reader(io.StringIO(first_line), dialect=metadata.dialect)
raw_headers = next(row_reader, None)
if not raw_headers:
return
normalized = _normalize_empty_headers(raw_headers)
reader = csv.DictReader(f, fieldnames=normalized, dialect=metadata.dialect, restval="")
for i, row in enumerate(reader, start=1):
yield i, dict(row)
return
fieldnames = metadata.fieldnames or list(plan.fieldnames or [])
if not fieldnames:
return
row_reader = csv.reader(f, dialect=metadata.dialect)
for i, cells in enumerate(row_reader, start=1):
if cells is None:
continue
pad = len(fieldnames) - len(cells)
normalized_cells = cells[: len(fieldnames)] + ([""] * pad if pad > 0 else [])
yield i, dict(zip(fieldnames, normalized_cells))
def iter_csv_rows_deduped_headers(
file_path: str,
fallback_name: str = "COL",
) -> Iterator[Tuple[int, Dict[str, Any]]]:
"""
Itera filas asumiendo cabecera en primera línea y deduplicando nombres repetidos.
"""
metadata = inspect_csv(file_path, CsvReadPlan(header_mode="header"))
with open(file_path, "r", encoding=metadata.encoding) as f:
first_line = f.readline()
if not first_line:
return
header_reader = csv.reader(io.StringIO(first_line), dialect=metadata.dialect)
raw_headers = next(header_reader, None)
if not raw_headers:
return
headers = dedupe_duplicate_headers(raw_headers, fallback_name=fallback_name)
dict_reader = csv.DictReader(f, fieldnames=headers, dialect=metadata.dialect, restval="")
for i, row in enumerate(dict_reader, start=1):
yield i, dict(row)
def iter_csv_rows(
file_path: str,
fieldnames: Optional[List[str]] = None,
@@ -60,65 +250,33 @@ def iter_csv_rows(
(quitando BOM, strip, upper) está en headerless_first_cell_values, se trata como dato y se usan fieldnames.
headerless_second_cell_key_pattern se ignora si no se usa (reservado para otros layouts).
"""
encoding = _detect_text_encoding(file_path)
with open(file_path, "r", encoding=encoding) as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
if fieldnames and headerless_first_cell_values is not None:
first_line = f.readline()
if not first_line:
return
row_reader = csv.reader(io.StringIO(first_line), dialect=dialect)
first_cells = next(row_reader, None)
if not first_cells:
return
first_cell_clean = (first_cells[0] or "").lstrip("\ufeff").strip().upper()
use_headerless = first_cell_clean in headerless_first_cell_values
if use_headerless:
pad = len(fieldnames) - len(first_cells)
cells = first_cells[: len(fieldnames)] + ([""] * pad if pad > 0 else [])
yield 1, dict(zip(fieldnames, cells))
reader = csv.DictReader(f, fieldnames=fieldnames, dialect=dialect, restval="")
for i, row in enumerate(reader, start=2):
yield i, dict(row)
return
f.seek(0)
first_line = f.readline()
if not first_line:
return
row_reader = csv.reader(io.StringIO(first_line), dialect=dialect)
raw_headers = next(row_reader, None)
if not raw_headers:
return
normalized = _normalize_empty_headers(raw_headers)
reader = csv.DictReader(f, fieldnames=normalized, dialect=dialect, restval="")
for i, row in enumerate(reader, start=1):
yield i, row
elif fieldnames:
reader = csv.DictReader(f, fieldnames=fieldnames, dialect=dialect)
for i, row in enumerate(reader, start=1):
yield i, dict(row)
else:
first_line = f.readline()
if not first_line:
return
row_reader = csv.reader(io.StringIO(first_line), dialect=dialect)
raw_headers = next(row_reader, None)
if not raw_headers:
return
normalized = _normalize_empty_headers(raw_headers)
reader = csv.DictReader(f, fieldnames=normalized, dialect=dialect, restval="")
for i, row in enumerate(reader, start=1):
yield i, row
if fieldnames and headerless_first_cell_values is not None:
plan = CsvReadPlan(
header_mode="auto",
fieldnames=fieldnames,
headerless_first_cell_values=headerless_first_cell_values,
)
elif fieldnames:
plan = CsvReadPlan(
header_mode="headerless",
fieldnames=fieldnames,
)
else:
plan = CsvReadPlan(header_mode="header")
for item in iter_csv_rows_with_plan(file_path, plan):
yield item
def count_csv_rows(file_path: str, has_header: bool = True) -> int:
def count_csv_rows(
file_path: str,
has_header: bool = True,
read_plan: Optional[CsvReadPlan] = None,
) -> int:
"""Cuenta filas del CSV. Si has_header=True (por defecto), no cuenta la cabecera."""
encoding = _detect_text_encoding(file_path)
metadata = inspect_csv(file_path, read_plan) if read_plan else None
encoding = metadata.encoding if metadata else detect_text_encoding(file_path)
effective_has_header = metadata.has_header if metadata else has_header
with open(file_path, "r", encoding=encoding) as f:
total_lines = sum(1 for _ in f)
return total_lines if not has_header else max(0, total_lines - 1)
return total_lines if not effective_has_header else max(0, total_lines - 1)

View File

@@ -7,7 +7,8 @@ from typing import Dict, Any, Optional, Set
MAX_LEN = {
"transporter_key": 5,
# Alineado a a76.transporter.transporter_key (23); CSV Clarion histórico usaba claves cortas
"transporter_key": 23,
"driver_name": 80,
"license_number": 29,
"express_line_id": 17,

View File

@@ -4,7 +4,6 @@ Flujo: scan_file (validación) → insert_valid_rows (commit).
Usa layouts_csv.common (storage, normalize, meta, responses); CSV con headers duplicados (dedupe) y clave de estado en Redis.
Paridad Clarion: actualizar, existing_driver_keys, valid_transporter_keys, valid_country_ame.
"""
import csv
import json
import logging
import os
@@ -18,6 +17,7 @@ from ..common import storage as common_storage
from ..common import normalize as common_normalize
from ..common import meta as common_meta
from ..common import responses as common_responses
from ..common import csv_reader as common_csv_reader
from .template_config import row_from_template
from .validators import validate_row_driver, validate_row_driver_desfase
from .common.mappers import row_to_driver_data
@@ -42,17 +42,6 @@ def _get_redis():
return redis.Redis.from_url(url, decode_responses=False)
def _dedupe_headers(headers: List[str]) -> List[str]:
counts: Dict[str, int] = {}
unique: List[str] = []
for header in headers:
name = str(header or "").strip() or "COL"
count = counts.get(name, 0) + 1
counts[name] = count
unique.append(name if count == 1 else f"{name} {count}")
return unique
def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, Any]:
file_path = common_storage.ensure_file_from_redis(JOB_TYPE, job_id, "Drivers import")
if not file_path:
@@ -62,8 +51,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
error_path = common_storage.error_path_for_job(JOB_TYPE, job_id)
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
total_rows = sum(1 for _ in f) - 1
total_rows = common_csv_reader.count_csv_rows(file_path, has_header=True)
except Exception as e:
return {"status": "failed", "error": str(e)}
@@ -117,24 +105,8 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
error_lines_list: List[int] = []
try:
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(
error_path, "w", encoding="utf-8"
) as f_err:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.reader(f_in, dialect=dialect)
try:
headers = next(reader)
except StopIteration:
headers = []
headers = _dedupe_headers(headers)
dict_reader = csv.DictReader(f_in, fieldnames=headers, dialect=dialect)
for i, row in enumerate(dict_reader, start=1):
with open(error_path, "w", encoding="utf-8") as f_err:
for i, row in common_csv_reader.iter_csv_rows_deduped_headers(file_path):
if progress_callback:
progress_callback(i, total_rows, error_count)
@@ -303,22 +275,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
try:
with CoreSessionLocal() as session:
with open(file_path, "r", encoding="utf-8-sig") as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.reader(f, dialect=dialect)
try:
headers = next(reader)
except StopIteration:
headers = []
headers = _dedupe_headers(headers)
dict_reader = csv.DictReader(f, fieldnames=headers, dialect=dialect)
for i, row in enumerate(dict_reader, start=1):
for i, row in common_csv_reader.iter_csv_rows_deduped_headers(file_path):
if i in error_lines:
continue

View File

@@ -26,6 +26,7 @@ from sqlalchemy import func
from ..common import storage as common_storage
from ..common import meta as common_meta
from ..common import responses as common_responses
from ..common import csv_reader as common_csv_reader
from .template_config import row_from_template
from .validators.encabezados_impo_temp import csv_tipo_moneda_es_me_mn_mc
from api.v1.modules.a76.app_settings.service import AppSettingsService
@@ -64,6 +65,27 @@ def _ensure_worker_has_meta_from_redis(job_id: str, file_path: str) -> bool:
def _delete_import_from_redis(job_id: str) -> None:
common_storage.delete_import_from_redis(JOB_TYPE, job_id)
def _ensure_utf8_compatible_import_file(file_path: str) -> None:
"""
Homogeneiza a UTF-8-SIG cuando el archivo venga en otro encoding
para que todo el flujo legado de facturas lea exactamente lo mismo.
"""
encoding = common_csv_reader.detect_text_encoding(file_path)
if encoding in ("utf-8", "utf-8-sig"):
return
with open(file_path, "r", encoding=encoding) as src:
content = src.read()
with open(file_path, "w", encoding="utf-8-sig") as dst:
dst.write(content)
def _facturas_csv_encoding(file_path: str) -> str:
"""
Encapsula la detección para centralizar la política de lectura CSV en facturas.
"""
return common_csv_reader.detect_text_encoding(file_path)
class ForeignKeyValidator:
def __init__(self, session, tenant_id, company_id):
self.session = session
@@ -462,6 +484,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if not file_path:
return {"status": "failed", "error": "File not found (missing or expired in queue). Please upload again."}
common_storage.ensure_meta_from_redis(effective_job_type, job_id, file_path, log_prefix)
_ensure_utf8_compatible_import_file(file_path)
error_path = common_storage.error_path_for_job(effective_job_type, job_id)
@@ -472,7 +495,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
# 3. Count Total (Quick Pass) or just estimate
try:
with open(file_path, 'r', encoding='utf-8-sig') as f:
with open(file_path, 'r', encoding=_facturas_csv_encoding(file_path)) as f:
total_rows = sum(1 for _ in f) - 1 # Minus header
except Exception as e:
return {"status": "failed", "error": f"Cannot read file: {e}"}
@@ -618,11 +641,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
csv_series_count_so_far: Dict[Tuple[str, str], int] = {}
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in, open(error_path, "w", encoding="utf-8") as f_err:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -793,11 +816,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
csv_series_count_so_far: Dict[Tuple[str, str], int] = {}
invoice_numbers_from_csv: Set[str] = set()
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -811,10 +834,10 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
else:
rfc_exception_updated = set()
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in, open(error_path, "w", encoding="utf-8") as f_err:
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(f_in.read(2048), delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(f_in.read(2048), delimiters=",;\t")
except Exception:
dialect = "excel"
f_in.seek(0)
@@ -992,11 +1015,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
csv_series_count_so_far: Dict[Tuple[str, str], int] = {}
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in, open(error_path, "w", encoding="utf-8") as f_err:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -1146,11 +1169,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
"number_id": nid or "",
})
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in, open(error_path, "w", encoding="utf-8") as f_err:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -1382,11 +1405,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
rfc_exception_updated: Set[str] = set()
rfc_exception_num_parte: Set[str] = set()
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -1766,11 +1789,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
rfc_exception_updated: Set[str] = set()
rfc_exception_num_parte: Set[str] = set()
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -2063,11 +2086,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
valid_part_numbers.add((row[0] or "").strip().upper())
invoice_numbers_from_csv = set()
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -2344,11 +2367,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
rfc_exception_updated: Set[str] = set()
rfc_exception_num_parte: Set[str] = set()
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -2699,11 +2722,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
else:
existing_tipo_moneda_by_number[str(num).strip()] = cur_str.upper()[:2]
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -3184,11 +3207,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
else:
existing_tipo_moneda_by_number[str(num).strip()] = (cur_str or "").upper()[:2]
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -3560,11 +3583,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
date_format = _fc.get("dateFormat") or meta.get("date_format")
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -3795,11 +3818,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
else:
existing_tipo_moneda_by_number[str(num).strip()] = (cur_str or "").upper()[:2]
with open(file_path, "r", encoding="utf-8-sig") as f_in:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
@@ -3911,7 +3934,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
}
with CoreSessionLocal() as session, \
open(file_path, 'r', encoding='utf-8-sig') as f_in, \
open(file_path, 'r', encoding=_facturas_csv_encoding(file_path)) as f_in, \
open(error_path, 'w', encoding='utf-8') as f_err:
validator = ForeignKeyValidator(session, tenant_id, company_id)
@@ -3955,7 +3978,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except:
dialect = 'excel'
@@ -4666,6 +4689,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
file_path = alt_path
else:
common_storage.ensure_meta_from_redis(effective_job_type, job_id, file_path, log_prefix)
_ensure_utf8_compatible_import_file(file_path)
try:
tenant_id, company_id = common_meta.require_tenant_context(file_path)
@@ -4734,11 +4758,11 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
skipped_invalid = 0
skipped_details: List[Dict[str, Any]] = []
with open(file_path, "r", encoding="utf-8-sig") as f:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f, dialect=dialect)
@@ -5009,11 +5033,11 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
skipped_invalid = 0
skipped_details: List[Dict[str, Any]] = []
with open(file_path, "r", encoding="utf-8-sig") as f:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f, dialect=dialect)
@@ -5280,11 +5304,11 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
skipped_invalid = 0
skipped_details: List[Dict[str, Any]] = []
with open(file_path, "r", encoding="utf-8-sig") as f:
with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f, dialect=dialect)
@@ -5692,12 +5716,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
except Exception:
pass
with open(file_path, 'r', encoding='utf-8-sig') as f:
with open(file_path, 'r', encoding=_facturas_csv_encoding(file_path)) as f:
# Detect Delimiter
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
except:
dialect = 'excel'

View File

@@ -37,6 +37,12 @@ PART_IMPORT_ERROR_LINES_PREFIX = "part_import_error_lines:"
PART_IMPORT_REDIS_TTL = common_storage.IMPORT_REDIS_TTL
def _read_plan_for_parts(fieldnames):
if fieldnames:
return common_csv.CsvReadPlan(header_mode="headerless", fieldnames=fieldnames)
return common_csv.CsvReadPlan(header_mode="header")
@celery_app.task(bind=True)
def scan_file(self, job_id: str, config: str = None):
logger.info("Parts import: starting scan for job %s", job_id)
@@ -49,8 +55,9 @@ def scan_file(self, job_id: str, config: str = None):
error_path = common_storage.error_path_for_job(JOB_TYPE, job_id)
fieldnames, has_header = detect_headers_or_data(file_path, common_normalize.normalize_header)
read_plan = _read_plan_for_parts(fieldnames)
try:
total_rows = common_csv.count_csv_rows(file_path, has_header=has_header)
total_rows = common_csv.count_csv_rows(file_path, has_header=has_header, read_plan=read_plan)
except Exception as e:
return {"status": "failed", "error": str(e)}
@@ -93,7 +100,7 @@ def scan_file(self, job_id: str, config: str = None):
try:
with open(error_path, "w", encoding="utf-8") as f_err:
for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames):
for i, row in common_csv.iter_csv_rows_with_plan(file_path, read_plan=read_plan):
self.update_state(
state="PROGRESS",
meta={"current": i, "total": total_rows, "errors": error_count},
@@ -204,6 +211,7 @@ def insert_valid_rows(self, job_id: str):
meta_path = common_meta.get_meta_path(file_path)
fieldnames, _ = detect_headers_or_data(file_path, common_normalize.normalize_header)
read_plan = _read_plan_for_parts(fieldnames)
try:
with CoreSessionLocal() as session:
@@ -216,7 +224,7 @@ def insert_valid_rows(self, job_id: str):
if key:
existing_by_part_number[key] = p
for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames):
for i, row in common_csv.iter_csv_rows_with_plan(file_path, read_plan=read_plan):
if i in error_lines:
continue

View File

@@ -7,6 +7,7 @@ import io
from typing import Dict, List, Any, Optional, Tuple
from ..common.cell_value import cell_to_str
from ..common import csv_reader as common_csv_reader
# Valores que indican que la primera fila es cabecera (primera columna normalizada)
@@ -24,17 +25,24 @@ def detect_headers_or_data(
- Si no -> has_header=False, fieldnames=TEMPLATE_DOWNLOAD_HEADERS (primera fila = dato).
"""
try:
with open(file_path, "r", encoding=encoding) as f:
sample = f.read(2048)
sample, _ = common_csv_reader.read_text_sample(
file_path,
requested_encoding=encoding,
sample_chars=2048,
)
except Exception:
return None, True
try:
sample, _ = common_csv_reader.read_text_sample(
file_path,
requested_encoding="auto",
sample_chars=2048,
)
except Exception:
return None, True
lines = sample.splitlines()
if not lines:
return None, True
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = csv.excel
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
reader = csv.reader(io.StringIO(lines[0]), dialect=dialect)
first_row = next(reader, None)
if not first_row:

View File

@@ -41,6 +41,12 @@ PED_IMPORT_ERROR_LINES_PREFIX = "ped_import_error_lines:"
PED_IMPORT_REDIS_TTL = common_storage.IMPORT_REDIS_TTL
def _read_plan_for_pedimentos(fieldnames):
if fieldnames:
return common_csv_reader.CsvReadPlan(header_mode="headerless", fieldnames=fieldnames)
return common_csv_reader.CsvReadPlan(header_mode="header")
def _norm_row(row: Dict[str, Any]) -> Dict[str, Any]:
return row_from_template(row, common_normalize.normalize_header, TEMPLATE_ID)
@@ -60,8 +66,9 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
common_normalize.normalize_header,
parse_pedimento_col_a,
)
read_plan = _read_plan_for_pedimentos(fieldnames)
try:
total_rows = common_csv_reader.count_csv_rows(file_path, has_header=has_header)
total_rows = common_csv_reader.count_csv_rows(file_path, has_header=has_header, read_plan=read_plan)
except Exception as e:
return {"status": "failed", "error": str(e)}
@@ -98,7 +105,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
try:
with open(error_path, "w", encoding="utf-8") as f_err:
for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=fieldnames):
for i, row in common_csv_reader.iter_csv_rows_with_plan(file_path, read_plan=read_plan):
if progress_callback:
progress_callback(i, total_rows, error_count)
@@ -217,6 +224,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
common_normalize.normalize_header,
parse_pedimento_col_a,
)
read_plan_commit = _read_plan_for_pedimentos(fieldnames_commit)
def _key_from_row(r: Dict[str, Any]) -> Optional[str]:
if is_clarion_layout(r):
@@ -240,7 +248,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
try:
with CoreSessionLocal() as session:
for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=fieldnames_commit):
for i, row in common_csv_reader.iter_csv_rows_with_plan(file_path, read_plan=read_plan_commit):
if i in error_lines:
continue

View File

@@ -10,6 +10,7 @@ from typing import Dict, List, Any, Optional, Tuple
# Convierte valor de celda a str; si es lista (p. ej. CSV con columnas duplicadas), toma el primer elemento.
# Re-exportado desde common para uso en validators; ver layouts_csv.common.cell_value.
from ..common.cell_value import cell_to_str as _cell_to_str
from ..common import csv_reader as common_csv_reader
# Longitudes para validación (sin afectar modelos)
@@ -138,17 +139,24 @@ def detect_headers_or_data(
- Si no -> has_header=True. Devuelve (fieldnames, has_header).
"""
try:
with open(file_path, "r", encoding=encoding) as f:
sample = f.read(2048)
sample, _ = common_csv_reader.read_text_sample(
file_path,
requested_encoding=encoding,
sample_chars=2048,
)
except Exception:
return None, True
try:
sample, _ = common_csv_reader.read_text_sample(
file_path,
requested_encoding="auto",
sample_chars=2048,
)
except Exception:
return None, True
lines = sample.splitlines()
if not lines:
return None, True
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = csv.excel
dialect = common_csv_reader.detect_csv_dialect(sample, delimiters=",;\t")
reader = csv.reader(io.StringIO(lines[0]), dialect=dialect)
first_row = next(reader, None)
if not first_row:

View File

@@ -3,7 +3,6 @@ Tareas Celery para importación CSV de Trailers y Cajas.
Flujo: scan_file (validación) → insert_valid_rows (commit).
Usa layouts_csv.common (storage, normalize, meta, responses); CSV con headers duplicados (dedupe).
"""
import csv
import json
import logging
import os
@@ -16,6 +15,7 @@ from ..common import storage as common_storage
from ..common import normalize as common_normalize
from ..common import meta as common_meta
from ..common import responses as common_responses
from ..common import csv_reader as common_csv_reader
from .template_config import row_from_template
from .validators import validate_row_trailer, validate_row_trailer_desfase
from .common.mappers import row_to_trailer_data, row_to_trailer_data_for_update
@@ -39,17 +39,6 @@ def _get_redis():
return redis.Redis.from_url(url, decode_responses=False)
def _dedupe_headers(headers: List[str]) -> List[str]:
counts: Dict[str, int] = {}
unique: List[str] = []
for header in headers:
name = str(header or "").strip() or "COL"
count = counts.get(name, 0) + 1
counts[name] = count
unique.append(name if count == 1 else f"{name} {count}")
return unique
def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, Any]:
file_path = common_storage.ensure_file_from_redis(JOB_TYPE, job_id, "Trailers import")
if not file_path:
@@ -59,8 +48,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
error_path = common_storage.error_path_for_job(JOB_TYPE, job_id)
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
total_rows = sum(1 for _ in f) - 1
total_rows = common_csv_reader.count_csv_rows(file_path, has_header=True)
except Exception as e:
return {"status": "failed", "error": str(e)}
@@ -103,24 +91,8 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
error_lines_list: List[int] = []
try:
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(
error_path, "w", encoding="utf-8"
) as f_err:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.reader(f_in, dialect=dialect)
try:
headers = next(reader)
except StopIteration:
headers = []
headers = _dedupe_headers(headers)
dict_reader = csv.DictReader(f_in, fieldnames=headers, dialect=dialect)
for i, row in enumerate(dict_reader, start=1):
with open(error_path, "w", encoding="utf-8") as f_err:
for i, row in common_csv_reader.iter_csv_rows_deduped_headers(file_path):
if progress_callback:
progress_callback(i, total_rows, error_count)
@@ -270,22 +242,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
try:
with CoreSessionLocal() as session:
with open(file_path, "r", encoding="utf-8-sig") as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.reader(f, dialect=dialect)
try:
headers = next(reader)
except StopIteration:
headers = []
headers = _dedupe_headers(headers)
dict_reader = csv.DictReader(f, fieldnames=headers, dialect=dialect)
for i, row in enumerate(dict_reader, start=1):
for i, row in common_csv_reader.iter_csv_rows_deduped_headers(file_path):
if i in error_lines:
continue

View File

@@ -0,0 +1,379 @@
"""
Validación de paridad con import CSV (mismas reglas y fk_loader) para CRUD Transportes.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set, Tuple
from sqlalchemy.orm import Session
from api.v1.common.catalog_validation_errors import CatalogValidationError
LINE = 1
def _raise_if_errors(errors: List[Dict[str, Any]]) -> None:
if errors:
raise CatalogValidationError(errors)
# --- Trailers ---
def trailer_fields_to_csv_row(d: Dict[str, Any]) -> Dict[str, Any]:
return {
"NUMERO TRAILER": (d.get("trailer_number") or "").strip(),
"CLAVE ACE": (d.get("ace_trailer_number") or "").strip() if d.get("ace_trailer_number") is not None else "",
"TIPO TRAILER": (d.get("trailer_type_key") or "").strip() if d.get("trailer_type_key") is not None else "",
"PRECINTO": (d.get("seal") or "").strip() if d.get("seal") is not None else "",
"CODIGO DE ENTIDAD": (d.get("entity_code") or "").strip() if d.get("entity_code") is not None else "",
"PLACAS": (d.get("plate_number") or "").strip() if d.get("plate_number") is not None else "",
"ESTADO": (d.get("state") or "").strip() if d.get("state") is not None else "",
"PAIS": (d.get("country") or "").strip() if d.get("country") is not None else "",
"CLAVE CONTENEDOR": (d.get("container_key") or "").strip() if d.get("container_key") is not None else "",
}
def trailer_model_to_row(tr) -> Dict[str, Any]:
return trailer_fields_to_csv_row(
{
"trailer_number": tr.trailer_number,
"ace_trailer_number": tr.ace_trailer_number,
"trailer_type_key": tr.trailer_type_key,
"seal": tr.seal,
"entity_code": tr.entity_code,
"plate_number": tr.plate_number,
"state": tr.state,
"country": tr.country,
"container_key": tr.container_key,
}
)
def validate_trailer_row_for_api(
tenant_id: int,
company_id: int,
row: Dict[str, Any],
*,
is_update: bool,
existing_trailer_numbers: Set[str],
) -> None:
from api.v1.modules.a76.layouts_csv.trailers.common.fk_loader import load_trailers_fk_sets
from api.v1.modules.a76.layouts_csv.trailers.validators.create import validate_row_trailer
(
valid_trailer_type_keys,
valid_country_ame,
state_descriptions_upper,
state_country_set,
state_ame_to_description,
) = load_trailers_fk_sets(tenant_id, company_id)
clave = (row.get("NUMERO TRAILER") or "").strip()
existing_norm = {x.strip() for x in existing_trailer_numbers if x}
actualizar = is_update and bool(clave and clave in existing_norm)
errs = validate_row_trailer(
row,
LINE,
actualizar=actualizar,
existing_trailer_numbers=existing_norm,
valid_trailer_type_keys=valid_trailer_type_keys,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
state_ame_to_description=state_ame_to_description,
)
_raise_if_errors(errs)
# --- Vehicles ---
def _fmt_insurance_date(val: Any) -> str:
if val is None:
return ""
if isinstance(val, int):
return str(val)
return str(val).strip()
def _fmt_monto(val: Any) -> str:
if val is None:
return ""
if isinstance(val, float):
return str(val)
return str(val).strip()
def vehicle_fields_to_csv_row(d: Dict[str, Any]) -> Dict[str, Any]:
return {
"CLAVE": (d.get("vehicle_key") or "").strip(),
"CLAVE ACE": (d.get("ace_vehicle_key") or "").strip() if d.get("ace_vehicle_key") is not None else "",
"CLAVE TRANSPORTE": (d.get("transporter_key") or "").strip() if d.get("transporter_key") is not None else "",
"VIN": (d.get("series") or "").strip() if d.get("series") is not None else "",
"TIPO TRANSPORTE": (d.get("transport_type") or "").strip() if d.get("transport_type") is not None else "",
"CODIGO DE ENTIDAD": (d.get("entity_code") or "").strip() if d.get("entity_code") is not None else "",
"TRANSPONDEDOR": (d.get("transponder_number") or "").strip() if d.get("transponder_number") is not None else "",
"NUMERO DOT": (d.get("dot_number") or "").strip() if d.get("dot_number") is not None else "",
"PLACAS": (d.get("plate_number") or "").strip() if d.get("plate_number") is not None else "",
"CIUDAD": (d.get("city") or "").strip() if d.get("city") is not None else "",
"ESTADO": (d.get("state") or "").strip() if d.get("state") is not None else "",
"PAIS": (d.get("country") or "").strip() if d.get("country") is not None else "",
"PRECINTO": (d.get("seal") or "").strip() if d.get("seal") is not None else "",
"EMPRESA ASEGURADORA": (d.get("insurance_company_name") or "").strip()
if d.get("insurance_company_name") is not None
else "",
"NUM. ASEGURADORA": (d.get("insurance_number") or "").strip() if d.get("insurance_number") is not None else "",
"MONTO ASEGURADO": _fmt_monto(d.get("insurance_amount")),
"FECHA DE ASEGURADORA": _fmt_insurance_date(d.get("insurance_date")),
}
def vehicle_model_to_row(v) -> Dict[str, Any]:
return vehicle_fields_to_csv_row(
{
"vehicle_key": v.vehicle_key,
"ace_vehicle_key": v.ace_vehicle_key,
"transporter_key": v.transporter_key,
"series": v.series,
"transport_type": v.transport_type,
"entity_code": v.entity_code,
"transponder_number": v.transponder_number,
"dot_number": v.dot_number,
"plate_number": v.plate_number,
"city": v.city,
"state": v.state,
"country": v.country,
"seal": v.seal,
"insurance_company_name": v.insurance_company_name,
"insurance_number": v.insurance_number,
"insurance_amount": float(v.insurance_amount) if v.insurance_amount is not None else None,
"insurance_date": v.insurance_date,
}
)
def validate_vehicle_transporter_key(
db: Session,
tenant_id: int,
company_id: int,
transporter_key: Optional[str],
) -> None:
if transporter_key is None or not str(transporter_key).strip():
return
from api.v1.modules.a76.transportation.transporters.services import TransporterService
t = TransporterService.get_by_id_ignore_case(
db, str(transporter_key).strip(), tenant_id, company_id
)
if not t:
_raise_if_errors(
[
{
"line": LINE,
"col": "CLAVE TRANSPORTE",
"msg": f"El transportista '{transporter_key}' no existe en el catálogo de esta empresa.",
}
]
)
def validate_vehicle_row_for_api(
db: Session,
tenant_id: int,
company_id: int,
row: Dict[str, Any],
*,
is_update: bool,
existing_vehicle_keys: Set[str],
) -> None:
from api.v1.modules.a76.layouts_csv.vehicles.common.fk_loader import load_vehicles_fk_sets
from api.v1.modules.a76.layouts_csv.vehicles.validators.create import validate_row_vehicle
tk = (row.get("CLAVE TRANSPORTE") or "").strip()
if tk:
validate_vehicle_transporter_key(db, tenant_id, company_id, tk)
(
valid_transport_codes,
valid_country_ame,
state_descriptions_upper,
state_country_set,
) = load_vehicles_fk_sets(tenant_id, company_id)
clave = (row.get("CLAVE") or "").strip()
existing_norm = {x.strip() for x in existing_vehicle_keys if x}
actualizar = is_update and bool(clave and clave in existing_norm)
errs = validate_row_vehicle(
row,
LINE,
actualizar=actualizar,
existing_vehicle_keys=existing_norm,
valid_transport_codes=valid_transport_codes,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
_raise_if_errors(errs)
# --- Transporters ---
def transporter_fields_to_csv_row(d: Dict[str, Any]) -> Dict[str, Any]:
def s(k: str) -> str:
v = d.get(k)
if v is None:
return ""
return str(v).strip()
return {
"CLAVE TRANSPORTISTA": s("transporter_key"),
"NOMBRE": s("name"),
"NOMBRE CORTO": s("short_name"),
"RESPONSABLE": s("responsible"),
"RFC": s("rfc"),
"CALLES": s("streets"),
"CODIGO POSTAL": s("postal_code"),
"CIUDAD": s("city"),
"ESTADO": s("state"),
"PAIS": s("country"),
"CODIGO CARGADOR": s("loader_code"),
"CODIGO CAAT": s("caat_code"),
"CODIGO TRANS": s("transport_code"),
"TIPO INTERFASE TRANS": s("transport_interface_type"),
"SERVIDOR FTP": s("ftp_server"),
"USUARIO FTP": s("ftp_user"),
"CLAVE ACCESO FTP": s("ftp_password"),
"DIRECTORIO FTP": s("ftp_directory"),
}
def transporter_model_to_row(t) -> Dict[str, Any]:
return transporter_fields_to_csv_row(
{
"transporter_key": t.transporter_key,
"name": t.name,
"short_name": t.short_name,
"responsible": t.responsible,
"rfc": t.rfc,
"streets": t.streets,
"postal_code": t.postal_code,
"city": t.city,
"state": t.state,
"country": t.country,
"loader_code": t.loader_code,
"caat_code": t.caat_code,
"transport_code": t.transport_code,
"transport_interface_type": t.transport_interface_type,
"ftp_server": t.ftp_server,
"ftp_user": t.ftp_user,
"ftp_password": t.ftp_password,
"ftp_directory": t.ftp_directory,
}
)
def validate_transporter_row_for_api(
tenant_id: int,
company_id: int,
row: Dict[str, Any],
*,
is_update: bool,
existing_transporter_keys: Set[str],
) -> None:
from api.v1.modules.a76.layouts_csv.transportistas.common.fk_loader import load_transportistas_fk_sets
from api.v1.modules.a76.layouts_csv.transportistas.validators.create import validate_row_transporter
(
existing_keys_loaded,
valid_country_ame,
state_descriptions_upper,
state_country_set,
) = load_transportistas_fk_sets(tenant_id, company_id)
clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper()
# existing set from loader is uppercased keys for this company
existing_norm = existing_keys_loaded | {x.strip().upper() for x in existing_transporter_keys if x}
actualizar = is_update and bool(clave and clave in existing_norm)
errs = validate_row_transporter(
row,
LINE,
actualizar=actualizar,
existing_transporter_keys=existing_norm,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
_raise_if_errors(errs)
# --- Drivers ---
def driver_fields_to_csv_row(d: Dict[str, Any]) -> Dict[str, Any]:
def s(k: str) -> str:
v = d.get(k)
if v is None:
return ""
return str(v).strip()
line = d.get("line")
line_s = str(line) if line is not None else ""
return {
"TRANSPORTISTA": s("transporter_key"),
"LINEA": line_s,
"CLAVE CONDUCTOR": s("driver_name"),
"LICENCIA": s("license_number"),
"PERMISO LINEA EXPRESS": s("express_line_id"),
"IDENTIFICACION ACE": s("ace_id"),
"FECHA NACIMIENTO": str(d.get("birth_date")) if d.get("birth_date") is not None else "",
"SEXO": s("gender"),
"PAIS NACIMIENTO": s("birth_country"),
"TRANSPORTA MAT. PELIGROSO?": s("hazardous_material_auth"),
"PERMISO MAT. PELIGROSO": s("hazardous_material_state"),
"NOMBRE(S)": s("first_name"),
"APELLIDO PATERNO": s("last_name"),
"FORMA IDENTIFICACION 1": s("id_key1"),
"NUM. IDENTIFICACION 1": s("id_number1"),
"ESTADO": s("id_state1"),
"PAIS": s("id_country1"),
"FORMA IDENTIFICACION 2": s("id_key2"),
"NUM. IDENTIFICACION 2": s("id_number2"),
"ESTADO 2": s("id_state2"),
"PAIS 2": s("id_country2"),
}
def validate_driver_row_for_api(
tenant_id: int,
company_id: int,
row: Dict[str, Any],
*,
is_update: bool,
existing_driver_keys: Set[Tuple[str, int]],
) -> None:
from api.v1.modules.a76.layouts_csv.drivers.common.fk_loader import load_drivers_fk_sets
from api.v1.modules.a76.layouts_csv.drivers.validators.create import validate_row_driver
valid_transporter_keys, valid_country_ame, _ = load_drivers_fk_sets(tenant_id, company_id)
transporter_key = (row.get("TRANSPORTISTA") or "").strip().upper()
from api.v1.modules.a76.layouts_csv.drivers.common.common_validators import parse_int
line = parse_int(row.get("LINEA"))
actualizar = is_update and bool(transporter_key and line is not None) and (transporter_key, line) in existing_driver_keys
errs = validate_row_driver(
row,
LINE,
actualizar=actualizar,
existing_driver_keys=existing_driver_keys,
valid_transporter_keys=valid_transporter_keys,
valid_country_ame=valid_country_ame,
)
_raise_if_errors(errs)

View File

@@ -3,6 +3,8 @@ from typing import Any, Dict, List
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query, status
from api.v1.common.catalog_validation_errors import CatalogValidationError
from sqlalchemy.orm import Session
from .dto import DriverCreateDTO, DriverResponseDTO, DriverUpdateDTO
@@ -104,7 +106,13 @@ async def create_driver(
)
# Usar la clave tal como está en BD (mismo caso)
driver_data.transporter_key = transporter.transporter_key
return DriverService.create_driver(db, driver_data)
try:
return DriverService.create_driver(db, driver_data)
except CatalogValidationError as e:
raise HTTPException(
status_code=422,
detail={"message": str(e), "errors": e.errors},
)
@router.put("/{transporter_key}/{line}", response_model=DriverResponseDTO)
@@ -117,14 +125,20 @@ async def update_driver(
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
driver = DriverService.update_driver(
db,
transporter_key,
line,
str(company_id),
tenant_id,
driver_data,
)
try:
driver = DriverService.update_driver(
db,
transporter_key,
line,
str(company_id),
tenant_id,
driver_data,
)
except CatalogValidationError as e:
raise HTTPException(
status_code=422,
detail={"message": str(e), "errors": e.errors},
)
if not driver:
raise HTTPException(status_code=404, detail="Driver not found")
return driver

View File

@@ -4,6 +4,10 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from . import dto, models
from api.v1.modules.a76.transportation.catalog_parity import (
driver_fields_to_csv_row,
validate_driver_row_for_api,
)
DRIVER_ID_SEQ = "a76.driver_driver_id_seq"
@@ -43,6 +47,13 @@ class DriverService:
@staticmethod
def create_driver(db: Session, driver_data: dto.DriverCreateDTO):
data = driver_data.model_dump()
validate_driver_row_for_api(
int(driver_data.tenant_id),
int(driver_data.company_id),
driver_fields_to_csv_row(data),
is_update=False,
existing_driver_keys=set(),
)
if data.get("driver_id") is None:
data["driver_id"] = allocate_driver_id(db)
new_driver = models.Driver(**data)
@@ -66,6 +77,42 @@ class DriverService:
if not driver:
return None
update_data = data.model_dump(exclude_unset=True)
merged = {
"transporter_key": driver.transporter_key,
"line": driver.line,
"driver_name": driver.driver_name,
"license_number": driver.license_number,
"express_line_id": driver.express_line_id,
"ace_id": driver.ace_id,
"birth_date": driver.birth_date,
"gender": driver.gender,
"birth_country": driver.birth_country,
"hazardous_material_auth": driver.hazardous_material_auth,
"hazardous_material_state": driver.hazardous_material_state,
"first_name": driver.first_name,
"last_name": driver.last_name,
"id_key1": driver.id_key1,
"id_number1": driver.id_number1,
"id_state1": driver.id_state1,
"id_country1": driver.id_country1,
"id_key2": driver.id_key2,
"id_number2": driver.id_number2,
"id_state2": driver.id_state2,
"id_country2": driver.id_country2,
"badge_number": driver.badge_number,
"class_type": driver.class_type,
"unique_badge_number": driver.unique_badge_number,
}
merged.update(update_data)
tid = int(driver.tenant_id)
cid = int(driver.company_id)
validate_driver_row_for_api(
tid,
cid,
driver_fields_to_csv_row(merged),
is_update=True,
existing_driver_keys={(driver.transporter_key.strip().upper(), driver.line)},
)
for key, value in update_data.items():
setattr(driver, key, value)
db.commit()

View File

@@ -1,6 +1,6 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
class TrailerBaseDTO(BaseModel):
@@ -15,6 +15,44 @@ class TrailerBaseDTO(BaseModel):
country: Optional[str] = None
container_key: Optional[str] = None
@field_validator("trailer_number", mode="before")
@classmethod
def strip_trailer_number(cls, v: object) -> object:
if isinstance(v, str):
return v.strip()
return v
@field_validator("trailer_type_key", mode="before")
@classmethod
def trailer_type_key_optional_fk(cls, v: object) -> Optional[str]:
"""Empty string from JSON must become NULL for FK; normalize case for catalog match."""
if v is None:
return None
if not isinstance(v, str):
return None
s = v.strip().upper()
return None if s == "" else s
@field_validator(
"ace_trailer_number",
"seal",
"entity_code",
"plate_number",
"state",
"country",
"container_key",
mode="before",
)
@classmethod
def empty_optional_str_to_none(cls, v: object) -> Optional[str]:
"""JSON often sends ''; nullable columns should get NULL, not ''."""
if v is None:
return None
if not isinstance(v, str):
return None
s = v.strip()
return None if s == "" else s
class TrailerCreateDTO(TrailerBaseDTO):
"""Schema for creating a trailer"""

View File

@@ -4,6 +4,10 @@ from sqlalchemy.orm import Session
from sqlalchemy import text
from . import dto, models
from api.v1.modules.a76.transportation.catalog_parity import (
trailer_fields_to_csv_row,
validate_trailer_row_for_api,
)
TRAILER_ID_SEQ = "a76.trailer_trailer_id_seq"
@@ -75,6 +79,13 @@ class TrailerService:
) -> models.Trailer:
"""Create a new trailer"""
data = trailer_data.model_dump()
validate_trailer_row_for_api(
tenant_id,
company_id,
trailer_fields_to_csv_row(data),
is_update=False,
existing_trailer_numbers=set(),
)
if data.get("trailer_id") is None:
data["trailer_id"] = allocate_trailer_id(db)
new_trailer = models.Trailer(
@@ -102,6 +113,25 @@ class TrailerService:
update_data = trailer_data.model_dump(
exclude_unset=True, exclude={"trailer_number"}
)
merged = {
"trailer_number": trailer.trailer_number,
"ace_trailer_number": trailer.ace_trailer_number,
"trailer_type_key": trailer.trailer_type_key,
"seal": trailer.seal,
"entity_code": trailer.entity_code,
"plate_number": trailer.plate_number,
"state": trailer.state,
"country": trailer.country,
"container_key": trailer.container_key,
}
merged.update(update_data)
validate_trailer_row_for_api(
tenant_id,
company_id,
trailer_fields_to_csv_row(merged),
is_update=True,
existing_trailer_numbers={trailer_number.strip()},
)
for field, value in update_data.items():
setattr(trailer, field, value)

View File

@@ -5,6 +5,10 @@ from sqlalchemy.orm import Session
from sqlalchemy import func, text
from . import dto, models
from api.v1.modules.a76.transportation.catalog_parity import (
transporter_fields_to_csv_row,
validate_transporter_row_for_api,
)
logger = logging.getLogger(__name__)
@@ -99,6 +103,13 @@ class TransporterService:
) -> models.Transporter:
"""Create a new transporter"""
data = transporter_data.model_dump()
validate_transporter_row_for_api(
tenant_id,
company_id,
transporter_fields_to_csv_row(data),
is_update=False,
existing_transporter_keys=set(),
)
if data.get("transporter_id") is None:
data["transporter_id"] = allocate_transporter_id(db)
new_transporter = models.Transporter(
@@ -128,6 +139,35 @@ class TransporterService:
update_data = transporter_data.model_dump(
exclude_unset=True, exclude={"transporter_key"}
)
merged = {
"transporter_key": transporter.transporter_key,
"name": transporter.name,
"short_name": transporter.short_name,
"responsible": transporter.responsible,
"rfc": transporter.rfc,
"streets": transporter.streets,
"postal_code": transporter.postal_code,
"city": transporter.city,
"state": transporter.state,
"country": transporter.country,
"loader_code": transporter.loader_code,
"caat_code": transporter.caat_code,
"transport_code": transporter.transport_code,
"transport_interface_type": transporter.transport_interface_type,
"ftp_server": transporter.ftp_server,
"ftp_user": transporter.ftp_user,
"ftp_password": transporter.ftp_password,
"ftp_directory": transporter.ftp_directory,
"filler_code": transporter.filler_code,
}
merged.update(update_data)
validate_transporter_row_for_api(
tenant_id,
company_id,
transporter_fields_to_csv_row(merged),
is_update=True,
existing_transporter_keys={transporter_key.strip().upper()},
)
for field, value in update_data.items():
setattr(transporter, field, value)

View File

@@ -4,6 +4,10 @@ from sqlalchemy.orm import Session
from sqlalchemy import text
from . import dto, models
from api.v1.modules.a76.transportation.catalog_parity import (
vehicle_fields_to_csv_row,
validate_vehicle_row_for_api,
)
VEHICLE_ID_SEQ = "a76.vehicle_vehicle_id_seq"
@@ -75,6 +79,14 @@ class VehicleService:
) -> models.Vehicle:
"""Create a new vehicle"""
data = vehicle_data.model_dump()
validate_vehicle_row_for_api(
db,
tenant_id,
company_id,
vehicle_fields_to_csv_row(data),
is_update=False,
existing_vehicle_keys=set(),
)
if data.get("vehicle_id") is None:
data["vehicle_id"] = allocate_vehicle_id(db)
new_vehicle = models.Vehicle(
@@ -100,6 +112,45 @@ class VehicleService:
# Update fields (excluding vehicle_key as it's the primary key)
update_data = vehicle_data.model_dump(exclude_unset=True, exclude={"vehicle_key"})
merged = {
"vehicle_key": vehicle.vehicle_key,
"ace_vehicle_key": vehicle.ace_vehicle_key,
"transporter_key": vehicle.transporter_key,
"transport_identifier": vehicle.transport_identifier,
"transport_type": vehicle.transport_type,
"entity_code": vehicle.entity_code,
"transponder_number": vehicle.transponder_number,
"dot_number": vehicle.dot_number,
"plate_number": vehicle.plate_number,
"city": vehicle.city,
"state": vehicle.state,
"country": vehicle.country,
"seal": vehicle.seal,
"insurance_company_name": vehicle.insurance_company_name,
"insurance_number": vehicle.insurance_number,
"insurance_amount": float(vehicle.insurance_amount)
if vehicle.insurance_amount is not None
else None,
"insurance_date": vehicle.insurance_date,
"box_number": vehicle.box_number,
"brand": vehicle.brand,
"year": vehicle.year,
"series": vehicle.series,
"description": vehicle.description,
"engine_number": vehicle.engine_number,
"sct_permission": vehicle.sct_permission,
"color": vehicle.color,
"container_key": vehicle.container_key,
}
merged.update(update_data)
validate_vehicle_row_for_api(
db,
tenant_id,
company_id,
vehicle_fields_to_csv_row(merged),
is_update=True,
existing_vehicle_keys={vehicle_key.strip()},
)
for field, value in update_data.items():
setattr(vehicle, field, value)

View File

@@ -1,6 +1,7 @@
import csv
import os
from sqlalchemy.orm import Session
from api.v1.modules.a76.layouts_csv.common import csv_reader as common_csv_reader
from .models import CartaPorte
def seed_carta_porte(db: Session):
@@ -16,7 +17,8 @@ def seed_carta_porte(db: Session):
print("Seeding Carta Porte catalog (this might take a while)...")
with open(csv_path, mode='r', encoding='utf-8-sig') as f:
encoding = common_csv_reader.detect_text_encoding(csv_path)
with open(csv_path, mode='r', encoding=encoding) as f:
# User provided comma-separated data
reader = csv.DictReader(f)

View File

@@ -1,6 +1,15 @@
from typing import Optional
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
class TrailerTypeListItemDTO(BaseModel):
"""Catálogo público GTipoTrailer (listado UI)."""
trailer_type_key: str = Field(..., max_length=2)
description: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class TrailerTypeBaseDTO(BaseModel):

View File

@@ -1,5 +1,7 @@
from typing import Any, Dict
from core.database import get_core_db
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from . import dto, services
@@ -7,6 +9,22 @@ from . import dto, services
router = APIRouter()
@router.get("/trailer-types/", response_model=Dict[str, Any])
def list_trailer_types(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
db: Session = Depends(get_core_db),
):
skip = (page - 1) * page_size
items, total = services.TrailerTypeService.list_trailer_types(db, skip, page_size)
return {
"items": [dto.TrailerTypeListItemDTO.model_validate(obj) for obj in items],
"total": total,
"page": page,
"page_size": page_size,
}
@router.get(
"/trailer-types/{trailer_type_key}", response_model=dto.TrailerTypeResponseDTO
)

View File

@@ -1,9 +1,20 @@
from typing import List, Tuple
from sqlalchemy.orm import Session
from . import dto, models
class TrailerTypeService:
@staticmethod
def list_trailer_types(
db: Session, skip: int = 0, limit: int = 50
) -> Tuple[List[models.TrailerType], int]:
q = db.query(models.TrailerType).order_by(models.TrailerType.trailer_type_key)
total = q.count()
items = q.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_trailer_type_by_key(db: Session, trailer_type_key: str):
return (

View File

@@ -0,0 +1,150 @@
from pathlib import Path
from api.v1.modules.a76.layouts_csv.common.csv_reader import (
CsvReadPlan,
detect_text_encoding,
inspect_csv,
iter_csv_rows,
iter_csv_rows_with_plan,
)
from api.v1.modules.a76.layouts_csv.classes.template_config import (
detect_headers_or_data as detect_classes_headers,
row_from_template as row_from_classes_template,
)
from api.v1.modules.a76.layouts_csv.parts.template_config import detect_headers_or_data as detect_parts_headers
from api.v1.modules.a76.layouts_csv.pedmientos.template_config import (
detect_headers_or_data as detect_pedimentos_headers,
parse_pedimento_col_a,
)
def _write_bytes(tmp_path: Path, name: str, payload: bytes) -> Path:
file_path = tmp_path / name
file_path.write_bytes(payload)
return file_path
def test_detect_text_encoding_handles_truncated_utf8_sample(tmp_path: Path):
# Regression: "ó" in "Descripción" empieza en byte 21 (0xC3 0xB3).
# sample_bytes=22 lee bytes 0-21, terminando en 0xC3 (primer byte de ó, secuencia incompleta).
# El código viejo: raw.decode("utf-8-sig") fallaba → caía a cp1252 → mojibake.
# El código nuevo: decoder incremental tolera el corte → retorna utf-8-sig.
payload = "DESCRIPCION\nDescripción español\n".encode("utf-8")
file_path = _write_bytes(tmp_path, "truncated_utf8.csv", payload)
enc = detect_text_encoding(str(file_path), sample_bytes=22)
assert enc in ("utf-8", "utf-8-sig"), (
f"Got {enc!r} — el archivo UTF-8 con corte de muestra a mitad de multibyte "
"fue detectado como cp1252, produciendo mojibake (español / Descripción)"
)
def test_utf8_enie_at_sample_boundary_not_detected_as_cp1252(tmp_path: Path):
# Regresión directa del bug mojibake reportado en producción.
# Construye un payload donde 'ñ' (0xC3 0xB1 en UTF-8) cae exactamente en el byte 19,
# y sample_bytes=20 lee sólo 0xC3 (primer byte) — secuencia incompleta.
# Resultado esperado: utf-8 / utf-8-sig (no cp1252).
header = b"CLASE,DESC\n" # 11 bytes
row = "C01,español\n".encode("utf-8") # ñ en bytes 19-20 del payload total
payload = header + row
file_path = _write_bytes(tmp_path, "regression_mojibake.csv", payload)
enc = detect_text_encoding(str(file_path), sample_bytes=20)
assert enc in ("utf-8", "utf-8-sig"), (
f"Got {enc!r} en lugar de utf-8 — leer como cp1252 produciría "
"'español' en lugar de 'español'"
)
def test_iter_csv_rows_preserves_utf8_values(tmp_path: Path):
payload = "CLASE,DESCRIPCION ESPAÑOL\nCLASE01,Clase prueba español\n".encode("utf-8")
file_path = _write_bytes(tmp_path, "utf8_values.csv", payload)
rows = list(iter_csv_rows(str(file_path)))
assert len(rows) == 1
_, row = rows[0]
assert row["DESCRIPCION ESPAÑOL"] == "Clase prueba español"
def test_iter_csv_rows_keeps_cp1252_compatibility(tmp_path: Path):
payload = "CLASE,DESCRIPCION ESPAÑOL\nCLASE01,Descripción\n".encode("cp1252")
file_path = _write_bytes(tmp_path, "cp1252_values.csv", payload)
rows = list(iter_csv_rows(str(file_path)))
assert len(rows) == 1
_, row = rows[0]
assert row["DESCRIPCION ESPAÑOL"] == "Descripción"
def test_parts_detect_headers_or_data_handles_cp1252(tmp_path: Path):
payload = "NUMERO DE PARTE,DESCRIPCION EN ESPAÑOL\nP-01,Descripción\n".encode("cp1252")
file_path = _write_bytes(tmp_path, "parts_cp1252.csv", payload)
fieldnames, has_header = detect_parts_headers(str(file_path), lambda s: (s or "").strip().upper())
assert has_header is True
assert fieldnames is None
def test_pedimentos_detect_headers_or_data_handles_cp1252_data_first_row(tmp_path: Path):
payload = "24,1234,1234567,I,A1\n".encode("cp1252")
file_path = _write_bytes(tmp_path, "pedimentos_cp1252_data.csv", payload)
fieldnames, has_header = detect_pedimentos_headers(
str(file_path),
lambda s: (s or "").strip().upper(),
parse_pedimento_col_a,
)
assert has_header is False
assert fieldnames is not None
def test_classes_detect_headers_or_data_handles_cp1252(tmp_path: Path):
payload = "CLAVE CLASE;DESCRIPCION ESPAÑOL\nC01;Descripción\n".encode("cp1252")
file_path = _write_bytes(tmp_path, "classes_cp1252_semicolon.csv", payload)
fieldnames, has_header = detect_classes_headers(str(file_path), lambda s: (s or "").strip().upper())
assert has_header is True
assert fieldnames is None
def test_classes_row_from_template_recovers_collapsed_header_with_semicolon():
row = {"CLAVE CLASE,DESCRIPCION ESPAÑOL": "C01;Descripción;Description"}
mapped = row_from_classes_template(row, lambda s: (s or "").strip().upper())
assert mapped["CLASE"] == "C01"
def test_iter_csv_rows_with_plan_headerless_and_semicolon(tmp_path: Path):
payload = "C01;Descripcion 1\nC02;Descripcion 2\n".encode("utf-8")
file_path = _write_bytes(tmp_path, "headerless_semicolon.csv", payload)
plan = CsvReadPlan(
header_mode="headerless",
fieldnames=["CLASE", "DESCRIPCIONE"],
)
rows = list(iter_csv_rows_with_plan(str(file_path), plan))
assert len(rows) == 2
assert rows[0][1]["CLASE"] == "C01"
assert rows[1][1]["DESCRIPCIONE"] == "Descripcion 2"
def test_inspect_csv_auto_mode_switches_to_headerless(tmp_path: Path):
payload = "C01,Descripcion\n".encode("utf-8")
file_path = _write_bytes(tmp_path, "auto_mode.csv", payload)
plan = CsvReadPlan(
header_mode="auto",
fieldnames=["CLASE", "DESCRIPCIONE"],
headerless_first_cell_values={"C01"},
)
metadata = inspect_csv(str(file_path), plan)
assert metadata.has_header is False
assert metadata.fieldnames == ["CLASE", "DESCRIPCIONE"]