Update Docker Compose files for production environment and enhance unit of measure handling

- Changed the default environment variable from 'development' to 'production' in docker-compose.prod.yml and docker-compose.yml.
- Added SITAR API credentials to the environment variables in both Docker Compose files.
- Updated the seed data for customs units of measure to include a76_unit_code.
- Refactored the UnitOfMeasureCustoms model and related DTOs to replace scaii_unit_code with a76_unit_code for consistency.
- Adjusted frontend components to reflect the updated unit of measure structure and ensure proper handling of the new a76_unit_code field.
This commit is contained in:
AlexeerCT
2026-03-15 20:52:01 -06:00
parent 44e621c36d
commit a4cf394447
18 changed files with 226 additions and 95 deletions

View File

@@ -364,12 +364,12 @@ def upgrade() -> None:
f"INSERT INTO a76.unit_of_measure_american (code, description) VALUES {val_ame} ON CONFLICT ON CONSTRAINT uq_uom_american_code DO NOTHING;"
)
# ADUA (Customs)
# ADUA (Customs) - seed_adua: (code, description, a76_unit_code)
val_adua = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in adua_seed]
[f"({format_value(code)}, {format_value(desc)}, {format_value(a76_code)})" for code, desc, a76_code in adua_seed]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_customs (code, description) VALUES {val_adua} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;"
f"INSERT INTO a76.unit_of_measure_customs (code, description, a76_unit_code) VALUES {val_adua} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;"
)
# Recolectar códigos adicionales que faltan en los catálogos
@@ -378,7 +378,7 @@ def upgrade() -> None:
additional_ace = set()
additional_oma = set()
existing_customs = {c for c, d in adua_seed}
existing_customs = {code for code, desc, a76_code in adua_seed}
existing_american = {c for c, d in ame_seed}
existing_ace = {c for c, d in ace_seed}
existing_oma = {c for c, d in oma_seed}
@@ -396,7 +396,7 @@ def upgrade() -> None:
# Insertar códigos adicionales
if additional_customs:
val_add_customs = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in additional_customs]
[f"({format_value(c)}, {format_value(d)}, {format_value(s)})" for c, d, s in additional_customs]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_customs (code, description, a76_unit_code) VALUES {val_add_customs} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;"

View File

@@ -23,7 +23,7 @@ class UnitOfMeasureAmericanBase(BaseModel):
class UnitOfMeasureCustomsBase(BaseModel):
code: str = Field(..., max_length=10, description="Customs Code")
description: Optional[str] = Field(None, max_length=50)
scaii_unit_code: Optional[str] = Field(None, max_length=5)
a76_unit_code: Optional[str] = Field(None, max_length=5, description="Unidad SCAII")
class UnitOfMeasureBase(BaseModel):
@@ -86,7 +86,7 @@ class UnitOfMeasureAmericanUpdate(BaseModel):
class UnitOfMeasureCustomsUpdate(BaseModel):
code: Optional[str] = Field(None, max_length=10)
description: Optional[str] = Field(None, max_length=50)
scaii_unit_code: Optional[str] = Field(None, max_length=5)
a76_unit_code: Optional[str] = Field(None, max_length=5)
class UnitOfMeasureUpdate(BaseModel):

View File

@@ -1,24 +1,24 @@
seed = [
("1", "Kilo"),
("2", "Gramo"),
("3", "Metro Lineal"),
("4", "Metro Cuadrado"),
("5", "Metro Cubico"),
("6", "Pieza"),
("7", "Cabeza"),
("8", "Litro"),
("9", "Par"),
("10", "Kilowatt"),
("11", "Millar"),
("12", "Juego"),
("13", "Kilowatt/Hora"),
("14", "Tonelada"),
("15", "Barril"),
("16", "Gramo Neto"),
("17", "Decenas"),
("18", "Cientos"),
("19", "Decenas"),
("20", "Caja"),
("21", "Botella"),
("22", "Carat"),
("1", "Kilo", "KGS"),
("2", "Gramo", "GR"),
("3", "Metro Lineal", "MT"),
("4", "Metro Cuadrado", "M2"),
("5", "Metro Cubico", "M3"),
("6", "Pieza", "PZA"),
("7", "Cabeza", "PZA"),
("8", "Litro", "LT"),
("9", "Par", "PAR "),
("10", "Kilowatt", ""),
("11", "Millar", "MILLR"),
("12", "Juego", "JGO"),
("13", "Kilowatt/Hora", ""),
("14", "Tonelada", "TON"),
("15", "Barril", "BARR"),
("16", "Gramo Neto", ""),
("17", "Decenas", "DEC"),
("18", "Cientos", "CIEN"),
("19", "Decenas", "DOCE"),
("20", "Caja", "CAJA"),
("21", "Botella", "PZA"),
("22", "Carat", "CARAT"),
]

View File

@@ -7,7 +7,7 @@ from core.exceptions import ErrorCollector
def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector):
if invoice.status == InvoiceStatus.PROCESSED:
errors.add(
errors.add_error(
"status",
"La factura ya fue procesada y no puede ser exportada",
solution=["Verifique el estatus de la factura antes de intentar exportarla"],
@@ -79,7 +79,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
LineItem.company_id == company_id,
).all()
fractions = {line.fraction for line in lines if line.fraction}
fractions = {line.customs.fraction for line in lines if line.customs.fraction}
if fractions:
warned_fractions = {
row.fraction
@@ -88,7 +88,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
.all()
}
for line in lines:
if line.fraction in warned_fractions:
if line.customs.fraction in warned_fractions:
errors.add_warning(
field="fraction",
message="Advertencia: Esta mercancía, sólo podrá entrar al territorio nacional por las aduanas del país, de lunes a sábado de 8:00 a 13:00 hrs. Ley 10, 18, LIGIE 1, Capítulo 87, RGCE 4.5.31., Anexo 4.",

View File

@@ -1,30 +1,65 @@
from typing import List
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import HistoricalTariffFraction
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import (
HistoricalTariffFraction,
)
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from core.exceptions import ErrorCollector
def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
"""Returns True if the fraction exists in TariffFraction (SFracciones) or
HistoricalTariffFraction (GFraccionesHistorico).
def _fraction_exists_via_sitar(fraction_code: str) -> bool:
"""Returns True if the fraction exists in SITAR (fracciones o fracciones-anteriores).
Fraction format: first 8 chars = base fraction, chars 9-10 = NICO/country (optional).
Falls back to False if SITAR is not configured or request fails.
"""
if not fraction_code:
return True
base_frac = fraction_code[:8].strip()
nico = fraction_code[8:10].strip() if len(fraction_code) > 8 else ""
try:
from api.v1.modules.sitar.fracciones.service import FraccionesService
from api.v1.modules.sitar.fracciones_anteriores.service import (
FraccionesAnterioresService,
)
# 1. Buscar en fracciones arancelarias (SITAR)
results = FraccionesService.search_sync(
fraccion=base_frac,
nico=nico if nico else None,
limit=1,
)
if results:
return True
# 2. Buscar en fracciones anteriores / histórico (SITAR)
hist_results = FraccionesAnterioresService.search_sync(
fraccion_anterior=base_frac,
limit=1,
)
return len(hist_results) > 0
except (ValueError, Exception):
# SITAR no configurado o error de red: se usa fallback a BD local
return False
def _fraction_exists_in_local_db(db: Session, fraction_code: str) -> bool:
"""Fallback: valida contra TariffFraction e HistoricalTariffFraction locales."""
if not fraction_code:
return True
base_frac = fraction_code[:8]
nico = fraction_code[8:10] if len(fraction_code) > 8 else ""
# Check SFracciones (TariffFraction)
tariff_q = db.query(TariffFraction).filter(
func.left(TariffFraction.code, 8) == base_frac
)
@@ -37,7 +72,6 @@ def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
if tariff_q.first() is not None:
return True
# Check GFraccionesHistorico (HistoricalTariffFraction)
hist_q = db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.historical_fraction == base_frac
)
@@ -53,6 +87,17 @@ def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
return hist_q.first() is not None
def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool:
"""Returns True if the fraction exists in SITAR (fracciones) or fallback a BD local.
Usa las funciones de SITAR de fracciones como fuente principal.
Si SITAR no está configurado o falla, valida contra TariffFraction e HistoricalTariffFraction.
"""
if _fraction_exists_via_sitar(fraction_code):
return True
return _fraction_exists_in_local_db(db, fraction_code)
def _validate_line_fraction(
db: Session, line: LineItem, errors: ErrorCollector
) -> None:

View File

@@ -4,6 +4,7 @@ from decimal import Decimal
from pydantic import BaseModel, Field, field_validator
from .models import (
DestinationOriginCove,
InvoiceStatus,
OperationType,
Currency,
TransportType,
@@ -56,7 +57,21 @@ class InvoiceHeaderBase(BaseModel):
)
invoice_date: date = Field(..., description="Invoice date")
emission_date: Optional[date] = Field(None, description="Emission date")
status: bool = Field(False, description="Status")
status: Optional[InvoiceStatus] = Field(None, description="Status: pending, processed, reversed")
@field_validator("status", mode="before")
@classmethod
def normalize_status(cls, v):
"""Coerce legacy boolean strings ('false'/'true') to InvoiceStatus."""
if v is None:
return v
if isinstance(v, str):
v_lower = v.lower()
if v_lower == "false":
return InvoiceStatus.PENDING.value
if v_lower == "true":
return InvoiceStatus.PROCESSED.value
return v
processed_date: Optional[datetime] = Field(None, description="Update date")
who_processed: Optional[str] = Field(None, max_length=20, description="Who processed")
capture_user: Optional[str] = Field(None, max_length=20, description="Capture user")

View File

@@ -1,5 +1,6 @@
"""Fracciones Service"""
import asyncio
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesResponse
@@ -44,3 +45,26 @@ class FraccionesService(SitarAPIBaseService):
"""Get single Fraccion record by SYSID"""
data = await self._make_request("GET", f"/api/v1/fracciones/{sysid}")
return FraccionesResponse(**data)
@classmethod
def search_sync(
cls,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
description: Optional[str] = None,
nivel: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesResponse]:
"""Search Mexican tariff fractions (sync wrapper for use in Celery/sync context)."""
service = cls.get_instance()
return asyncio.run(
service.search(
fraccion=fraccion,
nico=nico,
description=description,
nivel=nivel,
skip=skip,
limit=limit,
)
)

View File

@@ -1,5 +1,6 @@
"""FraccionesAnteriores Service"""
import asyncio
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesAnterioresResponse
@@ -34,3 +35,22 @@ class FraccionesAnterioresService(SitarAPIBaseService):
async def get_by_id(self, sysid: int) -> FraccionesAnterioresResponse:
data = await self._make_request("GET", f"/api/v1/fracciones-anteriores/{sysid}")
return FraccionesAnterioresResponse(**data)
@classmethod
def search_sync(
cls,
fraccion_actual: Optional[str] = None,
fraccion_anterior: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesAnterioresResponse]:
"""Search historical fractions (sync wrapper for use in Celery/sync context)."""
service = cls.get_instance()
return asyncio.run(
service.search(
fraccion_actual=fraccion_actual,
fraccion_anterior=fraccion_anterior,
skip=skip,
limit=limit,
)
)

View File

@@ -7,6 +7,10 @@ from api.v1.modules.public.reference_data.pedimento_regimens.models import Regim
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import (
CodePedimentoRegimen,
)
# InvoiceType debe cargarse antes de InvoiceHeader (FK invoice_header.invoice_type -> public.invoice_types.key)
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType # noqa: F401
# CustomsSection debe cargarse antes de InvoiceComplianceMx (FK invoice_compliance_mx.aduana -> public.customs_sections.customs_code)
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection # noqa: F401
# Import models in correct order for SQLAlchemy relationship resolution
# CRITICAL: FaLineItem must be imported BEFORE LineItem

View File

@@ -158,7 +158,7 @@ services:
container_name: anexo76-backend
environment:
- DEBUG=${DEBUG:-True}
- ENVIRONMENT=${ENVIRONMENT:-development}
- ENVIRONMENT=${ENVIRONMENT:-production}
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
@@ -227,6 +227,9 @@ services:
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
- CORE_DB_USER=${CORE_DB_USER:-postgres}
- CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres}
- SITAR_API_URL=${SITAR_API_URL}
- SITAR_API_USER=${SITAR_API_USER}
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
depends_on:
- backend
- valkey
@@ -247,7 +250,7 @@ services:
image: dev.aduanasoft.com/anexo76/frontend:latest
container_name: anexo76-frontend
environment:
- NODE_ENV=${NODE_ENV:-development}
- NODE_ENV=${NODE_ENV:-production}
- VITE_API_URL=${VITE_API_URL:-https://anexo76-dev.aduanasoft.com/api}
- INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/}
- VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-https://anexo76-dev.aduanasoft.com/kcauth/}

View File

@@ -290,6 +290,9 @@ services:
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
- VALKEY_URL=redis://valkey:6379/0
- SITAR_API_URL=${SITAR_API_URL}
- SITAR_API_USER=${SITAR_API_USER}
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
depends_on:
- backend
- valkey

View File

@@ -8,6 +8,7 @@ export interface UMCustomsMex {
id: number;
code: string;
description: string | null;
a76_unit_code: string | null;
created_at: string | null;
updated_at: string | null;
}
@@ -15,11 +16,13 @@ export interface UMCustomsMex {
export interface UMCustomsMexCreate {
code: string;
description?: string | null;
a76_unit_code?: string | null;
}
export interface UMCustomsMexUpdate {
code?: string;
description?: string | null;
a76_unit_code?: string | null;
}
export interface UMCustomsMexListResponse {

View File

@@ -222,7 +222,7 @@ export interface UnitOfMeasureCustoms {
id: number;
code: string;
description: string | null;
scaii_unit_code: string | null;
a76_unit_code: string | null;
created_at: string | null;
updated_at: string | null;
}
@@ -230,13 +230,13 @@ export interface UnitOfMeasureCustoms {
export interface UnitOfMeasureCustomsCreate {
code: string;
description?: string | null;
scaii_unit_code?: string | null;
a76_unit_code?: string | null;
}
export interface UnitOfMeasureCustomsUpdate {
code?: string;
description?: string | null;
scaii_unit_code?: string | null;
a76_unit_code?: string | null;
}
export interface UnitOfMeasureCustomsListResponse {

View File

@@ -208,7 +208,7 @@ export interface Invoice {
invoice_date?: string | null;
capture_date: string;
emission_date?: string | null;
status?: boolean | null;
status?: "pending" | "processed" | "reversed" | boolean | null;
processed_date?: string | null;
who_processed?: string | null;
capture_user?: string | null;
@@ -266,7 +266,7 @@ export interface CreateInvoiceData {
proforma_number?: string | null;
invoice_date?: string | null;
emission_date?: string | null;
status?: boolean | null;
status?: "pending" | "processed" | "reversed" | boolean | null;
processed_date?: string | null;
who_processed?: string | null;
capture_user?: string | null;
@@ -319,7 +319,7 @@ export interface UpdateInvoiceData {
cfdi_uuid?: string | null;
path_pdf?: string | null;
path_xml?: string | null;
status?: boolean | null;
status?: "pending" | "processed" | "reversed" | boolean | null;
compliance_mx?: Partial<InvoiceComplianceMx> | null;
financials?: Partial<InvoiceFinancials> | null;
logistics?: Partial<InvoiceLogistics>[] | null;

View File

@@ -14,8 +14,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef<UnitOfMeasureCu
header: "Descripción",
},
{
accessorKey: "scaii_unit_code",
header: "Código SCAII",
accessorKey: "a76_unit_code",
header: "Código A76 / SCAII",
},
{
id: "actions",

View File

@@ -28,7 +28,7 @@
let formData = $state({
code: '',
description: '',
scaii_unit_code: ''
a76_unit_code: ''
});
$effect(() => {
@@ -37,10 +37,10 @@
formData = {
code: unit.code,
description: unit.description || '',
scaii_unit_code: unit.scaii_unit_code || ''
a76_unit_code: unit.a76_unit_code || ''
};
} else {
formData = { code: '', description: '', scaii_unit_code: '' };
formData = { code: '', description: '', a76_unit_code: '' };
}
}
});
@@ -53,15 +53,22 @@
return;
}
const data: UnitOfMeasureCustomsCreate | UnitOfMeasureCustomsUpdate = {
code: formData.code,
description: formData.description || null,
scaii_unit_code: formData.scaii_unit_code || null
};
const response = unit
? await updateUnitOfMeasureCustoms(unit.id, data, activeCompanyId)
: await createUnitOfMeasureCustoms(data, activeCompanyId);
? await updateUnitOfMeasureCustoms(unit.id, {
code: formData.code,
description: formData.description || null,
a76_unit_code: formData.a76_unit_code || null
} satisfies UnitOfMeasureCustomsUpdate,
activeCompanyId
)
: await createUnitOfMeasureCustoms(
{
code: formData.code,
description: formData.description || null,
a76_unit_code: formData.a76_unit_code || null
} satisfies UnitOfMeasureCustomsCreate,
activeCompanyId
);
if (response.error) {
} else {
@@ -78,16 +85,16 @@
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="code">Código * (máx. 2 caracteres)</Label>
<Input id="code" bind:value={formData.code} required maxlength="2" />
<Label for="code">Código * (máx. 10 caracteres)</Label>
<Input id="code" bind:value={formData.code} required maxlength={10} />
</div>
<div class="space-y-2">
<Label for="description">Descripción (máx. 20 caracteres)</Label>
<Input id="description" bind:value={formData.description} maxlength="20" />
<Input id="description" bind:value={formData.description} maxlength={20} />
</div>
<div class="space-y-2">
<Label for="scaii_unit_code">Código SCAII</Label>
<Input id="scaii_unit_code" bind:value={formData.scaii_unit_code} maxlength="5" />
<Label for="a76_unit_code">Código A76 / SCAII</Label>
<Input id="a76_unit_code" bind:value={formData.a76_unit_code} maxlength={5} />
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>

View File

@@ -287,7 +287,9 @@ export function createColumns(
accessorKey: "status",
header: "Actualizado",
cell: ({ row }) => {
const isprocessed = row.original.status;
// status can be 'processed' | 'pending' | 'reversed' (string) or legacy boolean
const s = row.original.status;
const isprocessed = s === "processed" || s === true;
const processedSnippet = createRawSnippet<[{ isprocessed?: boolean | null }]>((getprocessed) => {
const { isprocessed } = getprocessed();

View File

@@ -38,41 +38,46 @@
}
}
async function pollOnce() {
if (!taskId) return;
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
const raw = await apiCall(taskId);
const response = raw?.data !== undefined ? raw.data : raw;
if (raw?.error) {
hasError = true;
statusMessage = `Error: ${raw.error}`;
stopPolling();
toast.error(raw.error);
return;
}
if (response?.state === 'PROCESSING' && response.info) {
progress = response.info.current || 0;
statusMessage = response.info.status || 'Procesando...';
} else if (response?.state === 'SUCCESS') {
progress = 100;
statusMessage = '¡Completado!';
isComplete = true;
stopPolling();
setTimeout(() => onComplete(response.result), 500);
} else if (response?.state === 'FAILURE') {
hasError = true;
const errMsg = response.result ? String(response.result) : 'Error desconocido';
statusMessage = `Error: ${errMsg}`;
stopPolling();
toast.error(`Falló: ${errMsg}`);
}
}
async function startPolling() {
stopPolling(); // Asegurar limpieza previa
await pollOnce(); // Primer poll inmediato para mostrar progreso sin esperar 1s
pollingInterval = setInterval(async () => {
if (!taskId) return;
if (isComplete || hasError) return;
try {
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
const response = await apiCall(taskId);
if (response.state === 'PROCESSING' && response.info) {
progress = response.info.current || 0;
statusMessage = response.info.status || 'Procesando...';
} else if (response.state === 'SUCCESS') {
progress = 100;
statusMessage = '¡Completado!';
isComplete = true;
stopPolling();
// Pequeña pausa para ver el 100%
setTimeout(() => {
onComplete(response.result);
}, 500);
} else if (response.state === 'FAILURE') {
hasError = true;
// Intenta mostrar el mensaje de error real si viene en 'result'
const errMsg = response.result ? String(response.result) : 'Error desconocido';
statusMessage = `Error: ${errMsg}`;
stopPolling();
toast.error(`Falló la generación: ${errMsg}`);
console.error('Task failed with result:', response);
}
await pollOnce();
} catch (error) {
console.error('Error polling task status:', error);
// No detenemos el polling inmediatamente por un error de red transitorio,
// pero podríamos contar intentos fallidos si fuera necesario.
}
}, 1000);
}