From ef5564dd25057e6ca5b76f2d59d75abe2dc52fbe Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Fri, 13 Mar 2026 16:53:42 -0600 Subject: [PATCH 1/3] Refactor Dockerfile and update models and frontend components - Removed the APP_VERSION argument from the Dockerfile and added TARGETARCH for architecture-specific wkhtmltopdf installation. - Updated models.py to reintroduce the import of PartCountry for consistency. - Enhanced parts.ts interface by adding new optional fields: immex_type, disable_movements, pga_program_code, non_discharge_clients, bom_items, and countries. - Changed formType default in partForm.svelte and edit page to 'fa' for better alignment with the intended functionality. - Improved type handling for non_discharge_clients in partForm.svelte. - Adjusted column span logic in partForm.svelte for better rendering. - Fixed image error handling in partForm.svelte for improved user experience. - Updated license expiration date calculation in init_first_time.sh for compatibility with macOS. --- backend/Dockerfile | 10 +++------- backend/api/v1/modules/a76/parts/models.py | 2 +- frontend/src/lib/api/dashboard/a76/parts.ts | 6 ++++++ .../components/dashboard/goods/parts/partForm.svelte | 8 ++++---- .../dashboard/goods/parts/edit/[[id]]/+page.svelte | 2 +- scripts/init_first_time.sh | 6 +++++- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index f6253da3..6f6a9c20 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,12 +2,6 @@ FROM python:3.11-slim WORKDIR /app -# ======================================== -# ARG para recibir la versión desde CI/CD -# ======================================== -ARG APP_VERSION="dev" -ENV APP_VERSION=${APP_VERSION} - # Instalar dependencias del sistema RUN apt-get update && apt-get install -y \ gcc \ @@ -32,7 +26,9 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # Instalar wkhtmltopdf binario oficial con soporte para footers/headers -RUN curl -k -L -o /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb \ +# TARGETARCH permite amd64 y arm64 (Apple Silicon) +ARG TARGETARCH +RUN curl -k -L -o /tmp/wkhtmltox.deb "https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_${TARGETARCH}.deb" \ && apt-get update \ && apt-get install -y /tmp/wkhtmltox.deb \ && rm /tmp/wkhtmltox.deb \ diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py index 3fd9bd1e..27ba357f 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -26,6 +26,7 @@ from api.v1.modules.public.reference_data.currency_types.models import CurrencyT from api.v1.modules.a24.fa.fa_parts.models import FaPart from api.v1.modules.a24.inv.inv_parts.models import InvPart from api.v1.modules.a24.inv.bom.models import BillOfMaterial +from api.v1.modules.a24.inv.part_countries.models import PartCountry if TYPE_CHECKING: @@ -33,7 +34,6 @@ if TYPE_CHECKING: from api.v1.modules.a76.general_catalogs.units_of_measure.models import ( UnitOfMeasure, ) - from api.v1.modules.a24.inv.part_countries.models import PartCountry class Part(Base, TenantScopedMixin, TimestampMixin): diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index 88ab372f..ece9c57b 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -66,6 +66,12 @@ export interface InvData { use_rule_8?: boolean | null; fraction_9801?: string | null; + immex_type?: string | null; + disable_movements?: boolean | null; + pga_program_code?: string | null; + non_discharge_clients?: unknown[] | null; + bom_items?: unknown[] | null; + countries?: unknown[] | null; fraction_type?: string | null; use_alternate_quantity?: boolean | null; repair_unit_cost?: number | null; diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index b989941b..9c7876a2 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -87,7 +87,7 @@ // --- PROPS --- let { partId = null, - formType = $bindable('inv') + formType = $bindable('fa') }: { partId?: number | null; formType?: 'inv' | 'fa' | 'both' } = $props(); // --- LÓGICA DE ESTADO --- @@ -552,7 +552,7 @@ mex_packing: Number(d.inv_data?.mex_packing) || 0, sales_order: d.inv_data?.sales_order || '', use_rule_8: d.inv_data?.use_rule_8 || false, - non_discharge_clients: d.inv_data?.non_discharge_clients || [] + non_discharge_clients: (d.inv_data?.non_discharge_clients || []) as { client_id: number; name: string; is_active: boolean }[] }; // Relaciones BOM / Paises @@ -1911,7 +1911,7 @@ {#if relationData.length === 0} No hay datos registrados para {relationType === 'bom' @@ -2245,7 +2245,7 @@ src={formData.part_photo} alt="Vista previa" class="h-full w-full object-contain" - onerror={(e) => (e.currentTarget.src = '')} + onerror={(e) => ((e.currentTarget as HTMLImageElement).src = '')} /> {:else} diff --git a/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte index 51ded7bd..ee342456 100644 --- a/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte @@ -5,7 +5,7 @@ import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte'; let id = $derived($page.params.id === 'new' ? null : Number($page.params.id)); - let type = $state<'inv' | 'fa' | 'both'>('inv'); + let type = $state<'inv' | 'fa' | 'both'>('fa'); // When editing, the PartForm auto-detects the type from the loaded data. // When creating, the user picks the type here. diff --git a/scripts/init_first_time.sh b/scripts/init_first_time.sh index 0469baaf..727b28a0 100755 --- a/scripts/init_first_time.sh +++ b/scripts/init_first_time.sh @@ -965,7 +965,11 @@ echo -e "\n${YELLOW}[9/9] Creando licencia Enterprise para el tenant...${NC}" # Calcular fechas de inicio y expiración (1 año desde hoy) LICENSE_START_DATE=$(date -u +"%Y-%m-%d %H:%M:%S") -LICENSE_EXPIRE_DATE=$(date -u -d "+1 year" +"%Y-%m-%d %H:%M:%S") +if [[ "$(uname)" == "Darwin" ]]; then + LICENSE_EXPIRE_DATE=$(date -u -v+1y +"%Y-%m-%d %H:%M:%S") +else + LICENSE_EXPIRE_DATE=$(date -u -d "+1 year" +"%Y-%m-%d %H:%M:%S") +fi # Verificar si ya existe una licencia para este tenant LICENSE_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM core.licenses WHERE tenant_id = ${TENANT_ID};") From a4cf394447112c17b48029152bd4326693a259dd Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Sun, 15 Mar 2026 20:52:01 -0600 Subject: [PATCH 2/3] 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. --- .../7937209f9718_seed_initial_data.py | 10 +-- .../general_catalogs/units_of_measure/dto.py | 4 +- .../units_of_measure/seed_adua.py | 44 ++++++------- .../imports/process/pre_validators.py | 6 +- .../process/sub_process/review_classes.py | 59 +++++++++++++++--- .../api/v1/modules/a76/invoices/schemas.py | 17 +++++- .../v1/modules/sitar/fracciones/service.py | 24 ++++++++ .../sitar/fracciones_anteriores/service.py | 20 ++++++ backend/core/celery_app.py | 4 ++ docker-compose.prod.yml | 7 ++- docker-compose.yml | 3 + .../a76/general_catalogs/um-customs-mex.ts | 3 + .../a76/general_catalogs/units-of-measure.ts | 6 +- .../src/lib/api/dashboard/a76/invoices.ts | 6 +- .../units_of_measure/customs/columns.ts | 4 +- .../customs/create-edit-dialog.svelte | 39 +++++++----- .../components/dashboard/invoices/columns.ts | 4 +- .../invoices/pdf-progress-dialog.svelte | 61 ++++++++++--------- 18 files changed, 226 insertions(+), 95 deletions(-) diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index e41ecd80..f83e68bb 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -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;" diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py index ef5997fe..0b593be6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py @@ -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): diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py index d0b91e84..62c67215 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py @@ -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"), ] \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py index c69039ab..9ab0af50 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py @@ -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.", diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py index bdd60853..db009bd6 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_classes.py @@ -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: diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index d1cec36a..f0edde67 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -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") diff --git a/backend/api/v1/modules/sitar/fracciones/service.py b/backend/api/v1/modules/sitar/fracciones/service.py index 640cf969..ada6de61 100644 --- a/backend/api/v1/modules/sitar/fracciones/service.py +++ b/backend/api/v1/modules/sitar/fracciones/service.py @@ -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, + ) + ) diff --git a/backend/api/v1/modules/sitar/fracciones_anteriores/service.py b/backend/api/v1/modules/sitar/fracciones_anteriores/service.py index a0f6d136..1002c805 100644 --- a/backend/api/v1/modules/sitar/fracciones_anteriores/service.py +++ b/backend/api/v1/modules/sitar/fracciones_anteriores/service.py @@ -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, + ) + ) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 0066fbf8..ef6c93e3 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2c828760..938efb43 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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/} diff --git a/docker-compose.yml b/docker-compose.yml index 3be97b57..6bb012de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-mex.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-mex.ts index 2eef1908..4f6cc20d 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-mex.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-mex.ts @@ -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 { diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts index eb8a2807..44721369 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts @@ -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 { diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index 73e72294..4cf0a679 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -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 | null; financials?: Partial | null; logistics?: Partial[] | null; diff --git a/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/customs/columns.ts b/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/customs/columns.ts index 6f5d2136..f8106bcc 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/customs/columns.ts +++ b/frontend/src/lib/components/dashboard/general_catalogs/units_of_measure/customs/columns.ts @@ -14,8 +14,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef { @@ -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 @@
- - + +
- +
- - + +
diff --git a/frontend/src/lib/components/dashboard/invoices/columns.ts b/frontend/src/lib/components/dashboard/invoices/columns.ts index 0e6af76b..62148a60 100644 --- a/frontend/src/lib/components/dashboard/invoices/columns.ts +++ b/frontend/src/lib/components/dashboard/invoices/columns.ts @@ -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(); diff --git a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte index 813c1f66..68fc0269 100644 --- a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte @@ -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); } From 6fa461991f755ec10386630814e2581fa76d773f Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Sun, 15 Mar 2026 22:34:11 -0600 Subject: [PATCH 3/3] Implement invoice revert functionality and enhance progress tracking - Added a new route for reverting invoices in the A76 module. - Updated the pre_validators to provide clearer error messages when processing invoices. - Enhanced the PDF progress dialog to support step-by-step progress tracking for both invoice processing and reverting. - Introduced a confirmation dialog for reverting invoices in the dashboard. - Updated frontend components to handle the new revert functionality and display appropriate progress messages. --- .../api/v1/modules/a24/discharges/models.py | 1 - .../imports/process/pre_validators.py | 8 +- .../invoices/imports/revert/main_process.py | 234 ++++++++++++++++++ .../invoices/imports/revert/pre_validators.py | 42 ++++ .../a76/invoices/imports/revert/routes.py | 76 ++++++ .../revert/sub_process/review_rule_octave.py | 94 +++++++ .../a76/invoices/imports/revert/task.py | 82 ++++++ backend/api/v1/modules/a76/router.py | 2 + backend/core/celery_app.py | 4 + .../src/lib/api/dashboard/a76/invoices.ts | 24 ++ .../components/dashboard/invoices/columns.ts | 20 ++ .../invoices/pdf-progress-dialog.svelte | 75 +++++- .../keyboard/KeyboardManager.svelte | 17 +- .../routes/dashboard/invoices/+page.svelte | 77 +++++- 14 files changed, 738 insertions(+), 18 deletions(-) create mode 100644 backend/api/v1/modules/a76/invoices/imports/revert/main_process.py create mode 100644 backend/api/v1/modules/a76/invoices/imports/revert/pre_validators.py create mode 100644 backend/api/v1/modules/a76/invoices/imports/revert/routes.py create mode 100644 backend/api/v1/modules/a76/invoices/imports/revert/sub_process/review_rule_octave.py create mode 100644 backend/api/v1/modules/a76/invoices/imports/revert/task.py diff --git a/backend/api/v1/modules/a24/discharges/models.py b/backend/api/v1/modules/a24/discharges/models.py index 25152746..2c8aed7f 100644 --- a/backend/api/v1/modules/a24/discharges/models.py +++ b/backend/api/v1/modules/a24/discharges/models.py @@ -26,7 +26,6 @@ import datetime from decimal import Decimal from enum import Enum from typing import TYPE_CHECKING, List, Optional -from ..discharges.models import DischargeDetail from sqlalchemy import ( BigInteger, diff --git a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py index 9ab0af50..1e96b84a 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py @@ -9,12 +9,14 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_ if invoice.status == InvoiceStatus.PROCESSED: 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"], + "La factura ya fue procesada y no puede volver a actualizarse. Desactualícela primero.", + solution=["Use el botón 'Desactualizar' antes de volver a procesar la factura."], code="ALREADY_PROCESSED", value=invoice.status, ) - + errors.raise_if_errors() + return + if not invoice.invoice_date: errors.add_required_error("invoice_date") diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py new file mode 100644 index 00000000..8f71e2b2 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py @@ -0,0 +1,234 @@ +from decimal import Decimal +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader, DischargeStatus +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + +from .sub_process.review_rule_octave import borra_saldos_regla_octava + + +# ───────────────────────────────────────────────────────────────────────────── +# Validación de cantidades retornadas con detalle de exportaciones activas +# ───────────────────────────────────────────────────────────────────────────── + +def _validate_returned_quantities( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + Verifica que ninguna partida tenga saldos pendientes por exportaciones + activas que la afecten. + Paridad: bloque 'REVISA CANTIDADES RETORNADAS' (Clarion SCAII). + + El Clarion recorre las partidas con (CantRetornadaTemp + CantRetornada + + CantExistencia) <> 0 y luego busca en QEqeMaq (exportaciones definitivas), + QEqeMaqRep (exportaciones de reparación) y QEqiMaqRep (importaciones de + reparación) para identificar qué factura de exportación activa (Estatus='AC') + tiene esa partida descargada. + + En Python los mismos vínculos viven en: + DischargeDetail.import_item_line_id → la partida de importación consumida + DischargeDetail.header → DischargeHeader + DischargeHeader.source_invoice_id → InvoiceHeader (la factura de exportación) + InvoiceHeader.status → InvoiceStatus.PROCESSED (≡ Estatus='AC') + + Para cada partida con saldo se buscan DischargeDetail con status APPLIED en + una factura de exportación procesada y se reporta qué factura debe + desactualizarse primero. + """ + lines_with_balance = [ + line for line in lines + if line.quantity is not None and ( + (line.quantity.quantity_returned_temp or Decimal(0)) + + (line.quantity.quantity_returned or Decimal(0)) + + (line.quantity.quantity_existence or Decimal(0)) + ) != Decimal(0) + ] + + if not lines_with_balance: + return + + for line in lines_with_balance: + qty_ret_temp = line.quantity.quantity_returned_temp or Decimal(0) + qty_ret = line.quantity.quantity_returned or Decimal(0) + qty_exist = line.quantity.quantity_existence or Decimal(0) + + # Buscar DischargeDetail vinculados a esta partida de importación + # cuya factura de exportación esté activa (PROCESSED). + # Paridad: bucle sobre QEqeMaq/QEqeMaqRep donde Descarga=1 y + # encabezado de exportación con Estatus='AC'. + details: List[DischargeDetail] = ( + db.query(DischargeDetail) + .join(DischargeDetail.header) + .filter( + DischargeDetail.import_item_line_id == line.id, + DischargeHeader.status == DischargeStatus.APPLIED, + ) + .all() + ) + + active_details = [ + d for d in details + if d.header + and d.header.source_invoice is not None + and d.header.source_invoice.status == InvoiceStatus.PROCESSED + ] + + if active_details: + # Reportar un error por cada factura de exportación activa distinta + # (equivale a QueErr en el Clarion). + seen_export_invoices: set = set() + for detail in active_details: + src_invoice = detail.header.source_invoice + src_number = src_invoice.invoice_number or str(src_invoice.id) + + if src_number in seen_export_invoices: + continue + seen_export_invoices.add(src_number) + + export_line_number = ( + detail.export_line.line_number if detail.export_line else "?" + ) + + errors.add_error( + field=f"line[{line.line_number}].discharge", + message=( + f"La Línea: {line.line_number} se ha descargado " + f"{detail.quantity_discharged} en la factura: {src_number} " + f"de exportación con Línea: {export_line_number}." + ), + solution=[ + f"Desactualizar la factura: {src_number} " + "para regresar saldos a la partida." + ], + code="LINE_HAS_ACTIVE_DISCHARGE", + ) + else: + # La partida tiene saldo pero no hay descarga activa rastreable — + # reportar el saldo directamente para que el usuario lo investigue. + errors.add_error( + field=f"line[{line.line_number}].quantities", + message=( + f"La Línea: {line.line_number} tiene saldos pendientes " + f"(retornada: {qty_ret}, retornada temp: {qty_ret_temp}, " + f"existencia: {qty_exist}) y no se puede desactualizar." + ), + solution=[ + "Verifique las exportaciones que afectan a esta partida " + "y desactualícelas primero." + ], + code="LINE_HAS_BALANCE", + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Reset de la factura e inventario +# ───────────────────────────────────────────────────────────────────────────── + +def _reset_invoice_financials(invoice: InvoiceHeader) -> None: + """ + Reinicia los totales financieros del encabezado de la factura a cero y + cambia el estatus a PENDING (equivalente a Estatus='NA' en Clarion). + Paridad: UPDATE QFacImp SET CantImpo=0, PesoNeto=0, PesoBruto=0, + Cantbultos=0, ValorImpoMN=0, ValorImpoME=0, ValorImpoMC=0, + ValorAduanasMN=0, ValorAduanasME=0, Estatus='NA', + ComofueProcesada='', ValorIVAMN=0, ValorIVAME=0 (Clarion SCAII). + """ + fin = invoice.financials + if fin is None: + return + + fin.total_quantity = 0.0 + fin.net_weight = 0.0 + fin.gross_weight = 0.0 + fin.total_packages = 0 + fin.value_mn = 0.0 + fin.value_me = 0.0 + fin.value_mc = 0.0 + fin.customs_value_mn = 0.0 + fin.customs_value_me = 0.0 + fin.iva_mn = 0.0 + fin.iva_me = 0.0 + + invoice.status = InvoiceStatus.PENDING + invoice.process_method = None + + +def _reset_line_quantities(lines: List[LineItem]) -> None: + """ + Reinicia los contadores de inventario de cada partida a cero. + Paridad: UPDATE QEqiMaq SET CantRetornada=0, CantRetornadaTemp=0, + ValorRetornadoMN=0, ValorRetornadoME=0, CantExistencia=0, + ValorIVAMNUsado=0, ValorIVAMEUsado=0 (Clarion SCAII). + """ + for line in lines: + if line.quantity is not None: + line.quantity.quantity_returned = Decimal(0) + line.quantity.quantity_returned_temp = Decimal(0) + line.quantity.quantity_existence = Decimal(0) + + if line.financial is not None: + line.financial.value_returned_mxn = Decimal(0) + line.financial.value_returned_usd = Decimal(0) + line.financial.vat_used_mxn = Decimal(0) + line.financial.vat_used_usd = Decimal(0) + + +# ───────────────────────────────────────────────────────────────────────────── +# Proceso principal de reversión +# ───────────────────────────────────────────────────────────────────────────── + +def revert_process( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + tenant_id: str, + company_id: str, + errors: ErrorCollector, +) -> list: + """ + Proceso principal de des-actualización de una factura de importación + temporal. + Paridad: rutina principal 'DESACTUALIZAR FACTURA IMPO TEMP' (Clarion SCAII). + + Flujo: + 1. Verifica que ninguna partida tenga saldos activos por exportaciones + procesadas (QueueErrorAct en Clarion). Si los hay → ValidationException. + 2. Si no hay errores de validación: + a. Do BORRASALDOS_REGLA_OCTAVA — revierte cupos de Regla Octava. + b. UPDATE QFacImp — reinicia totales del encabezado (status → PENDING). + c. UPDATE QEqiMaq — reinicia contadores de inventario por partida. + 3. Retorna sql_errors (errores no-bloqueantes de BD, equivalente a + QueueErrorSQL en Clarion). + + Raises: + ValidationException: si hay partidas con descargas activas + (equivale a Records(QueueErrorAct) <> 0). + """ + # ── Paso 1: REVISA CANTIDADES RETORNADAS ────────────────────────────────── + _validate_returned_quantities(db, invoice, lines, errors) + errors.raise_if_errors() + + # ── Paso 2a: Do BORRASALDOS_REGLA_OCTAVA ───────────────────────────────── + sql_errors: list = [] + borra_saldos_regla_octava( + db=db, + invoice_import=invoice.invoice_number or "", + tenant_id=tenant_id, + company_id=company_id, + sql_errors=sql_errors, + ) + + # ── Paso 2b: UPDATE QFacImp ─────────────────────────────────────────────── + _reset_invoice_financials(invoice) + + # ── Paso 2c: UPDATE QEqiMaq ─────────────────────────────────────────────── + _reset_line_quantities(lines) + + return sql_errors diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/pre_validators.py b/backend/api/v1/modules/a76/invoices/imports/revert/pre_validators.py new file mode 100644 index 00000000..cc9b6f8e --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/revert/pre_validators.py @@ -0,0 +1,42 @@ +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + + +def pre_validators( + db: Session, + invoice: InvoiceHeader, + tenant_id: str, + company_id: str, + errors: ErrorCollector, +) -> List[LineItem]: + """ + Validaciones previas a la reversión de una factura de importación temporal. + + - Verifica que la factura esté en estatus PROCESSED. + - Carga y retorna las partidas asociadas a la factura. + """ + if invoice.status != InvoiceStatus.PROCESSED: + errors.add_error( + "status", + "La factura no fue procesada y no puede ser revertida", + solution=["Verifique el estatus de la factura antes de intentar deshacer el proceso"], + code="NOT_PROCESSED", + value=invoice.status, + ) + + lines: List[LineItem] = ( + db.query(LineItem) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .all() + ) + + return lines diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/routes.py b/backend/api/v1/modules/a76/invoices/imports/revert/routes.py new file mode 100644 index 00000000..e380d746 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/revert/routes.py @@ -0,0 +1,76 @@ +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.celery_app import celery_app +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .task import revert_invoice_task + +router = APIRouter() + + +@router.post("/invoices/{invoice_id}/revert") +def trigger_invoice_revert( + invoice_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Inicia la des-actualización de una factura de importación temporal como + tarea Celery. + Retorna el task_id para hacer polling del progreso. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + task = revert_invoice_task.apply_async( + args=[invoice_id, str(tenant_id), str(company_id)] + ) + + return {"task_id": task.id} + + +@router.get("/invoices/revert/{task_id}/status") +def get_invoice_revert_status(task_id: str): + """ + Consulta el estado de progreso de una tarea de des-actualización de + factura. + + Retorna: + - state: 'PROCESSING' | 'SUCCESS' | 'FAILURE' + - info: { current: int, status: str } (cuando state == 'PROCESSING') + - result: dict (cuando state == 'SUCCESS' o 'FAILURE') + """ + task_result = celery_app.AsyncResult(task_id) + + if task_result.state in ("PENDING", "STARTED"): + return { + "state": "PROCESSING", + "info": {"current": 0, "status": "Iniciando..."}, + } + + if task_result.state == "PROGRESS": + return { + "state": "PROCESSING", + "info": task_result.info or {"current": 0, "status": "Procesando..."}, + } + + if task_result.state == "SUCCESS": + return { + "state": "SUCCESS", + "result": task_result.result, + } + + error_info = task_result.result + if isinstance(error_info, Exception): + error_msg = str(error_info) + else: + error_msg = str(error_info) if error_info else "Error desconocido" + + return { + "state": "FAILURE", + "result": error_msg, + } diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/sub_process/review_rule_octave.py b/backend/api/v1/modules/a76/invoices/imports/revert/sub_process/review_rule_octave.py new file mode 100644 index 00000000..94805475 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/revert/sub_process/review_rule_octave.py @@ -0,0 +1,94 @@ +from decimal import Decimal +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.rule_octave.balances.models import OctaveBalance +from api.v1.modules.a76.rule_octave.fractions.models import FractionRuleOctave + + +def borra_saldos_regla_octava( + db: Session, + invoice_import: str, + tenant_id: str, + company_id: str, + sql_errors: list, +) -> None: + """ + Revierte los saldos de Regla Octava registrados al procesar una factura de + importación temporal. + Paridad: BORRASALDOS_REGLA_OCTAVA (Clarion SCAII). + + Por cada registro en SSaldosReglaOctava (OctaveBalance) con origin='TEM' y + system='SCAF' que corresponda a la factura: + 1. Resta de vuelta la cantidad y el valor en GFracROctava (FractionRuleOctave). + 2. Elimina el registro de OctaveBalance. + + Los errores de actualización se acumulan en sql_errors como dicts con las + claves 'consecutive' y 'error'. + """ + consecutive_ref = [0] + + balances: List[OctaveBalance] = ( + db.query(OctaveBalance) + .filter( + OctaveBalance.tenant_id == tenant_id, + OctaveBalance.company_id == company_id, + OctaveBalance.invoice_import == invoice_import, + OctaveBalance.origin == "TEM", + OctaveBalance.system == "SCAF", + ) + .all() + ) + + for balance in balances: + fra_oct = ( + db.query(FractionRuleOctave) + .filter( + FractionRuleOctave.tenant_id == tenant_id, + FractionRuleOctave.company_id == company_id, + FractionRuleOctave.permission == balance.octave_permit, + FractionRuleOctave.line == balance.line, + ) + .first() + ) + + # REGRESAR EL SALDO AL PERMISO DE REGLA OCTAVA + # Si el permiso no existe (TryFetch falla en Clarion), se omite el PUT + # pero el DELETE del saldo se ejecuta de todas formas — paridad Clarion. + if fra_oct is not None: + qty_back = balance.quantity_stock or Decimal(0) + val_back = balance.value_me or Decimal(0) + + fra_oct.quantity_used = max( + Decimal(0), + (fra_oct.quantity_used or Decimal(0)) - qty_back, + ) + fra_oct.value_used = max( + Decimal(0), + (fra_oct.value_used or Decimal(0)) - val_back, + ) + + try: + db.flush([fra_oct]) + except Exception as exc: + consecutive_ref[0] += 1 + sql_errors.append({ + "consecutive": consecutive_ref[0], + "error": ( + f"Error al regresar el Cupo en (Permiso de Regla Octava) {exc}" + ), + }) + + # DELETE(SSaldosReglaOctava) — siempre se intenta, igual que en Clarion + try: + db.delete(balance) + db.flush([balance]) + except Exception as exc: + consecutive_ref[0] += 1 + sql_errors.append({ + "consecutive": consecutive_ref[0], + "error": ( + f"Error al Eliminar en (SSaldosReglaOctava) {exc}" + ), + }) diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/task.py b/backend/api/v1/modules/a76/invoices/imports/revert/task.py new file mode 100644 index 00000000..039baba7 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/revert/task.py @@ -0,0 +1,82 @@ +from celery import Task + +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from core.exceptions import ErrorCollector, ValidationException + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from .pre_validators import pre_validators +from .main_process import revert_process + + +def _progress(task: Task, current: int, status: str) -> None: + task.update_state(state="PROGRESS", meta={"current": current, "status": status}) + + +@celery_app.task(bind=True, name="revert_invoice_task") +def revert_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict: + """ + Des-actualiza una factura de importación temporal ejecutando todas las + validaciones y reversiones del proceso principal (revert/main_process) con + reporte de progreso. + """ + db = CoreSessionLocal() + try: + # ── Paso 1: Cargar factura ──────────────────────────────────────────── + _progress(self, 5, "Cargando factura...") + invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id) + if invoice is None: + return { + "status": "error", + "message": f"Factura con id {invoice_id} no encontrada.", + "errors": [], + } + + errors = ErrorCollector() + + # ── Paso 2: Pre-validaciones ────────────────────────────────────────── + _progress(self, 10, "Validando estatus de la factura...") + lines = pre_validators(db, invoice, tenant_id, company_id, errors) + if not lines: + errors.add_error( + field="line_items", + message="La factura no contiene partidas para revertir", + solution=["Verifique que la factura tenga partidas antes de intentar revertirla"], + code="NO_LINE_ITEMS", + ) + errors.raise_if_errors() + + # ── Paso 3: Validar cantidades y ejecutar reversión ─────────────────── + _progress(self, 40, "Verificando saldos de partidas...") + sql_errors = revert_process( + db=db, + invoice=invoice, + lines=lines, + tenant_id=tenant_id, + company_id=company_id, + errors=errors, + ) + + # ── Paso 4: Confirmar transacción ───────────────────────────────────── + _progress(self, 95, "Confirmando cambios...") + db.flush() + db.commit() + + return { + "status": "success", + "invoice_id": invoice_id, + "sql_errors": sql_errors, + } + + except ValidationException as exc: + db.rollback() + return { + "status": "validation_error", + "message": exc.message, + "errors": exc.errors, + } + except Exception as exc: + db.rollback() + raise exc + finally: + db.close() diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 0b8283ae..33e0dd48 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -11,6 +11,7 @@ from .customs_brokers.routes import router as customs_broker_router from .general_catalogs.router import router as general_catalogs_router from .invoices.routes import router as invoices_router from .invoices.imports.process.routes import router as invoice_process_router +from .invoices.imports.revert.routes import router as invoice_revert_router from .items.routes import router as items_router from .classes.routes import router as classes_router @@ -59,6 +60,7 @@ router = APIRouter() router.include_router(general_catalogs_router, prefix="/a76", tags=["a76 / general_catalogs"]) router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"]) router.include_router(invoice_process_router, prefix="/a76", tags=["a76 / invoices"]) +router.include_router(invoice_revert_router, prefix="/a76", tags=["a76 / invoices"]) router.include_router(items_router, prefix="/a76", tags=["a76 / items"]) router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"]) router.include_router(exportacion_imports_router, prefix="/a76/imports/exportacion", tags=["a76 / imports / exportacion"]) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index ef6c93e3..1df5980b 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -16,6 +16,9 @@ from api.v1.modules.public.reference_data.customs_sections.models import Customs # CRITICAL: FaLineItem must be imported BEFORE LineItem from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem # noqa: F401 from api.v1.modules.a76.items.models import LineItem # noqa: F401 +# CRITICAL: BalanceMovement must be loaded before DischargeDetail (FK a24.balance_movement) +from api.v1.modules.a24.balance_movements.models import BalanceMovement # noqa: F401 +from api.v1.modules.a24.discharges.models import DischargeHeader, DischargeDetail # noqa: F401 valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") print(f"DEBUG: Celery Broker URL: {valkey_url}") @@ -60,6 +63,7 @@ celery_app.conf.update( "api.v1.modules.core.help_center.tasks", "api.v1.modules.core.help_center.tasks", "api.v1.modules.a76.invoices.imports.process.task", + "api.v1.modules.a76.invoices.imports.revert.task", ] # Ruta al módulo donde están las tareas ) diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index 4cf0a679..371a8159 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -515,5 +515,29 @@ export const invoicesApi = { sql_errors?: Array<{ consecutive: number; error: string }>; }; }>(`/v1/a76/invoices/process/${taskId}/status`); + }, + + revertInvoice: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post<{ task_id: string }>( + `/v1/a76/invoices/${invoiceId}/revert?${params.toString()}`, + {} + ); + }, + + getRevertStatus: (taskId: string) => { + return api.get<{ + state: 'PROCESSING' | 'SUCCESS' | 'FAILURE'; + info?: { current: number; status: string }; + result?: { + status: 'success' | 'validation_error' | 'error'; + invoice_id?: number; + message?: string; + errors?: Array<{ field: string; message: string; code?: string; solution?: string[] }>; + sql_errors?: Array<{ consecutive: number; error: string }>; + }; + }>(`/v1/a76/invoices/revert/${taskId}/status`); } }; diff --git a/frontend/src/lib/components/dashboard/invoices/columns.ts b/frontend/src/lib/components/dashboard/invoices/columns.ts index 62148a60..9c4ffd83 100644 --- a/frontend/src/lib/components/dashboard/invoices/columns.ts +++ b/frontend/src/lib/components/dashboard/invoices/columns.ts @@ -86,6 +86,26 @@ export function createColumns( enableSorting: false, enableHiding: false, }, + { + id: "processed", + header: "Procesada", + cell: ({ row }) => { + const s = row.original.status; + const isProcessed = s === "processed" || s === true; + + const processedCheckSnippet = createRawSnippet<[{ checked: boolean }]>((getProps) => { + const { checked } = getProps(); + return { + render: () => `
+ +
` + }; + }); + return renderSnippet(processedCheckSnippet, { checked: isProcessed }); + }, + enableSorting: false, + enableHiding: false, + }, { accessorKey: "operation_type", header: "Operación", diff --git a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte index 68fc0269..fa7ee79f 100644 --- a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte @@ -12,6 +12,12 @@ export let onComplete: (result: any) => void; export let title: string = 'Generando PDF'; + /** Optional: steps for step-by-step progress (e.g. invoice processing). Each step has { label, percent } */ + export let steps: { label: string; percent: number }[] = []; + + /** Optional: message when complete (default: "Listo para descargar") */ + export let completeMessage: string = 'Listo para descargar'; + export let getStatus: ((taskId: string) => Promise) | null = null; let progress = 0; @@ -54,11 +60,20 @@ progress = response.info.current || 0; statusMessage = response.info.status || 'Procesando...'; } else if (response?.state === 'SUCCESS') { - progress = 100; - statusMessage = '¡Completado!'; - isComplete = true; + const result = response.result; stopPolling(); - setTimeout(() => onComplete(response.result), 500); + + // Si el worker reportó error de validación o error de aplicación, + // cerrar el dialog inmediatamente y dejar que onComplete muestre el toast. + if (result?.status === 'validation_error' || result?.status === 'error') { + open = false; + onComplete(result); + } else { + progress = 100; + statusMessage = '¡Completado!'; + isComplete = true; + setTimeout(() => onComplete(result), 500); + } } else if (response?.state === 'FAILURE') { hasError = true; const errMsg = response.result ? String(response.result) : 'Error desconocido'; @@ -98,12 +113,50 @@
-
- {statusMessage} - {progress}% -
- - + {#if steps.length > 0} + +
+ {#each steps as step, i} + {@const isDone = progress >= step.percent} + {@const isCurrent = !isDone && (i === 0 || progress >= steps[i - 1]?.percent)} +
+ + {i + 1}. + + + {step.label} + + + {#if isDone} + 100% + {:else if isCurrent} + {progress}% + {:else} + 0% + {/if} + + {#if isDone} + + {:else if isCurrent} + + {/if} +
+ {/each} +
+ + {:else} +
+ {statusMessage} + {progress}% +
+ + {/if}
{#if isComplete} @@ -111,7 +164,7 @@ class="animate-in fade-in zoom-in flex flex-col items-center text-green-600 duration-300" > - Listo para descargar + {completeMessage}
{:else if hasError}
{ @@ -725,6 +726,49 @@ } } + async function confirmRevertInvoice() { + if (!selectedInvoice || !companyStore.activeCompany) return; + isRevertConfirmOpen = false; + + try { + const response = await invoicesApi.revertInvoice( + selectedInvoice.id, + companyStore.activeCompany.id + ); + + if (response.error) { + toast.error(`Error al iniciar la des-actualización: ${response.error}`); + return; + } + + currentTaskId = response.data!.task_id; + currentStatusFunction = invoicesApi.getRevertStatus; + progressDialogTitle = 'Des-actualizando factura'; + showProgressDialog = true; + } catch (e) { + console.error('Error al iniciar des-actualización de factura:', e); + toast.error('No se pudo iniciar la des-actualización'); + } + } + + // Pasos del procesamiento de factura (deben coincidir con el backend) + const invoiceProcessSteps = [ + { label: 'Cargando factura', percent: 5 }, + { label: 'Validando datos de la factura', percent: 10 }, + { label: 'Revisando clases y tipo de cambio', percent: 30 }, + { label: 'Calculando valores por partida', percent: 50 }, + { label: 'Validando partidas', percent: 70 }, + { label: 'Validando cupos de Regla Octava', percent: 85 }, + { label: 'Actualizando totales', percent: 95 } + ]; + + const invoiceRevertSteps = [ + { label: 'Cargando factura', percent: 5 }, + { label: 'Validando estatus de la factura', percent: 10 }, + { label: 'Verificando saldos de partidas', percent: 40 }, + { label: 'Confirmando cambios', percent: 95 } + ]; + // Opciones de tipo de operación para el filtro const operationTypeOptions = [ { value: '', label: 'Todas' }, @@ -920,8 +964,39 @@ onComplete={onPdfComplete} onClose={closeProgressDialog} title={progressDialogTitle} + steps={ + progressDialogTitle === 'Procesando factura' + ? invoiceProcessSteps + : progressDialogTitle === 'Des-actualizando factura' + ? invoiceRevertSteps + : [] + } + completeMessage={ + progressDialogTitle === 'Procesando factura' + ? 'Factura procesada correctamente' + : progressDialogTitle === 'Des-actualizando factura' + ? 'Factura des-actualizada correctamente' + : 'Listo para descargar' + } /> + + + + Des-actualizar Factura de Importación Temporal + + Se va a des-actualizar la factura {selectedInvoice?.invoice_number}. + Esta operación revertirá los saldos de inventario y los cupos de Regla Octava + registrados al procesar la factura. ¿Desea continuar? + + + + Cancelar + Continuar + + + + @@ -1086,7 +1161,7 @@ variant="outline" size="sm" disabled={loading || selectedInvoiceIds.length !== 1} - onclick={() => handleUpdateStatus(false)} + onclick={() => (isRevertConfirmOpen = true)} > Desactualizar