Merge pull request 'feature/catalog-importation' (#223) from feature/catalog-importation into development

Reviewed-on: ADUANASOFT/anexo76#223
This commit is contained in:
2026-03-18 18:40:45 +00:00
8 changed files with 271 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string>('');
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 @@
/>
</div>
{#if legends?.length}
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">
<div class="space-y-1">
<Label for="legend_code" class="text-xs">Leyenda fija:</Label>
<Select.Root
type="single"
value={selectedLegendCode}
onValueChange={(v) => {
selectedLegendCode = v ?? '';
}}
>
<Select.Trigger id="legend_code" class="h-7 text-xs">
<span class="truncate">
{selectedLegendCode ? `Clave ${selectedLegendCode}` : 'Selecciona leyenda...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each legends as l}
<Select.Item value={String(l.code)}>
{l.code} - {l.description || ''}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex items-end">
<button
type="button"
class="h-7 px-3 rounded-md border text-xs hover:bg-muted"
onclick={appendLegendToEs}
>
Agregar a observaciones
</button>
</div>
</div>
{/if}
{#if invoiceType !== 'MEX'}
<div class="space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
@@ -410,21 +463,21 @@
<Label for="incoterm" class="text-xs">Incoterm:</Label>
<Select.Root
type="single"
value={formData.incoterm ? String(formData.incoterm) : ''}
value={formData.incoterm || ''}
onValueChange={(v) => {
formData.incoterm = v ? parseInt(v) : null;
formData.incoterm = v ?? '';
}}
>
<Select.Trigger id="incoterm" class="h-7 text-xs">
<span class="truncate"
>{formData.incoterm
? incoterms.find((p) => p.id === formData.incoterm)?.name || 'Selecciona...'
: 'Selecciona...'}</span
>{formData.incoterm || 'Selecciona...'}</span
>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each incoterms as inco}
<Select.Item value={String(inco.id)}>{inco.name}</Select.Item>
<Select.Item value={String(inco.code)}>
{inco.code} - {inco.description_es}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
@@ -460,18 +513,18 @@
>
<Select.Root
type="single"
value={formData.valuation_method ? String(formData.valuation_method) : ''}
value={formData.valuation_method || ''}
onValueChange={(v) => {
formData.valuation_method = v;
formData.valuation_method = v ?? '';
}}
>
<Select.Trigger id="valuation_method_exp" class="h-8 text-xs">
<span class="truncate">{formData.valuation_method || 'Selecciona...'}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="general">General</Select.Item>
<Select.Item value="devalued">Devaluado</Select.Item>
<Select.Item value="special">Especial</Select.Item>
{#each valuationMethods as m}
<Select.Item value={String(m.key)}>{m.key} - {m.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
@@ -564,18 +617,18 @@
<Label for="valuation_method" class="text-xs">Met. Valoracion:</Label>
<Select.Root
type="single"
value={formData.valuation_method ? String(formData.valuation_method) : ''}
value={formData.valuation_method || ''}
onValueChange={(v) => {
formData.valuation_method = v;
formData.valuation_method = v ?? '';
}}
>
<Select.Trigger id="valuation_method" class="h-7 text-xs">
<span class="truncate">{formData.valuation_method || 'Selecciona...'}</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value="general">General</Select.Item>
<Select.Item value="devalued">Devaluado</Select.Item>
<Select.Item value="special">Especial</Select.Item>
{#each valuationMethods as m}
<Select.Item value={String(m.key)}>{m.key} - {m.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>

View File

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

View File

@@ -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<boolean> {
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