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