Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user