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 fd489b6e..848e4181 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -937,6 +937,341 @@ def scan_file(self, job_id: str, model_target: str, config: str = None): logger.exception("Encabezados importación temporal scan failed: %s", e) return {"status": "failed", "error": str(e)} + # --- Encabezados de Importación Definitiva: flujo específico (Clarion VALIDA_TODA_FACIMPO_DEF / VALIDA_PARCIAL) --- + if model_target == "invoice_header" and template_id == "imp_def_header": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.customs_brokers.models import CustomsBroker + from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection + from api.v1.modules.public.reference_data.incoterms.models import Incoterm + from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate + from api.v1.modules.a76.transportation.transporters.models import Transporter + from .validators.encabezados_impo_def import ( + validate_row_encabezados_impo_def, + parse_pedimento_col_a_impo_def, + ) + from .validators.encabezados_impo_temp import _pedimento_key_from_parsed + + DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") + + def _ped_key_from_row_def(ped_str: str) -> Optional[str]: + parsed = parse_pedimento_col_a_impo_def(ped_str) + if not parsed: + return None + return _pedimento_key_from_parsed(parsed[0], parsed[1], parsed[2]) + + _fc = parse_footer_config(meta.get("footer_config")) + actualizar = meta.get("actualizar", False) + autonumerar_remesas = meta.get("autonumerar_remesas", False) + control_remesa = bool(_fc.get("control_remesa", False)) + remesa_inicio = _fc.get("remesa_inicio") + remesa_fin = _fc.get("remesa_fin") + if remesa_inicio is not None: + try: + remesa_inicio = int(remesa_inicio) + except (TypeError, ValueError): + remesa_inicio = None + if remesa_fin is not None: + try: + remesa_fin = int(remesa_fin) + except (TypeError, ValueError): + remesa_fin = None + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "autonumerar_remesas" in _fc: + autonumerar_remesas = bool(_fc["autonumerar_remesas"]) + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + invoice_exists_by_number = {} + invoice_updated_by_number = {} + for num, iid, is_upd in q_inv.all(): + if num: + n = str(num).strip() + invoice_exists_by_number[n] = True + invoice_updated_by_number[n] = bool(is_upd) + + pedimento_data_by_key = {} + for p in ( + session.query( + Pedimentos.id, + Pedimentos.year, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + Pedimentos.operation_type, + Pedimentos.regime, + Pedimentos.pedimento_type, + ) + .filter( + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + Pedimentos.operation_type == "imp", + Pedimentos.regime == "IMD", + ) + .all() + ): + co = (p.customs_office or "").strip() + lic = (p.license or "").strip() + num = (p.pedimento_number or "").strip() + if not co or not lic or not num: + continue + key = _pedimento_key_from_parsed(co, lic, num) + entry_date = None + end_date = None + pd = ( + session.query(PedimentoDates.entry_date, PedimentoDates.end_date) + .filter(PedimentoDates.pedimento_id == p.id).first() + ) + if pd: + entry_date = pd[0] + end_date = pd[1] + info = { + "id": p.id, + "regime": (p.regime or "").strip(), + "operation_type": (p.operation_type or "").strip(), + "pedimento_type": (p.pedimento_type or "").strip(), + "entry_date": entry_date, + "end_date": end_date, + } + if key not in pedimento_data_by_key: + pedimento_data_by_key[key] = [] + pedimento_data_by_key[key].append(info) + + remesa_por_pedimento_bd = {} + q_rem = ( + session.query( + InvoiceComplianceMx.remesa, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + ) + .join(InvoiceHeader, InvoiceHeader.id == InvoiceComplianceMx.invoice_id) + .join(Pedimentos, Pedimentos.id == InvoiceComplianceMx.pedimento_id) + .filter( + InvoiceComplianceMx.tenant_id == tenant_id, + InvoiceComplianceMx.company_id == company_id, + InvoiceComplianceMx.pedimento_id.isnot(None), + InvoiceComplianceMx.remesa.isnot(None), + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for rem, co, lic, num in q_rem.all(): + if co and lic and num and rem is not None: + key = _pedimento_key_from_parsed( + (co or "").strip(), + (lic or "").strip(), + (num or "").strip(), + ) + if key not in remesa_por_pedimento_bd: + remesa_por_pedimento_bd[key] = set() + remesa_por_pedimento_bd[key].add(int(rem)) + + valid_provider_ids = set() + valid_sold_to_ids = set() + valid_shipped_to_ids = set() + valid_provider_short_names = set() + valid_sold_to_short_names = set() + valid_shipped_to_short_names = set() + for cp in session.query(ClientProvider.id, ClientProvider.short_name).filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ).all(): + valid_provider_ids.add(cp[0]) + valid_sold_to_ids.add(cp[0]) + valid_shipped_to_ids.add(cp[0]) + if cp[1] and str(cp[1]).strip(): + sn_upper = str(cp[1]).strip().upper() + valid_provider_short_names.add(sn_upper) + valid_sold_to_short_names.add(sn_upper) + valid_shipped_to_short_names.add(sn_upper) + + valid_broker_ids = set() + valid_broker_claves = set() + for cb in session.query(CustomsBroker.id, CustomsBroker.broker_key).filter( + CustomsBroker.tenant_id == tenant_id, + CustomsBroker.company_id == company_id, + ).all(): + valid_broker_ids.add(cb[0]) + if cb[1] and str(cb[1]).strip(): + valid_broker_claves.add(str(cb[1]).strip()) + + valid_transporter_keys = set() + for t in session.query(Transporter.transporter_key).filter( + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ).all(): + if t[0]: + valid_transporter_keys.add((t[0] or "").strip().upper()) + + valid_incoterms = set() + for inc in session.query(Incoterm.code).all(): + if inc[0]: + valid_incoterms.add((inc[0] or "").strip().upper()) + + valid_aduana_codes = set() + for cs in session.query(CustomsSection.customs_code).all(): + if cs[0]: + valid_aduana_codes.add((cs[0] or "").strip()) + + valid_currency_codes = set() + for ct in session.query(CurrencyType.code).all(): + if ct[0]: + valid_currency_codes.add((ct[0] or "").strip().upper()) + + exchange_rate_by_date = {} + for er in session.query(ExchangeRate.date, ExchangeRate.value).filter( + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + ).all(): + if er[0] and er[1] is not None: + dk = er[0].strftime("%Y-%m-%d") if hasattr(er[0], "strftime") else str(er[0])[:10] + exchange_rate_by_date[dk] = er[1] + + invoice_has_partidas_by_number = {} + existing_tipo_moneda_by_number = {} + q_li_count = ( + session.query(InvoiceHeader.invoice_number, func.count(LineItem.id)) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + .group_by(InvoiceHeader.invoice_number) + ) + for num, cnt in q_li_count.all(): + if num: + invoice_has_partidas_by_number[str(num).strip()] = cnt > 0 + q_fin = ( + session.query(InvoiceHeader.invoice_number, InvoiceFinancials.currency) + .join(InvoiceFinancials, InvoiceFinancials.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for num, cur in q_fin.all(): + if num and cur: + cur_str = (cur or "").strip().lower() + if cur_str == "foreign": + existing_tipo_moneda_by_number[str(num).strip()] = "ME" + elif cur_str == "local": + existing_tipo_moneda_by_number[str(num).strip()] = "MN" + else: + existing_tipo_moneda_by_number[str(num).strip()] = (cur_str or "").upper()[:2] + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + remesa_por_pedimento_csv = {} + for row in rows_list: + row_norm = row_from_template(row, "imp_def_header", normalize_header) + ped = (row_norm.get("PEDIMENTO") or "").strip() + rem = row_norm.get("REMESA") + factura = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA") or "").strip() + if not ped or not factura: + continue + key = _ped_key_from_row_def(ped) + if not key: + continue + try: + rem_int = int(rem) if rem is not None and str(rem).strip() else None + except (TypeError, ValueError): + rem_int = None + if rem_int is not None: + if key not in remesa_por_pedimento_csv: + remesa_por_pedimento_csv[key] = {} + if rem_int not in remesa_por_pedimento_csv[key]: + remesa_por_pedimento_csv[key][rem_int] = factura + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "imp_def_header", normalize_header) + warnings_row = [] + err = validate_row_encabezados_impo_def( + row_norm, + i, + actualizar=actualizar, + invoice_exists_by_number=invoice_exists_by_number, + invoice_updated_by_number=invoice_updated_by_number, + pedimento_data_by_key=pedimento_data_by_key, + remesa_por_pedimento_bd=remesa_por_pedimento_bd, + remesa_por_pedimento_csv=remesa_por_pedimento_csv, + valid_provider_ids=valid_provider_ids, + valid_sold_to_ids=valid_sold_to_ids, + valid_shipped_to_ids=valid_shipped_to_ids, + valid_provider_short_names=valid_provider_short_names, + valid_sold_to_short_names=valid_sold_to_short_names, + valid_shipped_to_short_names=valid_shipped_to_short_names, + valid_broker_ids=valid_broker_ids, + valid_broker_claves=valid_broker_claves, + valid_transporter_keys=valid_transporter_keys, + valid_incoterms=valid_incoterms, + valid_aduana_codes=valid_aduana_codes, + valid_currency_codes=valid_currency_codes, + exchange_rate_by_date=exchange_rate_by_date, + invoice_has_partidas_by_number=invoice_has_partidas_by_number, + existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, + autonumerar_remesas=autonumerar_remesas, + control_remesa=control_remesa, + remesa_inicio=remesa_inicio, + remesa_fin=remesa_fin, + date_format=date_format, + parse_date_fn=parse_date, + warnings=warnings_row, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + for w in warnings_row: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + if error_lines_list: + common_storage.store_error_lines(JOB_TYPE, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Encabezados importación definitiva scan failed: %s", e) + return {"status": "failed", "error": str(e)} + try: from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.clients_and_providers.models import ClientProvider @@ -1857,6 +2192,11 @@ def insert_valid_rows(self, job_id: str, model_target: str): # Default types from config or fallback op_type_value = OperationType(meta.get('operation_type', 'imp').lower()) inv_type_value = normalize_public_code(footer_config.get('invoice_type') or 'TEM') or 'TEM' + _template_id_insert = meta.get("template_id") or ( + "imp_temp_header" if model_target == "invoice_header" else "imp_temp_details" + ) + if model_target == "invoice_header" and _template_id_insert == "imp_def_header": + inv_type_value = "DEF" logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}") @@ -2103,12 +2443,13 @@ def insert_valid_rows(self, job_id: str, model_target: str): customs_office_p, license_p, num_p = parsed key_p = _pedimento_key_from_parsed(customs_office_p, license_p, num_p) if key_p not in pedimento_id_cache: + co_prefix = (customs_office_p or "").strip()[:2] ped_row = ( session.query(Pedimentos.id) .filter( Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id, - Pedimentos.customs_office == customs_office_p, + Pedimentos.customs_office.startswith(co_prefix), Pedimentos.license == license_p, Pedimentos.pedimento_number == num_p, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py index 265f5e56..5b7999fc 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py @@ -49,8 +49,39 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "ORDEN COMPRA", "aliases": ["ORDENCOMPRA"]}, {"canonical": "FACTURA EXPO REF", "aliases": ["FACTURAEXPOREF"]}, ], - # --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - misma estructura --- - "imp_def_header": None, # se resuelve igual que imp_temp_header + # --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - 30 columnas Clarion --- + "imp_def_header": [ + {"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED"]}, + {"canonical": "REMESA"}, + {"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]}, + {"canonical": "FECHA FACTURA", "aliases": ["FECHA"]}, + {"canonical": "TIPO DE CAMBIO"}, + {"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]}, + {"canonical": "CLAVE PROVEEDOR"}, + {"canonical": "CLAVE VENDIDO A", "aliases": ["CLAVE VENDIDO A:"]}, + {"canonical": "CLAVE ENVIADO A"}, + {"canonical": "AGENTE ADUANAL"}, + {"canonical": "CLAVE TRANSPORTISTA"}, + {"canonical": "NOMBRE CONDUCTOR"}, + {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "NUMERO TRANSPORTE"}, + {"canonical": "TIPO MONEDA"}, + {"canonical": "CLAVE MONEDA"}, + {"canonical": "FLETES"}, + {"canonical": "VALOR SEGUROS"}, + {"canonical": "SEGUROS"}, + {"canonical": "EMBALAJES"}, + {"canonical": "OTROS INCREMENTABLES"}, + {"canonical": "CLAVE INCOTERM"}, + {"canonical": "PRECINTO"}, + {"canonical": "FECHA EMISION"}, + {"canonical": "TIPO PESO"}, + {"canonical": "E DOCUMENT", "aliases": ["E-DOCUMENT", "EDOCUMENT", "E DOCUMENT"]}, + {"canonical": "NUM OPERACION", "aliases": ["NUM. OPERACION", "NUMOPERACION", "NUM OPERACION"]}, + {"canonical": "ADUANA DE CRUCE"}, + {"canonical": "OBSERVACIONES E"}, + {"canonical": "OBSERVACIONES I"}, + ], # --- Encabezado factura: Expo (EstructuraEncFacExpoCamReg.xls) - misma estructura --- "exp_def_header": None, # --- Partidas factura: Impo Temp (EstructuraParFacImpoTemp - paridad Clarion A-AG) --- diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py index ea14dcaf..bc7ac06a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py @@ -5,10 +5,16 @@ from .encabezados_impo_temp import ( parse_pedimento_col_a, _pedimento_key_from_parsed, ) +from .encabezados_impo_def import ( + validate_row_encabezados_impo_def, + parse_pedimento_col_a_impo_def, +) __all__ = [ "validate_row_encabezados_impo_temp", + "validate_row_encabezados_impo_def", "row_to_transport_type_clarion", "parse_pedimento_col_a", + "parse_pedimento_col_a_impo_def", "_pedimento_key_from_parsed", ] diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py new file mode 100644 index 00000000..fdb969b1 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py @@ -0,0 +1,363 @@ +""" +Validaciones CSV para Encabezados de Facturas de Importación Definitiva. +Paridad Clarion: VALIDA_TODA_FACIMPO_DEF, VALIDA_PARCIAL_FACIMPO_DEF, VALIDACIONES_FACIMPO_DEF. +Reutiliza de encabezados_impo_temp: claves, short names y validaciones de catálogos, transporte, +moneda, tipo peso y tipo cambio (misma lógica que TEM). +""" +from datetime import datetime +from typing import Any, Dict, List, Optional, Set, Tuple + +# Reutilizar del TEM: helpers y validaciones de catálogos (claves/short names), transporte, moneda, tipo peso, tipo cambio +from .encabezados_impo_temp import ( + _clip, + _err, + _get, + _parse_int, + _pedimento_key_from_parsed, + _validaciones_catalogos, + _validaciones_factura_longitud, + _validaciones_moneda, + _validaciones_tipo_cambio, + _validaciones_tipo_peso, + _validaciones_transporte, +) + +# Impo Def: régimen único y pedimento formato ##-####-####### (15 chars) +REGIMEN_IMD = "IMD" +MAX_LEN_PEDIMENTO_DEF = 15 +MAX_LEN_FACTURA = 15 # mismo que TEM + + +def parse_pedimento_col_a_impo_def(pedimento_str: str) -> Optional[Tuple[str, str, str]]: + """ + Parsea Col A (PEDIMENTO) formato ##-####-####### (15 caracteres). + Guiones en posiciones 3 y 8 (1-based): índices 2 y 7. Retorna (aduana_2, patente_4, numero_7) o None. + Ejemplo válido: 01-1234-2312412 + """ + if not pedimento_str or not isinstance(pedimento_str, str): + return None + s = (pedimento_str or "").strip() + if len(s) != MAX_LEN_PEDIMENTO_DEF: + return None + if s[2:3] != "-" or s[7:8] != "-": + return None + part0, part1, part2 = s[0:2], s[3:7], s[8:15] + if not part0.isdigit() or not part1.isdigit() or not part2.isdigit(): + return None + return (part0, part1, part2) + + +def _validaciones_obligatorios_toda_def( + row: Dict[str, Any], + line_num: int, + tiene_pedimento: bool, +) -> Optional[Dict[str, Any]]: + """Obligatorios VALIDA_TODA para Impo Def: C, D, F, G, H, I, J. Aduana de Cruce (AB) no obligatoria.""" + obligatorios: List[str] = [] + if not _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA"): + obligatorios.append("(Col.C) Factura") + if not _get(row, "FECHA FACTURA", "FECHA"): + obligatorios.append("(Col.D) Fecha de la Factura") + if not _get(row, "REGIMEN", "CLAVEDOCUMENTO"): + obligatorios.append("(Col.F) Clave Régimen") + if not _get(row, "CLAVE PROVEEDOR"): + obligatorios.append("(Col.G) Clave del Proveedor") + if not _get(row, "CLAVE VENDIDO A"): + obligatorios.append("(Col.H) Clave del Vendido A") + if not _get(row, "CLAVE ENVIADO A"): + obligatorios.append("(Col.I) Clave del Enviado A") + if not _get(row, "AGENTE ADUANAL"): + obligatorios.append("(Col.J) Clave del Agente Aduanal") + if obligatorios: + return _err( + line_num, + "ARCHIVO CSV", + f"Existen campos vacíos que son obligatorios: {', '.join(obligatorios)}. Revisar para Importación Definitiva.", + ) + return None + + +def _validaciones_regimen_imd(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: + """Col F: solo IMD válido para Impo Def.""" + f = _get(row, "REGIMEN", "CLAVEDOCUMENTO") + if f and f.upper() != REGIMEN_IMD: + return _err( + line_num, + "REGIMEN", + f"Error: (Celda F{line_num}) El Régimen Aduanero: {f} no es válido para este tipo de movimiento. Los válidos son: IMD.", + ) + return None + + +def _validaciones_pedimento_remesa_def( + row: Dict[str, Any], + line_num: int, + pedimento_data_by_key: Dict[str, List[Dict[str, Any]]], + remesa_por_pedimento_bd: Dict[str, Set[int]], + remesa_por_pedimento_csv: Dict[str, Dict[int, str]], + autonumerar_remesas: bool, + control_remesa: bool, + remesa_inicio: Optional[int], + remesa_fin: Optional[int], + invoice_date_parsed: Optional[datetime], +) -> Optional[Dict[str, Any]]: + """Pedimento Col A: max 15 chars, formato ##-####-#######; catálogo con regime IMD. Remesa igual que TEM.""" + col_a = _get(row, "PEDIMENTO") + col_b_raw = row.get("REMESA") + col_b = _clip(col_b_raw) + col_f = _get(row, "REGIMEN", "CLAVEDOCUMENTO").upper() + + if not col_a: + if col_b: + return _err( + line_num, + "REMESA", + f"Error: (Celda B{line_num}) Está asignado el número de Remesa y no se tiene un pedimento en (Celda A{line_num}).", + ) + return None + + if len(col_a) > MAX_LEN_PEDIMENTO_DEF: + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El Pedimento: {col_a} supera la longitud de caracteres. Use formato ##-####-#######.", + ) + parsed = parse_pedimento_col_a_impo_def(col_a) + if not parsed: + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El Formato del Pedimento: {col_a} es incorrecto. Use ##-####-#######.", + ) + + customs_office, license_val, pedimento_number = parsed + key = _pedimento_key_from_parsed(customs_office, license_val, pedimento_number) + ped_info_list = pedimento_data_by_key.get(key) + if not ped_info_list: + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} no existe en el Catálogo de Pedimentos. " + "Darlo de alta como pedimento de Importación Definitiva.", + ) + + ped_info = ped_info_list[0] + regimen_ped = (ped_info.get("regime") or "").strip().upper() + if regimen_ped != REGIMEN_IMD: + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido para este tipo de movimiento. Válidos: IMD.", + ) + if col_f and col_f != REGIMEN_IMD: + return _err( + line_num, + "REGIMEN", + f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} no coincide con el del Pedimento. Debe ser IMD.", + ) + if col_f and col_f != regimen_ped: + return _err( + line_num, + "REGIMEN", + f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} capturado es diferente al del Pedimento: {regimen_ped}. Debe ser IMD.", + ) + + pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower() + if pedimento_type == "consolidated" and invoice_date_parsed and ped_info.get("entry_date") and ped_info.get("end_date"): + entry = ped_info["entry_date"] + end = ped_info["end_date"] + if hasattr(entry, "date"): + entry = entry.date() + if hasattr(end, "date"): + end = end.date() + inv_d = invoice_date_parsed.date() if hasattr(invoice_date_parsed, "date") else invoice_date_parsed + if inv_d < entry or inv_d > end: + return _err( + line_num, + "FECHA FACTURA", + f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango de fechas del Pedimento {col_a}.", + ) + + if not autonumerar_remesas and not col_b: + return _err( + line_num, + "REMESA", + f"Error: (Celda B{line_num}) El Número de Remesa está vacío y se tiene un Pedimento en la Celda A{line_num}.", + ) + remesa_int = _parse_int(col_b_raw) + if col_b and remesa_int is not None and remesa_int == 0: + return _err( + line_num, + "REMESA", + f"Error: (Celda B{line_num}) El Número de Remesa no puede ser 0.", + ) + if control_remesa and remesa_int is not None and remesa_inicio is not None and remesa_fin is not None: + if remesa_int < remesa_inicio or remesa_int > remesa_fin: + return _err( + line_num, + "REMESA", + f"Error: (Celda B{line_num}) El Número de Remesa: {col_b} está fuera del rango configurado ({remesa_inicio}-{remesa_fin}).", + ) + + factura_actual = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA") + if remesa_int is not None and key in remesa_por_pedimento_csv: + other = remesa_por_pedimento_csv[key].get(remesa_int) + if other and other != factura_actual: + return _err( + line_num, + "REMESA", + f"Error: (Celda B{line_num}) El Número de Remesa ya está asignado a la factura {other} en este archivo CSV.", + ) + return None + + +def _normalize_tipo_transporte_ferro(row: Dict[str, Any]) -> Dict[str, Any]: + """Clarion DEF acepta 'FERRO BARCAZA' (con espacio). Normaliza a FERROBARCAZA para reutilizar validación TEM.""" + out = dict(row) + m = out.get("TIPO TRANSPORTE") + if m is not None and str(m).strip().upper().replace(" ", "") == "FERROBARCAZA": + out["TIPO TRANSPORTE"] = "FERROBARCAZA" + return out + + +def validate_row_encabezados_impo_def( + row: Dict[str, Any], + line_num: int, + actualizar: bool, + invoice_exists_by_number: Dict[str, bool], + invoice_updated_by_number: Dict[str, bool], + pedimento_data_by_key: Dict[str, List[Dict[str, Any]]], + remesa_por_pedimento_bd: Dict[str, Set[int]], + remesa_por_pedimento_csv: Dict[str, Dict[int, str]], + valid_provider_ids: Set[int], + valid_sold_to_ids: Set[int], + valid_shipped_to_ids: Set[int], + valid_broker_ids: Set[int], + valid_transporter_keys: Set[str], + valid_incoterms: Set[str], + valid_aduana_codes: Set[str], + valid_currency_codes: Set[str], + valid_provider_short_names: Optional[Set[str]] = None, + valid_sold_to_short_names: Optional[Set[str]] = None, + valid_shipped_to_short_names: Optional[Set[str]] = None, + valid_broker_claves: Optional[Set[str]] = None, + exchange_rate_by_date: Optional[Dict[str, Any]] = None, + invoice_has_partidas_by_number: Optional[Dict[str, bool]] = None, + existing_tipo_moneda_by_number: Optional[Dict[str, str]] = None, + autonumerar_remesas: bool = False, + control_remesa: bool = False, + remesa_inicio: Optional[int] = None, + remesa_fin: Optional[int] = None, + date_format: Optional[str] = None, + parse_date_fn=None, + warnings: Optional[List[Dict[str, Any]]] = None, +) -> Optional[Dict[str, Any]]: + """ + Valida una fila de CSV de Encabezados de Importación Definitiva. + Clarion: VALIDA_TODA_FACIMPO_DEF vs VALIDA_PARCIAL_FACIMPO_DEF. + Reutiliza de encabezados_impo_temp las validaciones de catálogos (claves y short names), + transporte, moneda, tipo peso y tipo cambio. + """ + factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA") + if not factura: + return _err( + line_num, + "NUMERO FACTURA", + "Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.", + ) + + if invoice_updated_by_number.get(factura.strip(), False): + return _err( + line_num, + "NUMERO FACTURA", + f"Error: (Celda C{line_num}) El Número de Factura: {factura} ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas.", + ) + + if actualizar and factura.strip() not in invoice_exists_by_number: + return _err( + line_num, + "NUMERO FACTURA", + f"Error: (Col.C) Factura de importación Definitiva no existe (modo Actualizar).", + ) + + use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False) + tiene_pedimento = bool(_get(row, "PEDIMENTO")) + + if not use_partial: + err = _validaciones_obligatorios_toda_def(row, line_num, tiene_pedimento) + if err: + return err + + invoice_date_parsed = None + if parse_date_fn: + date_str = _get(row, "FECHA FACTURA", "FECHA") + if date_str: + invoice_date_parsed = parse_date_fn(date_str, date_format) + + err = _validaciones_pedimento_remesa_def( + row, + line_num, + pedimento_data_by_key, + remesa_por_pedimento_bd, + remesa_por_pedimento_csv, + autonumerar_remesas, + control_remesa, + remesa_inicio, + remesa_fin, + invoice_date_parsed, + ) + if err: + return err + + err = _validaciones_factura_longitud(row, line_num) + if err: + return err + err = _validaciones_regimen_imd(row, line_num) + if err: + return err + + # Normalizar FERRO BARCAZA -> FERROBARCAZA para reutilizar validación TEM + row_transport = _normalize_tipo_transporte_ferro(row) + err = _validaciones_transporte(row_transport, line_num) + if err: + return err + + has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False + existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None + err = _validaciones_moneda( + row, + line_num, + valid_currency_codes or set(), + has_partidas if use_partial else None, + existing_moneda if use_partial else None, + ) + if err: + return err + err = _validaciones_tipo_peso(row, line_num) + if err: + return err + # Mismas claves y short names que TEM (Proveedor, Vendido A, Enviado A, Agente, Transportista, Incoterm, Aduana) + err = _validaciones_catalogos( + row, + line_num, + valid_provider_ids or set(), + valid_sold_to_ids or set(), + valid_shipped_to_ids or set(), + valid_provider_short_names or set(), + valid_sold_to_short_names or set(), + valid_shipped_to_short_names or set(), + valid_broker_ids or set(), + valid_broker_claves or set(), + valid_transporter_keys or set(), + valid_incoterms or set(), + valid_aduana_codes or set(), + ) + if err: + return err + err = _validaciones_tipo_cambio( + row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings + ) + if err: + return err + + return None diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py index e4196ff3..eb841583 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py @@ -96,8 +96,9 @@ def parse_pedimento_col_a(pedimento_str: str) -> Optional[Tuple[str, str, str]]: def _pedimento_key_from_parsed(customs_office: str, license_val: str, pedimento_number: str) -> str: - """Clave para lookup: CC-LLLL-NNNNNNN (sin año; como viene en CSV).""" - return f"{customs_office}-{license_val}-{pedimento_number}" + """Clave para lookup: CC-LLLL-NNNNNNN (solo primeros 2 dígitos de aduana).""" + co = (customs_office or "").strip()[:2] + return f"{co}-{license_val}-{pedimento_number}" def _err(line_num: int, col: str, msg: str) -> Dict[str, Any]: