Merge pull request 'fix/partida-campos-obligatorios' (#288) from fix/partida-campos-obligatorios into development
Reviewed-on: ADUANASOFT/anexo76#288
This commit is contained in:
@@ -14,6 +14,9 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
from api.v1.modules.a76.general_catalogs.packages.models import Package
|
||||
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
|
||||
USTariffFraction,
|
||||
)
|
||||
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
from api.v1.modules.public.reference_data.countries.models import Country
|
||||
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
|
||||
@@ -360,18 +363,56 @@ def validate_common(
|
||||
)
|
||||
|
||||
if line.customs.american_fraction:
|
||||
american_fraction_exists = db.query(
|
||||
exists().where(
|
||||
LineCustom.american_fraction == line.customs.american_fraction
|
||||
def _normalize_american_fraction_code(raw_code: str) -> list[str]:
|
||||
normalized_raw = (raw_code or "").strip()
|
||||
if not normalized_raw:
|
||||
return []
|
||||
|
||||
digits_only = normalized_raw.replace(".", "").replace(" ", "").replace("-", "")
|
||||
candidates = [normalized_raw]
|
||||
|
||||
if len(digits_only) == 10:
|
||||
candidates.append(
|
||||
f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}.{digits_only[8:10]}"
|
||||
)
|
||||
elif len(digits_only) == 8:
|
||||
candidates.append(f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}")
|
||||
|
||||
candidates.append(digits_only)
|
||||
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for candidate in candidates:
|
||||
if not candidate or candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
deduped.append(candidate)
|
||||
return deduped
|
||||
|
||||
candidates = _normalize_american_fraction_code(line.customs.american_fraction)
|
||||
us_fraction: USTariffFraction | None = None
|
||||
for candidate in candidates:
|
||||
us_fraction = (
|
||||
db.query(USTariffFraction)
|
||||
.filter(
|
||||
USTariffFraction.code == candidate,
|
||||
USTariffFraction.tenant_id == tenant_id,
|
||||
USTariffFraction.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
).scalar()
|
||||
if not american_fraction_exists:
|
||||
if us_fraction:
|
||||
break
|
||||
|
||||
if not us_fraction:
|
||||
errors.add_error(
|
||||
field=f"line[{line_number}].customs.american_fraction",
|
||||
message="La fracción americana especificada no existe.",
|
||||
solution=["Proporciona una fracción americana valida."],
|
||||
code="AMERICAN_FRACTION_NOT_FOUND",
|
||||
)
|
||||
else:
|
||||
line.customs.american_fraction = us_fraction.code
|
||||
|
||||
if line.order:
|
||||
if len(line.order) > 20:
|
||||
|
||||
@@ -21,32 +21,55 @@
|
||||
let items = $state<USTariffFraction[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state("");
|
||||
let loaded = $state(false);
|
||||
let loadedForCompanyId = $state<number | null>(null);
|
||||
|
||||
const activeCompanyId = $derived(companyStore.activeCompany?.id);
|
||||
|
||||
function normalizeAmericanFractionCode(code: string) {
|
||||
return (code || '').replace(/[.\s-]/g, '');
|
||||
}
|
||||
|
||||
function isEligibleAmericanFraction(item: USTariffFraction) {
|
||||
const normalizedCode = normalizeAmericanFractionCode(item.code || '');
|
||||
return /^\d{8}$/.test(normalizedCode) || /^\d{10}$/.test(normalizedCode);
|
||||
}
|
||||
|
||||
// Filtro local
|
||||
let filteredItems = $derived(
|
||||
items.filter(i =>
|
||||
(i.code || "").includes(searchTerm) ||
|
||||
isEligibleAmericanFraction(i) &&
|
||||
((i.code || "").includes(searchTerm) ||
|
||||
(i.description || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open && !loaded && companyStore.activeCompany?.id) {
|
||||
loadFractions();
|
||||
if (!open) return;
|
||||
|
||||
if (!activeCompanyId) {
|
||||
items = [];
|
||||
loadedForCompanyId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedForCompanyId !== activeCompanyId) {
|
||||
searchTerm = '';
|
||||
items = [];
|
||||
void loadFractions(activeCompanyId);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFractions() {
|
||||
if (!companyStore.activeCompany?.id) {
|
||||
async function loadFractions(companyId: number) {
|
||||
if (!companyId) {
|
||||
toast.error("No hay empresa seleccionada");
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await getUSTariffFractions(1, 1000, companyStore.activeCompany.id);
|
||||
const response = await getUSTariffFractions(1, 1000, companyId);
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error al cargar fracciones americanas:", response.error);
|
||||
@@ -55,8 +78,8 @@
|
||||
}
|
||||
|
||||
if (response.data?.items) {
|
||||
items = response.data.items;
|
||||
loaded = true;
|
||||
items = response.data.items.filter((item) => isEligibleAmericanFraction(item));
|
||||
loadedForCompanyId = companyId;
|
||||
} else {
|
||||
console.warn("No se encontraron fracciones americanas:", response);
|
||||
toast.info("No se encontraron fracciones americanas registradas");
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
if (editingItem.fa_data.discharge === undefined) {
|
||||
editingItem.fa_data.discharge = false;
|
||||
}
|
||||
if ((editingItem.fa_data.movement_type_import === undefined || editingItem.fa_data.movement_type_import === '') && (showLinkToImportBlock || showRepairBlock)) {
|
||||
editingItem.fa_data.movement_type_import = 'TEM';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -359,6 +362,7 @@
|
||||
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
|
||||
<p class="text-[10px] text-muted-foreground -mt-1">Los campos marcados con * son obligatorios.</p>
|
||||
<RadioGroup
|
||||
value={editingItem.fa_data?.discharge === false ? 'no' : 'si'}
|
||||
onValueChange={(v) => {
|
||||
@@ -485,7 +489,7 @@
|
||||
<!-- Fila ligada: selector de factura (FK) + línea (FK) -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="min-w-[100px] flex-1 space-y-1">
|
||||
<Label class="text-xs">Tipo Importación</Label>
|
||||
<Label class="text-xs">Tipo Importación: <span class="text-red-500">*</span></Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={editingItem.fa_data?.movement_type_import || 'TEM'}
|
||||
|
||||
@@ -331,6 +331,9 @@
|
||||
|
||||
<fieldset class="border rounded-md p-3">
|
||||
<legend class="text-xs font-semibold px-2 bg-zinc-200 dark:bg-zinc-700">Main Data</legend>
|
||||
<p class="mt-1 px-1 text-[10px] text-muted-foreground">
|
||||
Los campos marcados con * son obligatorios.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Class - Full Width -->
|
||||
@@ -448,7 +451,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="fraccion" class="text-xs font-medium">Fracción:</Label>
|
||||
<Label for="fraccion" class="text-xs font-medium">Fracción: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fraccion"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import PackageDialog from './package-dialog.svelte';
|
||||
import USFractionSelectorDialog from '$lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte';
|
||||
|
||||
let {
|
||||
item = $bindable(),
|
||||
@@ -29,6 +30,7 @@
|
||||
|
||||
|
||||
let packageDialogOpen = $state(false);
|
||||
let americanFractionDialogOpen = $state(false);
|
||||
let package_key = $state('');
|
||||
let package_weight_unit = $state<number>(0);
|
||||
let isLoadingPackage = $state(false);
|
||||
@@ -114,10 +116,21 @@
|
||||
package_weight_unit = pkg.weight_unit || 0;
|
||||
quantities.package_description = pkg.description_es || pkg.description_en || pkg.key;
|
||||
}
|
||||
|
||||
function handleAmericanFractionSelect(fraction: any) {
|
||||
customs.american_fraction = fraction.code || '';
|
||||
(customs as any).american_fraction_description = fraction.description || '';
|
||||
if (fraction.ad_valorem !== null && fraction.ad_valorem !== undefined) {
|
||||
customs.advalorem_american = fraction.ad_valorem;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="border rounded-md p-2 space-y-2">
|
||||
<legend class="text-xs font-semibold px-2 bg-gray-200 dark:bg-gray-700">PACKAGES</legend>
|
||||
<p class="text-[10px] text-muted-foreground px-1">
|
||||
Los campos marcados con * son obligatorios.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-12 gap-2 items-end">
|
||||
<div class="col-span-2 space-y-1">
|
||||
@@ -169,7 +182,7 @@
|
||||
<div class="text-xs font-semibold mb-2">WEIGHTS</div>
|
||||
<div class="grid grid-cols-6 gap-2 items-end">
|
||||
<div class="col-span-2 space-y-1">
|
||||
<Label for="peso_neto" class="text-xs">Net:</Label>
|
||||
<Label for="peso_neto" class="text-xs">Net: <span class="text-red-500">*</span></Label>
|
||||
<Input id="peso_neto" type="number" step="0.00000001" min="0" bind:value={quantities.net_weight} disabled={disabled} class="h-7 text-xs text-right" />
|
||||
</div>
|
||||
|
||||
@@ -200,8 +213,28 @@
|
||||
|
||||
<div class="grid grid-cols-12 gap-2 items-end">
|
||||
<div class="col-span-4 space-y-1">
|
||||
<Label for="fraccion_americana" class="text-xs">American Fraction:</Label>
|
||||
<Input id="fraccion_americana" bind:value={customs.american_fraction} disabled={disabled} class="h-7 text-xs" />
|
||||
<Label for="fraccion_americana" class="text-xs">American Fraction: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fraccion_americana"
|
||||
value={customs.american_fraction || ''}
|
||||
readonly
|
||||
disabled={disabled}
|
||||
class="h-7 text-xs flex-1 bg-muted cursor-pointer"
|
||||
placeholder="Seleccionar..."
|
||||
onclick={() => !disabled && (americanFractionDialogOpen = true)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
disabled={disabled}
|
||||
onclick={() => !disabled && (americanFractionDialogOpen = true)}
|
||||
>
|
||||
<Folder class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 space-y-1">
|
||||
@@ -230,3 +263,4 @@
|
||||
</fieldset>
|
||||
|
||||
<PackageDialog bind:open={packageDialogOpen} onSelect={handlePackageSelect} />
|
||||
<USFractionSelectorDialog bind:open={americanFractionDialogOpen} onSelect={handleAmericanFractionSelect} />
|
||||
|
||||
@@ -259,12 +259,42 @@ const FIELD_MAP: Record<string, string> = {
|
||||
'financial.unit_cost_capture': 'Costo Unitario',
|
||||
'customs.fraction': 'Fracción Arancelaria',
|
||||
'customs.origin_country': 'País de Origen',
|
||||
'customs.american_fraction': 'Fracción Americana',
|
||||
'fa_data.search_invoice': 'Factura de Referencia',
|
||||
'fa_data.search_line': 'Línea de Referencia',
|
||||
'fa_data.search_type': 'Tipo de Búsqueda',
|
||||
'fa_data.movement_type_import': 'Tipo de Importación'
|
||||
};
|
||||
|
||||
function humanizeFieldPath(field: string): string {
|
||||
const rawField = (field || '').trim();
|
||||
if (!rawField) return 'campo';
|
||||
|
||||
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
|
||||
const fieldPath = lineMatch?.[2] || rawField;
|
||||
const mappedPath = fieldPath.replace(/^body\./i, '');
|
||||
const fieldLabel = FIELD_MAP[mappedPath] || mappedPath.replace(/\./g, ' → ');
|
||||
|
||||
if (lineMatch) {
|
||||
return `Partida ${lineMatch[1]} - ${fieldLabel}`;
|
||||
}
|
||||
|
||||
return fieldLabel;
|
||||
}
|
||||
|
||||
function humanizeValidationMessage(message: string): string {
|
||||
const rawMessage = (message || '').trim();
|
||||
if (!rawMessage) return 'error de validación';
|
||||
|
||||
return rawMessage
|
||||
.replace(/line\[(\d+)\]\.(\w+(?:\.\w+)*)/gi, (_match, lineNumber, fieldPath) => {
|
||||
return `Partida ${lineNumber} - ${humanizeFieldPath(fieldPath)}`;
|
||||
})
|
||||
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
|
||||
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
|
||||
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a backend error into a human-readable Spanish message.
|
||||
* Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error).
|
||||
@@ -285,12 +315,8 @@ export function formatItemError(error: any): string {
|
||||
// New structure (ApiResponse.validationErrors)
|
||||
if (status === 422 && Array.isArray(validationErrors)) {
|
||||
const errors = validationErrors.map((err: any) => {
|
||||
const field = err.field || '';
|
||||
const fieldName = FIELD_MAP[field] || field || 'campo';
|
||||
|
||||
let msg = err.message || 'error de validación';
|
||||
if (msg.includes('field required')) msg = 'es obligatorio';
|
||||
if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido';
|
||||
const fieldName = humanizeFieldPath(err.field || '');
|
||||
const msg = humanizeValidationMessage(err.message || 'error de validación');
|
||||
|
||||
return `• ${fieldName}: ${msg}`;
|
||||
});
|
||||
@@ -305,11 +331,8 @@ export function formatItemError(error: any): string {
|
||||
.filter((l: string) => l !== 'body')
|
||||
.join('.');
|
||||
|
||||
const fieldName = FIELD_MAP[locPath] || locPath || 'campo';
|
||||
|
||||
let msg = err.msg || 'error de validación';
|
||||
if (msg.includes('field required')) msg = 'es obligatorio';
|
||||
if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido';
|
||||
const fieldName = humanizeFieldPath(locPath);
|
||||
const msg = humanizeValidationMessage(err.msg || 'error de validación');
|
||||
|
||||
return `• ${fieldName}: ${msg}`;
|
||||
});
|
||||
@@ -322,7 +345,7 @@ export function formatItemError(error: any): string {
|
||||
if (d.includes('Access denied')) return 'No tienes permisos para realizar esta acción.';
|
||||
if (d.includes('not found')) return 'El registro no existe o fue eliminado.';
|
||||
if (d.includes('Class mismatch')) return 'Error de validación: ' + d;
|
||||
return d;
|
||||
return humanizeValidationMessage(d);
|
||||
}
|
||||
|
||||
// 4. Fallbacks by status code
|
||||
|
||||
Reference in New Issue
Block a user