From a57b01b190690a04a0a12b2a4c2b54b9deb789f7 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 12 Mar 2026 07:27:00 -0600 Subject: [PATCH 1/5] feature/items-ventana-por-tipo-factura --- .../modules/a76/layouts_csv/facturas/tasks.py | 28 ++- frontend/src/lib/api/dashboard/a76/items.ts | 4 + .../edit/items/fa/item-configuration.svelte | 6 + .../edit/items/fa/item-sheet-fa.svelte | 122 ++++++++++-- .../edit/items/fa/tab-continuation.svelte | 136 +++++++++---- .../edit/items/inv/item-sheet-inv.svelte | 181 +++++++++++++++++- .../invoices/edit/items/items-tab-form.svelte | 37 +++- .../src/lib/config/invoice-item-visibility.ts | 88 +++++++++ 8 files changed, 529 insertions(+), 73 deletions(-) create mode 100644 frontend/src/lib/config/invoice-item-visibility.ts diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py index 1b338e51..5bf23268 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -4569,12 +4569,28 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt _fc_insert = parse_footer_config(meta.get("footer_config")) autonumerar_remesas_insert = _fc_insert.get("autonumerar_remesas", False) class_id_by_code: Dict[str, int] = {} + class_uom_by_code: Dict[str, Optional[str]] = {} + class_fraction_by_code: Dict[str, Optional[str]] = {} + class_desc_es_by_code: Dict[str, Optional[str]] = {} + class_desc_en_by_code: Dict[str, Optional[str]] = {} uom_id_by_code: Dict[str, int] = {} package_id_by_key: Dict[str, int] = {} if model_target == 'invoice_details': - for c in session.query(Class.id, Class.class_code).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all(): + for c in session.query( + Class.id, + Class.class_code, + Class.unit_of_measure, + Class.fraction, + Class.description_es, + Class.description_en, + ).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all(): if c[1]: - class_id_by_code[(c[1] or "").strip().upper()] = c[0] + class_code_key = (c[1] or "").strip().upper() + class_id_by_code[class_code_key] = c[0] + class_uom_by_code[class_code_key] = (c[2] or "").strip().upper() or None + class_fraction_by_code[class_code_key] = (c[3] or "").strip() or None + class_desc_es_by_code[class_code_key] = (c[4] or "").strip() or None + class_desc_en_by_code[class_code_key] = (c[5] or "").strip() or None for u in session.query(UnitOfMeasure.id, UnitOfMeasure.code).filter(UnitOfMeasure.tenant_id == tenant_id, UnitOfMeasure.company_id == company_id).all(): if u[1]: uom_id_by_code[(u[1] or "").strip().upper()] = u[0] @@ -5257,6 +5273,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt class_code = (row_norm.get('CLASE') or '').strip().upper() class_id = class_id_by_code.get(class_code) if class_code else None uom_code = (row_norm.get('UNIDAD DE MEDIDA') or row_norm.get('UNIDAD MEDIDA') or '').strip().upper() + if not uom_code and class_code: + uom_code = class_uom_by_code.get(class_code) or '' uom_id = uom_id_by_code.get(uom_code) if uom_code else None bulk_key = (row_norm.get('CLAVE BULTOS') or row_norm.get('CLAVEBULTOS') or '').strip() package_id = package_id_by_key.get(bulk_key) if bulk_key else None @@ -5305,6 +5323,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip() fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCION') or row_norm.get('FRACCIONARANCELARIA') or '').strip() + if not fraction and class_code: + fraction = class_fraction_by_code.get(class_code) or '' fraction_type = (row_norm.get('PREFERENCIA ARANCELARIA') or row_norm.get('PREFERENCIA') or '').strip() sector = (row_norm.get('SECTOR') or '').strip() american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip() @@ -5318,7 +5338,11 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt )) desc_es = (row_norm.get('DESCRIPCION ESPAÑOL') or row_norm.get('DESCRIPCIONE') or row_norm.get('DESCRIPCION') or '').strip() + if not desc_es and class_code: + desc_es = class_desc_es_by_code.get(class_code) or '' desc_en = (row_norm.get('DESCRIPCION INGLES') or row_norm.get('DESCRIPCIONI') or '').strip() + if not desc_en and class_code: + desc_en = class_desc_en_by_code.get(class_code) or '' brand = (row_norm.get('MARCA') or '').strip() model = (row_norm.get('MODELO') or '').strip() extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip() diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index d833b4b5..c1ac4cbb 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -156,6 +156,9 @@ export interface Item { // Permits permit_number?: string; page_line?: string; + has_fda_code?: boolean; + fda_key?: string; + fcc_key?: string; has_certificate?: boolean; certificate_number?: string; octave_permit?: string; @@ -168,6 +171,7 @@ export interface Item { // Payment payment_method?: string; + igi_payment_method?: string; igi_amount?: number; // Additional notes diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index af2950b8..3ba8056a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -48,6 +48,12 @@ (lineItem as any).part_number_display = part.part_number; (lineItem as any).part_description_es = part.description_spanish; (lineItem as any).part_description_en = part.description_english; + if (!lineItem.fda_key && part.fda_key) { + lineItem.fda_key = part.fda_key; + } + if (!lineItem.fcc_key && part.fcc_key) { + lineItem.fcc_key = part.fcc_key; + } } diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 6cd59fe2..d645d531 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -16,12 +16,15 @@ import TabSeries from './tab-series.svelte'; import TabLabeling from './tab-labeling.svelte'; import TabIdentifiers from './tab-identifiers.svelte'; + import { getVisibility } from '$lib/config/invoice-item-visibility'; let { open = $bindable(), isEditMode = false, editingItem = $bindable(), invoice, + invoiceType = undefined, + operationType = undefined, onSave, onCancel, isTargetingPreset = false, @@ -31,6 +34,8 @@ isEditMode?: boolean; editingItem: Partial; invoice: Invoice | null; + invoiceType?: string | null; + operationType?: string | number | null; onSave: () => void; onCancel?: () => void; isTargetingPreset?: boolean; @@ -46,6 +51,40 @@ if (!Array.isArray(editingItem.series)) { editingItem.series = editingItem.series != null ? [editingItem.series] : []; } + if (!editingItem.fa_data) { + editingItem.fa_data = {}; + } + if (editingItem.fa_data.own_equipment === undefined) { + editingItem.fa_data.own_equipment = false; + } + if (editingItem.fa_data.omit_annex31 === undefined) { + editingItem.fa_data.omit_annex31 = false; + } + } + }); + + const visibility = $derived.by(() => getVisibility(invoiceType ?? invoice?.invoice_type, operationType ?? invoice?.operation_type)); + const showCrTrackingBlock = $derived.by(() => { + const normalizedOperationType = operationType ?? invoice?.operation_type; + if (normalizedOperationType === 1 || normalizedOperationType === 'exp') { + return false; + } + + return visibility.showCrTrackingHeader; + }); + const visibleTabs = $derived.by(() => [ + { value: 'generales', label: 'General', visible: true }, + { value: 'continuacion', label: 'Continuación', visible: true }, + { value: 'series', label: 'Series', visible: true }, + { value: 'etiquetado', label: 'Etiquetado', visible: true }, + { value: 'identificadores', label: 'IDs', visible: visibility.showIdentifiersTab } + ].filter((tab) => tab.visible)); + const tabListStyle = $derived(`grid-template-columns: repeat(${visibleTabs.length || 1}, minmax(0, 1fr));`); + let activeTab = $state('generales'); + + $effect(() => { + if (!visibleTabs.some((tab) => tab.value === activeTab)) { + activeTab = visibleTabs[0]?.value || 'generales'; } }); @@ -78,6 +117,56 @@
{#if line} + {#if showCrTrackingBlock} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
@@ -111,23 +200,13 @@
- - - - General - - - Continuación - - - Series - - - Etiquetado - - - IDs - + + + {#each visibleTabs as tab} + + {tab.label} + + {/each}
@@ -154,6 +233,7 @@ @@ -170,9 +250,11 @@ - - - + {#if visibility.showIdentifiersTab} + + + + {/if}
{:else} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte index 8a6e7541..b6b26c5a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte @@ -6,14 +6,17 @@ import { Button } from '$lib/components/ui/button'; import { Folder } from 'lucide-svelte'; import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; + import type { InvoiceItemVisibility } from '$lib/config/invoice-item-visibility'; import PaymentMethodDialog from './payment-method-dialog.svelte'; let { lineItem = $bindable(), - descriptions = $bindable() + descriptions = $bindable(), + visibility }: { lineItem: Partial; descriptions: LineDescriptions; + visibility: InvoiceItemVisibility; } = $props(); let taxPaidValue = $derived(lineItem.tax_payment ? 'si' : 'no'); @@ -30,9 +33,21 @@ if (lineItem.is_military_mcia === undefined) { lineItem.is_military_mcia = false; } + if (lineItem.has_fda_code === undefined) { + lineItem.has_fda_code = false; + } if (descriptions.consider_a31 === undefined) { descriptions.consider_a31 = false; } + if (!lineItem.fa_data) { + lineItem.fa_data = {}; + } + if (lineItem.fa_data.own_equipment === undefined) { + lineItem.fa_data.own_equipment = false; + } + if (lineItem.fa_data.omit_annex31 === undefined) { + lineItem.fa_data.omit_annex31 = false; + } let paymentMethodDialogOpen = $state(false); let payment_method_description = $state(''); @@ -76,7 +91,7 @@ } -
+
@@ -123,32 +138,58 @@
+
+ + +
+ + {#if visibility.showFdaFcc} +
+
FDA / FCC
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/if} - -
-
- Has Certificate of Origin? - +
+
+ Has Certificate of Origin? + -
- - -
-
- - -
-
-
-
- - - +
+ + +
+
+ + +
+ +
+
+ + + +
-
+ {/if}
@@ -167,6 +208,17 @@
+
+
+ + +
+
+ + +
+
+
@@ -181,26 +233,28 @@
-
- -
-
- -
- -
-
-
-
- - -
+
+ {#if visibility.showEighthRule} + +
- - + +
+ +
+
+
+
+ + +
+
+ + +
-
+ {/if}
@@ -209,12 +263,12 @@
-
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte index 13b20bf5..06daa656 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte @@ -12,6 +12,10 @@ import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosPestanasItemInv } from '$lib/config/shortcuts/dashboard/invoices/item/inventory'; import PartNumberDialog from '../fa/part-number-dialog.svelte'; + import ClassDialog from '../fa/class-dialog.svelte'; + import UnitOfMeasureDialog from '../fa/unit-of-measure-dialog.svelte'; + import CountryDialog from '../fa/country-dialog.svelte'; + import TariffFractionDialog from '../fa/tariff-fraction-dialog.svelte'; let { open = $bindable(), @@ -37,6 +41,10 @@ let activeTab = $state('general'); let showPartDialog = $state(false); + let showClassDialog = $state(false); + let showUnitDialog = $state(false); + let showCountryDialog = $state(false); + let showFractionDialog = $state(false); const tabMapping: Record = { tab1: 'general', @@ -56,8 +64,69 @@ if (editingItem.customs) { editingItem.customs.fraction = part.fraction; } + if (!editingItem.fda_key && part.fda_key) { + editingItem.fda_key = part.fda_key; + } + if (!editingItem.fcc_key && part.fcc_key) { + editingItem.fcc_key = part.fcc_key; + } } + function handleClassSelect(classItem: any) { + editingItem.class_id = classItem.id; + (editingItem as any).class_code = classItem.class_code; + (editingItem as any).class_description = classItem.description_es || classItem.description_en; + if (editingItem.description) { + if (classItem.description_es) editingItem.description.description_spanish = classItem.description_es; + if (classItem.description_en) editingItem.description.description_english = classItem.description_en; + } + } + + function handleUnitSelect(unit: any) { + editingItem.unit_of_measure = unit.id; + (editingItem as any).unit_code = unit.code; + (editingItem as any).unit_description = unit.description || unit.description_en; + if (editingItem.quantity) { + editingItem.quantity.unit_of_measure = unit.code; + } + } + + function handleCountrySelect(country: any) { + if (!editingItem.customs) editingItem.customs = {} as any; + editingItem.customs.origin_country = country.m3_key || country.mex_key; + (editingItem.customs as any).origin_country_name = country.description || country.description_en; + } + + function handleFractionSelect(fraction: any) { + if (!editingItem.customs) editingItem.customs = {} as any; + const fractionBase = fraction.fraction?.replace(/\./g, '') || ''; + const nico = fraction.nico || ''; + editingItem.customs.fraction = fractionBase + nico; + (editingItem.customs as any).fraction_description = fraction.description; + } + + const fractionDisplay = $derived.by(() => { + const fraction = editingItem.customs?.fraction; + if (!fraction) return ''; + if (fraction.includes('.')) return fraction; + if (fraction.length === 8) return `${fraction.slice(0, 4)}.${fraction.slice(4, 6)}.${fraction.slice(6, 8)}`; + if (fraction.length === 10) return `${fraction.slice(0, 4)}.${fraction.slice(4, 6)}.${fraction.slice(6, 8)}.${fraction.slice(8, 10)}`; + return fraction; + }); + + const systemLabel = $derived.by(() => { + switch ((invoice?.system || '').toLowerCase()) { + case 'fixed_asset': + return 'SCAF (Activo Fijo)'; + case 'csv': + return 'CSV'; + case 'scaii': + return 'SCAII (Inventario)'; + default: + return invoice?.system || 'Inventario'; + } + }); + useShortcuts( 'Invoice Item Form (Inventory)', obtenerAtajosPestanasItemInv({ @@ -80,11 +149,16 @@ if (editingItem && !editingItem.financial) editingItem.financial = {} as any; if (editingItem && !editingItem.customs) editingItem.customs = {} as any; if (editingItem && !editingItem.description) editingItem.description = {} as any; + if (editingItem.has_fda_code === undefined) editingItem.has_fda_code = false; } }); + + + + @@ -93,7 +167,7 @@ >
- {isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - SCAII (Inventario) + {isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - {systemLabel} {isEditMode @@ -101,7 +175,7 @@ : 'Completa la información del nuevo item de inventario.'} {#if editingItem} - {editingItem} items en esta partida + Línea {editingItem.line_number || 1} {/if} @@ -133,7 +207,7 @@ {#if !isTargetingPreset}
-

Información de la Factura (SCAII - Inventario)

+

Información de la Factura ({systemLabel})

{#if !invoice?.id}
⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la @@ -157,7 +231,7 @@
Sistema: SCAII (Inventory){systemLabel}
@@ -165,6 +239,101 @@
{/if} +
+
+ +
+ (showClassDialog = true)} + /> + +
+ {#if (editingItem as any).class_description} +

{(editingItem as any).class_description}

+ {/if} +
+ +
+ + {#if line?.quantity} + + {/if} +
+
+ +
+ (showUnitDialog = true)} + /> + +
+
+ +
+ + {#if line?.financial} + + {/if} +
+
+ +
+ (showCountryDialog = true)} + /> + +
+
+ +
+ +
+ (showFractionDialog = true)} + /> + +
+
+
+ + +
+
+
@@ -276,7 +445,9 @@
- + {#if line?.customs} + + {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 5c50968d..5d76cde3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -27,6 +27,7 @@ import { itemPresetsApi, type ItemPreset } from '$lib/api/dashboard/a76/item-presets'; import { Checkbox } from '$lib/components/ui/checkbox'; import { cleanLineData } from '$lib/utils/items-logic'; + import { getVisibility } from '$lib/config/invoice-item-visibility'; let { invoice, @@ -99,6 +100,13 @@ ); const invoiceSystem = $derived(invoice?.system || 'scaii'); + const itemVisibility = $derived.by(() => getVisibility(invoiceType, operationType)); + const showCrTrackingHeader = $derived(itemVisibility.showCrTrackingHeader); + const showTrackingHeaderColumns = $derived(operationType !== 1 && showCrTrackingHeader); + const emptyStateColspan = $derived.by(() => { + if (operationType === 1) return 11; + return showTrackingHeaderColumns ? 12 : 10; + }); const invoiceLabel = $derived.by(() => { if (invoice?.invoice_number) return `Factura ${invoice.invoice_number}`; if (invoice?.id) return `Factura ${invoice.id}`; @@ -256,10 +264,14 @@ alternate_unit: undefined, permit_number: undefined, page_line: undefined, + has_fda_code: false, + fda_key: undefined, + fcc_key: undefined, has_certificate: false, certificate_number: undefined, tax_payment: false, payment_method: undefined, + igi_payment_method: undefined, igi_amount: undefined, is_military_mcia: false, wildcard_field: undefined, @@ -316,6 +328,14 @@ reference: { serie_id: undefined }, + fa_data: { + search_invoice: undefined, + search_line: undefined, + search_type: undefined, + movement_type_import: undefined, + down_equipment: false, + omit_annex31: false + }, series: [] }; } @@ -1033,6 +1053,10 @@ Línea + {#if showTrackingHeaderColumns} + Factura Impo + Línea + {/if} P/S Clase Descripcion Clase @@ -1048,7 +1072,7 @@ {#if displayedItems.length === 0} No hay items disponibles @@ -1079,20 +1103,21 @@ {item.fa_data?.contains_subitems ? 'Sí' : 'No'} {item.warehouse || '-'} - {:else if invoiceType === 'CR'} + {:else if showCrTrackingHeader} {item.line_number} {item.fa_data?.search_invoice || '-'} {item.fa_data?.search_line || '-'} {item.is_subitem ? 'S' : 'P'} - {item.quantity?.quantity || '0'} {item.class_code || '-'} - {item.part_number_display || '-'} {item.description?.description_spanish || '-'} + {item.quantity?.quantity || '0'} + {item.unit_of_measure_code || '-'} + {item.reference_number || '-'} {item.fa_data?.contains_subitems ? 'Sí' : 'No'} {item.warehouse || '-'} {:else if invoiceType === 'REP' || invoiceType === 'REPAR'} @@ -1138,7 +1163,7 @@ {/each} {#if isLoadingMore} - + Cargando más items... @@ -1627,6 +1652,8 @@ {isEditMode} bind:editingItem {invoice} + {invoiceType} + {operationType} {isTargetingPreset} onSave={saveItem} onCancel={() => { diff --git a/frontend/src/lib/config/invoice-item-visibility.ts b/frontend/src/lib/config/invoice-item-visibility.ts new file mode 100644 index 00000000..2720f168 --- /dev/null +++ b/frontend/src/lib/config/invoice-item-visibility.ts @@ -0,0 +1,88 @@ +export interface InvoiceItemVisibility { + showCrTrackingHeader: boolean; + showEighthRule: boolean; + showFdaFcc: boolean; + showCertificateOfOrigin: boolean; + showIdentifiersTab: boolean; +} + +const defaultVisibility: InvoiceItemVisibility = { + showCrTrackingHeader: true, + showEighthRule: true, + showFdaFcc: true, + showCertificateOfOrigin: true, + showIdentifiersTab: true +}; + +function normalizeInvoiceType(invoiceType?: string | null): string { + return String(invoiceType || '') + .trim() + .toUpperCase(); +} + +function normalizeOperationType(operationType?: string | number | null): 'imp' | 'exp' | null { + if (operationType === null || operationType === undefined || operationType === '') { + return null; + } + + if (typeof operationType === 'number') { + if (operationType === 1) return 'exp'; + if (operationType === 2) return 'imp'; + } + + const normalized = String(operationType) + .trim() + .toLowerCase(); + + if (normalized === '1' || normalized === 'exp' || normalized === 'export' || normalized === 'exportacion') { + return 'exp'; + } + + if (normalized === '2' || normalized === 'imp' || normalized === 'import' || normalized === 'importacion') { + return 'imp'; + } + + return null; +} + +export function getVisibility( + invoiceType?: string | null, + operationType?: string | number | null +): InvoiceItemVisibility { + if (normalizeOperationType(operationType) === 'exp') { + return defaultVisibility; + } + + switch (normalizeInvoiceType(invoiceType)) { + case 'TEM': + case 'DEF': + return { + ...defaultVisibility, + showCrTrackingHeader: false, + showFdaFcc: false + }; + + case 'CR': + return { + ...defaultVisibility, + showEighthRule: false + }; + + case 'MEX': + return { + ...defaultVisibility, + showCrTrackingHeader: false, + showEighthRule: false, + showFdaFcc: false, + showCertificateOfOrigin: false, + showIdentifiersTab: false + }; + + default: + return defaultVisibility; + } +} + +export function isExportOperation(operationType?: string | number | null): boolean { + return normalizeOperationType(operationType) === 'exp'; +} \ No newline at end of file From e1d4ef4a683b5b8f509cc2967c092125d54b3a7c Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 12 Mar 2026 11:41:11 -0600 Subject: [PATCH 2/5] feature/correccion-de-carga-csv-falta-de-datos-fk --- .../items/exports/validators/calculations.py | 29 +- .../a76/items/exports/validators/create.py | 31 +- .../items/imports/validators/calculations.py | 103 +++-- .../a76/items/imports/validators/create.py | 31 +- .../modules/a76/layouts_csv/classes/tasks.py | 1 + .../a76/layouts_csv/exportacion/routes.py | 6 + .../facturas/line_item_enrichment/__init__.py | 12 + .../line_item_enrichment/export_enrichment.py | 99 +++++ .../line_item_enrichment/import_enrichment.py | 246 ++++++++++++ .../a76/layouts_csv/facturas/routes.py | 362 +++++++++--------- .../modules/a76/layouts_csv/facturas/tasks.py | 274 +++++++------ .../v1/modules/a76/layouts_csv/parts/tasks.py | 1 + .../a76/layouts_csv/pedmientos/tasks.py | 2 + 13 files changed, 852 insertions(+), 345 deletions(-) create mode 100644 backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/__init__.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/export_enrichment.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/import_enrichment.py diff --git a/backend/api/v1/modules/a76/items/exports/validators/calculations.py b/backend/api/v1/modules/a76/items/exports/validators/calculations.py index 4986b126..76dfa6a3 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/calculations.py +++ b/backend/api/v1/modules/a76/items/exports/validators/calculations.py @@ -69,9 +69,10 @@ def apply_calculations( line.depreciation_date = invoice_date - if (not line.description.description_spanish and not line.description.description_english) and (line.part_info.description_spanish and line.part_info.description_english): - line.description.description_spanish = line.part_info.description_spanish - line.description.description_english = line.part_info.description_english + part_info = getattr(line, "part_info", None) + if (not line.description.description_spanish and not line.description.description_english) and part_info and (part_info.description_spanish and part_info.description_english): + line.description.description_spanish = part_info.description_spanish + line.description.description_english = part_info.description_english else: if not line.description.description_spanish: class_desc = ( @@ -184,10 +185,14 @@ def calculate_values( # ========================================== # CÁLCULOS DE VALORES EN MONEDA - # foreign=ME, local=MN, manual=MC + # Una sola fuente: currency_type (USD/ME, MXN/MN) cuando está presente, paridad con import y CSV. # ========================================== result = ( - db.query(InvoiceFinancials.currency, InvoiceFinancials.exchange_rate) + db.query( + InvoiceFinancials.currency, + InvoiceFinancials.currency_type, + InvoiceFinancials.exchange_rate, + ) .filter( InvoiceFinancials.invoice_id == line.invoice_id, InvoiceFinancials.tenant_id == tenant_id, @@ -198,23 +203,27 @@ def calculate_values( if not result: return - currency, exchange_rate = result + currency, currency_type, exchange_rate = result + if currency_type in ("USD", "ME"): + currency = "foreign" + elif currency_type in ("MXN", "MN"): + currency = "local" if currency == "foreign": # ME line.financial.unit_cost_usd = line.financial.unit_cost_capture line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity - line.financial.unit_cost_mxn = line.financial.unit_cost_capture * exchange_rate + line.financial.unit_cost_mxn = line.financial.unit_cost_capture * (exchange_rate or 1) line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity elif currency == "local": # MN line.financial.unit_cost_mxn = line.financial.unit_cost_capture line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity - line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate + line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1) line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity elif currency == "manual": # MC - line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate + line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1) line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity - line.financial.unit_cost_mxn = line.financial.unit_cost_usd * exchange_rate + line.financial.unit_cost_mxn = line.financial.unit_cost_usd * (exchange_rate or 1) line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity line.financial.value_mc = line.financial.unit_cost_capture * line.quantity.quantity diff --git a/backend/api/v1/modules/a76/items/exports/validators/create.py b/backend/api/v1/modules/a76/items/exports/validators/create.py index d7b2328a..8ab754f8 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/create.py +++ b/backend/api/v1/modules/a76/items/exports/validators/create.py @@ -17,6 +17,17 @@ from api.v1.modules.public.reference_data.payment_methods.models import PaymentM from .common import validate_common +def _normalize_weight_type(logistics) -> str: + """Paridad con CSV: enum o string a 'KGS'/'LBS' para comparaciones.""" + if logistics is None: + return "KGS" + wt = getattr(logistics, "weight_type", None) or "KGS" + if hasattr(wt, "value"): + wt = wt.value + weight_str = str(wt).upper() if wt else "KGS" + return weight_str if weight_str in ("KGS", "LBS") else "KGS" + + def validate_create( db: Session, line: LineItem, @@ -212,16 +223,28 @@ def validate_create( line.financial.value_temp_material_usd = line.financial.value_usd line.financial.value_temp_material_mxn = line.financial.value_mxn + # value_mc: paridad con calculate_values y cargas CSV + currency = getattr(invoice.financials, "currency", None) + if currency == "foreign": + line.financial.value_mc = line.financial.value_usd + elif currency == "local": + line.financial.value_mc = line.financial.value_usd + elif currency == "manual": + line.financial.value_mc = unit_cost_capture * quantity + else: + line.financial.value_mc = line.financial.value_usd + # ========================================== # VALIDAR Y CONVERTIR PESOS NETOS # ========================================== - invoice_weight_type = invoice.logistics.weight_type # 'kgs' o 'lbs' + invoice_weight_type = _normalize_weight_type(invoice.logistics) quantity = line.quantity.quantity or Decimal("0") net_weight_input = line.quantity.net_weight or Decimal("0") - # Determinar si la unidad de medida es de peso - unit_is_kgs = line.unit_of_measure and line.unit_of_measure == "24" #KGS - unit_is_lbs = line.unit_of_measure and line.unit_of_measure == "25" #LBS + # UOM peso: aceptar 24/"24" y 25/"25" (paridad con CSV) + uom = line.unit_of_measure + unit_is_kgs = uom is not None and (str(uom) == "24" or uom == 24) + unit_is_lbs = uom is not None and (str(uom) == "25" or uom == 25) # Calcular peso neto en kilogramos (estándar interno) if unit_is_kgs: diff --git a/backend/api/v1/modules/a76/items/imports/validators/calculations.py b/backend/api/v1/modules/a76/items/imports/validators/calculations.py index 565b4ed2..d77bc44f 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/calculations.py +++ b/backend/api/v1/modules/a76/items/imports/validators/calculations.py @@ -1,8 +1,7 @@ from sqlalchemy.orm import Session -from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics +from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader from core.exceptions import ErrorCollector -from ...models import LineItem from ...models import LineItem from api.v1.modules.a76.classes.models import Class @@ -13,57 +12,97 @@ def apply_calculations( #TODO: SSisGen Logic # if ssisgen.calcularcostounitarioenbaseavalortotalscaf = 1: # unit_cost_capture = line.financial.total_value / line.financial.total_value <-- habria que revisar por que esta asi, por que para mi no tiene sentido, pero es lo que esta en clarion - caluclate_values(db, line, tenant_id, company_id) - - if not line.fa_data.is_subitem: - line.fa_data.subitem_number = None - - invoice_date = db.query(InvoiceHeader.invoice_date).filter(InvoiceHeader.id == line.invoice_id, InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id).scalar() - - line.depreciation_date = invoice_date - - if (not line.description.description_spanish and not line.description.description_english) and (line.part_info.description_spanish and line.part_info.description_english): - line.description.description_spanish = line.part_info.description_spanish - line.description.description_english = line.part_info.description_english - else: - if not line.description.description_spanish: - class_desc = ( - db.query(Class.description_es, Class.description_en) - .filter(Class.id == line.class_id, Class.tenant_id == tenant_id, Class.company_id == company_id) - .first() - ) - if class_desc: - line.description.description_spanish, line.description.description_english = class_desc - + calculate_values(db, line, tenant_id, company_id) + apply_calculations_after_values(db, line, tenant_id, company_id, line_number) -def caluclate_values( + +def apply_calculations_after_values( + db: Session, line: LineItem, tenant_id: int, company_id: int, line_number: int +): + """ + Aplica solo depreciation_date, descripción desde part/class y subitem_number. + Usado por el flujo CSV para no sobrescribir los valores ya calculados por currency_type. + """ + fa_data = getattr(line, "fa_data", None) + if fa_data is not None and not getattr(fa_data, "is_subitem", True): + fa_data.subitem_number = None + + invoice_date = db.query(InvoiceHeader.invoice_date).filter( + InvoiceHeader.id == line.invoice_id, + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + ).scalar() + if invoice_date is not None: + line.depreciation_date = invoice_date + + if not getattr(line.description, "description_spanish", None) and not getattr( + line.description, "description_english", None + ): + part_info = getattr(line, "part_info", None) + if part_info and getattr(part_info, "description_spanish", None) and getattr(part_info, "description_english", None): + line.description.description_spanish = part_info.description_spanish + line.description.description_english = part_info.description_english + else: + if not getattr(line.description, "description_spanish", None): + class_desc = ( + db.query(Class.description_es, Class.description_en) + .filter( + Class.id == line.class_id, + Class.tenant_id == tenant_id, + Class.company_id == company_id, + ) + .first() + ) + if class_desc: + line.description.description_spanish, line.description.description_english = class_desc + + +def calculate_values( db: Session, line: LineItem, tenant_id: int, company_id: int ): + """ + Una sola fuente de verdad: usa currency_type (USD/ME, MXN/MN) cuando está presente, + para paridad con create.py y cargas CSV. Si no hay currency_type, usa currency (foreign/local/manual). + """ result = ( - db.query(InvoiceFinancials.currency, InvoiceFinancials.exchange_rate) - .filter(InvoiceFinancials.invoice_id == line.invoice_id, InvoiceFinancials.tenant_id == tenant_id, InvoiceFinancials.company_id == company_id) + db.query( + InvoiceFinancials.currency, + InvoiceFinancials.currency_type, + InvoiceFinancials.exchange_rate, + ) + .filter( + InvoiceFinancials.invoice_id == line.invoice_id, + InvoiceFinancials.tenant_id == tenant_id, + InvoiceFinancials.company_id == company_id, + ) .first() ) if not result: return - currency, exchange_rate = result + currency, currency_type, exchange_rate = result + # Prioridad: currency_type para alinear con create.py y CSV + if currency_type in ("USD", "ME"): + currency = "foreign" + elif currency_type in ("MXN", "MN"): + currency = "local" + # si currency_type es otro o None, se usa currency tal cual if currency == "foreign": line.financial.unit_cost_usd = line.financial.unit_cost_capture line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity - line.financial.unit_cost_mxn = line.financial.unit_cost_capture * exchange_rate + line.financial.unit_cost_mxn = line.financial.unit_cost_capture * (exchange_rate or 1) line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity elif currency == "local": line.financial.unit_cost_mxn = line.financial.unit_cost_capture line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity - line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate + line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1) line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity elif currency == "manual": - line.financial.unit_cost_usd = line.financial.unit_cost_capture/exchange_rate + line.financial.unit_cost_usd = line.financial.unit_cost_capture / (exchange_rate or 1) line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity - line.financial.unit_cost_mxn = line.financial.unit_cost_usd * exchange_rate + line.financial.unit_cost_mxn = line.financial.unit_cost_usd * (exchange_rate or 1) line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity line.financial.value_mc = line.financial.unit_cost_capture * line.quantity.quantity \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/imports/validators/create.py b/backend/api/v1/modules/a76/items/imports/validators/create.py index a925d407..14780846 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/validators/create.py @@ -16,6 +16,17 @@ from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models im from .common import validate_common +def _normalize_weight_type(logistics) -> str: + """Paridad con CSV: enum o string a 'KGS'/'LBS' para comparaciones.""" + if logistics is None: + return "KGS" + wt = getattr(logistics, "weight_type", None) or "KGS" + if hasattr(wt, "value"): + wt = wt.value + weight_str = str(wt).upper() if wt else "KGS" + return weight_str if weight_str in ("KGS", "LBS") else "KGS" + + def validate_create( db: Session, line: LineItem, @@ -196,16 +207,28 @@ def validate_create( line.financial.value_temp_material_usd = line.financial.value_usd line.financial.value_temp_material_mxn = line.financial.value_mxn + # value_mc: paridad con calculate_values y cargas CSV + currency = getattr(invoice.financials, "currency", None) + if currency == "foreign": + line.financial.value_mc = line.financial.value_usd + elif currency == "local": + line.financial.value_mc = line.financial.value_usd + elif currency == "manual": + line.financial.value_mc = unit_cost_capture * quantity + else: + line.financial.value_mc = line.financial.value_usd + # ========================================== # VALIDAR Y CONVERTIR PESOS NETOS # ========================================== - invoice_weight_type = invoice.logistics.weight_type # 'kgs' o 'lbs' + invoice_weight_type = _normalize_weight_type(invoice.logistics) quantity = line.quantity.quantity or Decimal("0") net_weight_input = line.quantity.net_weight or Decimal("0") - # Determinar si la unidad de medida es de peso - unit_is_kgs = line.unit_of_measure and line.unit_of_measure == "24" #KGS - unit_is_lbs = line.unit_of_measure and line.unit_of_measure == "25" #LBS + # UOM peso: aceptar 24/"24" y 25/"25" (paridad con CSV) + uom = line.unit_of_measure + unit_is_kgs = uom is not None and (str(uom) == "24" or uom == 24) + unit_is_lbs = uom is not None and (str(uom) == "25" or uom == 25) # Calcular peso neto en kilogramos (estándar interno) if unit_is_kgs: diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py index eb765a83..153e661f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py @@ -2,6 +2,7 @@ Tareas Celery para importación CSV de Clases de Materiales. Flujo: scan_file (validación) → insert_valid_rows (commit). Usa layouts_csv.common (storage, normalize, csv_reader, meta, responses) y common.fk_loader, validators, mappers. +Sin ClassService de creación en API; los mappers CSV (row_to_class_data, row_to_class_data_merge_existing) son la fuente de verdad para reglas de negocio al crear/actualizar. """ import json import logging diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py index 8892781e..0af83ad1 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py @@ -65,6 +65,12 @@ async def upload_import_file( "tenant_id": tenant_id, "company_id": company_id, "user_id": current_user.get("id"), + "capture_user": ( + current_user.get("preferred_username") + or current_user.get("email") + or current_user.get("sub") + or "CSV" + ), "footer_config": footer_config, "operation_type": operation_type or "exp", "template_id": template_id or default_template, diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/__init__.py b/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/__init__.py new file mode 100644 index 00000000..5db6f4e2 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/__init__.py @@ -0,0 +1,12 @@ +""" +Enriquecimiento de partidas (line items) en la carga CSV de facturas. +Aplica las mismas reglas que el proceso manual (items/imports y items/exports validators) +para que los datos insertados por CSV coincidan con la API. +""" +from .import_enrichment import apply_import_defaults_and_calculations_for_csv +from .export_enrichment import apply_export_defaults_and_calculations_for_csv + +__all__ = [ + "apply_import_defaults_and_calculations_for_csv", + "apply_export_defaults_and_calculations_for_csv", +] diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/export_enrichment.py b/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/export_enrichment.py new file mode 100644 index 00000000..075210fa --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/export_enrichment.py @@ -0,0 +1,99 @@ +""" +Enriquecimiento de partidas de exportación cargadas por CSV. +Aplica las mismas reglas que items/exports/validators (create + calculations): +hereda de línea de importación y calcula valores por currency; defaults de export. +""" +from sqlalchemy.orm import Session, joinedload + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a24.fa.fa_item_lines.dto import FaLineItemCreateDTO + +from api.v1.modules.a76.items.schemas import LineItemCreate +from api.v1.modules.a76.items.exports.validators.calculations import ( + calculate_values, + apply_calculations, +) + + +def _ensure_nested(line_data: LineItemCreate) -> None: + """Asegura que existan los objetos anidados para calculate_values y apply_calculations.""" + from api.v1.modules.a76.items.line_financials.schemas import LineFinancialCreate + from api.v1.modules.a76.items.line_quantities.schemas import LineQuantityCreate + from api.v1.modules.a76.items.line_customs.schemas import LineCustomCreate + from api.v1.modules.a76.items.line_descriptions.schemas import LineDescriptionCreate + + if line_data.financial is None: + line_data.financial = LineFinancialCreate() + if line_data.quantity is None: + line_data.quantity = LineQuantityCreate() + if line_data.customs is None: + line_data.customs = LineCustomCreate() + if line_data.description is None: + line_data.description = LineDescriptionCreate() + if line_data.fa_data is None: + line_data.fa_data = FaLineItemCreateDTO(is_subitem=False, contains_subitems=False) + + +def apply_export_defaults_and_calculations_for_csv( + db: Session, + line_data: LineItemCreate, + tenant_id: int, + company_id: int, + line_number: int, +) -> bool: + """ + Aplica defaults y cálculos de partida de exportación según reglas del proceso manual + (copia desde línea de importación, currency, defaults has_fda_code, tax_payment, etc.). + Se invoca tras armar LineItemCreate desde el CSV. + Devuelve False si faltan factura/financials; True en caso contrario. + """ + _ensure_nested(line_data) + + invoice: InvoiceHeader = ( + db.query(InvoiceHeader) + .options(joinedload(InvoiceHeader.financials)) + .filter( + InvoiceHeader.id == line_data.invoice_id, + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not invoice or not invoice.financials: + return False + + part: Part | None = None + if line_data.part_number_id: + part = ( + db.query(Part) + .filter( + Part.id == line_data.part_number_id, + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ) + .first() + ) + setattr(line_data, "part_info", part) + + if not line_data.unit_of_measure and line_data.class_id: + class_info = ( + db.query(Class) + .filter( + Class.id == line_data.class_id, + Class.tenant_id == tenant_id, + Class.company_id == company_id, + ) + .first() + ) + if class_info: + line_data.unit_of_measure = class_info.unit_of_measure + + # Copia desde línea de importación y cálculos por currency (reglas manual) + calculate_values(db, line_data, tenant_id, company_id) + + # Defaults: has_fda_code, tax_payment, payment_method, depreciation_date, descripciones + apply_calculations(db, line_data, tenant_id, company_id, line_number) + + return True diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/import_enrichment.py b/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/import_enrichment.py new file mode 100644 index 00000000..e5f5fb77 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/line_item_enrichment/import_enrichment.py @@ -0,0 +1,246 @@ +""" +Enriquecimiento de partidas de importación cargadas por CSV. +Aplica las mismas reglas que items/imports/validators (create + calculations) +en base a currency_type y peso de la factura; no re-sobrescribe con currency +para evitar discrepancias con el proceso manual. +""" +from decimal import Decimal +from sqlalchemy.orm import Session, joinedload + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.parts.models import Part +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.dto import FaLineItemCreateDTO + +from api.v1.modules.a76.items.schemas import LineItemCreate +from api.v1.modules.a76.items.imports.validators.calculations import ( + apply_calculations_after_values, +) + + +def _normalize_weight_type(invoice) -> str: + """Obtiene weight_type de la factura como 'KGS' o 'LBS' (paridad con proceso manual).""" + wt = getattr(invoice.logistics, "weight_type", None) or "KGS" + if hasattr(wt, "value"): + wt = wt.value + weight_str = str(wt).upper() if wt else "KGS" + return weight_str if weight_str in ("KGS", "LBS") else "KGS" + + +def apply_import_defaults_and_calculations_for_csv( + db: Session, + line_data: LineItemCreate, + tenant_id: int, + company_id: int, + line_number: int, +) -> bool: + """ + Aplica defaults y cálculos de partida de importación según reglas del proceso manual + (currency_type, peso, descripciones). Se invoca tras armar LineItemCreate desde el CSV. + Devuelve False si faltan factura/financials/logistics; True en caso contrario. + """ + invoice: InvoiceHeader = ( + db.query(InvoiceHeader) + .options( + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + ) + .filter( + InvoiceHeader.id == line_data.invoice_id, + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not invoice or not invoice.financials or not invoice.logistics: + return False + + class_info: Class | None = None + if line_data.class_id: + class_info = ( + db.query(Class) + .filter( + Class.id == line_data.class_id, + Class.tenant_id == tenant_id, + Class.company_id == company_id, + ) + .first() + ) + + part: Part | None = None + if line_data.part_number_id: + part = ( + db.query(Part) + .filter( + Part.id == line_data.part_number_id, + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ) + .first() + ) + setattr(line_data, "part_info", part) + + if not line_data.fa_data: + line_data.fa_data = FaLineItemCreateDTO( + is_subitem=False, + contains_subitems=False, + ) + + exchange_rate = invoice.financials.exchange_rate or Decimal("1.0") + if not line_data.unit_of_measure and class_info: + line_data.unit_of_measure = class_info.unit_of_measure + + # Reglas de moneda igual que proceso manual (create.py): currency_type + currency_type = getattr(invoice.financials, "currency_type", None) or "USD" + unit_cost_capture = line_data.financial.unit_cost_capture or Decimal("0") + + if currency_type in ("USD", "ME"): + line_data.financial.unit_cost_capture = unit_cost_capture + line_data.financial.unit_cost_usd = unit_cost_capture + line_data.financial.unit_cost_mxn = unit_cost_capture * exchange_rate + elif currency_type in ("MXN", "MN"): + line_data.financial.unit_cost_capture = unit_cost_capture + line_data.financial.unit_cost_usd = ( + unit_cost_capture / exchange_rate if exchange_rate else Decimal("0") + ) + line_data.financial.unit_cost_mxn = unit_cost_capture + else: + line_data.financial.unit_cost_capture = unit_cost_capture + line_data.financial.unit_cost_usd = unit_cost_capture + line_data.financial.unit_cost_mxn = unit_cost_capture * exchange_rate + + quantity = line_data.quantity.quantity or Decimal("0") + if line_data.financial.unit_cost_usd is not None: + line_data.financial.value_usd = line_data.financial.unit_cost_usd * quantity + if line_data.financial.unit_cost_mxn is not None: + line_data.financial.value_mxn = line_data.financial.unit_cost_mxn * quantity + line_data.financial.customs_value_usd = line_data.financial.value_usd + line_data.financial.customs_value_mxn = line_data.financial.value_mxn + line_data.financial.value_temp_material_usd = line_data.financial.value_usd + line_data.financial.value_temp_material_mxn = line_data.financial.value_mxn + + # value_mc según currency de la factura (paridad con calculate_values manual) + currency = getattr(invoice.financials, "currency", None) + if currency == "foreign": + line_data.financial.value_mc = line_data.financial.value_usd + elif currency == "local": + line_data.financial.value_mc = line_data.financial.value_usd + elif currency == "manual": + line_data.financial.value_mc = unit_cost_capture * quantity + else: + line_data.financial.value_mc = line_data.financial.value_usd + + # Peso: misma lógica que proceso manual (create.py) con weight_type normalizado + weight_str = _normalize_weight_type(invoice) + quantity = line_data.quantity.quantity or Decimal("0") + net_weight_input = line_data.quantity.net_weight or Decimal("0") + uom = line_data.unit_of_measure + unit_is_kgs = uom is not None and (str(uom) == "24" or uom == 24) + unit_is_lbs = uom is not None and (str(uom) == "25" or uom == 25) + + if unit_is_kgs: + if weight_str == "KGS": + line_data.quantity.net_weight = quantity + else: + line_data.quantity.net_weight = quantity * Decimal("2.204624") + elif unit_is_lbs: + if weight_str == "KGS": + line_data.quantity.net_weight = quantity / Decimal("2.204624") + else: + line_data.quantity.net_weight = quantity + else: + if weight_str == "KGS": + line_data.quantity.net_weight = net_weight_input + else: + line_data.quantity.net_weight = net_weight_input / Decimal("2.204624") + + gross_weight_input = line_data.quantity.gross_weight + package_quantity = line_data.quantity.package_quantity or 0 + package_weight_unit = Decimal("0") + + if line_data.quantity.package_id: + package = ( + db.query(Package) + .filter( + Package.id == line_data.quantity.package_id, + Package.tenant_id == tenant_id, + Package.company_id == company_id, + ) + .first() + ) + if package and package.weight_unit: + package_weight_unit = package.weight_unit + + if not gross_weight_input or gross_weight_input == 0: + if weight_str == "KGS": + line_data.quantity.gross_weight = line_data.quantity.net_weight + ( + package_weight_unit * package_quantity + ) + else: + line_data.quantity.gross_weight = line_data.quantity.net_weight + ( + (package_weight_unit * Decimal("2.204624")) * package_quantity + ) + else: + if weight_str == "KGS": + line_data.quantity.gross_weight = gross_weight_input + else: + line_data.quantity.gross_weight = gross_weight_input / Decimal("2.204624") + + if line_data.quantity.gross_weight < line_data.quantity.net_weight: + line_data.quantity.gross_weight = line_data.quantity.net_weight + ( + package_weight_unit * package_quantity + ) + + if package_quantity and package_quantity > 0 and line_data.quantity.package_id: + package = ( + db.query(Package) + .filter( + Package.id == line_data.quantity.package_id, + Package.tenant_id == tenant_id, + Package.company_id == company_id, + ) + .first() + ) + if package: + line_data.description.package_description = package.description_es + else: + line_data.quantity.package_quantity = 0 + line_data.quantity.package_id = None + line_data.description.package_description = None + + if not line_data.customs.american_fraction and class_info and class_info.us_fraction: + line_data.customs.american_fraction = class_info.us_fraction + + if line_data.customs.american_fraction: + us_fraction = ( + db.query(USTariffFraction) + .filter( + USTariffFraction.code == line_data.customs.american_fraction, + USTariffFraction.tenant_id == tenant_id, + USTariffFraction.company_id == company_id, + ) + .first() + ) + if us_fraction: + if getattr(us_fraction, "type_code", None) == "foreign": + line_data.customs.advalorem_american = us_fraction.fixed_cost + else: + line_data.customs.advalorem_american = us_fraction.ad_valorem + + if not line_data.description.description_spanish and class_info: + line_data.description.description_spanish = class_info.description_es + if not line_data.description.description_english and class_info: + line_data.description.description_english = class_info.description_en + + if line_data.description.brand: + line_data.description.brand = line_data.description.brand.upper().strip() + if line_data.description.model: + line_data.description.model = line_data.description.model.upper().strip() + + # Solo depreciation_date, descripción part/class y subitem_number (sin re-calcular moneda) + apply_calculations_after_values(db, line_data, tenant_id, company_id, line_number) + return True diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py index cda7106d..a80af18f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py @@ -1,178 +1,184 @@ -from datetime import datetime -from uuid import uuid4 -import base64 -import os -import json -import logging -from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query -from sqlalchemy.orm import Session -from typing import Optional, Literal, Dict, Any - -from core.celery_app import celery_app -from core.config import settings -from core.database import get_core_db -from core.paths import layout_path -from core.security import get_current_user, validate_access_to_resource - -from .tasks import ( - scan_file, - insert_valid_rows, - IMPORT_FILE_KEY_PREFIX, - IMPORT_META_KEY_PREFIX, - IMPORT_REDIS_TTL, -) -from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest - -router = APIRouter() -logger = logging.getLogger(__name__) - - -def _get_redis(): - """Redis client (same broker as Celery so worker can read).""" - import redis - url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0")) - return redis.Redis.from_url(url, decode_responses=False) - -@router.post("/upload/{model_target}", response_model=ImportJobResponse) -async def upload_import_file( - model_target: Literal["invoice_header", "invoice_details", "invoice_series"], - file: UploadFile = File(...), - footer_config: Optional[str] = Form(None), # JSON string with settings - template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas - company_id: int = Query(..., description="Company ID"), # Required for context - operation_type: Optional[str] = Query("imp"), - db: Session = Depends(get_core_db), - current_user: Dict[str, Any] = Depends(get_current_user), -): - """ - Step 1: Upload CSV, save to temp, trigger scan task. - Si se envía template_id, solo se leen las columnas de esa plantilla. - """ - # 1. Validate Access & Get Tenant - try: - tenant_id = validate_access_to_resource(db, company_id, current_user) - except Exception as e: - logger.error(f"Access validation failed: {e}") - raise HTTPException(status_code=403, detail="Invalid company access") - - if not file.filename.endswith(".csv"): - raise HTTPException(status_code=400, detail="Only .csv files allowed") - - job_id = str(uuid4()) - contents = await file.read() - - meta_data = { - "tenant_id": tenant_id, - "company_id": company_id, - "user_id": current_user.get("id"), - "footer_config": footer_config, - "operation_type": operation_type, - "template_id": template_id, - } - - # Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed) - try: - redis_client = _get_redis() - redis_client.set( - f"{IMPORT_FILE_KEY_PREFIX}{job_id}", - base64.b64encode(contents), - ex=IMPORT_REDIS_TTL, - ) - redis_client.set( - f"{IMPORT_META_KEY_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=IMPORT_REDIS_TTL, - ) - except Exception as e: - logger.error(f"Redis store error: {e}") - raise HTTPException(status_code=500, detail="Failed to queue file for processing.") - - # Optional: also write to local disk (e.g. for same-machine worker or debugging) - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - file_path = os.path.join(upload_dir, f"{job_id}.csv") - meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") - with open(file_path, "wb") as f: - f.write(contents) - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Local file save failed (worker will use Redis): {e}") - - # Trigger Celery Task (Async). Worker loads file from Redis. - scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id) - - return ImportJobResponse( - job_id=job_id, - status="queued", - message="File uploaded. Scanning started." - ) - -@router.get("/{job_id}/status") -async def get_import_status(job_id: str): - """ - Poll to get progress or final report. Always returns an object with "status". - """ - task_result = celery_app.AsyncResult(job_id) - - if task_result.state == "PENDING": - return {"status": "processing", "progress": 0} - if task_result.state == "PROGRESS": - return { - "status": "processing", - "progress": (task_result.info or {}).get("current", 0), - "total": (task_result.info or {}).get("total", 0), - } - if task_result.state == "SUCCESS": - result = task_result.result - if isinstance(result, dict) and "status" in result: - return result - return {"status": "finished", "result": result} - # FAILURE: obtener mensaje real (traceback, result o get(propagate=False)) - logger.warning("Import task %s failed: state=%s", job_id, task_result.state) - err_msg = None - tb = getattr(task_result, "traceback", None) - if tb: - logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb) - if tb and isinstance(tb, str): - lines = [l.strip() for l in tb.strip().split("\n") if l.strip()] - if lines: - err_msg = lines[-1] - if not err_msg and len(lines) > 1: - err_msg = lines[-2] + " " + (lines[-1] or "") - if not err_msg: - try: - exc = task_result.get(propagate=False) - if exc is not None: - err_msg = str(exc) - except Exception: - pass - if not err_msg: - result = getattr(task_result, "result", None) - info = getattr(task_result, "info", None) - if result is not None and not isinstance(result, dict): - err_msg = str(result) - elif isinstance(result, dict) and (result.get("error") or result.get("message")): - err_msg = result.get("error") or result.get("message") - if not err_msg and isinstance(info, str): - err_msg = info - elif not err_msg and isinstance(info, dict) and "error" in info: - err_msg = str(info["error"]) - if not err_msg: - err_msg = "Task failed" - return {"status": "failed", "error": err_msg} - - -@router.post("/{job_id}/commit") -async def commit_import_job(job_id: str, body: CommitRequest): - """ - Step 2: User confirms import. Trigger bulk insert. - """ - task = insert_valid_rows.delay(job_id, body.model_target) - - return { - "status": "committing", - "message": "Bulk insert started.", - "commit_job_id": task.id - } +from datetime import datetime +from uuid import uuid4 +import base64 +import os +import json +import logging +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query +from sqlalchemy.orm import Session +from typing import Optional, Literal, Dict, Any + +from core.celery_app import celery_app +from core.config import settings +from core.database import get_core_db +from core.paths import layout_path +from core.security import get_current_user, validate_access_to_resource + +from .tasks import ( + scan_file, + insert_valid_rows, + IMPORT_FILE_KEY_PREFIX, + IMPORT_META_KEY_PREFIX, + IMPORT_REDIS_TTL, +) +from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _get_redis(): + """Redis client (same broker as Celery so worker can read).""" + import redis + url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0")) + return redis.Redis.from_url(url, decode_responses=False) + +@router.post("/upload/{model_target}", response_model=ImportJobResponse) +async def upload_import_file( + model_target: Literal["invoice_header", "invoice_details", "invoice_series"], + file: UploadFile = File(...), + footer_config: Optional[str] = Form(None), # JSON string with settings + template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas + company_id: int = Query(..., description="Company ID"), # Required for context + operation_type: Optional[str] = Query("imp"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Step 1: Upload CSV, save to temp, trigger scan task. + Si se envía template_id, solo se leen las columnas de esa plantilla. + """ + # 1. Validate Access & Get Tenant + try: + tenant_id = validate_access_to_resource(db, company_id, current_user) + except Exception as e: + logger.error(f"Access validation failed: {e}") + raise HTTPException(status_code=403, detail="Invalid company access") + + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Only .csv files allowed") + + job_id = str(uuid4()) + contents = await file.read() + + meta_data = { + "tenant_id": tenant_id, + "company_id": company_id, + "user_id": current_user.get("id"), + "capture_user": ( + current_user.get("preferred_username") + or current_user.get("email") + or current_user.get("sub") + or "CSV" + ), + "footer_config": footer_config, + "operation_type": operation_type, + "template_id": template_id, + } + + # Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed) + try: + redis_client = _get_redis() + redis_client.set( + f"{IMPORT_FILE_KEY_PREFIX}{job_id}", + base64.b64encode(contents), + ex=IMPORT_REDIS_TTL, + ) + redis_client.set( + f"{IMPORT_META_KEY_PREFIX}{job_id}", + json.dumps(meta_data).encode("utf-8"), + ex=IMPORT_REDIS_TTL, + ) + except Exception as e: + logger.error(f"Redis store error: {e}") + raise HTTPException(status_code=500, detail="Failed to queue file for processing.") + + # Optional: also write to local disk (e.g. for same-machine worker or debugging) + try: + upload_dir = layout_path("imports", "temp") + os.makedirs(upload_dir, exist_ok=True) + file_path = os.path.join(upload_dir, f"{job_id}.csv") + meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") + with open(file_path, "wb") as f: + f.write(contents) + with open(meta_path, "w") as f: + json.dump(meta_data, f) + except Exception as e: + logger.warning(f"Local file save failed (worker will use Redis): {e}") + + # Trigger Celery Task (Async). Worker loads file from Redis. + scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id) + + return ImportJobResponse( + job_id=job_id, + status="queued", + message="File uploaded. Scanning started." + ) + +@router.get("/{job_id}/status") +async def get_import_status(job_id: str): + """ + Poll to get progress or final report. Always returns an object with "status". + """ + task_result = celery_app.AsyncResult(job_id) + + if task_result.state == "PENDING": + return {"status": "processing", "progress": 0} + if task_result.state == "PROGRESS": + return { + "status": "processing", + "progress": (task_result.info or {}).get("current", 0), + "total": (task_result.info or {}).get("total", 0), + } + if task_result.state == "SUCCESS": + result = task_result.result + if isinstance(result, dict) and "status" in result: + return result + return {"status": "finished", "result": result} + # FAILURE: obtener mensaje real (traceback, result o get(propagate=False)) + logger.warning("Import task %s failed: state=%s", job_id, task_result.state) + err_msg = None + tb = getattr(task_result, "traceback", None) + if tb: + logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb) + if tb and isinstance(tb, str): + lines = [l.strip() for l in tb.strip().split("\n") if l.strip()] + if lines: + err_msg = lines[-1] + if not err_msg and len(lines) > 1: + err_msg = lines[-2] + " " + (lines[-1] or "") + if not err_msg: + try: + exc = task_result.get(propagate=False) + if exc is not None: + err_msg = str(exc) + except Exception: + pass + if not err_msg: + result = getattr(task_result, "result", None) + info = getattr(task_result, "info", None) + if result is not None and not isinstance(result, dict): + err_msg = str(result) + elif isinstance(result, dict) and (result.get("error") or result.get("message")): + err_msg = result.get("error") or result.get("message") + if not err_msg and isinstance(info, str): + err_msg = info + elif not err_msg and isinstance(info, dict) and "error" in info: + err_msg = str(info["error"]) + if not err_msg: + err_msg = "Task failed" + return {"status": "failed", "error": err_msg} + + +@router.post("/{job_id}/commit") +async def commit_import_job(job_id: str, body: CommitRequest): + """ + Step 2: User confirms import. Trigger bulk insert. + """ + task = insert_valid_rows.delay(job_id, body.model_target) + + return { + "status": "committing", + "message": "Bulk insert started.", + "commit_job_id": task.id + } diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py index 5bf23268..9d289514 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -1,3 +1,13 @@ +""" +Tareas Celery para importación CSV de facturas (encabezados, partidas, series). +Flujo: scan_file (validación) → insert_valid_rows (commit). + +Objetivo en BD (paridad con flujo normal): al terminar el commit, los datos deben quedar +igual que por UI/API: encabezados con capture_user/who_updated; partidas con costos, +pesos y descripciones calculados/heredados según items/imports/validators; series con +campos no presentes en CSV en null. No se modifican plantillas CSV; no se inventan +datos sin fuente (p. ej. LineReference solo si hay fuente explícita). +""" import os from datetime import datetime from decimal import Decimal @@ -4233,6 +4243,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt return {"status": "failed", "error": str(e)} # --- Series de Importación Temporal: commit (INSERT/UPDATE item_line_series) --- + # Paridad CSV: campos no presentes en CSV se persisten como null; no se exigen campos que no están en la plantilla. if use_series_flow: try: from api.v1.modules.a76.invoices.models import InvoiceHeader @@ -4490,6 +4501,17 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt from api.v1.modules.a76.items.line_quantities.models import LineQuantity from api.v1.modules.a76.items.line_customs.models import LineCustom from api.v1.modules.a76.items.line_descriptions.models import LineDescription + from api.v1.modules.a76.items.schemas import LineItemCreate + from api.v1.modules.a76.items.line_financials.schemas import LineFinancialCreate + from api.v1.modules.a76.items.line_quantities.schemas import LineQuantityCreate + from api.v1.modules.a76.items.line_customs.schemas import LineCustomCreate + from api.v1.modules.a76.items.line_descriptions.schemas import LineDescriptionCreate + from api.v1.modules.a24.fa.fa_item_lines.dto import FaLineItemCreateDTO + from api.v1.modules.a76.layouts_csv.facturas.line_item_enrichment import ( + apply_import_defaults_and_calculations_for_csv, + apply_export_defaults_and_calculations_for_csv, + ) + from api.v1.modules.a76.items.service import ItemService from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure @@ -4887,12 +4909,18 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt remesa_val = (max_rem or 0) + 1 if existing_header: - # UPDATE existing header + # UPDATE existing header (paridad con InvoiceService.update) header = existing_header header.invoice_date = invoice_date header.operation_type = op_type_value header.is_updated = True # Mark as updated header.updated_date = datetime.utcnow() + capture_user = meta.get("capture_user") or "CSV" + header.who_updated = capture_user + # Backfill capture_user if missing or generic (paridad con service) + if not header.capture_user or header.capture_user == "System": + if capture_user != "CSV": + header.capture_user = capture_user header.document_type = ( None if inv_type_value == "MEX" else resolve_public_code( @@ -4918,7 +4946,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt # SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly. else: - # CREATE new header + # CREATE new header (paridad con InvoiceService.create: capture_user, who_updated) + capture_user = meta.get("capture_user") or "CSV" header = InvoiceHeader( invoice_number=invoice_number, invoice_date=invoice_date, @@ -4926,6 +4955,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt is_updated=False, system="CSV", capture_date=datetime.utcnow(), + capture_user=capture_user, + who_updated=capture_user, invoice_type=inv_type_value, document_type=( None if inv_type_value == "MEX" else @@ -5115,7 +5146,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt skipped_missing_invoice += 1 continue - # --- Partidas Exportación Definitiva: inserción real --- + # --- Partidas Exportación Definitiva: paridad con flujo normal (validators + ItemService) --- if _template_id_insert == "exp_def_partidas": part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip() part_id = part_cache.get(part_num) if part_num else None @@ -5123,7 +5154,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt p = session.query(Part.id).filter(Part.part_number == part_num, Part.tenant_id == tenant_id, Part.company_id == company_id).first() if p: part_id = p.id - part_cache[part_num] = part_id + part_cache[part_num] = p.id line_num_val = (row_norm.get('LINEA EXPO') or row_norm.get('LINEA EXPO.') or row_norm.get('RENGLON EXPO')) line_num = parse_int(line_num_val) or (len(details_to_insert) + 1) @@ -5153,83 +5184,83 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False) cleared_invoices.add(invoice_id) - line = LineItem( - invoice_id=invoice_id, - line_number=line_num, - tenant_id=tenant_id, - company_id=company_id, - part_number_id=part_id, - unit_of_measure=uom_id, - order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None), - tax_payment=(se_pago == 'SI'), - payment_method=forma_pago, - ) - session.add(line) - session.flush() - - # FaLineItem (a24 extension: subpartidas, descarga, factura impo ref) - from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem - fa_line = FaLineItem( - id=line.id, - tenant_id=tenant_id, - company_id=company_id, - search_invoice=factura_impo or None, - search_line=parse_int(linea_impo_val), - search_type=tipo_impo or None, - download=(descarga_val == 'SI'), - is_subitem=is_subitem, - contains_subitems=contains_subitems, - subitem_number=parse_int(linea_principal_val) if is_subitem else None, - ) - session.add(fa_line) - price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('COSTOUNITARIO')) qty = parse_decimal(row_norm.get('CANTIDAD EXPORTADA/DESCARGAR') or row_norm.get('CANTIDAD EXPORTADA') or row_norm.get('CANTIDAD')) commercial_total = (price * qty) if price and qty else None - - session.add(LineFinancial( - item_line_id=line.id, - unit_cost_capture=decimal_or_zero(price), - total_commercial_value=decimal_or_zero(commercial_total), - )) - net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO')) gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO')) - session.add(LineQuantity( - item_line_id=line.id, - quantity=decimal_or_zero(qty), - net_weight=decimal_or_zero(net_w), - gross_weight=decimal_or_zero(gross_w), - package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), - package_id=package_id, - )) - origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip() fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCIONARANCELARIA') or '').strip() american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip() - session.add(LineCustom( - item_line_id=line.id, - origin_country=origin or None, - fraction=fraction or None, - american_fraction=american_fraction or None, - )) - extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip() additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip() lot = (row_norm.get('LOTE') or '').strip() entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUM ENTRADA') or '').strip() - session.add(LineDescription( - item_line_id=line.id, - extra_description=extra_desc or None, - additional_info_spanish=additional_info or None, - lot=lot or None, - entry_number=entry_number or None, - )) + order_compra = (row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None) + + line_data = LineItemCreate( + invoice_id=invoice_id, + line_number=line_num, + part_number_id=part_id, + class_id=None, + unit_of_measure=uom_id, + order=order_compra, + tax_payment=(se_pago == 'SI'), + payment_method=forma_pago, + financial=LineFinancialCreate( + unit_cost_capture=decimal_or_zero(price), + total_commercial_value=decimal_or_zero(commercial_total), + ), + quantity=LineQuantityCreate( + quantity=decimal_or_zero(qty), + net_weight=decimal_or_zero(net_w), + gross_weight=decimal_or_zero(gross_w), + package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + package_id=package_id, + ), + customs=LineCustomCreate( + origin_country=origin or None, + fraction=fraction or None, + american_fraction=american_fraction or None, + ), + description=LineDescriptionCreate( + extra_description=extra_desc or None, + additional_info_spanish=additional_info or None, + lot=lot or None, + entry_number=entry_number or None, + ), + fa_data=FaLineItemCreateDTO( + search_invoice=factura_impo or None, + search_line=parse_int(linea_impo_val), + search_type=tipo_impo or None, + download=(descarga_val == 'SI'), + is_subitem=is_subitem, + contains_subitems=contains_subitems, + subitem_number=parse_int(linea_principal_val) if is_subitem else 0, + ), + ) + if not apply_export_defaults_and_calculations_for_csv( + session, line_data, tenant_id, company_id, line_num + ): + skipped_invalid += 1 + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": "Factura sin datos financieros para enriquecer partida de exportación."}) + continue + + item_dict = line_data.model_dump( + exclude={"financial", "quantity", "customs", "description", "reference", "fa_data", "series"} + ) + item_dict["tenant_id"] = tenant_id + item_dict["company_id"] = company_id + item_dict["line_number"] = line_num + line = LineItem(**item_dict) + session.add(line) + session.flush() + ItemService._create_line_nested_data(session, line, line_data, tenant_id, company_id) session.add(InvoiceSalesDetails( invoice_id=invoice_id, line_number=line_num, - sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None), + sales_order=order_compra, line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), tenant_id=tenant_id, company_id=company_id, @@ -5258,7 +5289,9 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False) cleared_invoices.add(invoice_id) - # --- Partidas: LineItem with invoice_id (no Item parent) + full CSV mapping --- + # --- Partidas importación: paridad con flujo normal (validators + ItemService) --- + # LineReference: solo se crea si line_data.reference viene informado; no inventar datos sin fuente (plan paridad CSV). + # Build LineItemCreate from CSV, apply import defaults/calculations, then persist. part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip() part_id = part_cache.get(part_num) if part_num else None if part_id is None and part_num: @@ -5279,23 +5312,6 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt bulk_key = (row_norm.get('CLAVE BULTOS') or row_norm.get('CLAVEBULTOS') or '').strip() package_id = package_id_by_key.get(bulk_key) if bulk_key else None - line = LineItem( - invoice_id=invoice_id, - line_number=line_num, - tenant_id=tenant_id, - company_id=company_id, - part_number_id=part_id, - class_id=class_id, - unit_of_measure=uom_id, - order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), - material_type=(row_norm.get('ID TYPE') or row_norm.get('IDTYPE') or None), - tax_payment=(str(row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper() == 'SI'), - payment_method=(row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or None), - valuation_method=(row_norm.get('METODO DE VALORACION') or row_norm.get('METODODEVALORACION') or row_norm.get('METODO VALORACION') or None), - ) - session.add(line) - session.flush() - price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO')) if price is None: total_val = parse_decimal(row_norm.get('TOTAL')) @@ -5303,23 +5319,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt price = (total_val / qty) if (total_val and qty and qty != 0) else None qty = parse_decimal(row_norm.get('CANTIDAD IMPORTADA') or row_norm.get('CANTIDAD')) commercial_total = (price * qty) if price and qty else parse_decimal(row_norm.get('TOTAL')) - - session.add(LineFinancial( - item_line_id=line.id, - unit_cost_capture=decimal_or_zero(price), - total_commercial_value=decimal_or_zero(commercial_total), - )) - net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO')) gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO')) - session.add(LineQuantity( - item_line_id=line.id, - quantity=decimal_or_zero(qty), - net_weight=decimal_or_zero(net_w), - gross_weight=decimal_or_zero(gross_w), - package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), - package_id=package_id, - )) origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip() fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCION') or row_norm.get('FRACCIONARANCELARIA') or '').strip() @@ -5328,14 +5329,6 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt fraction_type = (row_norm.get('PREFERENCIA ARANCELARIA') or row_norm.get('PREFERENCIA') or '').strip() sector = (row_norm.get('SECTOR') or '').strip() american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip() - session.add(LineCustom( - item_line_id=line.id, - origin_country=origin or None, - fraction=fraction or None, - fraction_type=fraction_type or None, - sector=sector or None, - american_fraction=american_fraction or None, - )) desc_es = (row_norm.get('DESCRIPCION ESPAÑOL') or row_norm.get('DESCRIPCIONE') or row_norm.get('DESCRIPCION') or '').strip() if not desc_es and class_code: @@ -5349,18 +5342,65 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip() lot = (row_norm.get('LOTE') or '').strip() entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUMEROENTRADA') or row_norm.get('NUM ENTRADA') or '').strip() - session.add(LineDescription( - item_line_id=line.id, - description_spanish=desc_es or None, - description_english=desc_en or None, - brand=brand or None, - model=model or None, - extra_description=extra_desc or None, - additional_info_spanish=additional_info or None, - lot=lot or None, - entry_number=entry_number or None, - )) + line_data = LineItemCreate( + invoice_id=invoice_id, + line_number=line_num, + part_number_id=part_id, + class_id=class_id, + unit_of_measure=uom_id, + order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), + material_type=(row_norm.get('ID TYPE') or row_norm.get('IDTYPE') or None), + tax_payment=(str(row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper() == 'SI'), + payment_method=(row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or None), + valuation_method=(row_norm.get('METODO DE VALORACION') or row_norm.get('METODODEVALORACION') or row_norm.get('METODO VALORACION') or None), + financial=LineFinancialCreate( + unit_cost_capture=decimal_or_zero(price), + total_commercial_value=decimal_or_zero(commercial_total), + ), + quantity=LineQuantityCreate( + quantity=decimal_or_zero(qty), + net_weight=decimal_or_zero(net_w), + gross_weight=decimal_or_zero(gross_w), + package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + package_id=package_id, + ), + customs=LineCustomCreate( + origin_country=origin or None, + fraction=fraction or None, + fraction_type=fraction_type or None, + sector=sector or None, + american_fraction=american_fraction or None, + ), + description=LineDescriptionCreate( + description_spanish=desc_es or None, + description_english=desc_en or None, + brand=brand or None, + model=model or None, + extra_description=extra_desc or None, + additional_info_spanish=additional_info or None, + lot=lot or None, + entry_number=entry_number or None, + ), + fa_data=FaLineItemCreateDTO(is_subitem=False, contains_subitems=False), + ) + if not apply_import_defaults_and_calculations_for_csv( + session, line_data, tenant_id, company_id, line_num + ): + skipped_invalid += 1 + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": "Factura sin datos financieros/logísticos para enriquecer partida."}) + continue + + item_dict = line_data.model_dump( + exclude={"financial", "quantity", "customs", "description", "reference", "fa_data", "series"} + ) + item_dict["tenant_id"] = tenant_id + item_dict["company_id"] = company_id + item_dict["line_number"] = line_num + line = LineItem(**item_dict) + session.add(line) + session.flush() + ItemService._create_line_nested_data(session, line, line_data, tenant_id, company_id) session.add(InvoiceSalesDetails( invoice_id=invoice_id, line_number=line_num, diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py index 7c878b19..a3a54a3d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py @@ -2,6 +2,7 @@ Tareas Celery para importación CSV de Números de Parte. Flujo: scan_file (validación) → insert_valid_rows (commit). Paridad Clarion: actualizar (ACT), validación full/parcial, merge existente, reemplazar_sin_preguntar, RFC desde clase. +Sin PartService de creación en API; los mappers CSV (row_to_part_data, apply_rfc_exception_from_class) son la fuente de verdad para reglas de negocio al crear/actualizar. """ import json import logging diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py index 18ddb766..f8fe5d8d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py @@ -2,6 +2,8 @@ Tareas Celery para importación CSV de Pedimentos. Flujo: scan_file (validación) → insert_valid_rows (commit). Usa layouts_csv.common (storage, normalize, meta, responses, csv_reader), fk_loader, validators, mappers. +Create/update delegan en PedimentosService; defaults de fechas (pedimento_dates) y merge vs replace +están alineados con el servicio para paridad con el flujo API. """ import json import logging From 678f91ecdc0c1de5401d31cfe30e8dd745226aef Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 12 Mar 2026 12:54:33 -0600 Subject: [PATCH 3/5] feature/checkpoin-before-table --- frontend/src/lib/api/dashboard/a76/items.ts | 6 + .../edit/items/fa/item-sheet-fa.svelte | 165 +++++++++++++----- .../edit/items/fa/tab-continuation.svelte | 54 +++++- .../src/lib/config/invoice-item-visibility.ts | 95 +++++++++- 4 files changed, 271 insertions(+), 49 deletions(-) diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index c1ac4cbb..1d986317 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -161,6 +161,7 @@ export interface Item { fcc_key?: string; has_certificate?: boolean; certificate_number?: string; + certificate_end_date?: string; octave_permit?: string; // Flags @@ -174,6 +175,11 @@ export interface Item { igi_payment_method?: string; igi_amount?: number; + // Export valuation (Met Valor, Valor Det., Motivo De Uso) + valuation_method?: string; + valuation_determined_value?: number; + valuation_reason?: string; + // Additional notes wildcard_field?: string; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index d645d531..e5230066 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -72,6 +72,24 @@ return visibility.showCrTrackingHeader; }); + /** Show link-to-import block for import (CR tracking) or for export when showExportLinkToImportBlock. */ + const showLinkToImportBlock = $derived.by(() => { + const normalizedOperationType = operationType ?? invoice?.operation_type; + if (normalizedOperationType === 1 || normalizedOperationType === 'exp') { + return visibility.showExportLinkToImportBlock; + } + return visibility.showCrTrackingHeader; + }); + const isExport = $derived.by(() => { + const op = operationType ?? invoice?.operation_type; + return op === 1 || op === 'exp'; + }); + /** Show repair-import block (Factura de Expo / Línea de Expo) for REP/REPAR. */ + const showRepairBlock = $derived.by(() => { + const op = operationType ?? invoice?.operation_type; + if (op === 1 || op === 'exp') return false; + return visibility.showRepairLinkToExportBlock; + }); const visibleTabs = $derived.by(() => [ { value: 'generales', label: 'General', visible: true }, { value: 'continuacion', label: 'Continuación', visible: true }, @@ -117,52 +135,117 @@
{#if line} - {#if showCrTrackingBlock} -
-
-
+ {/if}
\ No newline at end of file diff --git a/frontend/src/lib/config/invoice-item-visibility.ts b/frontend/src/lib/config/invoice-item-visibility.ts index 2720f168..f0ce0a22 100644 --- a/frontend/src/lib/config/invoice-item-visibility.ts +++ b/frontend/src/lib/config/invoice-item-visibility.ts @@ -1,9 +1,36 @@ +/** Export invoice types that affect item visibility (tipo factura for operation_type === exp). */ +export type ExportInvoiceType = 'NODES' | 'AFIJO' | 'DONAC' | 'SCRAP'; + export interface InvoiceItemVisibility { showCrTrackingHeader: boolean; showEighthRule: boolean; showFdaFcc: boolean; showCertificateOfOrigin: boolean; showIdentifiersTab: boolean; + /** Show block: Genera Descarga? + Tipo Importación + Tipo Búsqueda + Factura de Impo + Línea (expo, or import CR). */ + showExportLinkToImportBlock: boolean; + /** Show Met Valor, Valor Det., Motivo De Uso in Continuación tab (expo). */ + showExportValuationFields: boolean; + /** Show Met Valor, Valor Det., Motivo De Uso for import (e.g. Cambio de Régimen). */ + showValuationFields: boolean; + /** Show block: Genera Descarga? + Factura de Expo + Línea de Expo (importación reparación, REP). */ + showRepairLinkToExportBlock: boolean; + /** Continuación tab: TAX PAID + Forma Pago. */ + showContinuationTaxPayment: boolean; + /** Continuación tab: IGI Amount + IGI Payment Method. */ + showContinuationIgi: boolean; + /** Continuación tab: Machinery and equipment location. */ + showContinuationLocation: boolean; + /** Continuación tab: Military Equipment checkbox. */ + showContinuationMilitary: boolean; + /** Continuación tab: Own Equipment + Omit Annex 31. */ + showContinuationOwnOmitAnnex: boolean; + /** Continuación tab: Lot + Entry No. */ + showContinuationLotEntry: boolean; + /** Continuación tab: Consider in A31. */ + showContinuationConsiderA31: boolean; + /** Continuación tab: Extra Description in Spanish. */ + showContinuationExtraDescription: boolean; } const defaultVisibility: InvoiceItemVisibility = { @@ -11,7 +38,19 @@ const defaultVisibility: InvoiceItemVisibility = { showEighthRule: true, showFdaFcc: true, showCertificateOfOrigin: true, - showIdentifiersTab: true + showIdentifiersTab: true, + showExportLinkToImportBlock: false, + showExportValuationFields: false, + showValuationFields: false, + showRepairLinkToExportBlock: false, + showContinuationTaxPayment: true, + showContinuationIgi: true, + showContinuationLocation: true, + showContinuationMilitary: true, + showContinuationOwnOmitAnnex: true, + showContinuationLotEntry: true, + showContinuationConsiderA31: true, + showContinuationExtraDescription: true }; function normalizeInvoiceType(invoiceType?: string | null): string { @@ -45,12 +84,40 @@ function normalizeOperationType(operationType?: string | number | null): 'imp' | return null; } +const EXPORT_INVOICE_TYPES: ExportInvoiceType[] = ['NODES', 'AFIJO', 'DONAC', 'SCRAP']; + +function normalizeExportInvoiceType(exportInvoiceType?: string | null): ExportInvoiceType { + const normalized = String(exportInvoiceType || '') + .trim() + .toUpperCase(); + if (EXPORT_INVOICE_TYPES.includes(normalized as ExportInvoiceType)) { + return normalized as ExportInvoiceType; + } + return 'AFIJO'; +} + export function getVisibility( invoiceType?: string | null, operationType?: string | number | null ): InvoiceItemVisibility { if (normalizeOperationType(operationType) === 'exp') { - return defaultVisibility; + const exportType = normalizeExportInvoiceType(invoiceType); + // Base visibility for export: existing flags stay true; export-only flags set by type. + const expVisibility: InvoiceItemVisibility = { + ...defaultVisibility, + showExportLinkToImportBlock: true, + showExportValuationFields: true + }; + // Per-type overrides can be added here (e.g. hide valuation for SCRAP). + switch (exportType) { + case 'NODES': + case 'AFIJO': + case 'DONAC': + case 'SCRAP': + return expVisibility; + default: + return expVisibility; + } } switch (normalizeInvoiceType(invoiceType)) { @@ -65,7 +132,29 @@ export function getVisibility( case 'CR': return { ...defaultVisibility, - showEighthRule: false + showEighthRule: false, + showValuationFields: true + }; + + case 'REP': + case 'REPAR': + // Continuación: solo campos de la captura (sin Valoración, FDA/FCC, Regla Octava, Own/Omit, Lote/Entrada, Consider A31) + return { + ...defaultVisibility, + showCrTrackingHeader: false, + showRepairLinkToExportBlock: true, + showValuationFields: false, + showFdaFcc: false, + showCertificateOfOrigin: true, + showEighthRule: false, + showContinuationTaxPayment: true, + showContinuationIgi: true, + showContinuationLocation: true, + showContinuationMilitary: true, + showContinuationOwnOmitAnnex: false, + showContinuationLotEntry: false, + showContinuationConsiderA31: false, + showContinuationExtraDescription: true }; case 'MEX': From 8d304fb98b56b35663493471499c086dfd5943a4 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 12 Mar 2026 16:14:34 -0600 Subject: [PATCH 4/5] feature/continuacion-vista-tablas-location --- .../v1/modules/a24/inv/location/__init__.py | 3 - .../api/v1/modules/a24/inv/location/dto.py | 43 -- .../api/v1/modules/a24/inv/location/models.py | 36 -- .../api/v1/modules/a24/inv/location/routes.py | 136 ----- .../v1/modules/a24/inv/location/service.py | 136 ----- .../a76/general_catalogs/location/__init__.py | 1 + .../a76/general_catalogs/location/dto.py | 36 ++ .../a76/general_catalogs/location/models.py | 84 +++ .../a76/general_catalogs/location/routes.py | 20 + .../a76/general_catalogs/location/service.py | 154 ++++++ .../v1/modules/a76/general_catalogs/router.py | 2 + backend/main.py | 2 + .../a76/general_catalogs/locations.ts | 80 +-- .../locations/create-edit-dialog.svelte | 85 ++- .../invoices/edit/InvoiceSelectorModal.svelte | 33 +- .../edit/items/fa/item-sheet-fa.svelte | 501 ++++++++++++++---- .../items/fa/location-selector-dialog.svelte | 309 +++++++++++ .../edit/items/fa/tab-continuation.svelte | 26 +- .../components/dashboard/locations/columns.ts | 49 +- .../locations/locations-catalog.svelte | 156 ++++++ .../src/lib/components/sidebar/modules.ts | 4 - .../locations/+page.server.ts | 69 --- .../general_catalogs/locations/+page.svelte | 89 ---- 23 files changed, 1337 insertions(+), 717 deletions(-) delete mode 100644 backend/api/v1/modules/a24/inv/location/__init__.py delete mode 100644 backend/api/v1/modules/a24/inv/location/dto.py delete mode 100644 backend/api/v1/modules/a24/inv/location/models.py delete mode 100644 backend/api/v1/modules/a24/inv/location/routes.py delete mode 100644 backend/api/v1/modules/a24/inv/location/service.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/location/__init__.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/location/dto.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/location/models.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/location/routes.py create mode 100644 backend/api/v1/modules/a76/general_catalogs/location/service.py create mode 100644 frontend/src/lib/components/dashboard/invoices/edit/items/fa/location-selector-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/locations/locations-catalog.svelte delete mode 100644 frontend/src/routes/dashboard/general_catalogs/locations/+page.server.ts delete mode 100644 frontend/src/routes/dashboard/general_catalogs/locations/+page.svelte diff --git a/backend/api/v1/modules/a24/inv/location/__init__.py b/backend/api/v1/modules/a24/inv/location/__init__.py deleted file mode 100644 index 935e07ae..00000000 --- a/backend/api/v1/modules/a24/inv/location/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Módulo de localización -""" diff --git a/backend/api/v1/modules/a24/inv/location/dto.py b/backend/api/v1/modules/a24/inv/location/dto.py deleted file mode 100644 index a6fc6735..00000000 --- a/backend/api/v1/modules/a24/inv/location/dto.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -DTOs (Data Transfer Objects) para módulo de localización -""" - -from typing import Optional - -from pydantic import BaseModel, Field - - -class LocationCreateDTO(BaseModel): - """DTO para crear una localización""" - - code: str = Field(..., max_length=5, description="Location code") - description: Optional[str] = Field( - None, max_length=200, description="Location description" - ) - - class Config: - from_attributes = True - - -class LocationUpdateDTO(BaseModel): - """DTO para actualizar una localización""" - - code: Optional[str] = Field( - None, max_length=5, description="Location code") - description: Optional[str] = Field( - None, max_length=200, description="Location description" - ) - - class Config: - from_attributes = True - - -class LocationResponseDTO(BaseModel): - """DTO para responder con datos de una localización""" - - id: int - code: str - description: Optional[str] = None - - class Config: - from_attributes = True diff --git a/backend/api/v1/modules/a24/inv/location/models.py b/backend/api/v1/modules/a24/inv/location/models.py deleted file mode 100644 index 53d3202c..00000000 --- a/backend/api/v1/modules/a24/inv/location/models.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Modelos ORM para gestión de localización -""" - -from typing import Optional - -from api.v1.common.base_models import TenantScopedMixin, TimestampMixin -from core.database import Base -from sqlalchemy import Integer, PrimaryKeyConstraint, String, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column - - -class Location(Base, TenantScopedMixin, TimestampMixin): - """ - Modelo para la tabla Location - Localización - """ - - __tablename__ = "location" # SLocalizacion - __table_args__ = ( - PrimaryKeyConstraint("id", name="location_pkey"), - UniqueConstraint("code", name="location_code_unique"), - {"schema": "a24"}, - ) - - # Primary key - id: Mapped[int] = mapped_column( - Integer, primary_key=True, autoincrement=True) - - # Location code (unique) - code: Mapped[str] = mapped_column(String(5), nullable=False, unique=True) - - # Location description - description: Mapped[Optional[str]] = mapped_column(String(200)) - - def __repr__(self): - return f"" diff --git a/backend/api/v1/modules/a24/inv/location/routes.py b/backend/api/v1/modules/a24/inv/location/routes.py deleted file mode 100644 index 0920bd4e..00000000 --- a/backend/api/v1/modules/a24/inv/location/routes.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Rutas para gestión de localización -""" - -from typing import List - -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from core.database import get_core_db -from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO -from .models import Location -from .service import LocationService - -router = APIRouter(prefix="/locations", tags=["locations"]) - - -@router.get( - "", - response_model=dict, - summary="Get all locations", -) -async def get_all_locations( - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=100), - code: str = Query(None), - description: str = Query(None), - db: Session = Depends(get_core_db), -): - """Get all locations with optional filtering and pagination""" - filters = {} - if code: - filters["code"] = code - if description: - filters["description"] = description - - locations, total = LocationService.get_all(db, skip, limit, filters) - - return { - "data": [LocationResponseDTO.model_validate(location) for location in locations], - "total": total, - "skip": skip, - "limit": limit, - } - - -@router.get( - "/{location_id}", - response_model=LocationResponseDTO, - summary="Get location by ID", -) -async def get_location( - location_id: int, - db: Session = Depends(get_core_db), -): - """Get a location by its ID""" - location = LocationService.get_by_id(db, location_id) - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found", - ) - return LocationResponseDTO.model_validate(location) - - -@router.get( - "/code/{code}", - response_model=LocationResponseDTO, - summary="Get location by code", -) -async def get_location_by_code( - code: str, - db: Session = Depends(get_core_db), -): - """Get a location by its code""" - location = LocationService.get_by_code(db, code) - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found", - ) - return LocationResponseDTO.model_validate(location) - - -@router.post( - "", - response_model=LocationResponseDTO, - status_code=status.HTTP_201_CREATED, - summary="Create location", -) -async def create_location( - location_data: LocationCreateDTO, - db: Session = Depends(get_core_db), -): - """Create a new location""" - location = LocationService.create(db, location_data) - return LocationResponseDTO.model_validate(location) - - -@router.put( - "/{location_id}", - response_model=LocationResponseDTO, - summary="Update location", -) -async def update_location( - location_id: int, - location_data: LocationUpdateDTO, - db: Session = Depends(get_core_db), -): - """Update a location""" - location = LocationService.update(db, location_id, location_data) - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found", - ) - return LocationResponseDTO.model_validate(location) - - -@router.delete( - "/{location_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete location", -) -async def delete_location( - location_id: int, - db: Session = Depends(get_core_db), -): - """Delete a location""" - success = LocationService.delete(db, location_id) - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found", - ) - return None diff --git a/backend/api/v1/modules/a24/inv/location/service.py b/backend/api/v1/modules/a24/inv/location/service.py deleted file mode 100644 index 0ef5726c..00000000 --- a/backend/api/v1/modules/a24/inv/location/service.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Capa de servicio para lógica de negocio de localización -""" - -import logging -from typing import Any, Dict, List, Optional, Tuple - -from fastapi import HTTPException -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session - -from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO -from .models import Location - -logger = logging.getLogger(__name__) - - -class LocationService: - """Servicio para gestión de localización""" - - def __init__(self, db: Session): - self.db = db - - @staticmethod - def get_all( - db: Session, - skip: int = 0, - limit: int = 50, - filters: Optional[Dict[str, Any]] = None, - ) -> Tuple[List[Location], int]: - """Get all locations with pagination""" - query = db.query(Location) - - if filters: - if filters.get("code"): - query = query.filter( - Location.code.ilike(f"%{filters['code']}%")) - if filters.get("description"): - query = query.filter( - Location.description.ilike(f"%{filters['description']}%") - ) - - total = query.count() - locations = query.offset(skip).limit(limit).all() - - return locations, total - - @staticmethod - def get_by_id(db: Session, location_id: int) -> Optional[Location]: - """Get location by ID""" - return db.query(Location).filter(Location.id == location_id).first() - - @staticmethod - def get_by_code(db: Session, code: str) -> Optional[Location]: - """Get location by code""" - return db.query(Location).filter(Location.code == code).first() - - @staticmethod - def create(db: Session, location_data: LocationCreateDTO) -> Location: - """Create a new location""" - try: - db_location = Location( - **location_data.model_dump(exclude_unset=True)) - - db.add(db_location) - db.commit() - db.refresh(db_location) - - return db_location - - except IntegrityError as e: - db.rollback() - logger.error(f"IntegrityError creating location: {str(e)}") - raise HTTPException( - status_code=400, - detail="Location code already exists", - ) - except Exception as e: - db.rollback() - logger.error(f"Error creating location: {str(e)}") - raise HTTPException( - status_code=500, detail="Error creating location") - - @staticmethod - def update( - db: Session, location_id: int, location_data: LocationUpdateDTO - ) -> Optional[Location]: - """Update a location""" - try: - db_location = db.query(Location).filter( - Location.id == location_id).first() - - if not db_location: - return None - - for key, value in location_data.model_dump(exclude_unset=True).items(): - setattr(db_location, key, value) - - db.commit() - db.refresh(db_location) - - return db_location - - except IntegrityError as e: - db.rollback() - logger.error(f"IntegrityError updating location: {str(e)}") - raise HTTPException( - status_code=400, - detail="Error updating location", - ) - except Exception as e: - db.rollback() - logger.error(f"Error updating location: {str(e)}") - raise HTTPException( - status_code=500, detail="Error updating location") - - @staticmethod - def delete(db: Session, location_id: int) -> bool: - """Delete a location""" - try: - db_location = db.query(Location).filter( - Location.id == location_id).first() - - if not db_location: - return False - - db.delete(db_location) - db.commit() - - return True - - except Exception as e: - db.rollback() - logger.error(f"Error deleting location: {str(e)}") - raise HTTPException( - status_code=500, detail="Error deleting location") diff --git a/backend/api/v1/modules/a76/general_catalogs/location/__init__.py b/backend/api/v1/modules/a76/general_catalogs/location/__init__.py new file mode 100644 index 00000000..5d2af8dd --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/location/__init__.py @@ -0,0 +1 @@ +# a76 general_catalogs.location diff --git a/backend/api/v1/modules/a76/general_catalogs/location/dto.py b/backend/api/v1/modules/a76/general_catalogs/location/dto.py new file mode 100644 index 00000000..b1be6e6b --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/location/dto.py @@ -0,0 +1,36 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from .models import LocationSystem + + +class LocationBase(BaseModel): + clave_localizacion: str = Field( + ..., max_length=20, description="Clave/código de la localización" + ) + localizacion: Optional[str] = Field( + None, max_length=200, description="Nombre o descripción" + ) + system: LocationSystem = Field( + ..., description="Contexto: fixed_asset (FA) o inventory" + ) + + +class LocationCreate(LocationBase): + """Optional extra fields for fixed_asset; used only when system == FIXED_ASSET.""" + department: Optional[str] = Field(None, max_length=100) + responsible: Optional[str] = Field(None, max_length=200) + observations: Optional[str] = Field(None, description="Free text") + + +class LocationUpdate(BaseModel): + clave_localizacion: Optional[str] = Field(None, max_length=20) + localizacion: Optional[str] = Field(None, max_length=200) + system: Optional[LocationSystem] = None + + +class LocationResponse(LocationBase): + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/location/models.py b/backend/api/v1/modules/a76/general_catalogs/location/models.py new file mode 100644 index 00000000..47119425 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/location/models.py @@ -0,0 +1,84 @@ +""" +Modelo ORM para catálogo de localización (a76). +Tabla compartida para contexto Fixed Asset (FA) e inventory. +""" + +import enum +from typing import Optional + +from sqlalchemy import Integer, String, Text, UniqueConstraint, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + + +class LocationSystem(str, enum.Enum): + """Contexto de uso de la localización.""" + FIXED_ASSET = "fixed_asset" + INVENTORY = "inventory" + + +class Location(Base, TenantScopedMixin, TimestampMixin): + """ + Catálogo de localización (clave + localizacion), compartido por FA e inventory. + """ + + __tablename__ = "location" + __table_args__ = ( + UniqueConstraint( + "clave_localizacion", + "tenant_id", + "company_id", + "system", + name="uq_location_clave_tenant_company_system", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + clave_localizacion: Mapped[str] = mapped_column(String(20), nullable=False) + localizacion: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + system: Mapped[str] = mapped_column( + String(20), nullable=False + ) # 'fixed_asset' | 'inventory' + + def __repr__(self) -> str: + return ( + f"" + ) + + +class FaLocationExt(Base, TenantScopedMixin, TimestampMixin): + """ + Extra info for Fixed Asset locations only (1:1 with Location). + Table: Fa_Location_Ext (fa_location_ext). + """ + + __tablename__ = "fa_location_ext" + __table_args__ = ( + UniqueConstraint("location_id", name="uq_fa_location_ext_location_id"), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + location_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("a76.location.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + department: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + responsible: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + observations: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/api/v1/modules/a76/general_catalogs/location/routes.py b/backend/api/v1/modules/a76/general_catalogs/location/routes.py new file mode 100644 index 00000000..bd9949a5 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/location/routes.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from .dto import LocationCreate, LocationResponse, LocationUpdate +from .models import Location +from .service import LocationService + +router = TenantCRUDRoutes( + service=LocationService, + create_schema=LocationCreate, + update_schema=LocationUpdate, + response_schema=LocationResponse, + prefix="/locations", + tags=["a76.general_catalogs.locations"], + resource_name="Location", + enable_list=True, + enable_filters=True, + max_page_size=1000, +).router diff --git a/backend/api/v1/modules/a76/general_catalogs/location/service.py b/backend/api/v1/modules/a76/general_catalogs/location/service.py new file mode 100644 index 00000000..fb48d4ba --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/location/service.py @@ -0,0 +1,154 @@ +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError + +from .models import Location, FaLocationExt +from .dto import LocationCreate, LocationUpdate + + +class LocationService: + @staticmethod + def get_all( + db: Session, + tenant_id: int, + company_id: Optional[int], + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None, + ) -> Tuple[List[Location], int]: + query = db.query(Location).filter(Location.tenant_id == tenant_id) + + if company_id is not None: + query = query.filter(Location.company_id == company_id) + + if filters: + if filters.get("clave_localizacion"): + query = query.filter( + Location.clave_localizacion.ilike( + f"%{filters['clave_localizacion']}%" + ) + ) + if filters.get("localizacion"): + query = query.filter( + Location.localizacion.ilike(f"%{filters['localizacion']}%") + ) + if filters.get("system"): + query = query.filter(Location.system == filters["system"]) + + total = query.count() + items = query.offset(skip).limit(limit).all() + return items, total + + @staticmethod + def get_by_id( + db: Session, + id: int, + tenant_id: int, + company_id: int, + ) -> Optional[Location]: + return ( + db.query(Location) + .filter( + Location.id == id, + Location.tenant_id == tenant_id, + Location.company_id == company_id, + ) + .first() + ) + + @staticmethod + def get_by_clave( + db: Session, + clave_localizacion: str, + tenant_id: int, + company_id: int, + system: Optional[str] = None, + ) -> Optional[Location]: + query = db.query(Location).filter( + Location.clave_localizacion == clave_localizacion, + Location.tenant_id == tenant_id, + Location.company_id == company_id, + ) + if system is not None: + query = query.filter(Location.system == system) + return query.first() + + @staticmethod + def create( + db: Session, + data: LocationCreate, + tenant_id: int, + company_id: int, + ) -> Location: + db_obj = Location( + clave_localizacion=data.clave_localizacion, + localizacion=data.localizacion, + system=data.system.value, + tenant_id=tenant_id, + company_id=company_id, + ) + db.add(db_obj) + try: + db.flush() # get db_obj.id without committing + if data.system.value == "fixed_asset" and ( + data.department is not None + or data.responsible is not None + or data.observations is not None + ): + ext = FaLocationExt( + location_id=db_obj.id, + department=data.department, + responsible=data.responsible, + observations=data.observations, + tenant_id=tenant_id, + company_id=company_id, + ) + db.add(ext) + db.commit() + db.refresh(db_obj) + return db_obj + except IntegrityError: + db.rollback() + raise ValueError("Ya existe una localización con esa clave y system") + + @staticmethod + def update( + db: Session, + id: int, + tenant_id: int, + data: LocationUpdate, + company_id: int, + ) -> Optional[Location]: + db_obj = LocationService.get_by_id(db, id, tenant_id, company_id) + if not db_obj: + return None + + update_dict = data.model_dump(exclude_unset=True) + for key, value in update_dict.items(): + if key == "system" and value is not None: + setattr(db_obj, key, value.value) + else: + setattr(db_obj, key, value) + + try: + db.commit() + db.refresh(db_obj) + return db_obj + except IntegrityError: + db.rollback() + raise ValueError("Ya existe una localización con esa clave y system") + + @staticmethod + def delete( + db: Session, + id: int, + tenant_id: int, + company_id: int, + ) -> bool: + db_obj = LocationService.get_by_id(db, id, tenant_id, company_id) + if not db_obj: + return False + db.delete(db_obj) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/general_catalogs/router.py b/backend/api/v1/modules/a76/general_catalogs/router.py index aaa47326..291dde07 100644 --- a/backend/api/v1/modules/a76/general_catalogs/router.py +++ b/backend/api/v1/modules/a76/general_catalogs/router.py @@ -25,12 +25,14 @@ from .error_catalogs.routes import router as error_catalogs_router from .doda.routes import router as doda_router from .prevalidators.routes import router as prevalidators_router from .electronic_notices.routes import router as electronic_notices_router +from .location.routes import router as location_router router = APIRouter() router.include_router(company_router, tags=["a76 / company"]) router.include_router(package_router) router.include_router(ports_router) +router.include_router(location_router) router.include_router(tariff_fractions_router) router.include_router(us_tariff_fractions_router) router.include_router(historical_tariff_fractions_router, prefix="/fractions/historical-tariff-fractions", tags=["a76 / historical_tariff_fractions"]) diff --git a/backend/main.py b/backend/main.py index e158475c..08987b2e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -47,6 +47,7 @@ from api.v1.modules.a76.general_catalogs.legends.models import Legend from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType from api.v1.modules.a76.general_catalogs.packages.models import Package from api.v1.modules.a76.general_catalogs.ports.models import Port +from api.v1.modules.a76.general_catalogs.location.models import Location, FaLocationExt from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator from api.v1.modules.a76.general_catalogs.seal.models import Seal from api.v1.modules.a76.general_catalogs.signatures.models import Signature @@ -255,6 +256,7 @@ from api.v1.modules.a76.general_catalogs.multi_currency_types.models import ( ) from api.v1.modules.a76.general_catalogs.packages.models import Package from api.v1.modules.a76.general_catalogs.ports.models import Port +from api.v1.modules.a76.general_catalogs.location.models import Location, FaLocationExt from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator from api.v1.modules.a76.general_catalogs.seal.models import Seal from api.v1.modules.a76.general_catalogs.signatures.models import Signature diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/locations.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/locations.ts index 2139acb9..f45e8768 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/locations.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/locations.ts @@ -1,64 +1,84 @@ -import type { PaginatedResponse } from '$lib/types'; import { api } from '$lib/api'; +/** Contexto de uso: Fixed Asset (activo fijo) o inventario */ +export type LocationSystem = 'fixed_asset' | 'inventory'; + export interface Location { id: number; - location_code: string; - location_description: string | null; - company_id: number; - tenant_id: number; + clave_localizacion: string; + localizacion: string | null; + system: LocationSystem; } export interface LocationCreate { - location_code: string; - location_description?: string | null; + clave_localizacion: string; + localizacion?: string | null; + system: LocationSystem; + /** Used when system === 'fixed_asset' */ + department?: string | null; + responsible?: string | null; + observations?: string | null; } export interface LocationUpdate { - location_description?: string | null; + clave_localizacion?: string; + localizacion?: string | null; + system?: LocationSystem; } -export interface LocationListResponse extends PaginatedResponse { +export interface LocationListResponse { items: Location[]; + total: number; + page: number; + page_size: number; } export interface LocationFilters { - location_code?: string; - location_description?: string; + clave_localizacion?: string; + localizacion?: string; + system?: LocationSystem; page?: number; page_size?: number; } -import { portsApi, PortType } from './ports'; +const BASE_URL = '/v1/a76/locations'; + +function buildQuery(companyId: number, params?: LocationFilters): string { + const search = new URLSearchParams(); + search.set('company_id', String(companyId)); + if (params?.page != null) search.set('page', String(params.page)); + if (params?.page_size != null) search.set('page_size', String(params.page_size)); + if (params?.clave_localizacion) search.set('clave_localizacion', params.clave_localizacion); + if (params?.localizacion) search.set('localizacion', params.localizacion); + if (params?.system) search.set('system', params.system); + return search.toString(); +} export async function getLocations( companyId: number, filters?: LocationFilters ): Promise { - const res = await portsApi.list(companyId, filters || {}); - return (res.data || res) as unknown as LocationListResponse; + const q = buildQuery(companyId, filters); + const res = await api.get(`${BASE_URL}/?${q}`); + return (res.data ?? res) as LocationListResponse; } export async function getLocation( locationId: number, companyId: number ): Promise { - const res = await portsApi.get(locationId, companyId); - return (res.data || res) as unknown as Location; + const q = new URLSearchParams({ company_id: String(companyId) }); + const res = await api.get(`${BASE_URL}/${locationId}?${q}`); + return (res.data ?? res) as Location; } export async function createLocation( data: LocationCreate, companyId: number ): Promise { - const res = await portsApi.create({ - port_code: data.location_code, - location_code: data.location_code, - description: null, - location_description: data.location_description || null, - port_type: PortType.ENTRY - }, companyId); - return (res.data || res) as unknown as Location; + const q = new URLSearchParams({ company_id: String(companyId) }); + const res = await api.post(`${BASE_URL}/?${q}`, data); + return (res.data ?? res) as Location; } export async function updateLocation( @@ -66,15 +86,15 @@ export async function updateLocation( data: LocationUpdate, companyId: number ): Promise { - const res = await portsApi.update(locationId, { - location_description: data.location_description - }, companyId); - return (res.data || res) as unknown as Location; + const q = new URLSearchParams({ company_id: String(companyId) }); + const res = await api.put(`${BASE_URL}/${locationId}/?${q}`, data); + return (res.data ?? res) as Location; } export async function deleteLocation( locationId: number, companyId: number ): Promise { - await portsApi.delete(locationId, companyId); -} \ No newline at end of file + const q = new URLSearchParams({ company_id: String(companyId) }); + await api.delete(`${BASE_URL}/${locationId}?${q}`); +} diff --git a/frontend/src/lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte index 794c7350..52197404 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte @@ -6,7 +6,8 @@ import { createLocation, updateLocation, - type Location + type Location, + type LocationSystem } from '$lib/api/dashboard/a76/general_catalogs/locations'; import { companyStore } from '$lib/stores/company.svelte'; import { obtenerAtajosFormularioLocalidades } from '$lib/config/shortcuts/dashboard/general_catalogs/locations/edit'; @@ -21,14 +22,18 @@ onSuccess?: () => void; } = $props(); - // Atajos - const isEdit = $derived(!!item); const title = $derived(isEdit ? 'Editar Ubicación' : 'Nueva Ubicación'); + const systemOptions: { value: LocationSystem; label: string }[] = [ + { value: 'fixed_asset', label: 'Activo fijo (FA)' }, + { value: 'inventory', label: 'Inventario' } + ]; + let formData = $state({ - location_code: '', - location_description: '' + clave_localizacion: '', + localizacion: '', + system: 'fixed_asset' as LocationSystem }); let loading = $state(false); @@ -38,11 +43,16 @@ if (open) { if (item) { formData = { - location_code: item.location_code || '', - location_description: item.location_description || '' + clave_localizacion: item.clave_localizacion ?? '', + localizacion: item.localizacion ?? '', + system: item.system ?? 'fixed_asset' }; } else { - formData = { location_code: '', location_description: '' }; + formData = { + clave_localizacion: '', + localizacion: '', + system: 'fixed_asset' + }; } error = null; } @@ -55,20 +65,31 @@ const companyId = companyStore.activeCompany?.id; if (!companyId) throw new Error('No hay una compañía seleccionada'); - if (!formData.location_code.trim()) throw new Error('El código es requerido'); + if (!formData.clave_localizacion.trim()) throw new Error('La clave es requerida'); const basePayload = { - location_description: formData.location_description?.trim() || null + localizacion: formData.localizacion?.trim() || null }; if (isEdit && item) { - await updateLocation(item.id, basePayload, companyId); + await updateLocation( + item.id, + { + ...basePayload, + clave_localizacion: formData.clave_localizacion.trim(), + system: formData.system + }, + companyId + ); } else { - const createPayload = { - location_code: formData.location_code.trim(), - ...basePayload - }; - await createLocation(createPayload, companyId); + await createLocation( + { + clave_localizacion: formData.clave_localizacion.trim(), + ...basePayload, + system: formData.system + }, + companyId + ); } open = false; @@ -107,12 +128,12 @@
- +
@@ -120,16 +141,32 @@
- +
+ +
+ +
+ +
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte index 978f0a31..3b47096b 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte @@ -10,10 +10,12 @@ let { open = $bindable(false), regimen = 'Temporal', + operationType = 'imp' as 'imp' | 'exp', onSelect }: { open: boolean; - regimen: string; + regimen?: string; + operationType?: 'imp' | 'exp'; onSelect: (invoice: Invoice) => void; } = $props(); @@ -25,15 +27,16 @@ if (!companyStore.activeCompany) return; loading = true; try { - let filters: any = { - operation_type: 'imp', - invoice_number: searchTerm + const filters: any = { + operation_type: operationType, + invoice_number: searchTerm || undefined }; - - if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') { - filters.invoice_type = 'TEM'; - } else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') { - filters.invoice_type = 'DEF'; + if (operationType === 'imp' && regimen) { + if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') { + filters.invoice_type = 'TEM'; + } else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') { + filters.invoice_type = 'DEF'; + } } const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters); @@ -60,12 +63,18 @@ }); - + - Seleccionar Factura ({regimen}) + + {operationType === 'exp' ? 'Seleccionar Factura de Exportación' : `Seleccionar Factura (${regimen})`} + - Busca y selecciona una factura del catálogo de importación para el régimen {regimen}. + {#if operationType === 'exp'} + Busca y selecciona una factura del catálogo de exportación. + {:else} + Busca y selecciona una factura del catálogo de importación para el régimen {regimen}. + {/if} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index e5230066..2ed9af1f 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -1,13 +1,21 @@ + + + + + Catálogo de ubicaciones (maquinaria y equipo) + + + {#if showRegisterForm} + +
{ + e.preventDefault(); + handleRegisterSubmit(); + }} + > +
+ {#if formError} +
+ {formError} +
+ {/if} +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ + +
+
+ {:else} + +
+
+ + +
+ +
+ +
+ {#if loading} +
+ +
+ {:else if error} +
+

{error}

+
+ {:else} +
+ + + + + + + + + {#each filtered as loc} + handleSelect(loc)} + > + + + + {/each} + {#if filtered.length === 0} + + + + {/if} + +
ClaveLocalización
{loc.clave_localizacion ?? '—'}{loc.localizacion ?? '—'}
+ No se encontraron resultados +
+
+ {/if} +
+ +
+ +
+ {/if} +
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte index 238984b0..d071bc47 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte @@ -8,6 +8,7 @@ import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; import type { InvoiceItemVisibility } from '$lib/config/invoice-item-visibility'; import PaymentMethodDialog from './payment-method-dialog.svelte'; + import LocationSelectorDialog from './location-selector-dialog.svelte'; let { lineItem = $bindable(), @@ -51,6 +52,7 @@ let paymentMethodDialogOpen = $state(false); let payment_method_description = $state(''); + let locationSelectorOpen = $state(false); // Load payment method description when payment_method exists $effect(() => { @@ -89,6 +91,10 @@ lineItem.payment_method = method.key; payment_method_description = method.description; } + + function handleLocationSelect(loc: { clave_localizacion: string; localizacion?: string | null }) { + descriptions.machinery_location = loc.localizacion ?? loc.clave_localizacion ?? ''; + }
@@ -224,15 +230,27 @@ {/if} {#if visibility.showContinuationLocation} - -
+ +
- +
- +
+
{/if} diff --git a/frontend/src/lib/components/dashboard/locations/columns.ts b/frontend/src/lib/components/dashboard/locations/columns.ts index 29212a6b..96e9897f 100644 --- a/frontend/src/lib/components/dashboard/locations/columns.ts +++ b/frontend/src/lib/components/dashboard/locations/columns.ts @@ -3,24 +3,35 @@ import type { Location } from '$lib/api/dashboard/a76/general_catalogs/locations import { renderComponent } from '$lib/components/ui/data-table'; import DataTableActions from './data-table-actions.svelte'; +const SYSTEM_LABELS: Record = { + fixed_asset: 'Activo fijo (FA)', + inventory: 'Inventario' +}; + export function createColumns(onSuccess?: () => void): ColumnDef[] { - return [ - { - accessorKey: 'location_code', - header: 'Código', - }, - { - accessorKey: 'location_description', - header: 'Descripción', - cell: ({ row }) => row.original.location_description || '—' - }, - { - id: 'actions', - header: 'Acciones', - cell: ({ row }) => renderComponent(DataTableActions, { - item: row.original, - onSuccess - }) - } - ]; + return [ + { + accessorKey: 'clave_localizacion', + header: 'Clave' + }, + { + accessorKey: 'localizacion', + header: 'Localización', + cell: ({ row }) => row.original.localizacion ?? '—' + }, + { + accessorKey: 'system', + header: 'Sistema', + cell: ({ row }) => SYSTEM_LABELS[row.original.system] ?? row.original.system + }, + { + id: 'actions', + header: 'Acciones', + cell: ({ row }) => + renderComponent(DataTableActions, { + item: row.original, + onSuccess + }) + } + ]; } diff --git a/frontend/src/lib/components/dashboard/locations/locations-catalog.svelte b/frontend/src/lib/components/dashboard/locations/locations-catalog.svelte new file mode 100644 index 00000000..11c64574 --- /dev/null +++ b/frontend/src/lib/components/dashboard/locations/locations-catalog.svelte @@ -0,0 +1,156 @@ + + +
+ {#if !compact} +
+
+

Ubicaciones

+

+ Clave y localización por sistema (FA / Inventario) +

+
+ +
+ {:else} +
+ +
+ {/if} + +
+
+ +
+
+ +
+ {#if showSystemFilter} +
+ +
+ {/if} +
+ +
+ {#if loading} +
+ Cargando... +
+ {:else} + + {/if} +
+ + +
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 0566199d..36c42eed 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -278,10 +278,6 @@ export function getSidebarData(): SidebarData { title: m["sidebar.general_catalogs.customs_warehouses"](), url: "/dashboard/reference_data/customs_warehouses", }, - { - title: m["sidebar.general_catalogs.locations"](), - url: "/dashboard/general_catalogs/locations", - }, { title: m["sidebar.general_catalogs.doda"](), url: "/dashboard/general_catalogs/doda", diff --git a/frontend/src/routes/dashboard/general_catalogs/locations/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/locations/+page.server.ts deleted file mode 100644 index bd4dc983..00000000 --- a/frontend/src/routes/dashboard/general_catalogs/locations/+page.server.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; - -export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { - const parentData = await parent(); - const { accessToken } = getAuthTokens(cookies); - - if (!accessToken) { - return { - error: 'No authenticated', - locations: { items: [], total: 0, page: 1, page_size: 10, pages: 0 } - }; - } - - const page = Number(url.searchParams.get('page')) || 1; - const pageSize = Number(url.searchParams.get('page_size')) || 50; - - const cookieCompanyId = cookies.get('active_company_id'); - const companyId = cookieCompanyId - ? parseInt(cookieCompanyId) - : parentData.companies?.[0]?.id; - - if (!companyId) { - return { - error: 'No company selected', - locations: { items: [], total: 0, page, page_size: pageSize, pages: 0 } - }; - } - - const filters: Record = {}; - const location_code = url.searchParams.get('location_code'); - const location_description = url.searchParams.get('location_description'); - - if (location_code) filters.location_code = location_code; - if (location_description) filters.location_description = location_description; - - try { - const queryParams = new URLSearchParams({ - page: page.toString(), - page_size: pageSize.toString(), - company_id: companyId.toString(), - ...filters - }); - - const response = await authenticatedFetch( - `v1/a76/ports/?${queryParams.toString()}`, - { method: 'GET', cache: 'no-store' }, - cookies, - fetch - ); - - if (!response.ok) { - return { - error: 'Failed to load', - locations: { items: [], total: 0, page, page_size: pageSize, pages: 0 } - }; - } - - const data = await response.json(); - - return { locations: data }; - } catch (error) { - console.error('Error loading locations:', error); - return { - error: 'Error loading', - locations: { items: [], total: 0, page: 1, page_size: pageSize, pages: 0 } - }; - } -}; diff --git a/frontend/src/routes/dashboard/general_catalogs/locations/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/locations/+page.svelte deleted file mode 100644 index 3d413e70..00000000 --- a/frontend/src/routes/dashboard/general_catalogs/locations/+page.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - -
-
-
-

Ubicaciones

-

Catálogo de ubicaciones de puertos

-
- -
- -
-
- -
-
- -
-
- -
- -
- - -
From 4a52e9ec980d544b50efc853b960c7c7eebc17bd Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 13 Mar 2026 08:32:29 -0600 Subject: [PATCH 5/5] feature/visibility-comp-mex --- frontend/src/lib/config/invoice-item-visibility.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/config/invoice-item-visibility.ts b/frontend/src/lib/config/invoice-item-visibility.ts index f0ce0a22..283660af 100644 --- a/frontend/src/lib/config/invoice-item-visibility.ts +++ b/frontend/src/lib/config/invoice-item-visibility.ts @@ -158,13 +158,19 @@ export function getVisibility( }; case 'MEX': + // Compras Mexicanas: lo esencial + localización (como otros tipos); sin IGI, militar, A31, Own/Omit return { ...defaultVisibility, showCrTrackingHeader: false, showEighthRule: false, showFdaFcc: false, showCertificateOfOrigin: false, - showIdentifiersTab: false + showIdentifiersTab: false, + showContinuationIgi: false, + showContinuationLocation: true, + showContinuationMilitary: false, + showContinuationOwnOmitAnnex: false, + showContinuationConsiderA31: false }; default: