diff --git a/app/Manual_Reintegro.docx b/app/Manual_Reintegro.docx new file mode 100644 index 0000000..65d3f8c Binary files /dev/null and b/app/Manual_Reintegro.docx differ diff --git a/app/app.ipynb b/app/app.ipynb index 327956a..8df9dbb 100644 --- a/app/app.ipynb +++ b/app/app.ipynb @@ -15716,23 +15716,86 @@ "\n", "INSERT_DESC_SQL = \"\"\"INSERT INTO SDescargaT\n", " (CONSECUTIVO, FACTEXPO, FACREFERENCIA, FACTIMPO, PEDIMENTOIMPO, PEDIMENTOEXPO, CLASE,\n", - " VALORMN, VALORME, PESONETO, PESOBRUTO, PAISMERCANCIA, FECHADESC, TIPOFRACCION,\n", - " NUMPARTE, CANTDESC, UNIMED, LINEAEXPO, PARTEORIGINAL, PORUTILERIA, TIPOMATEXPO,\n", - " MONTOIGI, ADVALOREMIMPO, TIPODESC)\n", - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\"\n", - "UPDATE_FACEXP_SQL = \"UPDATE SFacExp SET ESTATUS='AC' WHERE FACTURAEXPO=?\"\n", + " VALORMN, VALORME, VALORIVAMN, VALORIVAME, PESONETO, PESOBRUTO, PAISMERCANCIA, FECHADESC, TIPOFRACCION,\n", + " SECTOR, NUMPARTEEXPO, NUMPARTE, CANTDESC, UNIMED, LINEAEXPO, PARTEORIGINAL, PORUTILERIA, TIPOMATEXPO,\n", + " MONTOIGI, ADVALOREMIMPO, TIPODESC,\n", + " PAGOIMPUESTO, TIENECERT, ORDENVENTA, ACTUALREPARACION,\n", + " CONSECPARCIAL, LINEAEXPOREF, APARTADOCTM, DESCARGASM, CANTIDADRETORNADASM)\n", + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\"\n", + "def _to_clarion_time(t=None):\n", + " \"\"\"HORAACTUAL en formato Clarion (centisegundos desde medianoche, base 1).\"\"\"\n", + " if t is None: t = _dt.datetime.now().time()\n", + " return (t.hour*3600 + t.minute*60 + t.second) * 100 + 1\n", + "\n", + "def _build_update_facexp_sql(cambia_estatus, manual):\n", + " \"\"\"Devuelve (sql, lista_params_extra_a_anteceder). El orden de placeholders es:\n", + " [APLICADESCMANUAL,] FECHAACTUAL, HORAACTUAL, [FECHACAPTURA,]\n", + " COMENTARIOSESTATUS, COMOFUEPROCESADA, FACTURAEXPO\n", + " y el SQL contiene la subquery de CANT_PARTIDAS.\"\"\"\n", + " sets = []\n", + " if cambia_estatus:\n", + " sets.append(\"ESTATUS = 'AC'\")\n", + " sets.append(\"APLICADESCMANUAL = ?\")\n", + " sets.append(\"CANT_PARTIDAS = (SELECT COUNT(*) FROM SPartidasExpo WHERE FACTURAEXPO = SFacExp.FACTURAEXPO)\")\n", + " sets.append(\"GENDESCPARTIDAS = 'S'\")\n", + " sets.append(\"FECHAACTUAL = ?\")\n", + " sets.append(\"HORAACTUAL = ?\")\n", + " if cambia_estatus:\n", + " sets.append(\"FECHACAPTURA = ?\")\n", + " sets.append(\"USUARIOACT = 'UTILERIA'\")\n", + " if cambia_estatus:\n", + " sets.append(\"USUARIOCAP = 'UTILERIA'\")\n", + " sets.append(\"COMENTARIOSESTATUS = ?\")\n", + " sets.append(\"COMOFUEPROCESADA = ?\")\n", + " sql = \"UPDATE SFacExp SET \" + \", \".join(sets) + \" WHERE FACTURAEXPO = ?\"\n", + " return sql\n", "UPDATE_SALDO_SQL = \"\"\"UPDATE SSaldoTem\n", " SET CANTUSADA=ISNULL(CANTUSADA,0)+?, VALORUSADOMN=ISNULL(VALORUSADOMN,0)+?,\n", " VALORUSADOME=ISNULL(VALORUSADOME,0)+?, PESOUSADO=ISNULL(PESOUSADO,0)+?,\n", " PESOBRUTOUSADO=ISNULL(PESOBRUTOUSADO,0)+?\n", " WHERE FACTURAIMPO=? AND NUMPARTE=? AND UMEXITENCIA=?\"\"\"\n", "\n", - "def _build_descarga_df(modo_calc='STANDARD', facturas_df=None, partidas_df=None, prev_dict=None, progress=None):\n", + "def _build_descarga_df(modo_calc='STANDARD', facturas_df=None, partidas_df=None, prev_dict=None, progress=None, log=None):\n", " prog = _Progress(progress)\n", " _load_catalogos()\n", " saldos = _load_saldos_snapshot()\n", " f_use = facturas_df if facturas_df is not None else _state['df_facturas_NA']\n", " p_use = partidas_df if partidas_df is not None else _state['df_partidas_all']\n", + " # Si no se paso prev_dict, calcularlo ahora: descargas existentes para\n", + " # las facturas que estamos procesando. Asi siempre descontamos lo previo\n", + " # y NUNCA hay sobredescarga, sin importar si la factura es NA o AC.\n", + " if prev_dict is None:\n", + " try:\n", + " factura_list = f_use['FACTURAEXPO'].astype(str).str.strip().tolist()\n", + " except Exception:\n", + " factura_list = []\n", + " if factura_list:\n", + " prev_parts = []\n", + " for i in range(0, len(factura_list), 500):\n", + " chunk = factura_list[i:i+500]\n", + " ph = ','.join(['?']*len(chunk))\n", + " try:\n", + " prev_parts.append(pd.read_sql(f\"\"\"\n", + " SELECT FACTEXPO AS FACTURAEXPO, LINEAEXPO AS LINEA,\n", + " PARTEORIGINAL AS COMPONENTE_MP,\n", + " SUM(ISNULL(CANTDESC,0)) AS CANT_PREV\n", + " FROM SDescargaT WHERE FACTEXPO IN ({ph})\n", + " GROUP BY FACTEXPO, LINEAEXPO, PARTEORIGINAL\n", + " \"\"\", scaii_conn, params=chunk))\n", + " except Exception:\n", + " pass\n", + " df_prev = pd.concat(prev_parts, ignore_index=True) if prev_parts else pd.DataFrame()\n", + " if not df_prev.empty:\n", + " df_prev['LINEA'] = df_prev['LINEA'].astype(int)\n", + " df_prev['COMPONENTE_MP'] = df_prev['COMPONENTE_MP'].fillna('').astype(str).str.strip()\n", + " prev_dict = df_prev.set_index(['FACTURAEXPO','LINEA','COMPONENTE_MP'])['CANT_PREV'].to_dict()\n", + " fact_con_prev = df_prev['FACTURAEXPO'].nunique()\n", + " if log is not None and fact_con_prev > 0:\n", + " log(f' [INFO] Descargas previas detectadas en {fact_con_prev} factura(s); se descontaran del calculo (incremental).')\n", + " else:\n", + " prev_dict = {}\n", + " else:\n", + " prev_dict = {}\n", " b_all, s_all, pp_all = _state['df_boms_all'], _state['df_sustitutos_all'], _state['df_partepais_all']\n", " partidas_por_factura = p_use.groupby('FACTURAEXPO')\n", " boms_por_pt = b_all.groupby('PT')\n", @@ -15757,12 +15820,10 @@ " cant_req_total = (float(c['CANTIDAD']) / 100.0) * peso_neto\n", " else:\n", " cant_req_total = float(p['CANTEXPO']) * float(c['CANTIDAD'])\n", - " cant_prev = 0.0\n", - " if prev_dict is not None:\n", - " cant_prev = float(prev_dict.get((factura, linea, comp), 0.0))\n", + " cant_prev = float(prev_dict.get((factura, linea, comp), 0.0))\n", " cant_pend = cant_req_total - cant_prev\n", - " if prev_dict is not None and cant_pend <= 1e-9: continue\n", - " cant_req = cant_pend if prev_dict is not None else cant_req_total\n", + " if cant_pend <= 1e-9: continue\n", + " cant_req = cant_pend\n", " base_row = {'FACTURAEXPO': factura, 'FECHA_FACTURAEXPO': fecha_export, 'PEDIMENTOEXPO': pedimento_ex,\n", " 'LINEA': linea, 'PT': p['PT'], 'CANT_PT': float(p['CANTEXPO']),\n", " 'PESONETO_PARTIDA': peso_neto, 'PCT_BOM': float(c['CANTIDAD']),\n", @@ -15814,52 +15875,94 @@ " cob_fact['pct_promedio'] = cob_fact['pct_promedio'].round(2)\n", " return cob_fact\n", "\n", - "def calcular_pronostico_paso8(progress=None):\n", - " df = _build_descarga_df(modo_calc='STANDARD', progress=progress)\n", + "def calcular_pronostico_paso8(progress=None, log=print):\n", + " df = _build_descarga_df(modo_calc='STANDARD', progress=progress, log=log)\n", " cob = _build_cobertura(df)\n", " _state['df_descarga_all'] = df\n", " _state['cobertura_factura'] = cob\n", " return df, cob\n", "\n", - "def calcular_pronostico_paso12_kg(progress=None):\n", - " df = _build_descarga_df(modo_calc='KG_PCT', progress=progress)\n", + "def calcular_pronostico_paso12_kg(progress=None, log=print):\n", + " df = _build_descarga_df(modo_calc='KG_PCT', progress=progress, log=log)\n", " cob = _build_cobertura(df)\n", " _state['df_descarga_kg'] = df\n", " _state['cobertura_factura_kg']= cob\n", " return df, cob\n", "\n", - "def _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=None):\n", + "def _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=None, insert_sdm=False, aplica_desc_manual=False, paso_label=\"Descarga\", modo=\"NATURAL\"):\n", + " \"\"\"Inserta en SDescargaT y actualiza SSaldoTem por cada fila de df_ins.\n", + " Si insert_sdm=True, ademas inserta una fila espejo en SDescargaM\n", + " (formato como CTM): CONSECUTIVO secuencial, CONSECUTIVOEXPO = SFacExp.CONSECUTIVO,\n", + " PROCEDENCIA='TEM', TIPODESPERDICIO='N', FACTURA = FACTURAIMPO.\"\"\"\n", " prog = _Progress(progress)\n", " if df_ins.empty:\n", " log('Nada que insertar.'); prog.done('Nada que insertar'); return 0, 0, 0, []\n", " with scaii_conn.cursor() as cur:\n", " cur.execute(\"SELECT ISNULL(MAX(CONSECUTIVO),0) FROM SDescargaT\")\n", " next_consec = int(cur.fetchone()[0]) + 1\n", - " log(f'Proximo CONSECUTIVO: {next_consec}')\n", + " log(f'Proximo CONSECUTIVO SDescargaT: {next_consec}')\n", + " next_consec_m = 0\n", + " if insert_sdm and not dry_run:\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute(\"SELECT ISNULL(MAX(CONSECUTIVO),0) FROM SDescargaM\")\n", + " next_consec_m = int(cur.fetchone()[0]) + 1\n", + " log(f'Proximo CONSECUTIVO SDescargaM: {next_consec_m}')\n", + " INSERT_M_SQL = (\"INSERT INTO SDescargaM (CONSECUTIVO, CONSECUTIVOEXPO, FACTURAEXPO, LINEA, \"\n", + " \"NUMPARTE, CLASE, CANTIDAD, UNIMED, FACTURAIMPO, NUMPARTEMP, \"\n", + " \"VALORIMPOMN, VALORIMPOME, PAIS, PESONETO, PESOBRUTO, \"\n", + " \"TIPOFRACCION, SECTOR, FACTURADEF, PROCEDENCIA, FACTURA, \"\n", + " \"TIPODESPERDICIO, ORDENVENTA, TOMARSALDOBASEALPT) \"\n", + " \"VALUES (\" + ','.join(['?']*23) + \")\")\n", " grupos = list(df_ins.groupby('FACTURAEXPO'))\n", " prog.setup(len(grupos), 'Insertando facturas')\n", - " inserted, updated, saldos_upd, errores = 0, 0, 0, []\n", + " inserted, updated, saldos_upd, ins_m, errores = 0, 0, 0, 0, []\n", " for factura, df_f in grupos:\n", - " cb, ib, sb = next_consec, inserted, saldos_upd\n", + " cb, ib, sb, mb = next_consec, inserted, saldos_upd, ins_m\n", + " consec_fac = 0\n", + " if insert_sdm and not dry_run:\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute(\"SELECT CONSECUTIVO FROM SFacExp WHERE FACTURAEXPO=?\", (factura,))\n", + " _rf = cur.fetchone()\n", + " consec_fac = int(_rf[0]) if _rf and _rf[0] is not None else 0\n", " ok = True\n", " try:\n", " with scaii_conn.cursor() as cur:\n", " for _, r in df_f.iterrows():\n", " if not dry_run:\n", + " # Helpers locales para evitar NULLs: '' para strings, 0/0.0 para numericos.\n", + " def _ss(v):\n", + " r = _s(v); return '' if r is None else r\n", + " def _ff(v):\n", + " r = _f(v); return 0.0 if r is None else r\n", + " def _aa(v):\n", + " r = _adv(v); return 0 if r is None else r\n", + " _pt_val = r['PT'] if 'PT' in r and pd.notna(r.get('PT')) else ''\n", + " _np_val = r['NUMPARTE_USADO'] if pd.notna(r.get('NUMPARTE_USADO')) else ''\n", + " _comp_val = r['COMPONENTE_MP'] if pd.notna(r.get('COMPONENTE_MP')) else ''\n", + " _um_val = (r['UNIMED_SALDO'] if pd.notna(r.get('UNIMED_SALDO'))\n", + " else (r['UNIMED_BOM'] if pd.notna(r.get('UNIMED_BOM')) else ''))\n", + " _fec_val = int(r['FECHADESC_CLARION']) if pd.notna(r.get('FECHADESC_CLARION')) else 0\n", " cur.execute(INSERT_DESC_SQL, (\n", " next_consec, r['FACTURAEXPO'], r['FACTURAEXPO'],\n", - " r['FACTURAIMPO_SALDO'], _s(r['PEDIMENTOIMPO']),\n", - " _s(r['PEDIMENTOEXPO']), _s(r['CLASE']),\n", - " _f(r['VALORMN']), _f(r['VALORME']),\n", - " _f(r['PESONETO_DESC']), _f(r['PESONETO_DESC']),\n", - " _s(r['PAISMERCANCIA']),\n", - " int(r['FECHADESC_CLARION']) if pd.notna(r['FECHADESC_CLARION']) else None,\n", - " _s(r['TIPOFRACCION']), r['NUMPARTE_USADO'],\n", + " r['FACTURAIMPO_SALDO'], _ss(r['PEDIMENTOIMPO']),\n", + " _ss(r['PEDIMENTOEXPO']), _ss(r['CLASE']),\n", + " _ff(r['VALORMN']), _ff(r['VALORME']),\n", + " 0.0, 0.0, # VALORIVAMN, VALORIVAME\n", + " _ff(r['PESONETO_DESC']), _ff(r['PESONETO_DESC']),\n", + " _ss(r['PAISMERCANCIA']),\n", + " _fec_val, # FECHADESC\n", + " _ss(r['TIPOFRACCION']),\n", + " '', # SECTOR\n", + " _pt_val, # NUMPARTEEXPO = PT\n", + " _np_val,\n", " float(r['CANT_DESCARGADA']),\n", - " r['UNIMED_SALDO'] if pd.notna(r['UNIMED_SALDO']) else r['UNIMED_BOM'],\n", + " _um_val,\n", " int(r['LINEA']) if pd.notna(r['LINEA']) else 0,\n", - " r['COMPONENTE_MP'], 1, '',\n", - " _f(r['MONTOIGI']), _adv(r['ADVALOREMIMPO']), '0 PARTE'))\n", + " _comp_val, 1, '',\n", + " _ff(r['MONTOIGI']), _aa(r['ADVALOREMIMPO']), '0 PARTE',\n", + " '', '', '', '', # PAGOIMPUESTO, TIENECERT, ORDENVENTA, ACTUALREPARACION\n", + " 0, 0, '', # CONSECPARCIAL, LINEAEXPOREF, APARTADOCTM\n", + " '', 0.0)) # DESCARGASM (vacio), CANTIDADRETORNADASM\n", " cur.execute(UPDATE_SALDO_SQL, (\n", " float(r['CANT_DESCARGADA']),\n", " _f0(r['VALORMN']), _f0(r['VALORME']),\n", @@ -15867,19 +15970,59 @@ " r['FACTURAIMPO_SALDO'], r['NUMPARTE_USADO'],\n", " r['UNIMED_SALDO'] if pd.notna(r['UNIMED_SALDO']) else r['UNIMED_BOM']))\n", " saldos_upd += 1\n", + " if insert_sdm:\n", + " # Helpers locales para evitar NULLs (igual que en SDescargaT).\n", + " _ssm = lambda v: '' if _s(v) is None else _s(v)\n", + " _ffm = lambda v: 0.0 if _f(v) is None else _f(v)\n", + " _ptm = r['PT'] if 'PT' in r and pd.notna(r.get('PT')) else ''\n", + " _npm = r['NUMPARTE_USADO'] if pd.notna(r.get('NUMPARTE_USADO')) else ''\n", + " _compm = r['COMPONENTE_MP'] if pd.notna(r.get('COMPONENTE_MP')) else ''\n", + " _umm = (r['UNIMED_SALDO'] if pd.notna(r.get('UNIMED_SALDO'))\n", + " else (r['UNIMED_BOM'] if pd.notna(r.get('UNIMED_BOM')) else ''))\n", + " _factimpo_m = r['FACTURAIMPO_SALDO'] if pd.notna(r.get('FACTURAIMPO_SALDO')) else ''\n", + " cur.execute(INSERT_M_SQL, (\n", + " next_consec_m, consec_fac, r['FACTURAEXPO'],\n", + " int(r['LINEA']) if pd.notna(r['LINEA']) else 0,\n", + " _ptm, _ssm(r['CLASE']),\n", + " float(r['CANT_DESCARGADA']),\n", + " _umm,\n", + " _factimpo_m, _compm,\n", + " _ffm(r['VALORMN']), _ffm(r['VALORME']),\n", + " _ssm(r['PAISMERCANCIA']),\n", + " _ffm(r['PESONETO_DESC']), _ffm(r['PESONETO_DESC']),\n", + " _ssm(r['TIPOFRACCION']), '',\n", + " '', 'TEM', _factimpo_m, 'N', '', ''))\n", + " next_consec_m += 1\n", + " ins_m += 1\n", " next_consec += 1\n", " inserted += 1\n", - " if not dry_run and do_update_status:\n", - " cur.execute(UPDATE_FACEXP_SQL, (factura,))\n", + " if not dry_run:\n", + " # Siempre actualizamos SFacExp (NATURAL y DIRIGIDA) con los campos\n", + " # de auditoria. ESTATUS solo cambia cuando viene de NA (do_update_status=True).\n", + " _hoy_cl = to_clarion(_dt.date.today())\n", + " _hora_cl = _to_clarion_time()\n", + " _coment = f\"{paso_label} {modo} - Utilerias 2.0 {_dt.date.today().isoformat()}\"\n", + " _manual = 'S' if aplica_desc_manual else 'N'\n", + " sql_upd = _build_update_facexp_sql(\n", + " cambia_estatus=bool(do_update_status), manual=_manual)\n", + " if do_update_status:\n", + " params = (_manual, _hoy_cl, _hora_cl, _hoy_cl,\n", + " _coment, _coment, factura)\n", + " else:\n", + " params = (_manual, _hoy_cl, _hora_cl,\n", + " _coment, _coment, factura)\n", + " cur.execute(sql_upd, params)\n", " if not dry_run: scaii_conn.commit()\n", " except Exception as e:\n", " ok = False\n", " if not dry_run: scaii_conn.rollback()\n", - " next_consec, inserted, saldos_upd = cb, ib, sb\n", + " next_consec, inserted, saldos_upd, ins_m = cb, ib, sb, mb\n", " errores.append((factura, str(e)))\n", " if ok and do_update_status: updated += 1\n", " prog.step()\n", " prog.done(f'{inserted} insertados')\n", + " if insert_sdm:\n", + " log(f' SDescargaM insertadas (espejo modo DIRIGIDA): {ins_m}')\n", " return inserted, updated, saldos_upd, errores\n", "\n", "def _ejecutar_descarga_NA(modo, dry_run, fecha_desde, fecha_hasta, log, key_df, key_cob, paso_label, progress=None):\n", @@ -15905,7 +16048,11 @@ " mask = df_da['FACTURAEXPO'].isin(elig) & df_da['TIPO'].isin(['ORIGINAL','SUSTITUTO'])\n", " df_ins = df_da[mask].copy()\n", " log(f'Filas a insertar: {len(df_ins)}')\n", - " inserted, updated, saldos_upd, errores = _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=progress)\n", + " inserted, updated, saldos_upd, errores = _do_inserts(\n", + " df_ins, dry_run, log, do_update_status=True, progress=progress,\n", + " insert_sdm=(modo == 'DIRIGIDA'),\n", + " aplica_desc_manual=(modo == 'DIRIGIDA'),\n", + " paso_label=paso_label, modo=modo)\n", " log(f'\\n=== RESUMEN {paso_label} ({modo}, DRY_RUN={dry_run}) ===')\n", " log(f' Insertados : {inserted}')\n", " log(f' Updates SSaldoTem : {saldos_upd}')\n", @@ -15952,7 +16099,7 @@ " df_desc_prev['LINEA'] = df_desc_prev['LINEA'].astype(int)\n", " prev_dict = df_desc_prev.set_index(['FACTURAEXPO','LINEA','COMPONENTE_MP'])['CANT_PREV'].to_dict()\n", " _load_catalogos()\n", - " df_comp = _build_descarga_df(modo_calc=modo_calc, facturas_df=df_fact_ac, prev_dict=prev_dict, progress=progress)\n", + " df_comp = _build_descarga_df(modo_calc=modo_calc, facturas_df=df_fact_ac, prev_dict=prev_dict, progress=progress, log=log)\n", " log(f'Filas calculadas: {len(df_comp)}')\n", " if df_comp.empty: return\n", " if modo_comp == 'NATURAL':\n", @@ -15962,7 +16109,11 @@ " else:\n", " df_to_ins = df_comp[df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", " log(f'Filas a insertar: {len(df_to_ins)}')\n", - " inserted, _, saldos_upd, errores = _do_inserts(df_to_ins, dry_run, log, do_update_status=False, progress=progress)\n", + " inserted, _, saldos_upd, errores = _do_inserts(\n", + " df_to_ins, dry_run, log, do_update_status=False, progress=progress,\n", + " insert_sdm=(modo_comp == 'DIRIGIDA'),\n", + " aplica_desc_manual=(modo_comp == 'DIRIGIDA'),\n", + " paso_label=paso_label, modo=modo_comp)\n", " log(f'\\n=== RESUMEN {paso_label} ({modo_comp}, DRY_RUN={dry_run}) ===')\n", " log(f' Insertados : {inserted}')\n", " log(f' Updates SSaldoTem : {saldos_upd}')\n", @@ -16416,7 +16567,722 @@ " for f, e in errores[:5]: log(f' {f}: {e}')\n", "\n", "\n", - "# ─────────────────────────────────────────────────────────────────────────────\n", + "# =============================================================\n", + "# DESCARGAS UNO A UNO\n", + "# IMPO.NUMPARTE = EXPO.NUMPARTE (mismo SKU entra y sale)\n", + "# Match: NUMPARTE + FRACCION + UM.\n", + "# =============================================================\n", + "\n", + "import io as _io_u1\n", + "\n", + "def _listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin):\n", + " \"\"\"Trae partidas EXPO en el rango, excluyendo TIPOFACTURA='NODES'.\n", + " Incluye facturas NA y AC. Trae el CANT_PREV ya descargado por\n", + " (FACTURAEXPO, LINEA, NUMPARTE) para detectar las que no estan completas.\"\"\"\n", + " sql = \"\"\"\n", + " SELECT pe.FACTURAEXPO, pe.LINEA, pe.NUMPARTE,\n", + " pe.CANTEXPO, pe.UNIMED, pe.FRACCIONEXPO,\n", + " ISNULL(pe.PESONETO,0) AS PESONETO,\n", + " pe.MONTOIGIME AS MONTOIGI,\n", + " fe.FECHAFACTURA_ISO, fe.PEDIMENTOEXPO, fe.ESTATUS, fe.TIPOFACTURA\n", + " FROM SPartidasExpo pe\n", + " INNER JOIN SFacExp fe ON fe.FACTURAEXPO = pe.FACTURAEXPO\n", + " WHERE ISNULL(fe.TIPOFACTURA,'') <> 'NODES'\n", + " AND fe.FECHAFACTURA_ISO BETWEEN ? AND ?\n", + " ORDER BY fe.FECHAFACTURA_ISO, pe.FACTURAEXPO, pe.LINEA\n", + " \"\"\"\n", + " df_part = pd.read_sql(sql, scaii_conn, params=(fecha_ini, fecha_fin))\n", + " if df_part.empty:\n", + " return df_part\n", + " facturas = df_part['FACTURAEXPO'].unique().tolist()\n", + " prev_parts = []\n", + " for i in range(0, len(facturas), 500):\n", + " chunk = facturas[i:i+500]\n", + " ph = ','.join(['?']*len(chunk))\n", + " prev_parts.append(pd.read_sql(f\"\"\"\n", + " SELECT FACTEXPO AS FACTURAEXPO, LINEAEXPO AS LINEA, NUMPARTE,\n", + " SUM(ISNULL(CANTDESC,0)) AS CANT_PREV\n", + " FROM SDescargaT WHERE FACTEXPO IN ({ph})\n", + " GROUP BY FACTEXPO, LINEAEXPO, NUMPARTE\n", + " \"\"\", scaii_conn, params=chunk))\n", + " df_prev = pd.concat(prev_parts, ignore_index=True) if prev_parts else pd.DataFrame()\n", + " if not df_prev.empty:\n", + " df_prev['LINEA'] = df_prev['LINEA'].astype(int)\n", + " df_part['LINEA'] = df_part['LINEA'].astype(int)\n", + " if df_prev.empty:\n", + " df_part['CANT_PREV'] = 0.0\n", + " else:\n", + " df_part = df_part.merge(df_prev, on=['FACTURAEXPO','LINEA','NUMPARTE'], how='left')\n", + " df_part['CANT_PREV'] = df_part['CANT_PREV'].fillna(0.0)\n", + " df_part['CANT_FALTANTE'] = (df_part['CANTEXPO'].astype(float)\n", + " - df_part['CANT_PREV'].astype(float)).round(6)\n", + " return df_part\n", + "\n", + "\n", + "def listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin):\n", + " \"\"\"Vista para el usuario: lista partidas EXPO afectadas + resumen.\"\"\"\n", + " df = _listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin)\n", + " if df.empty:\n", + " return pd.DataFrame(), pd.DataFrame()\n", + " detalle = df[['FACTURAEXPO','LINEA','NUMPARTE','UNIMED','FRACCIONEXPO',\n", + " 'CANTEXPO','CANT_PREV','CANT_FALTANTE','FECHAFACTURA_ISO','ESTATUS']].copy()\n", + " resumen = (df.groupby(['ESTATUS'], as_index=False)\n", + " .agg(partidas=('LINEA','count'),\n", + " facturas=('FACTURAEXPO','nunique'),\n", + " cantexpo_total=('CANTEXPO','sum'),\n", + " faltante_total=('CANT_FALTANTE','sum')))\n", + " return detalle, resumen\n", + "\n", + "\n", + "def _cargar_saldos_vencidos_uno_a_uno(numpartes):\n", + " \"\"\"SSaldoTem vencidos (< hoy) y disponibles, filtrados a los NUMPARTES dados.\"\"\"\n", + " import datetime as _dt\n", + " hoy = _dt.date.today().isoformat()\n", + " nps = [str(n).strip() for n in numpartes if str(n).strip()]\n", + " if not nps:\n", + " return pd.DataFrame()\n", + " out = []\n", + " LOTE = 1000\n", + " for i in range(0, len(nps), LOTE):\n", + " chunk = nps[i:i+LOTE]\n", + " ph = ','.join(['?']*len(chunk))\n", + " out.append(pd.read_sql(f\"\"\"\n", + " SELECT NUMPARTE, FACTURAIMPO, PEDIMENTOIMPO, CLASE, PAISORIGEN, FRACCIONIMPO,\n", + " FECHAFACTURA_ISO AS FECHA_ENTRADA, FECHAVENC_ISO AS FECHA_EXPIRACION,\n", + " UMEXITENCIA AS UNIDAD_MEDIDA, CANTEXITENCIA AS CANT_LOTE_ORIG,\n", + " ISNULL(VALORIMPOMN,0) AS VALORIMPOMN,\n", + " ISNULL(VALORIMPOME,0) AS VALORIMPOME,\n", + " ISNULL(PESONETO,0) AS PESONETO_LOTE,\n", + " (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_DISPONIBLE\n", + " FROM SSaldoTem\n", + " WHERE NUMPARTE IN ({ph})\n", + " AND FECHAVENC_ISO IS NOT NULL\n", + " AND FECHAVENC_ISO < ?\n", + " AND (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " \"\"\", scaii_conn, params=tuple(chunk) + (hoy,)))\n", + " df = pd.concat(out, ignore_index=True) if out else pd.DataFrame()\n", + " if df.empty: return df\n", + " for c in ('NUMPARTE','FRACCIONIMPO','UNIDAD_MEDIDA'):\n", + " df[c] = df[c].fillna('').astype(str).str.strip()\n", + " return df\n", + "\n", + "\n", + "def analizar_descargas_uno_a_uno(fecha_ini, fecha_fin, progress=None, log=print):\n", + " \"\"\"Paso A: arma df_comp con la misma estructura que _build_descarga_df.\n", + " Devuelve (df_comp, resumen, inconsistencias).\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(3, 'Listando partidas EXPO...')\n", + " df_part = _listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin)\n", + " log(f'Partidas EXPO en rango (sin NODES): {len(df_part):,}')\n", + " if df_part.empty:\n", + " prog.done('Sin partidas'); return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n", + " # Solo las que aun tienen faltante\n", + " pendientes = df_part[df_part['CANT_FALTANTE'] > 1e-9].copy()\n", + " log(f'Partidas con faltante: {len(pendientes):,}')\n", + " if pendientes.empty:\n", + " prog.done('Sin pendientes'); return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n", + " prog.step(desc='Cargando saldos vencidos...')\n", + " numpartes = pendientes['NUMPARTE'].astype(str).str.strip().unique().tolist()\n", + " saldos = _cargar_saldos_vencidos_uno_a_uno(numpartes)\n", + " log(f'Saldos vencidos disponibles para esos NUMPARTES: {len(saldos):,}')\n", + " if saldos.empty:\n", + " prog.done('Sin saldos'); return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n", + " # Indexar saldos por NUMPARTE para acceso rapido\n", + " saldos = saldos.sort_values(['NUMPARTE','FECHA_ENTRADA']).reset_index(drop=True)\n", + " saldos_por_np = {k: list(g.index) for k, g in saldos.groupby('NUMPARTE')}\n", + "\n", + " prog.setup(len(pendientes), 'Procesando partidas...')\n", + " all_rows = []\n", + " inconsistencias = []\n", + " for _, p in pendientes.iterrows():\n", + " factura = p['FACTURAEXPO']\n", + " linea = int(p['LINEA'])\n", + " numparte = str(p['NUMPARTE']).strip()\n", + " unimed_e = str(p.get('UNIMED','') or '').strip().upper()\n", + " fracc_e = str(p.get('FRACCIONEXPO','') or '').strip()\n", + " falta = float(p['CANT_FALTANTE'])\n", + " base_row = {'FACTURAEXPO': factura, 'FECHA_FACTURAEXPO': p['FECHAFACTURA_ISO'],\n", + " 'PEDIMENTOEXPO': p['PEDIMENTOEXPO'],\n", + " 'LINEA': linea, 'PT': numparte, 'CANT_PT': float(p['CANTEXPO']),\n", + " 'PESONETO_PARTIDA': float(p['PESONETO']),\n", + " 'PCT_BOM': 100.0, 'MONTOIGI': p.get('MONTOIGI'),\n", + " 'COMPONENTE_MP': numparte, 'UNIMED_BOM': unimed_e,\n", + " 'CANT_REQUERIDA': falta, 'CANT_PREV': float(p['CANT_PREV'])}\n", + " idxs = saldos_por_np.get(numparte, [])\n", + " # Filtrar saldos con misma fraccion y misma UM\n", + " idxs_ok = [i for i in idxs\n", + " if saldos.at[i,'FRACCIONIMPO'] == fracc_e\n", + " and saldos.at[i,'UNIDAD_MEDIDA'].upper() == unimed_e]\n", + " if not idxs_ok:\n", + " inconsistencias.append({**{k: base_row[k] for k in ('FACTURAEXPO','LINEA')},\n", + " 'NUMPARTE': numparte, 'UNIMED': unimed_e, 'FRACCIONEXPO': fracc_e,\n", + " 'CANT_FALTANTE': falta,\n", + " 'STATUS': 'SIN_SALDO_COMPATIBLE',\n", + " 'DETALLE': 'No hay saldos vencidos con misma fraccion+UM'})\n", + " # Generar fila FALTANTE para que aparezca en df_comp\n", + " all_rows.append({**base_row, 'NUMPARTE_USADO': None, 'TIPO': 'FALTANTE',\n", + " 'FACTURAIMPO_SALDO': None, 'PEDIMENTOIMPO': None, 'CLASE': None,\n", + " 'PAISMERCANCIA': None, 'FRACCION_SALDO': None, 'UNIMED_SALDO': None,\n", + " 'FECHA_ENTRADA': None, 'FECHA_EXPIRACION': None, 'CANT_LOTE_ORIG': None,\n", + " 'CANT_DESCARGADA': falta, 'VALORMN': None, 'VALORME': None,\n", + " 'PESONETO_DESC': None, 'STATUS': 'FALTANTE'})\n", + " prog.step(); continue\n", + " # Consumir FIFO (saldos ya estan ordenados por FECHA_ENTRADA)\n", + " faltante, rows = _consumir(saldos, idxs_ok, falta, base_row, 'ORIGINAL', numparte)\n", + " all_rows.extend(rows)\n", + " if faltante > 1e-9:\n", + " inconsistencias.append({'FACTURAEXPO': factura, 'LINEA': linea,\n", + " 'NUMPARTE': numparte, 'UNIMED': unimed_e, 'FRACCIONEXPO': fracc_e,\n", + " 'CANT_REQUERIDA': falta,\n", + " 'CANT_DESCARGADA': falta - faltante, 'CANT_FALTANTE': faltante,\n", + " 'STATUS': 'SALDO_INSUFICIENTE',\n", + " 'DETALLE': f'Faltan {faltante:.4f} {unimed_e} sin cubrir'})\n", + " all_rows.append({**base_row, 'NUMPARTE_USADO': None, 'TIPO': 'FALTANTE',\n", + " 'FACTURAIMPO_SALDO': None, 'PEDIMENTOIMPO': None, 'CLASE': None,\n", + " 'PAISMERCANCIA': None, 'FRACCION_SALDO': None, 'UNIMED_SALDO': None,\n", + " 'FECHA_ENTRADA': None, 'FECHA_EXPIRACION': None, 'CANT_LOTE_ORIG': None,\n", + " 'CANT_DESCARGADA': faltante, 'VALORMN': None, 'VALORME': None,\n", + " 'PESONETO_DESC': None, 'STATUS': 'FALTANTE'})\n", + " prog.step()\n", + " df_comp = pd.DataFrame(all_rows)\n", + " if not df_comp.empty:\n", + " try:\n", + " _load_catalogos()\n", + " pp = _state.get('df_partepais_all', pd.DataFrame())\n", + " if not pp.empty:\n", + " pp_d = pp.drop_duplicates(subset=['FRACCION','PAIS'], keep='last') .set_index(['FRACCION','PAIS'])[['TIPOFRACCION','TASAIM']].to_dict('index')\n", + " def _pp(row):\n", + " info = pp_d.get((row['FRACCION_SALDO'], row['PAISMERCANCIA']))\n", + " if info is None: return pd.Series([None, None])\n", + " return pd.Series([info.get('TIPOFRACCION'), info.get('TASAIM')])\n", + " df_comp[['TIPOFRACCION','ADVALOREMIMPO']] = df_comp.apply(_pp, axis=1)\n", + " except Exception as e:\n", + " log(f' WARN catalogos: {e}')\n", + " df_comp['FECHADESC_CLARION'] = df_comp['FECHA_FACTURAEXPO'].apply(to_clarion)\n", + " resumen = pd.DataFrame()\n", + " if not df_comp.empty:\n", + " resumen = df_comp.groupby('TIPO').size().reset_index(name='filas')\n", + " prog.done('Analisis listo')\n", + " log(f'Filas calculadas: {len(df_comp):,} | Inconsistencias: {len(inconsistencias):,}')\n", + " return df_comp, resumen, pd.DataFrame(inconsistencias)\n", + "\n", + "\n", + "def ejecutar_descargas_uno_a_uno(df_comp, modo_comp='DIRIGIDA', dry_run=True,\n", + " progress=None, log=print):\n", + " \"\"\"Paso B: usa _do_inserts. No cambia ESTATUS (do_update_status=False) porque\n", + " mezcla facturas NA y AC; el usuario puede correr Paso 9 estandar despues si\n", + " quiere cerrar las NA que queden 100% cubiertas.\"\"\"\n", + " assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n", + " if df_comp is None or df_comp.empty:\n", + " log('ERROR: plan vacio.'); return\n", + " if modo_comp == 'NATURAL':\n", + " bad = set(df_comp[df_comp['TIPO']=='FALTANTE']['FACTURAEXPO'])\n", + " df_to_ins = df_comp[~df_comp['FACTURAEXPO'].isin(bad) & df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", + " log(f'Excluidas en NATURAL (con faltante): {len(bad)}')\n", + " else:\n", + " df_to_ins = df_comp[df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", + " log(f'Filas a insertar: {len(df_to_ins)}')\n", + " inserted, _, saldos_upd, errores = _do_inserts(\n", + " df_to_ins, dry_run, log, do_update_status=False, progress=progress,\n", + " insert_sdm=(modo_comp == 'DIRIGIDA'),\n", + " aplica_desc_manual=(modo_comp == 'DIRIGIDA'),\n", + " paso_label='descargas uno a uno', modo=modo_comp)\n", + " log(f'\\n=== RESUMEN Descargas Uno a Uno ({modo_comp}, DRY_RUN={dry_run}) ===')\n", + " log(f' Insertados : {inserted}')\n", + " log(f' Updates SSaldoTem : {saldos_upd}')\n", + " log(f' Errores : {len(errores)}')\n", + " for f, e in errores[:5]: log(f' {f}: {e}')\n", + "\n", + "\n", + "# =============================================================\n", + "# REINTEGRO DE DESCARGAS\n", + "# Reduce CANTDESC en SDescargaT y devuelve al saldo IMPO en SSaldoTem.\n", + "# =============================================================\n", + "\n", + "import io as _io_re\n", + "\n", + "def generar_plantilla_reintegro():\n", + " \"\"\"Plantilla inicial: NUMPARTE_MP, UNIMED, FRACCION.\"\"\"\n", + " df = pd.DataFrame([\n", + " {'NUMPARTE_MP': 'MP-ABC-001', 'UNIMED': 'KGS', 'FRACCION': '72082700'},\n", + " {'NUMPARTE_MP': 'MP-XYZ-002', 'UNIMED': 'PZA', 'FRACCION': '84149099'},\n", + " ])\n", + " buf = _io_re.BytesIO()\n", + " with pd.ExcelWriter(buf, engine='openpyxl') as w:\n", + " df.to_excel(w, sheet_name='Reintegro', index=False)\n", + " buf.seek(0)\n", + " return buf.read()\n", + "\n", + "\n", + "def cargar_excel_reintegro_inicial(path):\n", + " \"\"\"Lee Excel inicial: NUMPARTE_MP + UNIMED + FRACCION.\"\"\"\n", + " df = pd.read_excel(path, dtype=str)\n", + " norm = {c: c.strip().upper().replace(' ', '_') for c in df.columns}\n", + " df = df.rename(columns=norm)\n", + " aliases = {\n", + " 'NUMPARTE_MP': ['NUMPARTE_MP','NUMPARTE','NUM_PARTE','COMPONENTE'],\n", + " 'UNIMED': ['UNIMED','UM','UNIDAD','UNIDAD_DE_MEDIDA'],\n", + " 'FRACCION': ['FRACCION','FRACCION_ARANCELARIA','FRACC'],\n", + " }\n", + " out = {}\n", + " for std, opts in aliases.items():\n", + " for o in opts:\n", + " if o in df.columns:\n", + " out[std] = df[o]; break\n", + " if std not in out:\n", + " raise ValueError(f'Falta la columna {std} (acepta: {opts})')\n", + " df2 = pd.DataFrame(out)\n", + " for c in df2.columns:\n", + " df2[c] = df2[c].fillna('').astype(str).str.strip()\n", + " df2['UNIMED'] = df2['UNIMED'].str.upper()\n", + " return df2[df2['NUMPARTE_MP'] != ''].reset_index(drop=True)\n", + "\n", + "\n", + "def listar_partidas_para_reintegro(df_mp, fecha_ini, fecha_fin, progress=None, log=print):\n", + " \"\"\"Lista descargas existentes en SDescargaT cuyas PARTE_ORIGINAL coincide con\n", + " los MP del Excel, agrupado por (FACTURAEXPO, LINEA, NUMPARTE_MP) con el PT\n", + " correspondiente. Solo facturas en el rango de fechas.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(3, 'Buscando descargas...')\n", + " if df_mp is None or df_mp.empty:\n", + " log('Excel vacio.'); return pd.DataFrame()\n", + " mps = df_mp['NUMPARTE_MP'].astype(str).str.strip().unique().tolist()\n", + " if not mps:\n", + " log('Sin MPs.'); return pd.DataFrame()\n", + " LOTE = 1000\n", + " detalle = []\n", + " for i in range(0, len(mps), LOTE):\n", + " chunk = mps[i:i+LOTE]\n", + " ph = ','.join(['?'] * len(chunk))\n", + " try:\n", + " detalle.append(pd.read_sql(f\"\"\"\n", + " SELECT d.FACTEXPO AS FACTURAEXPO,\n", + " d.LINEAEXPO AS LINEA,\n", + " d.PARTEORIGINAL AS NUMPARTE_MP,\n", + " d.NUMPARTE AS NUMPARTE_USADO,\n", + " d.UNIMED AS UNIMED,\n", + " d.TIPOFRACCION AS FRACCION,\n", + " d.FACTIMPO,\n", + " d.PAISMERCANCIA,\n", + " SUM(ISNULL(d.CANTDESC,0)) AS CANTDESC_ACTUAL,\n", + " SUM(ISNULL(d.VALORMN,0)) AS VALORMN_ACTUAL,\n", + " SUM(ISNULL(d.VALORME,0)) AS VALORME_ACTUAL,\n", + " SUM(ISNULL(d.PESONETO,0)) AS PESONETO_ACTUAL,\n", + " SUM(ISNULL(d.PESOBRUTO,0)) AS PESOBRUTO_ACTUAL,\n", + " SUM(ISNULL(d.MONTOIGI,0)) AS MONTOIGI_ACTUAL,\n", + " COUNT(*) AS DESCARGAS,\n", + " f.FECHAFACTURA_ISO,\n", + " f.ESTATUS\n", + " FROM SDescargaT d\n", + " INNER JOIN SFacExp f ON f.FACTURAEXPO = d.FACTEXPO\n", + " WHERE d.PARTEORIGINAL IN ({ph})\n", + " AND f.FECHAFACTURA_ISO BETWEEN ? AND ?\n", + " AND ISNULL(d.CANTDESC,0) > 0\n", + " GROUP BY d.FACTEXPO, d.LINEAEXPO, d.PARTEORIGINAL, d.NUMPARTE, d.UNIMED,\n", + " d.TIPOFRACCION, d.FACTIMPO, d.PAISMERCANCIA, f.FECHAFACTURA_ISO, f.ESTATUS\n", + " ORDER BY f.FECHAFACTURA_ISO, d.FACTEXPO, d.LINEAEXPO\n", + " \"\"\", scaii_conn, params=tuple(chunk) + (fecha_ini, fecha_fin)))\n", + " except Exception as e:\n", + " log(f'WARN chunk MP: {e}')\n", + " df = pd.concat(detalle, ignore_index=True) if detalle else pd.DataFrame()\n", + " prog.step(desc=f'Descargas matched: {len(df)}')\n", + " if df.empty:\n", + " prog.done('Sin resultados'); return df\n", + " # Filtrar por UNIMED y FRACCION del Excel (si vienen)\n", + " df['UNIMED'] = df['UNIMED'].fillna('').astype(str).str.strip().str.upper()\n", + " df['FRACCION'] = df['FRACCION'].fillna('').astype(str).str.strip()\n", + " # join con df_mp para validar match\n", + " df_mp2 = df_mp.copy()\n", + " df_mp2.columns = [c.upper() for c in df_mp2.columns]\n", + " df_mp2['UNIMED'] = df_mp2['UNIMED'].str.upper()\n", + " df_mp2['FRACCION'] = df_mp2['FRACCION'].astype(str).str.strip()\n", + " # Solo conservamos los registros cuyo NUMPARTE_MP + UNIMED + FRACCION coincide\n", + " df = df.merge(df_mp2, on='NUMPARTE_MP', how='inner', suffixes=('','_xls'))\n", + " df = df[(df['UNIMED'] == df['UNIMED_xls']) & (df['FRACCION'] == df['FRACCION_xls'])]\n", + " df = df.drop(columns=['UNIMED_xls','FRACCION_xls']).reset_index(drop=True)\n", + " # Agregar columna vacia CANTDESC_NUEVA para que el usuario la llene\n", + " df['CANTDESC_NUEVA'] = ''\n", + " prog.done(f'{len(df)} partidas/MP afectadas')\n", + " return df\n", + "\n", + "\n", + "def exportar_excel_listado_reintegro(df, ruta):\n", + " if df is None or df.empty: return None\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as w:\n", + " df.to_excel(w, sheet_name='Reintegro_a_llenar', index=False)\n", + " return ruta\n", + "\n", + "\n", + "def cargar_excel_reintegro_lleno(path):\n", + " \"\"\"Excel con CANTDESC_NUEVA llena. Valida que sea > 0 y < CANTDESC_ACTUAL.\"\"\"\n", + " df = pd.read_excel(path, dtype=str)\n", + " norm = {c: c.strip().upper().replace(' ', '_') for c in df.columns}\n", + " df = df.rename(columns=norm)\n", + " required = ['FACTURAEXPO','LINEA','NUMPARTE_MP','CANTDESC_ACTUAL','CANTDESC_NUEVA']\n", + " missing = [c for c in required if c not in df.columns]\n", + " if missing:\n", + " raise ValueError(f'Faltan columnas: {missing}')\n", + " for c in ('FACTURAEXPO','NUMPARTE_MP'):\n", + " df[c] = df[c].fillna('').astype(str).str.strip()\n", + " df['LINEA'] = pd.to_numeric(df['LINEA'], errors='coerce').fillna(0).astype(int)\n", + " df['CANTDESC_ACTUAL'] = pd.to_numeric(df['CANTDESC_ACTUAL'], errors='coerce').fillna(0)\n", + " df['CANTDESC_NUEVA'] = pd.to_numeric(df['CANTDESC_NUEVA'], errors='coerce')\n", + " df = df[df['CANTDESC_NUEVA'].notna()].reset_index(drop=True)\n", + " df = df[df['FACTURAEXPO'] != ''].reset_index(drop=True)\n", + " return df\n", + "\n", + "\n", + "def analizar_reintegro(df_xls, progress=None, log=print):\n", + " \"\"\"Para cada fila del Excel:\n", + " 1. Valida CANTDESC_NUEVA > 0 y < CANTDESC_ACTUAL.\n", + " 2. Toma todas las filas SDescargaT actuales para (FACTURAEXPO, LINEA, NUMPARTE_MP).\n", + " 3. Distribuye la reduccion proporcional al CANTDESC de cada fila.\n", + " 4. Calcula proporciones de VALORMN/VALORME/PESONETO/PESOBRUTO/MONTOIGI a devolver.\n", + " Devuelve (plan, inconsistencias).\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " if df_xls is None or df_xls.empty:\n", + " log('Excel lleno vacio.'); return pd.DataFrame(), pd.DataFrame()\n", + " prog.setup(len(df_xls), 'Analizando filas...')\n", + " plan_rows = []\n", + " incons = []\n", + " for _, r in df_xls.iterrows():\n", + " factura = r['FACTURAEXPO']\n", + " linea = int(r['LINEA'])\n", + " mp = r['NUMPARTE_MP']\n", + " actual = float(r['CANTDESC_ACTUAL'])\n", + " nueva = float(r['CANTDESC_NUEVA'])\n", + " if nueva <= 0:\n", + " incons.append({**r.to_dict(), 'STATUS': 'NUEVA_INVALIDA',\n", + " 'DETALLE': 'CANTDESC_NUEVA debe ser mayor que 0'})\n", + " prog.step(); continue\n", + " if nueva >= actual:\n", + " incons.append({**r.to_dict(), 'STATUS': 'NUEVA_NO_REDUCE',\n", + " 'DETALLE': 'CANTDESC_NUEVA debe ser menor que CANTDESC_ACTUAL'})\n", + " prog.step(); continue\n", + " # Cargar las descargas individuales para esa combinacion\n", + " try:\n", + " df_d = pd.read_sql(\"\"\"\n", + " SELECT CONSECUTIVO, FACTIMPO, UNIMED, NUMPARTE,\n", + " ISNULL(CANTDESC,0) AS CANTDESC,\n", + " ISNULL(VALORMN,0) AS VALORMN,\n", + " ISNULL(VALORME,0) AS VALORME,\n", + " ISNULL(PESONETO,0) AS PESONETO,\n", + " ISNULL(PESOBRUTO,0) AS PESOBRUTO,\n", + " ISNULL(MONTOIGI,0) AS MONTOIGI\n", + " FROM SDescargaT\n", + " WHERE FACTEXPO=? AND LINEAEXPO=? AND PARTEORIGINAL=?\n", + " AND ISNULL(CANTDESC,0) > 0\n", + " ORDER BY CONSECUTIVO\n", + " \"\"\", scaii_conn, params=(factura, linea, mp))\n", + " except Exception as e:\n", + " incons.append({**r.to_dict(), 'STATUS': 'ERROR_QUERY',\n", + " 'DETALLE': str(e)})\n", + " prog.step(); continue\n", + " if df_d.empty:\n", + " incons.append({**r.to_dict(), 'STATUS': 'SIN_DESCARGAS',\n", + " 'DETALLE': 'No hay descargas vigentes con esa combinacion'})\n", + " prog.step(); continue\n", + " suma_actual = float(df_d['CANTDESC'].sum())\n", + " if abs(suma_actual - actual) > 0.01:\n", + " incons.append({**r.to_dict(), 'STATUS': 'DESACTUALIZADO',\n", + " 'DETALLE': f'Suma actual en BD={suma_actual} no coincide con CANTDESC_ACTUAL del Excel'})\n", + " prog.step(); continue\n", + " reduccion_total = actual - nueva\n", + " # Distribuir proporcional al CANTDESC de cada fila\n", + " for _, d in df_d.iterrows():\n", + " prop = float(d['CANTDESC']) / suma_actual if suma_actual > 1e-9 else 0\n", + " reduccion_fila = round(reduccion_total * prop, 6)\n", + " plan_rows.append({\n", + " 'FACTURAEXPO': factura, 'LINEA': linea, 'NUMPARTE_MP': mp,\n", + " 'CONSECUTIVO': int(d['CONSECUTIVO']),\n", + " 'FACTIMPO': d['FACTIMPO'], 'UNIMED': d['UNIMED'],\n", + " 'NUMPARTE_DESC': d['NUMPARTE'],\n", + " 'CANTDESC_ACTUAL': float(d['CANTDESC']),\n", + " 'CANTDESC_NUEVA': round(float(d['CANTDESC']) - reduccion_fila, 6),\n", + " 'REDUCCION_CANT': reduccion_fila,\n", + " 'DEVOLVER_VALORMN': round(float(d['VALORMN']) * prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_VALORME': round(float(d['VALORME']) * prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_PESONETO': round(float(d['PESONETO']) * prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_PESOBRUTO': round(float(d['PESOBRUTO'])* prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_MONTOIGI': round(float(d['MONTOIGI']) * prop * (reduccion_total/suma_actual), 6),\n", + " })\n", + " prog.step()\n", + " prog.done(f'Plan: {len(plan_rows)} filas | Inconsistencias: {len(incons)}')\n", + " return pd.DataFrame(plan_rows), pd.DataFrame(incons)\n", + "\n", + "\n", + "def ejecutar_reintegro(plan, dry_run=True, ajustar_sdm=False, progress=None, log=print):\n", + " \"\"\"Paso B: aplica los UPDATEs a SDescargaT y SSaldoTem. Opcionalmente\n", + " ajusta tambien SDescargaM (espejo).\"\"\"\n", + " assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n", + " if plan is None or plan.empty:\n", + " log('ERROR: plan vacio.'); return\n", + " prog = _Progress(progress)\n", + " UPD_DESC = \"\"\"UPDATE SDescargaT\n", + " SET CANTDESC = ?,\n", + " VALORMN = ISNULL(VALORMN,0) - ?,\n", + " VALORME = ISNULL(VALORME,0) - ?,\n", + " PESONETO = ISNULL(PESONETO,0) - ?,\n", + " PESOBRUTO= ISNULL(PESOBRUTO,0)- ?,\n", + " MONTOIGI = ISNULL(MONTOIGI,0) - ?\n", + " WHERE CONSECUTIVO = ?\"\"\"\n", + " UPD_SAL = \"\"\"UPDATE SSaldoTem\n", + " SET CANTUSADA = ISNULL(CANTUSADA,0) - ?,\n", + " VALORUSADOMN = ISNULL(VALORUSADOMN,0) - ?,\n", + " VALORUSADOME = ISNULL(VALORUSADOME,0) - ?,\n", + " PESOUSADO = ISNULL(PESOUSADO,0) - ?,\n", + " PESOBRUTOUSADO = ISNULL(PESOBRUTOUSADO,0) - ?\n", + " WHERE FACTURAIMPO=? AND NUMPARTE=? AND UMEXITENCIA=?\"\"\"\n", + " UPD_SDM = \"\"\"UPDATE SDescargaM\n", + " SET CANTIDAD = ISNULL(CANTIDAD,0) - ?,\n", + " VALORIMPOMN = ISNULL(VALORIMPOMN,0) - ?,\n", + " VALORIMPOME = ISNULL(VALORIMPOME,0) - ?,\n", + " PESONETO = ISNULL(PESONETO,0) - ?,\n", + " PESOBRUTO = ISNULL(PESOBRUTO,0) - ?\n", + " WHERE FACTURAEXPO=? AND LINEA=? AND NUMPARTEMP=? AND FACTURAIMPO=?\"\"\"\n", + " prog.setup(len(plan), 'Aplicando reintegro')\n", + " upd_t = upd_s = upd_m = errores = 0\n", + " err_list = []\n", + " # Para agrupar updates a SSaldoTem (suma de devoluciones por FACTIMPO+NUMPARTE+UNIMED)\n", + " # Lo hacemos por fila para mantener consistencia y rollback fino.\n", + " for _, p in plan.iterrows():\n", + " try:\n", + " with scaii_conn.cursor() as cur:\n", + " if not dry_run:\n", + " # 1) SDescargaT: poner CANTDESC al nuevo valor y restar proporcionales\n", + " cur.execute(UPD_DESC,\n", + " float(p['CANTDESC_NUEVA']),\n", + " float(p['DEVOLVER_VALORMN']), float(p['DEVOLVER_VALORME']),\n", + " float(p['DEVOLVER_PESONETO']), float(p['DEVOLVER_PESOBRUTO']),\n", + " float(p['DEVOLVER_MONTOIGI']),\n", + " int(p['CONSECUTIVO']))\n", + " upd_t += 1\n", + " # 2) SSaldoTem: devolver al saldo IMPO\n", + " cur.execute(UPD_SAL,\n", + " float(p['REDUCCION_CANT']),\n", + " float(p['DEVOLVER_VALORMN']), float(p['DEVOLVER_VALORME']),\n", + " float(p['DEVOLVER_PESONETO']), float(p['DEVOLVER_PESOBRUTO']),\n", + " p['FACTIMPO'], p['NUMPARTE_DESC'], p['UNIMED'])\n", + " upd_s += 1\n", + " # 3) SDescargaM (opcional)\n", + " if ajustar_sdm:\n", + " cur.execute(UPD_SDM,\n", + " float(p['REDUCCION_CANT']),\n", + " float(p['DEVOLVER_VALORMN']), float(p['DEVOLVER_VALORME']),\n", + " float(p['DEVOLVER_PESONETO']), float(p['DEVOLVER_PESOBRUTO']),\n", + " p['FACTURAEXPO'], int(p['LINEA']), p['NUMPARTE_MP'], p['FACTIMPO'])\n", + " upd_m += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0\n", + " else:\n", + " upd_t += 1; upd_s += 1\n", + " if ajustar_sdm: upd_m += 1\n", + " if not dry_run: scaii_conn.commit()\n", + " except Exception as e:\n", + " if not dry_run: scaii_conn.rollback()\n", + " errores += 1\n", + " etq = f\"{p['FACTURAEXPO']}/{p['LINEA']}/{p['NUMPARTE_MP']}/CONSEC {p['CONSECUTIVO']}\"\n", + " err_list.append((etq, str(e)))\n", + " log(f' ERROR {etq}: {e}')\n", + " prog.step()\n", + " prog.done(f'{upd_t} desc / {upd_s} saldos / {upd_m} sdm')\n", + " log(f'\\n=== RESUMEN Reintegro (DRY_RUN={dry_run}, ajustar_sdm={ajustar_sdm}) ===')\n", + " log(f' SDescargaT actualizadas: {upd_t:,}')\n", + " log(f' SSaldoTem actualizadas: {upd_s:,}')\n", + " log(f' SDescargaM actualizadas: {upd_m:,}')\n", + " log(f' Errores : {errores:,}')\n", + " for f, e in err_list[:5]: log(f' {f}: {e}')\n", + "\n", + "\n", + "# =============================================================\n", + "# DIAGNOSTICO PASO 12 (% KGS)\n", + "# Lista facturas EXPO pendientes (NA) y explica por que el Paso 12\n", + "# no las pudo descargar (en este momento). Solo lectura.\n", + "# =============================================================\n", + "\n", + "def diagnosticar_paso12_pendientes(fecha_ini=None, fecha_fin=None,\n", + " progress=None, log=print):\n", + " \"\"\"Para cada factura EXPO en NA (opcionalmente filtrada por rango de\n", + " FECHAFACTURA_ISO) revisa por que no se pudo descargar con el flujo\n", + " KG_PCT del Paso 12. Devuelve dos DataFrames: bloqueos por factura y\n", + " detalle por componente faltante.\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(5, 'Cargando facturas NA...')\n", + " # 1) Facturas NA\n", + " sql_fact = (\"SELECT FACTURAEXPO, PEDIMENTOEXPO, FECHAFACTURA_ISO, \"\n", + " \"ISNULL(TIPOFACTURA,'') AS TIPOFACTURA \"\n", + " \"FROM SFacExp WHERE ESTATUS = 'NA'\")\n", + " params = []\n", + " if fecha_ini:\n", + " sql_fact += ' AND FECHAFACTURA_ISO >= ?'; params.append(fecha_ini)\n", + " if fecha_fin:\n", + " sql_fact += ' AND FECHAFACTURA_ISO <= ?'; params.append(fecha_fin)\n", + " sql_fact += ' ORDER BY FECHAFACTURA_ISO'\n", + " df_fact = pd.read_sql(sql_fact, scaii_conn, params=params)\n", + " log(f'Facturas NA en rango: {len(df_fact)}')\n", + " if df_fact.empty:\n", + " prog.done('Sin facturas NA'); return pd.DataFrame(), pd.DataFrame()\n", + "\n", + " # 2) Cargar catalogos (PT con BOM, partepais, etc.)\n", + " prog.step(desc='Cargando catalogos...')\n", + " _load_catalogos()\n", + " df_part = _state.get('df_partidas_all', pd.DataFrame())\n", + " df_bom = _state.get('df_boms_all', pd.DataFrame())\n", + " saldos = _load_saldos_snapshot()\n", + "\n", + " if df_part.empty:\n", + " log('Sin SPartidasExpo en estado.'); df_part = pd.DataFrame()\n", + " if df_bom.empty:\n", + " log('Sin SMatBOM en estado.'); df_bom = pd.DataFrame()\n", + "\n", + " pts_con_bom = set(df_bom['PT'].astype(str).str.strip().unique().tolist()) if not df_bom.empty else set()\n", + " partidas_por_factura = (df_part.groupby('FACTURAEXPO')\n", + " if not df_part.empty else None)\n", + "\n", + " # 3) Saldos disponibles agrupados por (NUMPARTE, UM_KEY)\n", + " saldos_disp = saldos.groupby(['NUMPARTE','UM_KEY'])['SALDO_DISPONIBLE'].sum().to_dict() if not saldos.empty else {}\n", + " # 3.1) Mapa de sustitutos {NUMPARTE: [(alt, [um1,um2]), ...]} para emular\n", + " # el flujo real del Paso 12 (original primero, luego sustitutos).\n", + " df_sub = _state.get('df_sustitutos_all', pd.DataFrame())\n", + " sust_map = {}\n", + " if not df_sub.empty:\n", + " for _, r in df_sub.iterrows():\n", + " k = str(r['NUMPARTE']).strip()\n", + " alt = str(r['COMPONENTE_ALTERNO']).strip()\n", + " um_keys = [str(u).strip().upper() for u in [r.get('UNIDADMEDIDA1'), r.get('UNIDADMEDIDA2')] if u and str(u).strip()]\n", + " if k and alt and um_keys:\n", + " sust_map.setdefault(k, []).append((alt, um_keys))\n", + "\n", + " bloqueos = [] # uno por factura con su motivo principal\n", + " componentes = [] # detalle de cada componente faltante / problematico\n", + "\n", + " prog.setup(len(df_fact), 'Analizando facturas...')\n", + " for _, f in df_fact.iterrows():\n", + " factura = f['FACTURAEXPO']\n", + " tipof = (f['TIPOFACTURA'] or '').strip().upper()\n", + " fecha = f['FECHAFACTURA_ISO']\n", + " ped = f['PEDIMENTOEXPO']\n", + " # 3a) TIPOFACTURA NODES\n", + " if tipof == 'NODES':\n", + " bloqueos.append({'FACTURAEXPO': factura, 'PEDIMENTOEXPO': ped,\n", + " 'FECHAFACTURA_ISO': fecha, 'TIPOFACTURA': tipof,\n", + " 'PARTIDAS': 0, 'PT_SIN_BOM': 0, 'PESONETO_CERO': 0,\n", + " 'COMPONENTES_SIN_SALDO': 0,\n", + " 'MOTIVO_PRINCIPAL': 'TIPOFACTURA_NODES',\n", + " 'DETALLE': 'Las facturas NODES estan excluidas del Paso 12.'})\n", + " prog.step(); continue\n", + " # 3b) Sin partidas\n", + " if partidas_por_factura is None or factura not in partidas_por_factura.groups:\n", + " bloqueos.append({'FACTURAEXPO': factura, 'PEDIMENTOEXPO': ped,\n", + " 'FECHAFACTURA_ISO': fecha, 'TIPOFACTURA': tipof,\n", + " 'PARTIDAS': 0, 'PT_SIN_BOM': 0, 'PESONETO_CERO': 0,\n", + " 'COMPONENTES_SIN_SALDO': 0,\n", + " 'MOTIVO_PRINCIPAL': 'SIN_PARTIDAS',\n", + " 'DETALLE': 'La factura no tiene partidas EXPO cargadas.'})\n", + " prog.step(); continue\n", + " df_p = partidas_por_factura.get_group(factura)\n", + " n_partidas = len(df_p)\n", + " n_pt_sin_bom = 0\n", + " n_peso_cero = 0\n", + " n_comp_sin_saldo = 0\n", + " problemas = []\n", + " for _, p in df_p.iterrows():\n", + " pt = str(p['PT']).strip()\n", + " linea = int(p['LINEA']) if pd.notna(p['LINEA']) else 0\n", + " peso = float(p.get('PESONETO', 0) or 0)\n", + " if pt not in pts_con_bom:\n", + " n_pt_sin_bom += 1\n", + " componentes.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n", + " 'COMPONENTE': None, 'UNIMED': None,\n", + " 'CANT_REQUERIDA': 0, 'SALDO_DISPONIBLE': 0,\n", + " 'MOTIVO': 'PT_SIN_BOM',\n", + " 'DETALLE': f'El producto terminado {pt} no tiene BOM en SMatBOM.'})\n", + " problemas.append('PT_SIN_BOM')\n", + " continue\n", + " if peso <= 1e-9:\n", + " n_peso_cero += 1\n", + " componentes.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n", + " 'COMPONENTE': None, 'UNIMED': None,\n", + " 'CANT_REQUERIDA': 0, 'SALDO_DISPONIBLE': 0,\n", + " 'MOTIVO': 'PESONETO_CERO',\n", + " 'DETALLE': 'PESONETO=0 en la partida. El modo KG_PCT calcula cant_req=0 y no descarga.'})\n", + " problemas.append('PESONETO_CERO')\n", + " continue\n", + " # 3c) Revisar cada componente del BOM\n", + " df_b = df_bom[df_bom['PT'] == pt]\n", + " for _, c in df_b.iterrows():\n", + " comp = str(c['COMPONENTE_MP']).strip()\n", + " unimed = (c['UNIMED'] or '').strip().upper()\n", + " pct = float(c['CANTIDAD'] or 0)\n", + " cant_req = (pct / 100.0) * peso\n", + " if cant_req <= 1e-9:\n", + " continue\n", + " # Saldo del ORIGINAL: cualquier UM (replica _consumir con UM del BOM,\n", + " # aqui agregamos por NUMPARTE sin filtrar UM para no perder casos validos).\n", + " disp_orig = sum(v for (np_, um), v in saldos_disp.items() if np_ == comp)\n", + " # Saldo de SUSTITUTOS: solo si UM en UNIDADMEDIDA1/2 del registro de sust.\n", + " disp_sust = 0.0\n", + " lista_alt = []\n", + " for alt, um_keys in sust_map.get(comp, []):\n", + " s_alt = sum(v for (np_, um), v in saldos_disp.items()\n", + " if np_ == alt and um in um_keys)\n", + " if s_alt > 0:\n", + " disp_sust += s_alt\n", + " lista_alt.append(f'{alt}({s_alt:.2f})')\n", + " disp = disp_orig + disp_sust\n", + " if disp < cant_req:\n", + " n_comp_sin_saldo += 1\n", + " extra = f' | orig={disp_orig:.2f} sust={disp_sust:.2f}'\n", + " if lista_alt: extra += ' [' + ', '.join(lista_alt[:3]) + (']' if len(lista_alt)<=3 else ' ...]')\n", + " componentes.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n", + " 'COMPONENTE': comp, 'UNIMED': unimed,\n", + " 'CANT_REQUERIDA': round(cant_req, 6),\n", + " 'SALDO_DISPONIBLE': round(disp, 6),\n", + " 'SALDO_ORIGINAL': round(disp_orig, 6),\n", + " 'SALDO_SUSTITUTOS': round(disp_sust, 6),\n", + " 'N_SUSTITUTOS_CON_SALDO': len(lista_alt),\n", + " 'MOTIVO': 'COMPONENTE_SIN_SALDO',\n", + " 'DETALLE': f'Faltan {cant_req - disp:.4f} {unimed} (incluyendo sustitutos).{extra}'})\n", + " problemas.append('COMPONENTE_SIN_SALDO')\n", + " # 3d) Motivo principal\n", + " if not problemas:\n", + " motivo = 'COBERTURA_POSIBLE'\n", + " detalle = 'La factura tiene cobertura completa segun el snapshot actual. Si el Paso 12 no la pudo procesar revisa el modo NATURAL/DIRIGIDA o si hay descargas previas que la dejaron en un estado intermedio.'\n", + " else:\n", + " from collections import Counter\n", + " counter = Counter(problemas)\n", + " motivo = counter.most_common(1)[0][0]\n", + " detalle = ', '.join(f'{k}:{v}' for k, v in counter.most_common())\n", + " bloqueos.append({'FACTURAEXPO': factura, 'PEDIMENTOEXPO': ped,\n", + " 'FECHAFACTURA_ISO': fecha, 'TIPOFACTURA': tipof,\n", + " 'PARTIDAS': n_partidas, 'PT_SIN_BOM': n_pt_sin_bom,\n", + " 'PESONETO_CERO': n_peso_cero,\n", + " 'COMPONENTES_SIN_SALDO': n_comp_sin_saldo,\n", + " 'MOTIVO_PRINCIPAL': motivo,\n", + " 'DETALLE': detalle})\n", + " prog.step()\n", + " prog.done(f'{len(bloqueos)} facturas analizadas')\n", + " df_bloq = pd.DataFrame(bloqueos)\n", + " df_comp = pd.DataFrame(componentes)\n", + " # Resumen por motivo\n", + " if not df_bloq.empty:\n", + " res_motivo = df_bloq['MOTIVO_PRINCIPAL'].value_counts().reset_index()\n", + " res_motivo.columns = ['MOTIVO', 'FACTURAS']\n", + " log('\\n=== Resumen por motivo principal ===')\n", + " for _, row in res_motivo.iterrows():\n", + " log(f' {row[\"MOTIVO\"]:<30} {row[\"FACTURAS\"]:>5,}')\n", + " return df_bloq, df_comp\n", + "\n", + "\n", + "def exportar_excel_diagnostico_paso12(df_bloq, df_comp, ruta):\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as w:\n", + " if df_bloq is not None and not df_bloq.empty:\n", + " df_bloq.to_excel(w, sheet_name='Bloqueos_por_factura', index=False)\n", + " if df_comp is not None and not df_comp.empty:\n", + " df_comp.to_excel(w, sheet_name='Detalle_componentes', index=False)\n", + " return ruta\n", + "\n", + "\n", "# DESCARGA POR FRACCIÓN + UM\n", "# Match: LEFT(FRACCIONIMPO, 8) = LEFT(FRACCIONEXPO, 8) AND UMEXITENCIA = UNIMED\n", "# Sin BOM — cantidad directamente de CANTEXPO en SPartidasExpo.\n", @@ -16638,6 +17504,41 @@ }, "outputs": [], "source": [ + "def _fmt_an_df(df):\n", + " \"\"\"Formatea las tablas con separador de miles, decimales y moneda.\n", + " Solo aplica formato numerico a columnas numericas (evita ValueError en strings).\n", + " Devuelve un pandas Styler (visual) sin alterar el DataFrame original.\"\"\"\n", + " if df is None or getattr(df, 'empty', True):\n", + " return df\n", + " fmt = {}\n", + " for col in df.columns:\n", + " # Solo formatear columnas numericas\n", + " if not pd.api.types.is_numeric_dtype(df[col]):\n", + " continue\n", + " c = str(col).lower()\n", + " # Anios y lineas: enteros sin coma (decoracion)\n", + " if c in ('anio', 'anio_entrada', 'linea'):\n", + " fmt[col] = '{:.0f}'\n", + " continue\n", + " if c.startswith('ratio'):\n", + " fmt[col] = '{:,.4f}'\n", + " elif any(k in c for k in ('vmn', 'vme', 'valor', 'dif_valor')):\n", + " fmt[col] = '${:,.2f}'\n", + " elif any(k in c for k in ('partidas', 'partes', 'lotes', 'unicas', 'n_sustitutos', 'pts_', 'relaciones', 'componentes_distintos', 'n_sustitutos_con_saldo')):\n", + " fmt[col] = '{:,.0f}'\n", + " else:\n", + " fmt[col] = '{:,.2f}'\n", + " try:\n", + " sty = df.style.format(fmt, na_rep='-')\n", + " try:\n", + " sty = sty.hide(axis='index')\n", + " except Exception:\n", + " try: sty = sty.hide_index()\n", + " except Exception: pass\n", + " return sty\n", + " except Exception:\n", + " return df\n", + "\n", "def cargar_analisis_saldos(progress=None):\n", " prog = _Progress(progress)\n", " prog.setup(4, 'Cargando SSaldoTem...')\n", @@ -17527,13 +18428,16 @@ "\n", " # SOLO INSERT espejo en SDescargaM. SDescargaT no se toca.\n", " if not dry_run:\n", + " # Defaults: '' para strings, 0/0.0 para numericos.\n", + " _x = lambda v, d='': (d if (v is None or (isinstance(v, float) and pd.isna(v))) else v)\n", + " _xn = lambda v: 0.0 if (v is None or (isinstance(v, float) and pd.isna(v))) else float(v)\n", " cur.execute(INSERT_M,\n", " next_m, consec_factura_ctm, factura_ctm, linea_ctm,\n", - " numparte_pt_ctm, r.CLASE, m_cantidad, r.UNIMED,\n", - " r.FACTIMPO, r.PARTEORIGINAL,\n", - " m_vmn, m_vme, r.PAISMERCANCIA, m_pneto, m_pbruto,\n", - " r.TIPOFRACCION, r.SECTOR,\n", - " '', 'TEM', r.FACTIMPO, 'N', r.ORDENVENTA, '')\n", + " _x(numparte_pt_ctm), _x(r.CLASE), _xn(m_cantidad), _x(r.UNIMED),\n", + " _x(r.FACTIMPO), _x(r.PARTEORIGINAL),\n", + " _xn(m_vmn), _xn(m_vme), _x(r.PAISMERCANCIA), _xn(m_pneto), _xn(m_pbruto),\n", + " _x(r.TIPOFRACCION), _x(r.SECTOR),\n", + " '', 'TEM', _x(r.FACTIMPO), 'N', _x(r.ORDENVENTA), '')\n", " next_m += 1; ins_m += 1\n", "\n", " if not dry_run:\n", @@ -17554,9 +18458,7 @@ " log(f' SDescargaT NO se modifica (solo se usa como referencia)')\n", " log(f' Errores : {len(errores):,}')\n", " for f, e in errores[:5]:\n", - " log(f' {f}: {e}')\n", - "\n", - "\n" + " log(f' {f}: {e}')" ] }, { @@ -18110,6 +19012,223 @@ " return fig" ] }, + { + "cell_type": "code", + "id": "logic-bom", + "metadata": {}, + "outputs": [], + "execution_count": null, + "source": [ + "# ============================================================================\n", + "# Logic: Analisis BOM + Sustitutos + Saldos (vigentes/vencidos)\n", + "# Pestana general - muestra composicion de PT, sus sustitutos y disponibilidad\n", + "# ============================================================================\n", + "\n", + "def cargar_bom_completo(progress=None):\n", + " \"\"\"Trae todo SMatBOM con info del PT (tipo de material si esta en SPartes).\n", + " Una fila = un componente de un PT.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(2, 'Cargando SMatBOM...')\n", + " df = pd.read_sql(\"\"\"\n", + " SELECT B.NUMPARTE AS PT,\n", + " B.NUMPARTEBOM AS COMPONENTE,\n", + " B.CANTIDAD AS CANTIDAD_BOM,\n", + " B.UNIMED AS UNIMED_BOM,\n", + " P.TIPOMAT AS TIPOMAT_PT,\n", + " P.DESCRIPCIONE AS DESC_PT\n", + " FROM SMatBOM B\n", + " LEFT JOIN SPartes P ON P.NUMPARTE = B.NUMPARTE\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='SMatBOM cargado')\n", + " for c in ('PT', 'COMPONENTE', 'UNIMED_BOM', 'TIPOMAT_PT', 'DESC_PT'):\n", + " df[c] = df[c].fillna('').astype(str).str.strip()\n", + " df['CANTIDAD_BOM'] = pd.to_numeric(df['CANTIDAD_BOM'], errors='coerce').fillna(0.0)\n", + " prog.done(f'BOM: {len(df):,} relaciones PT-componente')\n", + " return df\n", + "\n", + "def cargar_sustitutos_map(progress=None):\n", + " \"\"\"Devuelve dict {NUMPARTE: [sustitutos]} desde SPartesSustitutos.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(1, 'Cargando SPartesSustitutos...')\n", + " df = pd.read_sql(\"SELECT NUMPARTE, NUMPARTESUSTITUTO FROM SPartesSustitutos\", scaii_conn)\n", + " d = {}\n", + " for _, r in df.iterrows():\n", + " k = str(r['NUMPARTE']).strip()\n", + " v = str(r['NUMPARTESUSTITUTO']).strip()\n", + " if k and v:\n", + " d.setdefault(k, []).append(v)\n", + " prog.done(f'Sustitutos: {len(d):,} partes con al menos un sustituto')\n", + " return d\n", + "\n", + "def cargar_saldos_agrupados_por_parte(progress=None):\n", + " \"\"\"Saldos disponibles por NUMPARTE con sub-totales vigente / vencido.\n", + " Vigente = FECHAVENC_ISO IS NULL o > hoy. Vencido = FECHAVENC_ISO <= hoy.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(1, 'Cargando saldos por parte...')\n", + " df = pd.read_sql(\"\"\"\n", + " SELECT NUMPARTE,\n", + " CASE WHEN FECHAVENC_ISO IS NULL OR FECHAVENC_ISO > CAST(GETDATE() AS DATE)\n", + " THEN 'VIG' ELSE 'VEN' END AS ESTADO,\n", + " SUM(CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_CANT,\n", + " SUM(ISNULL(VALORIMPOMN,0) - ISNULL(VALORUSADOMN,0)) AS SALDO_VMN,\n", + " SUM(ISNULL(VALORIMPOME,0) - ISNULL(VALORUSADOME,0)) AS SALDO_VME,\n", + " SUM(ISNULL(PESONETO,0) - ISNULL(PESOUSADO,0)) AS SALDO_PESO,\n", + " COUNT(*) AS LOTES\n", + " FROM SSaldoTem\n", + " WHERE (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " GROUP BY NUMPARTE,\n", + " CASE WHEN FECHAVENC_ISO IS NULL OR FECHAVENC_ISO > CAST(GETDATE() AS DATE)\n", + " THEN 'VIG' ELSE 'VEN' END\n", + " \"\"\", scaii_conn)\n", + " df['NUMPARTE'] = df['NUMPARTE'].fillna('').astype(str).str.strip()\n", + " # Pivot a una fila por NUMPARTE con columnas separadas vig/ven\n", + " piv = df.pivot_table(index='NUMPARTE', columns='ESTADO',\n", + " values=['SALDO_CANT','SALDO_VMN','SALDO_VME','SALDO_PESO','LOTES'],\n", + " aggfunc='sum', fill_value=0.0)\n", + " piv.columns = [f'{a}_{b}' for a,b in piv.columns]\n", + " piv = piv.reset_index()\n", + " # Garantizar columnas (puede faltar VIG o VEN si no hay)\n", + " for col in ('SALDO_CANT_VIG','SALDO_CANT_VEN','SALDO_VMN_VIG','SALDO_VMN_VEN',\n", + " 'SALDO_VME_VIG','SALDO_VME_VEN','SALDO_PESO_VIG','SALDO_PESO_VEN',\n", + " 'LOTES_VIG','LOTES_VEN'):\n", + " if col not in piv.columns: piv[col] = 0.0\n", + " prog.done(f'Saldos: {len(piv):,} partes con saldo > 0')\n", + " return piv\n", + "\n", + "def analizar_bom_completo(progress=None):\n", + " \"\"\"Arma la tabla plana BOM con columnas:\n", + " PT, DESC_PT, TIPOMAT_PT, COMPONENTE, UNIMED_BOM, CANTIDAD_BOM,\n", + " N_SUSTITUTOS, LISTA_SUSTITUTOS,\n", + " SALDO_VIG_ORIG, SALDO_VEN_ORIG,\n", + " SALDO_VIG_SUST, SALDO_VEN_SUST,\n", + " SALDO_VIG_TOTAL, SALDO_VEN_TOTAL, SALDO_TOTAL,\n", + " ESTATUS_COBERTURA, LOTES_VIG, LOTES_VEN\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(5, 'Cargando BOM...')\n", + " bom = cargar_bom_completo(progress=None)\n", + " prog.step(desc='Cargando sustitutos...')\n", + " sust = cargar_sustitutos_map(progress=None)\n", + " prog.step(desc='Cargando saldos...')\n", + " sal = cargar_saldos_agrupados_por_parte(progress=None)\n", + " prog.step(desc='Cruzando datos...')\n", + "\n", + " # Indice de saldos por NUMPARTE\n", + " sal_map = sal.set_index('NUMPARTE').to_dict('index')\n", + "\n", + " def _saldo_de(parte):\n", + " r = sal_map.get(parte)\n", + " if not r:\n", + " return (0.0, 0.0, 0, 0)\n", + " return (float(r.get('SALDO_CANT_VIG',0) or 0),\n", + " float(r.get('SALDO_CANT_VEN',0) or 0),\n", + " int(r.get('LOTES_VIG',0) or 0),\n", + " int(r.get('LOTES_VEN',0) or 0))\n", + "\n", + " rows = []\n", + " for _, b in bom.iterrows():\n", + " comp = b['COMPONENTE']\n", + " if not comp:\n", + " continue\n", + " s_vig_o, s_ven_o, lt_vig_o, lt_ven_o = _saldo_de(comp)\n", + " lista_sust = sust.get(comp, [])\n", + " s_vig_s = s_ven_s = 0.0\n", + " lt_vig_s = lt_ven_s = 0\n", + " for x in lista_sust:\n", + " v, w, lv, lw = _saldo_de(x)\n", + " s_vig_s += v; s_ven_s += w\n", + " lt_vig_s += lv; lt_ven_s += lw\n", + " vig_total = s_vig_o + s_vig_s\n", + " ven_total = s_ven_o + s_ven_s\n", + " total = vig_total + ven_total\n", + " if vig_total > 0:\n", + " estatus = 'DISPONIBLE_VIG'\n", + " elif ven_total > 0:\n", + " estatus = 'SOLO_VENCIDO'\n", + " else:\n", + " estatus = 'SIN_SALDO'\n", + " rows.append({\n", + " 'PT': b['PT'],\n", + " 'DESC_PT': b['DESC_PT'][:60],\n", + " 'TIPOMAT_PT': b['TIPOMAT_PT'],\n", + " 'COMPONENTE': comp,\n", + " 'UNIMED_BOM': b['UNIMED_BOM'],\n", + " 'CANTIDAD_BOM': b['CANTIDAD_BOM'],\n", + " 'N_SUSTITUTOS': len(lista_sust),\n", + " 'LISTA_SUSTITUTOS': ', '.join(lista_sust[:5]) + (' ...' if len(lista_sust) > 5 else ''),\n", + " 'SALDO_VIG_ORIG': s_vig_o,\n", + " 'SALDO_VEN_ORIG': s_ven_o,\n", + " 'SALDO_VIG_SUST': s_vig_s,\n", + " 'SALDO_VEN_SUST': s_ven_s,\n", + " 'SALDO_VIG_TOTAL': vig_total,\n", + " 'SALDO_VEN_TOTAL': ven_total,\n", + " 'SALDO_TOTAL': total,\n", + " 'ESTATUS_COBERTURA': estatus,\n", + " 'LOTES_VIG': lt_vig_o + lt_vig_s,\n", + " 'LOTES_VEN': lt_ven_o + lt_ven_s,\n", + " })\n", + " prog.step(desc='Tabla armada')\n", + " df_out = pd.DataFrame(rows)\n", + " if df_out.empty:\n", + " df_out = pd.DataFrame(columns=['PT','DESC_PT','TIPOMAT_PT','COMPONENTE','UNIMED_BOM',\n", + " 'CANTIDAD_BOM','N_SUSTITUTOS','LISTA_SUSTITUTOS','SALDO_VIG_ORIG','SALDO_VEN_ORIG',\n", + " 'SALDO_VIG_SUST','SALDO_VEN_SUST','SALDO_VIG_TOTAL','SALDO_VEN_TOTAL','SALDO_TOTAL',\n", + " 'ESTATUS_COBERTURA','LOTES_VIG','LOTES_VEN'])\n", + " df_out = df_out.sort_values(['ESTATUS_COBERTURA','PT','COMPONENTE']).reset_index(drop=True)\n", + " prog.done(f'BOM analizado: {len(df_out):,} filas (PT-componente)')\n", + " return df_out\n", + "\n", + "def resumen_bom(df_bom):\n", + " \"\"\"Resumen agregado: cuantos PTs totales, cuantos con problema, etc.\"\"\"\n", + " if df_bom is None or df_bom.empty:\n", + " return pd.DataFrame()\n", + " tot_pt = df_bom['PT'].nunique()\n", + " tot_comp_unicos = df_bom['COMPONENTE'].nunique()\n", + " por_estatus = df_bom.groupby('ESTATUS_COBERTURA', as_index=False).agg(\n", + " relaciones=('PT','count'),\n", + " pts_distintos=('PT','nunique'),\n", + " componentes_distintos=('COMPONENTE','nunique'))\n", + " pts_con_problema = df_bom[df_bom['ESTATUS_COBERTURA'] != 'DISPONIBLE_VIG']['PT'].nunique()\n", + " print(f'Total PTs con BOM: {tot_pt:,}')\n", + " print(f'Total componentes distintos: {tot_comp_unicos:,}')\n", + " print(f'PTs con al menos 1 problema: {pts_con_problema:,}')\n", + " print()\n", + " return por_estatus\n", + "\n", + "def exportar_excel_bom(df_bom, ruta='Analisis_BOM.xlsx'):\n", + " \"\"\"Exporta a Excel: hoja 'BOM_completo' + hoja 'Componentes_sin_saldo'.\"\"\"\n", + " ruta = Path(ruta).resolve()\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as xw:\n", + " df_bom.to_excel(xw, sheet_name='BOM_completo', index=False)\n", + " problema = df_bom[df_bom['ESTATUS_COBERTURA'] != 'DISPONIBLE_VIG']\n", + " problema.to_excel(xw, sheet_name='Componentes_sin_saldo_vig', index=False)\n", + " # Resumen por componente\n", + " if not df_bom.empty:\n", + " por_comp = df_bom.groupby(['COMPONENTE','UNIMED_BOM'], as_index=False).agg(\n", + " pts_que_lo_usan=('PT','nunique'),\n", + " saldo_vig_total=('SALDO_VIG_TOTAL','max'),\n", + " saldo_ven_total=('SALDO_VEN_TOTAL','max'),\n", + " n_sustitutos=('N_SUSTITUTOS','max'),\n", + " estatus=('ESTATUS_COBERTURA','first'))\n", + " por_comp = por_comp.sort_values(['estatus','pts_que_lo_usan'], ascending=[True, False])\n", + " por_comp.to_excel(xw, sheet_name='Por_componente', index=False)\n", + " return str(ruta)\n", + "\n", + "def filtrar_bom(df, pt_filtro='', comp_filtro='', solo_problemas=False):\n", + " \"\"\"Filtros simples sobre la tabla del BOM.\"\"\"\n", + " if df is None or df.empty:\n", + " return df\n", + " out = df.copy()\n", + " if pt_filtro:\n", + " out = out[out['PT'].str.contains(pt_filtro, case=False, regex=False, na=False)]\n", + " if comp_filtro:\n", + " out = out[out['COMPONENTE'].str.contains(comp_filtro, case=False, regex=False, na=False)]\n", + " if solo_problemas:\n", + " out = out[out['ESTATUS_COBERTURA'] != 'DISPONIBLE_VIG']\n", + " return out.reset_index(drop=True)\n", + "" + ] + }, { "cell_type": "code", "execution_count": null, @@ -19676,13 +20795,13 @@ "\n", "# ----- Fraccion / UM -----\n", "out_pron_frac = W.Output(layout=OUT_STYLE); bar_pron_frac = _mkbar('Pron.Frac')\n", - "out_frac = W.Output(layout=OUT_STYLE); bar_frac = _mkbar('Frac/UM')\n", + "out_frac = W.Output(layout=OUT_STYLE); bar_frac_um = _mkbar('Frac/UM')\n", "btn_pron_frac = W.Button(description='Calcular pronostico (Frac/UM)', button_style='primary', icon='play', layout={'width':'280px'})\n", "modo_frac = W.Dropdown(options=['NATURAL','DIRIGIDA'], value='NATURAL', description='Modo:', layout={'width':'250px'})\n", "dry_frac = W.Checkbox(value=True, description='DRY_RUN (simular)')\n", "fd_frac = W.Text(placeholder='YYYY-MM-DD', description='Desde:', layout={'width':'250px'})\n", "fh_frac = W.Text(placeholder='YYYY-MM-DD', description='Hasta:', layout={'width':'250px'})\n", - "btn_frac = W.Button(description='Ejecutar descarga Frac/UM (NA->AC)', button_style='warning', icon='check', layout={'width':'280px'})\n", + "btn_frac_um = W.Button(description='Ejecutar descarga Frac/UM (NA->AC)', button_style='warning', icon='check', layout={'width':'280px'})\n", "\n", "def _on_pron_frac(_):\n", " with out_pron_frac:\n", @@ -19697,8 +20816,10 @@ " clear_output()\n", " if not CONEXION_OK: print(CONEXION_MSG); return\n", " print(f'(DB actual: {DB_ACTUAL})')\n", - " ejecutar_fraccion_um(modo_frac.value, dry_frac.value, fd_frac.value or None, fh_frac.value or None, log=print, progress=bar_frac)\n", - "btn_frac.on_click(_on_frac)\n", + " ejecutar_fraccion_um(modo_frac.value, dry_frac.value, fd_frac.value or None, fh_frac.value or None, log=print, progress=bar_frac_um)\n", + "btn_frac_um.on_click(_on_frac)\n", + "\n", + "\n", "\n", "\n", "\n", @@ -19721,7 +20842,7 @@ " W.HTML('
Match por primeros 8 digitos de fraccion arancelaria + unidad de medida. Sin BOM.
'),\n", " btn_pron_frac, bar_pron_frac, out_pron_frac,\n", - " W.HBox([modo_frac, dry_frac]), W.HBox([fd_frac, fh_frac]), btn_frac, bar_frac, out_frac,\n", + " W.HBox([modo_frac, dry_frac]), W.HBox([fd_frac, fh_frac]), btn_frac_um, bar_frac_um, out_frac,\n", "])\n", "\n", "# ----- Tab 2: Analisis Saldos -----\n", @@ -19766,31 +20887,31 @@ " with out_an_anio:\n", " clear_output()\n", " display(HTML(''\n", + " 'Para empresas que importan y re-exportan el mismo producto sin transformacion. '\n", + " 'Cada partida EXPO descarga el saldo IMPO del MISMO NUMPARTE, con la MISMA fraccion '\n", + " '(SSaldoTem.FRACCIONIMPO = SPartidasExpo.FRACCIONEXPO) y la MISMA unidad de medida. '\n", + " 'Solo consume saldos vencidos (FECHAVENC_ISO < hoy y SALDO_DISPONIBLE > 0). '\n", + " 'Omite facturas con TIPOFACTURA = NODES y respeta CANTEXPO (no sobre-descarga). '\n", + " 'Incluye facturas NA y AC con lineas faltantes.
NATURAL excluye facturas con faltantes; '\n", + " 'DIRIGIDA inserta lo que se pudo y registra en SDescargaM espejo + APLICADESCMANUAL=S. '\n", + " 'Corre primero con DRY_RUN activado.
'),\n", + " W.HBox([w_u1_modo]), W.HBox([dry_u1]),\n", + " btn_u1_ejecutar,\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== Reintegro de descargas =====\n", + "upload_re_inicial = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir lista MP')\n", + "btn_re_plantilla = W.Button(description='Descargar plantilla', button_style='info', icon='download', layout={'width':'220px'})\n", + "w_re_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_re_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_re_listar = W.Button(description='Listar partidas EXPO afectadas', button_style='primary', icon='search', layout={'width':'310px'})\n", + "btn_re_export_lista = W.Button(description='Exportar lista a Excel (a llenar)', button_style='', icon='file-excel', layout={'width':'280px'})\n", + "bar_re = _mkbar('Reintegro')\n", + "out_re_log = W.Output(layout=OUT_STYLE)\n", + "out_re_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "upload_re_lleno = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel lleno')\n", + "btn_re_analizar = W.Button(description='Analizar reintegro', button_style='primary', icon='check', layout={'width':'240px'})\n", + "btn_re_plan_xlsx = W.Button(description='Exportar plan a Excel', button_style='', icon='file-excel', layout={'width':'240px'})\n", + "chk_re_sdm = W.Checkbox(value=False, description='Ajustar tambien SDescargaM (espejo)', indent=False, layout={'width':'380px'})\n", + "dry_re = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n", + "btn_re_ejecutar = W.Button(description='Ejecutar reintegro', button_style='warning', icon='play', layout={'width':'260px'})\n", + "out_re_plan = W.Output(layout=OUT_STYLE)\n", + "\n", + "_html_re = (''\n", + " 'Reduce la CANTDESC de descargas existentes y devuelve el saldo al IMPO. '\n", + " 'Sube primero un Excel con los NUMPARTE_MP, UNIMED, FRACCION y define '\n", + " 'el rango de fechas (FECHAFACTURA de las facturas EXPO). La utileria lista las '\n", + " 'partidas EXPO afectadas con su CANTDESC actual y una columna vacia para que '\n", + " 'indiques la NUEVA cantidad que debe quedar. La nueva cantidad debe ser '\n", + " 'mayor que 0 y menor que la actual. La reduccion se distribuye '\n", + " 'proporcionalmente entre las descargas reales que cubren esa partida.'\n", + " '
Paso 1 - Sube el Excel con NUMPARTE_MP, UNIMED, FRACCION y define el rango de fechas:
'),\n", + " W.HBox([upload_re_inicial]),\n", + " W.HBox([w_re_fi, w_re_ff]),\n", + " W.HBox([btn_re_listar, btn_re_export_lista]), bar_re,\n", + " out_re_tabla,\n", + " W.HTML('Paso 2 - Sube el Excel con la columna CANTDESC_NUEVA llena:
'),\n", + " W.HBox([upload_re_lleno]),\n", + " W.HBox([btn_re_analizar, btn_re_plan_xlsx]),\n", + " out_re_plan,\n", + " W.HTML(''\n", + " 'Revisa las facturas EXPO pendientes (estatus NA) y explica por que el Paso 12 '\n", + " 'no las pudo descargar. Motivos posibles: TIPOFACTURA NODES, SIN_PARTIDAS, '\n", + " 'PT_SIN_BOM (producto sin receta), PESONETO_CERO (la partida no tiene peso), '\n", + " 'COMPONENTE_SIN_SALDO (no hay saldo IMPO suficiente). Es solo lectura: '\n", + " 'no escribe en la base.
{len(df):,} filas (PT-componente)
'))\n", + " display(_fmt_an_df(df.head(500)))\n", + " if len(df) > 500:\n", + " display(HTML(f'(mostrando primeras 500 filas de {len(df):,} -- exporta a Excel para ver todas)
'))\n", + "\n", + "def _on_bom(_):\n", + " with out_bom_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " t0 = time.time()\n", + " df = analizar_bom_completo(progress=bar_bom)\n", + " _state['bom_full'] = df\n", + " print(f'Tiempo total: {time.time()-t0:.1f}s')\n", + " with out_bom_resumen:\n", + " clear_output()\n", + " display(HTML('Cruza SMatBOM con SPartesSustitutos y SSaldoTem '\n", + " 'para mostrar, por cada producto terminado, sus componentes, sustitutos disponibles y '\n", + " 'cobertura (vigente vs vencido). Util para entender por que una factura EXPO no descarga.
'\n", + " '