Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -1,12 +1,15 @@
# /home/Autopartes/pos/blueprints/cashregister_bp.py
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""
import logging
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.audit import log_action
logger = logging.getLogger('pos')
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
@@ -18,6 +21,29 @@ def _can_operate_register():
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
def _int_arg(name, default=None, min_value=None, max_value=None):
"""Parse an integer query-string argument safely.
Bad input (e.g. ?per_page=abc) falls back to the default instead of
raising ValueError and returning a 500.
"""
raw = request.args.get(name)
if raw is None or raw == '':
value = default
else:
try:
value = int(raw)
except (TypeError, ValueError):
value = default
if value is None:
return None
if min_value is not None and value < min_value:
value = min_value
if max_value is not None and value > max_value:
value = max_value
return value
@cashregister_bp.route('/open', methods=['POST'])
@require_auth()
def open_register():
@@ -220,8 +246,35 @@ def _compute_register_summary(conn, register_id):
total_sales += amount
total_sales_count += count
# Cash sales specifically (for expected cash calculation)
cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
# Cash received for the expected-cash calculation:
# - credit sales collect nothing at sale time (their abonos arrive as
# cash movements), so they contribute 0
# - cash sales contribute their total (change is discounted below)
# - mixed/other sales contribute only their cash portion from sale_payments
# Layaways already log every deposit as a cash movement, so their
# conversion sale is excluded here to avoid counting that cash twice.
cur.execute("""
SELECT COALESCE(SUM(
CASE
WHEN s.sale_type = 'credit' THEN 0
WHEN s.payment_method = 'efectivo' THEN s.total
ELSE COALESCE(cash.cash_amount, 0)
END
), 0)
FROM sales s
LEFT JOIN (
SELECT sale_id, SUM(amount) AS cash_amount
FROM sale_payments
WHERE method = 'efectivo'
GROUP BY sale_id
) cash ON cash.sale_id = s.id
WHERE s.register_id = %s AND s.status = 'completed'
AND s.id NOT IN (
SELECT converted_sale_id FROM layaways
WHERE converted_sale_id IS NOT NULL
)
""", (register_id,))
cash_from_sales = float(cur.fetchone()[0])
# Change given (cash out)
cur.execute("""
@@ -230,15 +283,26 @@ def _compute_register_summary(conn, register_id):
""", (register_id,))
change_given = float(cur.fetchone()[0])
# Cash movements
# Cash movements. Refunds from devoluciones (reason 'Devolucion #...') are
# kept out of the generic 'out' bucket and subtracted separately below, so
# a refund lowers the expected cash exactly once.
cur.execute("""
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
WHERE register_id = %s GROUP BY type
WHERE register_id = %s
AND NOT (type = 'out' AND reason LIKE 'Devolucion #%%')
GROUP BY type
""", (register_id,))
movements = {'in': 0.0, 'out': 0.0}
for r in cur.fetchall():
movements[r[0]] = float(r[1])
# Cash refunds handed back at this register (devoluciones in cash)
cur.execute("""
SELECT COALESCE(SUM(amount), 0) FROM cash_movements
WHERE register_id = %s AND type = 'out' AND reason LIKE 'Devolucion #%%'
""", (register_id,))
refunds_out = float(cur.fetchone()[0])
# Cancelled sales
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
@@ -248,9 +312,10 @@ def _compute_register_summary(conn, register_id):
cancelled_count = cancelled[0]
cancelled_amount = float(cancelled[1])
# Expected cash = opening + cash sales - change + cash_in - cash_out
# Expected cash = opening + cash sales - change + cash_in - cash_out - refunds
expected_cash = round(
opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
opening_amount + cash_from_sales - change_given
+ movements['in'] - movements['out'] - refunds_out,
2
)
@@ -277,6 +342,7 @@ def _compute_register_summary(conn, register_id):
'change_given': round(change_given, 2),
'cash_movements_in': round(movements['in'], 2),
'cash_movements_out': round(movements['out'], 2),
'refunds_out': round(refunds_out, 2),
'movement_detail': movement_detail,
'cancelled_count': cancelled_count,
'cancelled_amount': round(cancelled_amount, 2),
@@ -330,7 +396,7 @@ def cut_z():
cur = conn.cursor()
cur.execute("""
SELECT id FROM cash_registers
SELECT id, register_number FROM cash_registers
WHERE employee_id = %s AND status = 'open'
""", (g.employee_id,))
reg = cur.fetchone()
@@ -338,7 +404,7 @@ def cut_z():
cur.close(); conn.close()
return jsonify({'error': 'No open register'}), 400
register_id = reg[0]
register_id, register_number = reg
summary = _compute_register_summary(conn, register_id)
expected = summary['expected_cash']
@@ -362,6 +428,22 @@ def cut_z():
conn.commit()
# Accounting entry (non-blocking): move the counted cash to Bancos.
# The cut is already committed, so a failure here never loses the corte.
try:
from services.accounting_engine import record_cash_cut_entry
record_cash_cut_entry(conn, {
'id': register_id,
'register_number': register_number,
'closing_amount': closing_amount,
})
conn.commit()
except Exception as acc_err:
conn.rollback()
logger.error(
f'Accounting entry for cash cut #{register_id} failed: {acc_err}'
)
# Push notification to owner if cash difference > $500
if abs(difference) > 500:
try:
@@ -410,8 +492,8 @@ def register_history():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
page = _int_arg('page', 1, min_value=1)
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
where_clauses = ["cr.status = 'closed'"]
params = []
@@ -422,7 +504,7 @@ def register_history():
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
employee_id = request.args.get('employee_id')
employee_id = _int_arg('employee_id')
if not can_view_all:
employee_id = g.employee_id
@@ -435,7 +517,7 @@ def register_history():
params.append(date_to)
if employee_id:
where_clauses.append("cr.employee_id = %s")
params.append(int(employee_id))
params.append(employee_id)
where = " AND ".join(where_clauses)