hbox-docs/backend/compras.py

666 lines
36 KiB
Python

"""Compras: las órdenes de compra del cotizador, dentro del centro de mando (28-oct-2026).
Fase 1 de la integración cotizador ↔ centro de mando (documento
`2026-10-27_Drogheda_Integracion-Cotizador-Inventario_ES_v1.0.md`, decisiones D-27-1 a D-27-5).
Lo engancha app.py al final con registrar(app, globals()); vive aparte para no crecer app.py.
Qué hace, en una línea: el cotizador manda cada cambio de una OC (sin montos); aquí se guarda, el
material aparece **en camino** en la pestaña del sitio del inventario, se abre **un pendiente por OC**
para el responsable del sitio, y cuando ese material se recibe el pendiente se cierra solo y el
cotizador se entera en su siguiente consulta.
POST /api/integracion/cotizador/evento (firmado, sin sesión)
oc.autorizada · oc.pagada · oc.en_camino · oc.cancelada · req.cerrada
GET /api/integracion/inventario/items (firmado) — el selector de ítems del cotizador
GET /api/integracion/cotizador/recepciones?desde=… (firmado) — lo que ya llegó, para cerrar la requisición
GET /api/compras[?sitio=&estado=] (con sesión) — la página Compras
POST /api/compras/ligar (con sesión) — ligar un renglón a un ítem del inventario
POST /api/compras/recibir (con sesión) — recibir una OC completa o en parte
Seguridad de las rutas de integración: **no** usan la sesión de una persona. Cada llamada va firmada
con un secreto compartido (HMAC-SHA256 de la hora + "\\n" + el cuerpo). El secreto vive en el servicio
(`COTIZADOR_SECRETO`) y en las propiedades del script del cotizador (`PORTAL_SECRETO`); nunca en el
código ni en un chat. Se rechaza una firma de hace más de 5 minutos. Sin secreto configurado, las tres
rutas contestan 503 y la página lo dice: el sitio sigue funcionando igual.
Montos: el centro de mando **no** los recibe ni los guarda. Si un aviso trae costo, importe, subtotal,
IVA, total, tipo de cambio o margen, se descartan antes de escribir (lo ve todo el equipo).
Datos: SQLite en `/srv/projects/drogheda/drogheda.db` (D-27-2), en modo WAL porque el servicio corre con
dos trabajadores. El inventario sigue en sus JSON: una OK «en camino» crea sus envíos con las mismas
funciones que usa la página de Inventario, así que las cuentas por sitio no cambian de origen.
"""
import datetime
import hashlib
import hmac
import json
import os
import sqlite3
from flask import jsonify, request
VENTANA_FIRMA = 300 # segundos de tolerancia de la firma (reloj y red)
MAX_CUERPO = 256 * 1024 # un aviso más grande que esto no es una OC
MONTOS = ("costo", "costo_unitario", "precio", "precio_unitario", "importe", "subtotal", "iva",
"impuestos", "total", "tipo_cambio", "moneda", "margen", "descuento")
ESTADOS_OC = ("autorizada", "pagada", "en-camino", "recibida-parcial", "recibida", "cancelada")
TIPOS = ("oc.autorizada", "oc.pagada", "oc.en_camino", "oc.cancelada", "req.cerrada")
# Quién recibe el material en cada sitio (el que da «Recibir» y a quien se le abre el pendiente).
# Se puede cambiar sin tocar el código con COMPRAS_RESPONSABLES="VER:alan,ZLO:jesush,…".
RESPONSABLES = {"VER": "alan", "ZLO": "jesush", "ESE": "miguel", "LZC": "antonio", "QRO": "jesus"}
RESPONSABLE_OMISION = "adriana"
ESQUEMA = """
CREATE TABLE IF NOT EXISTS ordenes (
folio TEXT PRIMARY KEY, req_folio TEXT, proyecto_folio TEXT, proyecto_clave TEXT,
sitio TEXT, sitio_portal TEXT, proveedor TEXT, estado TEXT, fecha_entrega TEXT,
paqueteria TEXT, guia TEXT, en_camino_en TEXT, solicitante TEXT, autorizo TEXT,
tarea_id TEXT, prueba INTEGER DEFAULT 0, req_cerrada INTEGER DEFAULT 0,
recepcion_en TEXT, recepcion_por TEXT, creado_en TEXT, actualizado_en TEXT);
CREATE TABLE IF NOT EXISTS renglones (
oc_folio TEXT, n INTEGER, descripcion TEXT, unidad TEXT, cantidad REAL,
item_codigo TEXT, item_id TEXT, envio_id TEXT, recibido REAL DEFAULT 0,
recibido_en TEXT, recibido_por TEXT, nota TEXT,
PRIMARY KEY (oc_folio, n));
CREATE TABLE IF NOT EXISTS eventos (
evento_id TEXT PRIMARY KEY, oc_folio TEXT, tipo TEXT, cuando TEXT, quien TEXT,
origen TEXT, recibido_en TEXT, datos TEXT);
CREATE INDEX IF NOT EXISTS ix_ordenes_sitio ON ordenes (sitio_portal, estado);
CREATE INDEX IF NOT EXISTS ix_renglones_envio ON renglones (envio_id);
"""
def _responsables():
m = dict(RESPONSABLES)
for par in os.environ.get("COMPRAS_RESPONSABLES", "").split(","):
if ":" in par:
k, v = par.split(":", 1)
m[k.strip().upper()] = v.strip().lower()
return m
def _secreto():
return os.environ.get("COTIZADOR_SECRETO", "").strip()
def _num(v, defecto=0.0):
try:
n = float(v)
except (TypeError, ValueError):
return defecto
return n if n == n and abs(n) != float("inf") else defecto
def _entero_exacto(v):
"""La cantidad del renglón como entero, o None si no lo es (el inventario cuenta piezas y metros)."""
n = _num(v, -1)
return int(round(n)) if n > 0 and abs(n - round(n)) < 1e-9 else None
def _sin_montos(d):
"""Copia del diccionario sin ninguna llave de dinero, a cualquier profundidad."""
if isinstance(d, dict):
return {k: _sin_montos(v) for k, v in d.items() if k.lower() not in MONTOS}
if isinstance(d, list):
return [_sin_montos(x) for x in d]
return d
class Compras:
"""Todo el estado del módulo: la base y las funciones de app.py que necesita."""
def __init__(self, g):
self.g = g
self.ruta = os.environ.get("COMPRAS_DB", os.path.join(g["DATOS_BASE"], "drogheda.db"))
# ---------------------------------------------------------------- base de datos
def con(self):
c = sqlite3.connect(self.ruta, timeout=10)
c.row_factory = sqlite3.Row
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA busy_timeout=8000")
c.execute("PRAGMA foreign_keys=ON")
return c
def arrancar(self):
os.makedirs(os.path.dirname(self.ruta) or ".", exist_ok=True)
with self.con() as c:
c.executescript(ESQUEMA)
# ---------------------------------------------------------------- sitios y equipo
def sitio_portal(self, codigo):
"""'ZLO' o 'timsa' → 'timsa'. Devuelve None si no es un sitio del centro de mando."""
s = self.g["_norm_id"](self.g["_texto"](codigo, 40))
s = self.g["ALIAS_SITIO"].get(s, s)
return s if s in self.g["SITIOS_TAREA_IDS"] else None
def codigo(self, sitio_portal):
return self.g["CODIGO_SITIO"].get(sitio_portal, (sitio_portal or "").upper())
def nombre_sitio(self, sitio_portal):
for i, n, _ in self.g["SITIOS_TAREA"]:
if i == sitio_portal:
return "Querétaro" if i == "qro" else n
return sitio_portal or "?"
def responsable(self, codigo):
quien = _responsables().get((codigo or "").upper(), RESPONSABLE_OMISION)
miembros = self.g["_miembros"]()
return quien if quien in miembros else (RESPONSABLE_OMISION if RESPONSABLE_OMISION in miembros
else (sorted(miembros)[0] if miembros else "adriana"))
# ---------------------------------------------------------------- inventario
def items(self):
return self.g["_listar_json"](self.g["INV_ITEMS"]())
def item_por_codigo(self, codigo):
c = (codigo or "").strip().upper()
if not c:
return None
for it in self.items():
if (it.get("codigo") or "").upper() == c:
return it
return None
def envio(self, envio_id):
if not envio_id:
return None
m = self.g["_leer_json"](self.g["_ruta_registro"](self.g["INV_MOVS"](), envio_id))
return m if isinstance(m, dict) and m.get("tipo") == "envio" else None
# ---------------------------------------------------------------- pendiente de la OC
def abrir_pendiente(self, oc, quien):
"""Un pendiente por OC: «Recibir OC-… en ZLO», para el responsable del sitio, que vence el día
de entrega. Si ya existe (un aviso repetido, o alguien lo abrió a mano), no se duplica."""
g = self.g
oc = dict(oc) # llega como sqlite3.Row, que no tiene .get()
tid = "t-oc-" + oc["folio"].lower().replace("/", "-")
ruta = g["_ruta_registro"](g["TAREAS_DIR"], tid)
if os.path.exists(ruta):
return tid
sitio = oc["sitio_portal"]
d = {"titulo": f"Recibir {oc['folio']} en {self.codigo(sitio)}"
+ (f" — {oc['proveedor']}" if oc.get("proveedor") else ""),
"descripcion": ("Orden de compra del cotizador. Cuando llegue el material, ábrela en "
f"**Compras** y dale **Recibir** con lo que llegó de verdad.\n\n"
f"- Proveedor: {oc.get('proveedor') or '—'}\n"
f"- Requisición: {oc.get('req_folio') or '—'} · pidió {oc.get('solicitante') or '—'}\n"
f"- Sitio de entrega: {self.nombre_sitio(sitio)}\n"
"- Los montos y el pago se quedan en el cotizador."),
"pista": "compras", "estado": "por-hacer",
"responsable": self.responsable(self.codigo(sitio)),
"sitios": [sitio], "etiquetas": ["compras", "oc"],
"enlaces": [{"titulo": "Compras", "url": "/tableros/compras/?oc=" + oc["folio"]}]}
if oc.get("fecha_entrega"):
d["fecha_limite"] = oc["fecha_entrega"]
t, err = g["_tarea_valida"](d)
if err:
return None
t.pop("_aviso", None)
ahora = g["ahora"]()
t.update(id=tid, orden=0, creado_por=quien, creado_en=ahora, modificado_por=quien,
modificado_en=ahora,
historial=[{"quien": quien, "cuando": ahora,
"cambio": f"creado en por-hacer desde el cotizador ({oc['folio']})"}])
g["_guardar_tarea"](t)
return tid
def nota_pendiente(self, tarea_id, cambio, quien, estado=None):
g = self.g
if not tarea_id:
return
ruta = g["_ruta_registro"](g["TAREAS_DIR"], tarea_id)
t = g["_leer_json"](ruta)
if not t:
return
ahora = g["ahora"]()
if estado and t.get("estado") != estado:
t["estado"] = estado
t["modificado_por"], t["modificado_en"] = quien, ahora
t.setdefault("historial", []).append({"quien": quien, "cuando": ahora, "cambio": cambio})
g["_guardar_tarea"](t)
# ---------------------------------------------------------------- envíos «en camino»
def crear_envios(self, c, folio, quien):
"""Pone en camino lo que se pueda de esa OC: un envío por renglón que ya tenga su ítem.
Los renglones sin ítem quedan en «por ligar» y se mandan cuando alguien los liga."""
oc = c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone()
if not oc or oc["estado"] not in ("en-camino", "pagada", "autorizada"):
return []
hechos = []
for r in c.execute("SELECT * FROM renglones WHERE oc_folio=? ORDER BY n", (folio,)).fetchall():
if r["envio_id"] or not r["item_codigo"]:
continue
env = self.crear_envio(c, oc, r, quien)
if env:
hechos.append(env)
return hechos
def crear_envio(self, c, oc, r, quien):
it = self.item_por_codigo(r["item_codigo"])
if not it:
c.execute("UPDATE renglones SET item_codigo=NULL, nota=? WHERE oc_folio=? AND n=?",
(f"El ítem {r['item_codigo']} no existe en el inventario: hay que ligarlo a mano.",
oc["folio"], r["n"]))
return None
cant = _entero_exacto(r["cantidad"])
if cant is None:
c.execute("UPDATE renglones SET nota=? WHERE oc_folio=? AND n=?",
(f"Cantidad {r['cantidad']}: el inventario cuenta piezas y metros enteros. "
"Recíbelo a mano cuando llegue.", oc["folio"], r["n"]))
return None
motivo = (f"compra en camino: {oc['folio']}"
+ (f" ({oc['proveedor']})" if oc["proveedor"] else "")
+ (f", guía {oc['guia']}" if oc["guia"] else ""))
m = self.g["_registrar_movimiento"](it, "envio", cant, motivo[:200], oc["folio"], quien,
sitio=self.g["ORIGEN_FUERA"], destino=oc["sitio_portal"])
c.execute("UPDATE renglones SET envio_id=?, item_id=?, nota=NULL WHERE oc_folio=? AND n=?",
(m["id"], it["id"], oc["folio"], r["n"]))
return m["id"]
def cancelar_envios(self, c, folio, quien, motivo):
"""Una OC cancelada: lo que iba en camino deja de contar. Se cierra cada envío con una
recepción de 0 (el inventario baja el total, que es justo lo que pasó: nunca llegó)."""
for r in c.execute("SELECT * FROM renglones WHERE oc_folio=? AND envio_id IS NOT NULL",
(folio,)).fetchall():
env = self.envio(r["envio_id"])
if not env or env.get("estado") != "en-camino":
continue
it = self.g["_leer_json"](self.g["_ruta_registro"](self.g["INV_ITEMS"](), env.get("item_id")))
if it:
self.g["_registrar_movimiento"](it, "recepcion", 0, motivo[:200], folio, quien,
sitio=env.get("destino"), envio=env)
# ---------------------------------------------------------------- conciliación
def conciliar(self, c, folio=None):
"""Se entera de las recepciones hechas desde la pestaña del sitio en Inventario: si el envío de
un renglón ya se recibió, lo anota aquí y, si llegó todo, cierra la OC y su pendiente."""
q = "SELECT * FROM renglones WHERE envio_id IS NOT NULL AND recibido_en IS NULL"
filas = c.execute(q + (" AND oc_folio=?" if folio else ""), (folio,) if folio else ()).fetchall()
tocadas = set()
for r in filas:
env = self.envio(r["envio_id"])
if not env or env.get("estado") != "recibido":
continue
c.execute("UPDATE renglones SET recibido=?, recibido_en=?, recibido_por=? WHERE oc_folio=? AND n=?",
(_num(env.get("recibido"), 0), env.get("recibido_en") or self.g["ahora"](),
env.get("recibido_por") or "", r["oc_folio"], r["n"]))
tocadas.add(r["oc_folio"])
for f in (tocadas | ({folio} if folio else set())):
self.cerrar_si_llego_todo(c, f)
return tocadas
def cerrar_si_llego_todo(self, c, folio):
oc = c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone()
if not oc or oc["estado"] in ("cancelada", "recibida"):
return
rs = c.execute("SELECT * FROM renglones WHERE oc_folio=?", (folio,)).fetchall()
if not rs:
return
con_algo = [r for r in rs if r["recibido_en"]]
if not con_algo:
return
completa = all(r["recibido_en"] and _num(r["recibido"]) >= _num(r["cantidad"]) for r in rs)
quien = con_algo[-1]["recibido_por"] or "equipo"
cuando = max(r["recibido_en"] for r in con_algo)
estado = "recibida" if completa else "recibida-parcial"
if oc["estado"] != estado:
c.execute("UPDATE ordenes SET estado=?, recepcion_en=?, recepcion_por=?, actualizado_en=? WHERE folio=?",
(estado, cuando, quien, self.g["ahora"](), folio))
falta = [f"{r['descripcion']}: llegaron {_num(r['recibido']):g} de {_num(r['cantidad']):g}"
for r in rs if _num(r["recibido"]) < _num(r["cantidad"])]
if completa:
self.nota_pendiente(oc["tarea_id"], f"llegó todo lo de {folio}: recibido por {quien}",
quien, estado="hecho")
else:
self.nota_pendiente(oc["tarea_id"],
(f"llegó parte de {folio} ({quien}). Falta: " + "; ".join(falta))[:300], quien)
# ---------------------------------------------------------------- recibir desde el centro de mando
def recibir(self, c, folio, lineas, quien):
"""lineas = [{n, recibido}]. Recibe por el inventario lo que tiene envío; lo que no tiene ítem
se anota aquí (así la requisición se puede cerrar igual). Devuelve (hechos, errores)."""
oc = c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone()
if not oc:
return [], [f"No existe {folio}."]
if oc["estado"] == "cancelada":
return [], [f"{folio} está cancelada en el cotizador."]
hechos, errores = [], []
for ln in lineas:
n = self.g["_entero"](ln.get("n"), -1)
r = c.execute("SELECT * FROM renglones WHERE oc_folio=? AND n=?", (folio, n)).fetchone()
if not r:
errores.append(f"El renglón {n} no existe en {folio}.")
continue
if r["recibido_en"]:
continue # ya se había recibido: no se recibe dos veces
cant = _num(ln.get("recibido"), -1)
if cant < 0 or cant > _num(r["cantidad"]):
errores.append(f"Renglón {n}: se pidieron {_num(r['cantidad']):g} {r['unidad'] or ''}"
.strip() + f"; no pueden llegar {ln.get('recibido')}.")
continue
ahora = self.g["ahora"]()
env = self.envio(r["envio_id"])
if env and env.get("estado") == "en-camino":
entero = _entero_exacto(cant) if cant else 0
if entero is None:
errores.append(f"Renglón {n}: el inventario cuenta enteros.")
continue
it = self.g["_leer_json"](self.g["_ruta_registro"](self.g["INV_ITEMS"](), env.get("item_id")))
if not it:
errores.append(f"Renglón {n}: ya no existe el ítem del envío.")
continue
self.g["_registrar_movimiento"](it, "recepcion", entero,
f"llegó la compra {folio}"[:200], folio, quien,
sitio=env.get("destino"), envio=env)
c.execute("UPDATE renglones SET recibido=?, recibido_en=?, recibido_por=? WHERE oc_folio=? AND n=?",
(cant, ahora, quien, folio, n))
hechos.append(n)
if hechos:
self.cerrar_si_llego_todo(c, folio)
return hechos, errores
# ---------------------------------------------------------------- avisos del cotizador
def guardar_oc(self, c, d, tipo, quien, prueba):
"""Alta o actualización de la OC con lo que trae el aviso. Devuelve (oc, error)."""
g = self.g
folio = g["_texto"](d.get("folio"), 40).upper()
if not folio:
return None, "Falta el folio de la orden de compra."
sitio = self.sitio_portal(d.get("sitio"))
if not sitio:
return None, (f"Sitio inválido: {g['_texto'](d.get('sitio'), 20) or '(vacío)'} "
"(usa VER, ZLO, ESE, LZC o QRO)")
ahora = g["ahora"]()
campos = {"req_folio": g["_texto"](d.get("req_folio"), 40).upper(),
"proyecto_folio": g["_texto"](d.get("proyecto_folio"), 40).upper(),
"proyecto_clave": g["_texto"](d.get("proyecto_clave"), 20).upper(),
"sitio": self.codigo(sitio), "sitio_portal": sitio,
"proveedor": g["_texto"](d.get("proveedor"), 120),
"fecha_entrega": g["_texto"](d.get("fecha_entrega"), 10),
"paqueteria": g["_texto"](d.get("paqueteria"), 60),
"guia": g["_texto"](d.get("guia"), 60),
"solicitante": g["_texto"](d.get("solicitante"), 120),
"autorizo": g["_texto"](d.get("autorizo"), 120),
"prueba": 1 if prueba else 0, "actualizado_en": ahora}
if campos["fecha_entrega"] and not g["FECHA_RE"].match(campos["fecha_entrega"]):
return None, "La fecha de entrega va como AAAA-MM-DD."
base = c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone()
if base and base["sitio_portal"] != sitio and base["estado"] not in ("autorizada", "pagada"):
return None, (f"{folio} ya iba a {self.codigo(base['sitio_portal'])}: el sitio no se cambia "
"con el material en camino. Cancélala y haz otra.")
estado = {"oc.autorizada": "autorizada", "oc.pagada": "pagada", "oc.en_camino": "en-camino",
"oc.cancelada": "cancelada"}.get(tipo)
if estado and base and base["estado"] in ("recibida", "recibida-parcial") and estado != "cancelada":
estado = None # ya llegó: el estado del portal manda
if estado:
campos["estado"] = estado
if estado == "en-camino":
campos["en_camino_en"] = ahora
if tipo == "req.cerrada":
campos["req_cerrada"] = 1
if base:
c.execute("UPDATE ordenes SET " + ", ".join(k + "=?" for k in campos) + " WHERE folio=?",
tuple(campos.values()) + (folio,))
else:
campos.update(folio=folio, creado_en=ahora, estado=campos.get("estado") or "autorizada")
c.execute("INSERT INTO ordenes (" + ", ".join(campos) + ") VALUES ("
+ ", ".join("?" * len(campos)) + ")", tuple(campos.values()))
self.guardar_renglones(c, folio, d.get("renglones"))
return c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone(), None
def guardar_renglones(self, c, folio, renglones):
"""Los renglones se actualizan, no se borran: un renglón que ya tiene envío conserva su envío
aunque el cotizador vuelva a mandar la OC."""
g = self.g
if not isinstance(renglones, list):
return
for i, r in enumerate(renglones[:200], start=1):
if not isinstance(r, dict):
continue
n = g["_entero"](r.get("n"), i)
desc = g["_texto"](r.get("descripcion"), 200)
unidad = g["_texto"](r.get("unidad"), 10)
cant = _num(r.get("cantidad"), 0)
item = g["_texto"](r.get("item"), 40).upper() or None
vieja = c.execute("SELECT * FROM renglones WHERE oc_folio=? AND n=?", (folio, n)).fetchone()
if vieja:
# el ítem ligado en el centro de mando gana: aquí es donde se ve el inventario
item = vieja["item_codigo"] or item
if vieja["envio_id"]:
cant = _num(vieja["cantidad"], cant)
c.execute("UPDATE renglones SET descripcion=?, unidad=?, cantidad=?, item_codigo=? "
"WHERE oc_folio=? AND n=?", (desc, unidad, cant, item, folio, n))
else:
c.execute("INSERT INTO renglones (oc_folio, n, descripcion, unidad, cantidad, item_codigo) "
"VALUES (?,?,?,?,?,?)", (folio, n, desc, unidad, cant, item))
# ---------------------------------------------------------------- salida para la página
def ficha(self, c, oc, con_renglones=True):
d = {k: oc[k] for k in oc.keys()}
d["prueba"] = bool(d.get("prueba"))
d["req_cerrada"] = bool(d.get("req_cerrada"))
d["sitio_nombre"] = self.nombre_sitio(oc["sitio_portal"])
if con_renglones:
rs = [{k: r[k] for k in r.keys()} for r in
c.execute("SELECT * FROM renglones WHERE oc_folio=? ORDER BY n", (oc["folio"],)).fetchall()]
for r in rs:
r["pendiente"] = _num(r["cantidad"]) - _num(r["recibido"]) if r["recibido_en"] else _num(r["cantidad"])
d["renglones"] = rs
d["por_ligar"] = [r["n"] for r in rs if not r["item_codigo"]]
d["recibidos"] = sum(1 for r in rs if r["recibido_en"])
return d
def registrar(app, g):
C = Compras(g)
try:
C.arrancar()
except Exception as e: # noqa: BLE001 — sin base, el resto del sitio sigue igual
app.logger.error("compras.py: no se pudo abrir %s: %s", C.ruta, e)
# ------------------------------------------------------------ firma
def firmado():
"""Revisa la firma del cotizador. Devuelve (cuerpo_en_texto, None) o (None, respuesta de error)."""
sec = _secreto()
if not sec:
return None, (jsonify(ok=False, error="El centro de mando no tiene configurado "
"COTIZADOR_SECRETO."), 503)
crudo = request.get_data(cache=True) or b""
if len(crudo) > MAX_CUERPO:
return None, (jsonify(ok=False, error="Aviso demasiado grande."), 413)
tiempo = request.headers.get("X-Hbox-Tiempo", "")
firma = request.headers.get("X-Hbox-Firma", "")
if firma.startswith("sha256="):
firma = firma[7:]
try:
t = datetime.datetime.fromisoformat(tiempo)
except ValueError:
return None, (jsonify(ok=False, error="Firma inválida o vencida"), 401)
ahora = datetime.datetime.now(t.tzinfo) if t.tzinfo else datetime.datetime.now()
if abs((ahora - t).total_seconds()) > VENTANA_FIRMA:
return None, (jsonify(ok=False, error="Firma inválida o vencida"), 401)
esperada = hmac.new(sec.encode(), tiempo.encode() + b"\n" + crudo, hashlib.sha256).hexdigest()
if not hmac.compare_digest(esperada, (firma or "").strip().lower()):
return None, (jsonify(ok=False, error="Firma inválida o vencida"), 401)
return crudo.decode("utf-8", "replace"), None
# ------------------------------------------------------------ avisos del cotizador
@app.post("/api/integracion/cotizador/evento")
def integracion_evento():
texto, err = firmado()
if err:
return err
try:
d = json.loads(texto or "{}")
except ValueError:
return jsonify(ok=False, error="JSON inválido"), 400
if not isinstance(d, dict):
return jsonify(ok=False, error="JSON inválido"), 400
d = _sin_montos(d) # los montos nunca entran al centro de mando
tipo = g["_texto"](d.get("tipo"), 30)
if tipo not in TIPOS:
return jsonify(ok=False, error=f"Tipo de aviso inválido: {tipo or '(vacío)'}. "
"Usa " + ", ".join(TIPOS) + "."), 400
evento_id = g["_texto"](d.get("evento_id"), 120)
if not evento_id:
return jsonify(ok=False, error="Falta evento_id."), 400
oc_in = d.get("oc") if isinstance(d.get("oc"), dict) else {}
quien = g["_texto"](d.get("quien"), 120) or "cotizador"
prueba = bool(d.get("prueba"))
with C.con() as c:
if c.execute("SELECT 1 FROM eventos WHERE evento_id=?", (evento_id,)).fetchone():
return jsonify(ok=True, repetido=True)
oc, error = C.guardar_oc(c, oc_in, tipo, quien, prueba)
if error:
return jsonify(ok=False, error=error), 400
folio = oc["folio"]
if prueba:
# las rondas del proyecto de PRUEBAS no tocan el inventario ni el tablero (contrato §prueba)
c.execute("INSERT INTO eventos (evento_id, oc_folio, tipo, cuando, quien, origen, datos) "
"VALUES (?,?,?,?,?,?,?)",
(evento_id, folio, tipo, g["ahora"](), quien, "cotizador", json.dumps(d)[:20000]))
return jsonify(ok=True, prueba=True, estado_portal=oc["estado"], tarea_id=None,
por_ligar=[r["n"] for r in c.execute(
"SELECT n FROM renglones WHERE oc_folio=? AND item_codigo IS NULL", (folio,))])
tarea_id = oc["tarea_id"]
if tipo == "oc.autorizada" and not tarea_id:
tarea_id = C.abrir_pendiente(c.execute("SELECT * FROM ordenes WHERE folio=?",
(folio,)).fetchone(), quien)
c.execute("UPDATE ordenes SET tarea_id=? WHERE folio=?", (tarea_id, folio))
if tipo == "oc.en_camino":
C.crear_envios(c, folio, quien)
C.nota_pendiente(tarea_id, (f"el proveedor ya lo mandó"
+ (f" ({oc['paqueteria']} {oc['guia']})" if oc["guia"] else "")
+ f"; llega ≈ {oc['fecha_entrega'] or 'sin fecha'}")[:300], quien)
elif tipo == "oc.pagada":
C.nota_pendiente(tarea_id, "pagada en el cotizador", quien)
elif tipo == "oc.cancelada":
C.cancelar_envios(c, folio, quien, f"{folio} cancelada en el cotizador")
C.nota_pendiente(tarea_id, f"{folio} se canceló en el cotizador: ya no llega.",
quien, estado="hecho")
c.execute("INSERT INTO eventos (evento_id, oc_folio, tipo, cuando, quien, origen, datos) "
"VALUES (?,?,?,?,?,?,?)",
(evento_id, folio, tipo, g["ahora"](), quien, "cotizador", json.dumps(d)[:20000]))
fila = c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone()
por_ligar = [r["n"] for r in c.execute(
"SELECT n FROM renglones WHERE oc_folio=? AND item_codigo IS NULL ORDER BY n", (folio,))]
return jsonify(ok=True, estado_portal=fila["estado"], tarea_id=fila["tarea_id"], por_ligar=por_ligar)
@app.get("/api/integracion/inventario/items")
def integracion_items():
_, err = firmado()
if err:
return err
items = [{"codigo": it.get("codigo"), "descripcion": it.get("descripcion"),
"unidad": it.get("unidad"), "categoria": it.get("categoria")}
for it in C.items() if it.get("codigo")]
items.sort(key=lambda x: (x["categoria"] or "", x["codigo"] or ""))
return jsonify(ok=True, items=items)
@app.get("/api/integracion/cotizador/recepciones")
def integracion_recepciones():
_, err = firmado()
if err:
return err
desde = g["_texto"](request.args.get("desde"), 40)
hasta = g["ahora"]()
with C.con() as c:
C.conciliar(c)
q = ("SELECT DISTINCT oc_folio FROM renglones WHERE recibido_en IS NOT NULL"
+ (" AND recibido_en > ?" if desde else ""))
folios = [r[0] for r in c.execute(q, (desde,) if desde else ()).fetchall()]
out = []
for f in sorted(folios):
oc = c.execute("SELECT * FROM ordenes WHERE folio=?", (f,)).fetchone()
if not oc or oc["prueba"]:
continue
rs = c.execute("SELECT * FROM renglones WHERE oc_folio=? ORDER BY n", (f,)).fetchall()
con_algo = [r for r in rs if r["recibido_en"]]
out.append({"oc_folio": f, "req_folio": oc["req_folio"], "sitio": oc["sitio"],
"completa": all(r["recibido_en"] and _num(r["recibido"]) >= _num(r["cantidad"])
for r in rs),
"recibio": (con_algo[-1]["recibido_por"] if con_algo else ""),
"cuando": max((r["recibido_en"] for r in con_algo), default=""),
"renglones": [{"n": r["n"], "pedido": _num(r["cantidad"]),
"recibido": _num(r["recibido"]) if r["recibido_en"] else 0}
for r in rs]})
return jsonify(ok=True, hasta=hasta, recepciones=out)
# ------------------------------------------------------------ la página Compras
@app.get("/api/compras")
def compras_listar():
sitio = C.sitio_portal(request.args.get("sitio")) if request.args.get("sitio") else None
estado = g["_texto"](request.args.get("estado"), 20).lower()
with C.con() as c:
C.conciliar(c)
filas = c.execute("SELECT * FROM ordenes WHERE prueba=0 ORDER BY creado_en DESC").fetchall()
ocs = [C.ficha(c, f) for f in filas]
if sitio:
ocs = [o for o in ocs if o["sitio_portal"] == sitio]
if estado:
ocs = [o for o in ocs if o["estado"] == estado]
resumen = {}
for o in ocs:
r = resumen.setdefault(o["sitio_portal"], {k: 0 for k in ESTADOS_OC})
r[o["estado"]] = r.get(o["estado"], 0) + 1
items = [{"codigo": it.get("codigo"), "descripcion": it.get("descripcion"),
"unidad": it.get("unidad"), "categoria": it.get("categoria")}
for it in C.items() if it.get("codigo")]
items.sort(key=lambda x: (x["categoria"] or "", x["codigo"] or ""))
return jsonify(ok=True, total=len(ocs), ordenes=ocs, resumen=resumen, items=items,
estados=ESTADOS_OC, user=g["current_user"](), admin=g["is_admin"](),
integracion=bool(_secreto()),
sitios=[{"id": i, "nombre": n, "corto": c2, "codigo": C.codigo(i)}
for i, n, c2 in g["SITIOS_TAREA"]])
@app.post("/api/compras/ligar")
def compras_ligar():
u = g["quien"]()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
folio = g["_texto"](d.get("oc"), 40).upper()
n = g["_entero"](d.get("n"), -1)
codigo = g["_texto"](d.get("item"), 40).upper()
if not C.item_por_codigo(codigo):
return jsonify(ok=False, error=f"No existe el ítem {codigo} en el inventario."), 404
with C.con() as c:
oc = c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone()
r = c.execute("SELECT * FROM renglones WHERE oc_folio=? AND n=?", (folio, n)).fetchone()
if not oc or not r:
return jsonify(ok=False, error=f"No existe el renglón {n} de {folio}."), 404
if r["envio_id"]:
return jsonify(ok=False, error="Ese renglón ya está en camino en el inventario."), 409
if r["recibido_en"]:
return jsonify(ok=False, error="Ese renglón ya se recibió."), 409
c.execute("UPDATE renglones SET item_codigo=?, nota=NULL WHERE oc_folio=? AND n=?",
(codigo, folio, n))
if oc["estado"] == "en-camino": # ya venía en camino: se manda ahora que ya tiene ítem
C.crear_envio(c, oc, c.execute("SELECT * FROM renglones WHERE oc_folio=? AND n=?",
(folio, n)).fetchone(), u)
ficha = C.ficha(c, c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone())
return jsonify(ok=True, orden=ficha)
@app.post("/api/compras/recibir")
def compras_recibir():
u = g["quien"]()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
folio = g["_texto"](d.get("oc"), 40).upper()
lineas = d.get("renglones")
if not isinstance(lineas, list) or not lineas:
return jsonify(ok=False, error="Dime qué llegó: al menos un renglón."), 400
with C.con() as c:
hechos, errores = C.recibir(c, folio, lineas, u)
if errores and not hechos:
return jsonify(ok=False, error=" ".join(errores)), 400
ficha = C.ficha(c, c.execute("SELECT * FROM ordenes WHERE folio=?", (folio,)).fetchone())
return jsonify(ok=True, orden=ficha, recibidos=hechos, avisos=errores)