From d37d6ef6999b2d9f97ac70fe08f296a25b20e4a4 Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 18 Mar 2026 08:28:20 -0600 Subject: [PATCH] feature/catalog-importation --- .../modules/a76/invoices/catalog_service.py | 5 + backend/api/v1/modules/a76/invoices/routes.py | 24 ++++- .../api/v1/modules/a76/invoices/schemas.py | 2 + .../api/v1/modules/a76/invoices/services.py | 59 ++++++++++++ .../public/reference_data/incoterms/routes.py | 2 +- .../edit/observations-tab-form.svelte | 93 +++++++++++++++---- .../invoices/edit/[id]/+page.server.ts | 19 ++++ .../dashboard/invoices/edit/[id]/+page.svelte | 89 ++++++++++++++++++ 8 files changed, 271 insertions(+), 22 deletions(-) diff --git a/backend/api/v1/modules/a76/invoices/catalog_service.py b/backend/api/v1/modules/a76/invoices/catalog_service.py index 2c7feb47..8249dc84 100644 --- a/backend/api/v1/modules/a76/invoices/catalog_service.py +++ b/backend/api/v1/modules/a76/invoices/catalog_service.py @@ -10,6 +10,7 @@ from api.v1.modules.public.reference_data.customs_sections.models import Customs from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen from api.v1.modules.public.reference_data.incoterms.models import Incoterm +from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod from api.v1.modules.public.reference_data.transport_modes.models import TransportMode # Import A76 Services @@ -31,6 +32,7 @@ from api.v1.modules.public.reference_data.transport_types.dto import TransportTy from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO from api.v1.modules.public.reference_data.incoterms.dto import IncotermDTO +from api.v1.modules.public.reference_data.valuation_methods.dto import ValuationMethodDTO from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO # Additional DTOs @@ -72,6 +74,9 @@ class InvoiceCatalogService: response.incoterms = [ IncotermDTO.model_validate(obj) for obj in db.query(Incoterm).all() ] + response.valuation_methods = [ + ValuationMethodDTO.model_validate(obj) for obj in db.query(ValuationMethod).all() + ] response.transport_modes = [ TransportModeDTO.model_validate(obj) for obj in db.query(TransportMode).all() ] diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index bf95bf9f..fa6c888e 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -3,9 +3,10 @@ from api.v1.common.tenant_crud_routes import TenantCRUDRoutes 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, Path +from sqlalchemy import func from sqlalchemy.orm import Session -from . import schemas, services +from . import schemas, services, models from .catalog_service import InvoiceCatalogService # Create main router @@ -36,6 +37,27 @@ def get_edition_data( return data +@router.get("/invoices/remesa-suggestion", response_model=Dict[str, int]) +def get_remesa_suggestion( + pedimento_id: int = Query(..., description="Pedimento ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Suggest next remesa for selected pedimento (max+1).""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + max_rem = ( + db.query(func.max(models.InvoiceComplianceMx.remesa)) + .filter( + models.InvoiceComplianceMx.pedimento_id == pedimento_id, + models.InvoiceComplianceMx.tenant_id == tenant_id, + models.InvoiceComplianceMx.company_id == company_id, + ) + .scalar() + ) + return {"next_remesa": int((max_rem or 0) + 1)} + + # Create CRUD routes for Invoice Header using TenantCRUDRoutes invoice_crud = TenantCRUDRoutes( service=services.InvoiceService, diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index e6aee827..46d0997a 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -605,6 +605,7 @@ from api.v1.modules.public.reference_data.currency_types.dto import CurrencyType from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO from api.v1.modules.public.reference_data.incoterms.dto import IncotermDTO +from api.v1.modules.public.reference_data.valuation_methods.dto import ValuationMethodDTO from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO @@ -634,6 +635,7 @@ class InvoiceCatalogsResponse(BaseModel): code_pedimento_regimens: List[CodePedimentoRegimenDTO] = [] seals: List[dict] = [] # Placeholder, refine with actual DTO incoterms: List[IncotermDTO] = [] + valuation_methods: List[ValuationMethodDTO] = [] pedimentos: List[dict] = [] # Placeholder, refine with actual DTO transport_modes: List[TransportModeDTO] = [] default_settings: Optional[dict] = None diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index ecd8cc25..63304a24 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -34,6 +34,60 @@ def _get_current_username() -> str: return "System" +def _autofill_remesa_if_needed(db: Session, invoice_data, tenant_id: int, company_id: int) -> None: + """ + Autocalcula remesa cuando hay pedimento consolidado y remesa viene vacía. + Se hace ANTES de validar para que cumpla reglas de required en validators. + """ + try: + compliance = getattr(invoice_data, "compliance_mx", None) + if not compliance: + return + + pedimento_id = getattr(compliance, "pedimento_id", None) + remesa = getattr(compliance, "remesa", None) + + if not pedimento_id or remesa: + return + + # No aplicar a MEX (por consistencia con CSV import donde remesa es None para MEX) + invoice_type = getattr(invoice_data, "invoice_type", None) + if invoice_type == "MEX": + return + + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos, PedimentoType + + ped = ( + db.query(Pedimentos) + .filter( + Pedimentos.id == pedimento_id, + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + ) + .first() + ) + if not ped: + return + + if getattr(ped, "pedimento_type", None) != PedimentoType.CONSOLIDATED: + return + + max_rem = ( + db.query(func.max(models.InvoiceComplianceMx.remesa)) + .filter( + models.InvoiceComplianceMx.pedimento_id == pedimento_id, + models.InvoiceComplianceMx.tenant_id == tenant_id, + models.InvoiceComplianceMx.company_id == company_id, + ) + .scalar() + ) + next_rem = (max_rem or 0) + 1 + compliance.remesa = next_rem + except Exception: + # No bloquear guardado por fallo de autocalculo; validación normal aplicará. + return + + class InvoiceService: """Service for Invoice Header operations""" @@ -126,6 +180,9 @@ class InvoiceService: # Validaciones con ErrorCollector errors = ErrorCollector() + # Autocalculo remesa (si aplica) ANTES de validar + _autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id) + # Validar si la factura ya existe invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors) if invoice_data.operation_type == "exp": @@ -284,6 +341,8 @@ class InvoiceService: if invoice_data.operation_type == "exp": validate_update_export(db, invoice_data, invoice, tenant_id, company_id, errors) else: + # Autocalculo remesa (si aplica) ANTES de validar + _autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id) validate_update_import(db, invoice_data, invoice, tenant_id, company_id, errors) # Si hay errores, lanzar excepción ANTES de actualizar diff --git a/backend/api/v1/modules/public/reference_data/incoterms/routes.py b/backend/api/v1/modules/public/reference_data/incoterms/routes.py index 20a7e315..02b278fc 100644 --- a/backend/api/v1/modules/public/reference_data/incoterms/routes.py +++ b/backend/api/v1/modules/public/reference_data/incoterms/routes.py @@ -78,7 +78,7 @@ async def delete_incoterm( db: Session = Depends(get_core_db), current_user: dict = Depends(has_role("admin")), ): - obj = db.query(Incoterm).filter(Incoterm.key == key).first() + obj = db.query(Incoterm).filter(Incoterm.code == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") db.delete(obj) diff --git a/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte index a10d6ef6..ba0387b7 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte @@ -11,6 +11,8 @@ exists = $bindable(), seals = [], incoterms = [], + valuationMethods = [], + legends = [], enclosure = [], operationType = undefined, invoiceType = undefined @@ -20,11 +22,25 @@ exists?: boolean; seals?: any[]; incoterms?: any[]; + valuationMethods?: any[]; + legends?: any[]; enclosure?: any[]; operationType?: number; invoiceType?: string; } = $props(); + let selectedLegendCode = $state(''); + + function appendLegendToEs() { + const code = selectedLegendCode; + if (!code) return; + const legend = legends.find((l) => String(l.code) === String(code)); + const text = legend?.description?.trim(); + if (!text) return; + const current = (formData.observation_es || '').trim(); + formData.observation_es = current ? `${current}\n${text}` : text; + } + if (!formData && invoice) { formData = { // Campos de observaciones @@ -39,13 +55,13 @@ total_increments_mn: invoice.financials?.total_increments_mn || null, total_increments_me: invoice.financials?.total_increments_me || null, // Incoterm y recinto - incoterm: invoice.logistics?.incoterm || null, + incoterm: invoice.logistics?.incoterm || '', enclosure: invoice.compliance_mx?.enclosure || null, // Campos de esta pestaña num_seals: null, movement_type: invoice.compliance_mx?.movement_type || '', alternate_invoice: invoice.alternate_invoice || '', - valuation_method: invoice.compliance_mx?.value_method || null, + valuation_method: invoice.compliance_mx?.value_method || '', // New fields for Export other_deductibles: invoice.financials?.other_deductibles || null, proforma_number: invoice.proforma_number || '', @@ -73,13 +89,13 @@ total_increments_mn: null, total_increments_me: null, // Incoterm y recinto - incoterm: null, + incoterm: '', enclosure: null, // Campos de esta pestaña num_seals: null, movement_type: '', alternate_invoice: '', - valuation_method: null, + valuation_method: '', // New fields for Export other_deductibles: null, proforma_number: '', @@ -117,6 +133,43 @@ /> + {#if legends?.length} +
+
+ + { + selectedLegendCode = v ?? ''; + }} + > + + + {selectedLegendCode ? `Clave ${selectedLegendCode}` : 'Selecciona leyenda...'} + + + + {#each legends as l} + + {l.code} - {l.description || ''} + + {/each} + + +
+
+ +
+
+ {/if} + {#if invoiceType !== 'MEX'}

@@ -410,21 +463,21 @@ { - formData.incoterm = v ? parseInt(v) : null; + formData.incoterm = v ?? ''; }} > {formData.incoterm - ? incoterms.find((p) => p.id === formData.incoterm)?.name || 'Selecciona...' - : 'Selecciona...'}{formData.incoterm || 'Selecciona...'} {#each incoterms as inco} - {inco.name} + + {inco.code} - {inco.description_es} + {/each} @@ -460,18 +513,18 @@ > { - formData.valuation_method = v; + formData.valuation_method = v ?? ''; }} > {formData.valuation_method || 'Selecciona...'} - General - Devaluado - Especial + {#each valuationMethods as m} + {m.key} - {m.description} + {/each}

@@ -564,18 +617,18 @@ { - formData.valuation_method = v; + formData.valuation_method = v ?? ''; }} > {formData.valuation_method || 'Selecciona...'} - General - Devaluado - Especial + {#each valuationMethods as m} + {m.key} - {m.description} + {/each} diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts index eb71d203..5dab1d30 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts @@ -31,6 +31,7 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { let catalogsData: any = {}; let defaultSettings: any = null; let isCreate = false; + let legendsData: any = { items: [] }; // Fetch Default Settings separately if applicable (for creation) const settingsPromise = (params.id === 'new' && parsedOperationType && invoiceTypeParam && companyId) @@ -98,6 +99,22 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { invoiceData = editionData.invoice; } + // Leyendas fijas (para observaciones) + try { + const legendsResponse = await authenticatedFetch( + `v1/a76/legends/?company_id=${companyId}&page=1&page_size=200`, + { method: 'GET' }, + cookies, + fetch + ); + if (legendsResponse.ok) { + legendsData = await legendsResponse.json(); + } + } catch (e) { + // No bloquear la carga de factura por fallo en leyendas + legendsData = { items: [] }; + } + // Map snake_case response to camelCase props expected by Svelte Page // Providing empty arrays as defaults if something is missing return { @@ -118,8 +135,10 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { codePedimentoRegimens: catalogsData.code_pedimento_regimens || [], seals: catalogsData.seals || [], incoterms: catalogsData.incoterms || [], + valuationMethods: catalogsData.valuation_methods || [], pedimentos: catalogsData.pedimentos || [], transportModes: catalogsData.transport_modes || [], + legends: legendsData?.items || [], defaultSettings: defaultSettings, filters: { operation_type: parsedOperationType, diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index d58e7afb..0368c8b6 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -66,7 +66,9 @@ providers?: ClientProvider[]; seals?: any[]; incoterms?: any[]; + valuationMethods?: any[]; enclosure?: any[]; + legends?: any[]; currencyTypes?: any[]; transportTypes?: any[]; transportModes?: any[]; @@ -483,6 +485,91 @@ return list.filter((p: { operation_type?: string }) => p.operation_type === opType); }); + // Al seleccionar pedimento, autollenar Aduana y Clave de régimen (document_type) + // Fuente de verdad: pedimento.customs_office (aduana) y pedimento.regime (régimen) + $effect(() => { + // Si se marca pedimento pendiente, el componente ya limpia pedimento/remesa; + // aquí limpiamos también los campos que dependen del pedimento. + if (InvoiceTopFieldsFormData?.is_pedimento_pending) { + if (generalFormData) { + generalFormData.aduana = ''; + generalFormData.document_type = ''; + } + return; + } + + const pid = InvoiceTopFieldsFormData?.pedimento_id; + if (!pid || !generalFormData) return; + const id = typeof pid === 'string' ? parseInt(pid, 10) : pid; + if (!id || Number.isNaN(id)) return; + + const ped = (filteredPedimentos || []).find((p: any) => p?.id === id); + if (!ped) return; + + // Solo autollenar si están vacíos (no pisar captura manual) + if (!generalFormData.aduana && ped.customs_office) { + generalFormData.aduana = ped.customs_office; + } + if (!generalFormData.document_type && ped.regime) { + generalFormData.document_type = ped.regime; + } + }); + + let remesaSuggestionReqId = 0; + let lastRemesaPedimentoId: number | null = null; + let lastAutoSuggestedRemesa: string | null = null; + $effect(() => { + if (InvoiceTopFieldsFormData?.is_pedimento_pending) return; + + const companyId = companyStore?.activeCompany?.id; + const pid = InvoiceTopFieldsFormData?.pedimento_id; + if (!companyId || !pid) { + lastRemesaPedimentoId = null; + lastAutoSuggestedRemesa = null; + return; + } + + const pedimentoId = typeof pid === 'string' ? parseInt(pid, 10) : pid; + if (!pedimentoId || Number.isNaN(pedimentoId)) return; + + const currentRemesa = String(InvoiceTopFieldsFormData?.remesa || ''); + const isCurrentRemesaAuto = lastAutoSuggestedRemesa != null && currentRemesa === lastAutoSuggestedRemesa; + + // Si cambió el pedimento, solo recalcular si la remesa está vacía + // (o si la remesa actual fue auto-sugerida previamente) + if (lastRemesaPedimentoId !== pedimentoId) { + lastRemesaPedimentoId = pedimentoId; + if (!currentRemesa || isCurrentRemesaAuto) { + InvoiceTopFieldsFormData.remesa = ''; + lastAutoSuggestedRemesa = null; + } else { + // Remesa ya capturada / existente: no sobrescribir + return; + } + } else { + // Mismo pedimento: solo sugerir si está vacía + if (currentRemesa) return; + } + + const myReq = ++remesaSuggestionReqId; + (async () => { + try { + const res = await api.get( + `/v1/a76/invoices/remesa-suggestion?company_id=${companyId}&pedimento_id=${pedimentoId}` + ); + if (myReq !== remesaSuggestionReqId) return; + const next = (res as any)?.data?.next_remesa; + if (typeof next === 'number' && next > 0) { + const nextStr = String(next); + InvoiceTopFieldsFormData.remesa = nextStr; + lastAutoSuggestedRemesa = nextStr; + } + } catch (e) { + // Silencioso: no bloquear selección de pedimento por fallo de sugerencia + } + })(); + }); + async function checkExchangeRate(date: string): Promise { if (!date || !companyStore?.activeCompany?.id) return true; @@ -915,7 +1002,9 @@ bind:exists={observationExists} seals={data.seals || []} incoterms={data.incoterms || []} + valuationMethods={data.valuationMethods || []} enclosure={data.enclosure || []} + legends={data.legends || []} {invoiceType} operationType={InvoiceTopFieldsFormData?.operation_type === 'exp' ? 1