File size: 12,322 Bytes
c226a30 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """Streamlit tab: PDF/image quotation -> editable structured JSON/CSV with validation."""
from __future__ import annotations
import hashlib
import io
import json
from pathlib import Path
import pandas as pd
import streamlit as st
from quotation_pipeline import extract_pages, extract_quote, validate_quote, build_payload
HERE = Path(__file__).resolve().parent
DEMO = HERE / 'demos'
FIELD_NAMES = [
('folio', 'Folio'), ('fecha', 'Fecha del documento'), ('cliente', 'Cliente / razón social'),
('rfc', 'RFC del cliente'), ('correo', 'Correo del cliente'), ('proyecto', 'Proyecto / obra'),
('moneda', 'Moneda'), ('subtotal', 'Subtotal declarado'), ('iva', 'IVA declarado'), ('total', 'Total declarado'),
]
EDITABLE = ['codigo', 'descripcion', 'cantidad', 'unidad', 'precio_unitario', 'importe_documento']
def render_quotation_ui():
st.subheader('Cotizaciones estructuradas')
st.caption('Demostración de ingestión y control: PDF o fotografía → texto/OCR → campos → validación → revisión → JSON/CSV.')
st.info(
'La extracción de PDF con texto es determinista; las imágenes y PDFs escaneados pasan por RapidOCR neuronal. '
'Los conceptos se interpretan con un formato de tabla explícito; otros diseños de proveedor pueden requerir '
'corregir datos o completar partidas. Nunca se inventan precios faltantes.'
)
st.markdown('#### 1. Prueba con dos cotizaciones de ejemplo')
a, b = st.columns(2)
with a:
st.download_button('⬇️ Cotización correcta (PDF)',
data=(DEMO / 'cotizacion_demo_correcta.pdf').read_bytes(),
file_name='cotizacion_demo_correcta.pdf', mime='application/pdf', key='q_demo_ok')
st.caption('3 partidas · Subtotal 9,800 MXN · IVA 1,568 · Total 11,368. Valores ficticios.')
with b:
st.download_button('⬇️ Cotización con error (PDF)',
data=(DEMO / 'cotizacion_demo_error.pdf').read_bytes(),
file_name='cotizacion_demo_error.pdf', mime='application/pdf', key='q_demo_bad')
st.caption('La primera partida declara 5,990 en lugar de 6,000. Debe generar alertas.')
uploaded = st.file_uploader('Sube una cotización PDF, JPG o PNG (máximo 20 MB)',
type=['pdf', 'jpg', 'jpeg', 'png'], key='q_upload')
if uploaded is None:
st.markdown('**Para demostrarlo:** descarga el PDF con error, súbelo aquí y pulsa **Extraer datos**. '
'Después corrige las partidas y enseña cómo se desbloquea la exportación validada.')
return
data = uploaded.getvalue()
identity = hashlib.sha256(uploaded.name.encode() + data).hexdigest()
if len(data) > 20 * 1024 * 1024:
st.error('El archivo supera el máximo de 20 MB.')
return
if st.button('🔎 Extraer datos y detectar conceptos', type='primary', key='q_parse'):
with st.spinner('Leyendo PDF, identificando campos y comprobando importes...'):
try:
extracted = extract_pages(data, uploaded.name)
parsed = extract_quote(extracted['texto'])
initial_check = validate_quote(parsed['campos'], parsed['conceptos'])
st.session_state['q_identity'] = identity
st.session_state['q_extracted'] = extracted
st.session_state['q_original'] = parsed
st.session_state['q_initial_alerts'] = initial_check['alertas']
st.session_state['q_approval_sig'] = None
st.session_state['q_ack_source'] = False
st.session_state['q_reviewer_note'] = ''
st.session_state['q_confirm_content'] = False
for key, _ in FIELD_NAMES:
st.session_state[f'q_field_{key}'] = parsed['campos'].get(key, '')
st.session_state['q_editor_items'] = pd.DataFrame(parsed['conceptos'], columns=EDITABLE)
st.success(f"Extracción terminada · {extracted['paginas']} página(s) · "
f"{len(parsed['conceptos'])} partida(s) detectada(s).")
except Exception as exc:
st.error(f'No se pudo extraer el documento: {exc}')
return
if st.session_state.get('q_identity') != identity:
st.warning('Pulsa **Extraer datos** para cargar este nuevo archivo.')
return
parsed = st.session_state['q_original']
extracted = st.session_state['q_extracted']
raw_alerts = st.session_state.get('q_initial_alerts', [])
motors = ', '.join(dict.fromkeys(extracted['motores']))
st.caption(f'Archivo: **{uploaded.name}** · Extracción: **{motors}** · {extracted["paginas"]} página(s)')
st.markdown('#### 2. Verificar la información del cliente y los importes')
st.caption('Puedes corregir cualquier campo. Los valores se revisarán de nuevo después de cada edición.')
cols = st.columns(3)
for index, (key, label) in enumerate(FIELD_NAMES):
with cols[index % 3]:
st.text_input(label, key=f'q_field_{key}')
fields = {key: st.session_state.get(f'q_field_{key}', '') for key, _ in FIELD_NAMES}
st.markdown('#### 3. Conceptos extraídos — tabla editable')
st.caption('Añade filas manualmente si el formato del proveedor no se puede identificar. '
'Cantidad × precio se recalcula independientemente del importe del documento.')
original_frame = st.session_state.get('q_editor_items', pd.DataFrame(columns=EDITABLE))
edit = st.data_editor(
original_frame,
hide_index=True,
num_rows='dynamic',
use_container_width=True,
key=f'q_data_editor_{identity[:12]}',
column_config={
'codigo': st.column_config.TextColumn('Código'),
'descripcion': st.column_config.TextColumn('Descripción', width='large'),
'cantidad': st.column_config.NumberColumn('Cantidad', format='%.2f'),
'unidad': st.column_config.TextColumn('Unidad'),
'precio_unitario': st.column_config.NumberColumn('Precio unitario', format='%.2f'),
'importe_documento': st.column_config.NumberColumn('Importe declarado', format='%.2f'),
},
)
rows = edit[EDITABLE].to_dict(orient='records')
checked = validate_quote(fields, rows)
counts = st.columns(3)
with counts[0]:
st.metric('Partidas registradas', len(rows))
with counts[1]:
sub = checked['totales']['subtotal_recalculado']
st.metric('Subtotal recalculado', f'{sub:,.2f} {fields.get("moneda", "")}' if sub is not None else 'N/D')
with counts[2]:
st.metric('Alertas actuales', len(checked['alertas']))
if checked['alertas']:
st.error('**Documento pendiente de revisión:**')
for a in checked['alertas']:
st.markdown('- ' + a)
else:
st.success('Las verificaciones **aritméticas y de campos obligatorios** pasaron. '
'Esto no garantiza autenticidad del cliente, validez fiscal ni precios de mercado.')
if raw_alerts:
with st.expander(f'Alertas del documento original ({len(raw_alerts)}) — historial conservado', expanded=True):
for a in raw_alerts:
st.write('• ' + a)
st.warning('Si se corrigió el dato, se guardará como modificación del revisor. '
'No cambia el PDF original: una discrepancia debe confirmarse con su emisor.')
with st.expander('Auditoría técnica: texto extraído del PDF/OCR'):
st.code(extracted['texto'][:18000], language=None)
if parsed['lineas_no_estructuradas']:
st.caption(f"{len(parsed['lineas_no_estructuradas'])} línea(s) fuera de la tabla estructurada. "
'Revisa que no falten conceptos antes de aprobar.')
st.markdown('#### 4. Revisión y autorización para exportar')
note = st.text_area('Nota de revisión (qué comprobaste y con quién)',
key='q_reviewer_note', placeholder='Ejemplo: Importes contrastados con el proveedor...')
reviewed = st.checkbox('Confirmo que cotejé el cliente, todos los conceptos y cantidades con la cotización.',
key='q_confirm_content')
acknowledged = True
if raw_alerts:
acknowledged = st.checkbox(
'Confirmo que cualquier discrepancia del PDF original fue aclarada con el emisor; '
'el registro conservará el cambio y las alertas originales.',
key='q_ack_source'
)
if raw_alerts and not note.strip():
st.caption('Con discrepancias de origen, agrega una nota de revisión antes de aprobar.')
# Any change to values invalidates a previous manual approval.
fingerprint_source = json.dumps({'fields': fields, 'items': rows, 'ack': acknowledged,
'reviewed': reviewed, 'note': note},
ensure_ascii=False, sort_keys=True, default=str)
approval_fingerprint = hashlib.sha256(fingerprint_source.encode('utf-8')).hexdigest()
eligible = bool(checked['apto_aprobacion'] and reviewed and acknowledged and (not raw_alerts or note.strip()))
if st.button('✅ Aprobar estructura revisada', disabled=not eligible, key='q_approve'):
st.session_state['q_approval_sig'] = approval_fingerprint
approved = eligible and st.session_state.get('q_approval_sig') == approval_fingerprint
if approved:
st.success('Estructura **aprobada por el revisor**. Lista para exportación; no se envía automáticamente a ningún ERP.')
else:
st.info('Estado actual: **REVISIÓN PENDIENTE**. Puedes exportar el borrador, pero no enviarlo como aprobado.')
# Audit of changed fields and item values, without tampering with original PDF.
changes = []
for key, _ in FIELD_NAMES:
old = str(parsed['campos'].get(key, '') or '')
new = str(fields.get(key, '') or '')
if old != new:
changes.append({'campo': key, 'original': old, 'revisado': new})
original_items = parsed.get('conceptos', [])
for index, row in enumerate(rows):
oldrow = original_items[index] if index < len(original_items) else {}
for key in EDITABLE:
if str(oldrow.get(key, '')) != str(row.get(key, '')):
changes.append({'campo': f'concepto_{index+1}.{key}',
'original': oldrow.get(key), 'revisado': row.get(key)})
payload = build_payload(fields, checked, uploaded.name, extracted['motores'], approved)
payload['control']['alertas_de_origen'] = raw_alerts
payload['control']['correcciones_registradas'] = changes
payload['control']['nota_revisor'] = note.strip()
payload['control']['formato_reconocido'] = 'TABLA_DELIMITADA' if parsed['conceptos'] else 'REVISION_MANUAL'
payload['control']['advertencia'] = (
'Prototipo independiente. Estructura generada tras extracción heurística y revisión, '
'no implica envío, integración ni validación fiscal.'
)
with st.expander('Ver JSON estructurado antes de descargar', expanded=True):
st.json(payload, expanded=False)
json_bytes = json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False).encode('utf-8')
export_df = pd.DataFrame(checked['conceptos'])
export_df.insert(0, 'folio', payload['folio'])
export_df.insert(1, 'cliente', payload['cliente']['nombre'])
export_df.insert(2, 'proyecto', payload['proyecto'])
export_df.insert(3, 'moneda', payload['moneda'])
c1, c2 = st.columns(2)
with c1:
st.download_button(
'⬇️ Exportar JSON ' + ('APROBADO' if approved else 'BORRADOR'),
data=json_bytes, mime='application/json',
file_name=f"cotizacion_{'aprobada' if approved else 'borrador'}.json", key='q_json')
with c2:
st.download_button(
'⬇️ Exportar conceptos CSV ' + ('APROBADO' if approved else 'BORRADOR'),
data=export_df.to_csv(index=False).encode('utf-8-sig'), mime='text/csv',
file_name=f"conceptos_{'aprobados' if approved else 'borrador'}.csv", key='q_csv')
st.caption('La salida queda disponible para una integración futura mediante API o ETL. '
'Aquí **no se transmite** ningún dato a un CRM, ERP, base de datos o correo.')
|