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:
@@ -18,6 +18,11 @@ from services.pos_engine import (
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
from services.cross_sell import get_cross_sell
|
||||
from services.anomaly_detector import (
|
||||
detect_anomalies, DEFAULT_MIN_SAMPLES as _ANOM_MIN_SAMPLES,
|
||||
DEFAULT_Z as _ANOM_Z,
|
||||
)
|
||||
from config import JWT_SECRET
|
||||
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
@@ -41,6 +46,88 @@ def _tenant_allows_zero_price(conn):
|
||||
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _float_arg(name, default, min_value=None, max_value=None):
|
||||
"""Parse a float query-string argument safely (bad input → default)."""
|
||||
raw = request.args.get(name)
|
||||
try:
|
||||
value = float(raw) if raw not in (None, '') else default
|
||||
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
|
||||
|
||||
|
||||
def _validate_price_floor(conn, items):
|
||||
"""Raise ValueError if any line price is below item cost and the employee
|
||||
is not owner/admin. Prevents margin leaks via price editing."""
|
||||
from flask import g as _g
|
||||
if getattr(_g, 'employee_role', None) in ('owner', 'admin'):
|
||||
return
|
||||
cur = conn.cursor()
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
unit_price = float(item.get('unit_price', 0) or 0)
|
||||
if not inv_id or unit_price <= 0:
|
||||
continue
|
||||
cur.execute("SELECT part_number, cost FROM inventory WHERE id = %s", (inv_id,))
|
||||
row = cur.fetchone()
|
||||
if row and row[1] and unit_price < float(row[1]):
|
||||
cur.close()
|
||||
raise ValueError(
|
||||
f"Precio por debajo del costo en {row[0]} (${unit_price:,.2f} < ${float(row[1]):,.2f}). "
|
||||
"Solicita autorización de gerencia.")
|
||||
cur.close()
|
||||
|
||||
|
||||
def _validate_max_discount(conn, items):
|
||||
"""Raise ValueError if any line discount exceeds the employee's cap.
|
||||
|
||||
Mirrors the check done by pos_engine.process_sale so quotations and
|
||||
layaways cannot be used to bypass the discount limit.
|
||||
"""
|
||||
from flask import g as _g
|
||||
if getattr(_g, 'employee_role', None) in ('owner', 'admin'):
|
||||
return
|
||||
from services.pos_engine import get_max_discount_pct
|
||||
max_discount = get_max_discount_pct(conn)
|
||||
for item in items:
|
||||
discount_pct = float(item.get('discount_pct', 0) or 0)
|
||||
if discount_pct > max_discount:
|
||||
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
|
||||
raise ValueError(
|
||||
f"Descuento {discount_pct}% excede tu máximo permitido {max_discount}% "
|
||||
f"para {name}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_zero_price(conn, items):
|
||||
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
|
||||
if _tenant_allows_zero_price(conn):
|
||||
@@ -156,7 +243,38 @@ def create_sale():
|
||||
try:
|
||||
_validate_stock_availability(conn, data.get('items', []), branch_id)
|
||||
_validate_zero_price(conn, data.get('items', []))
|
||||
_validate_price_floor(conn, data.get('items', []))
|
||||
|
||||
# Idempotencia offline: si el client_uuid ya existe, devolver esa venta
|
||||
client_uuid = (data.get('client_uuid') or '').strip() or None
|
||||
cur_iu = conn.cursor()
|
||||
if client_uuid:
|
||||
cur_iu.execute("SELECT id FROM sales WHERE client_uuid = %s LIMIT 1", (client_uuid,))
|
||||
dup = cur_iu.fetchone()
|
||||
if dup:
|
||||
cur_iu.execute("SELECT id, total FROM sales WHERE id = %s", (dup[0],))
|
||||
srow = cur_iu.fetchone()
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': srow[0], 'total': float(srow[1]),
|
||||
'duplicate': True,
|
||||
'message': 'Venta ya registrada (offline sync)'}), 200
|
||||
|
||||
sale = process_sale(conn, data)
|
||||
if client_uuid:
|
||||
try:
|
||||
cur_iu.execute("UPDATE sales SET client_uuid = %s WHERE id = %s",
|
||||
(client_uuid, sale['id']))
|
||||
except Exception:
|
||||
conn.rollback() # duplicate race: fetch existing
|
||||
cur_iu.execute("SELECT id FROM sales WHERE client_uuid = %s LIMIT 1", (client_uuid,))
|
||||
dup = cur_iu.fetchone()
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': dup[0], 'duplicate': True}), 200
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(sale), 201
|
||||
@@ -267,22 +385,22 @@ def list_sales():
|
||||
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 = ["1=1"]
|
||||
params = []
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
employee_id = _int_arg('employee_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
sale_type = request.args.get('sale_type')
|
||||
q = request.args.get('q')
|
||||
customer_q = request.args.get('customer')
|
||||
courier_id = request.args.get('courier_id')
|
||||
register_id = request.args.get('register_id')
|
||||
courier_id = _int_arg('courier_id')
|
||||
register_id = _int_arg('register_id')
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("s.created_at >= %s")
|
||||
@@ -292,10 +410,10 @@ def list_sales():
|
||||
params.append(date_to)
|
||||
if employee_id:
|
||||
where_clauses.append("s.employee_id = %s")
|
||||
params.append(int(employee_id))
|
||||
params.append(employee_id)
|
||||
if customer_id:
|
||||
where_clauses.append("s.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("s.status = %s")
|
||||
params.append(status)
|
||||
@@ -310,10 +428,10 @@ def list_sales():
|
||||
params.append(f'%{customer_q}%')
|
||||
if courier_id:
|
||||
where_clauses.append("s.courier_id = %s")
|
||||
params.append(int(courier_id))
|
||||
params.append(courier_id)
|
||||
if register_id:
|
||||
where_clauses.append("s.register_id = %s")
|
||||
params.append(int(register_id))
|
||||
params.append(register_id)
|
||||
|
||||
# Default to current branch
|
||||
if g.branch_id:
|
||||
@@ -386,7 +504,7 @@ def recent_sales():
|
||||
|
||||
date_from = request.args.get('date_from') or str(date.today())
|
||||
date_to = request.args.get('date_to') or date_from
|
||||
limit = min(int(request.args.get('limit', 10)), 50)
|
||||
limit = _int_arg('limit', 10, min_value=1, max_value=50)
|
||||
|
||||
where_clauses = [
|
||||
"s.created_at >= %s",
|
||||
@@ -454,8 +572,8 @@ def list_historical_sales():
|
||||
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 = ["1=1"]
|
||||
params = []
|
||||
@@ -616,6 +734,25 @@ def api_cancel_sale(sale_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = cancel_sale(conn, sale_id, reason)
|
||||
# Auto-cancel stamped CFDI (motivo 03: no se llevó a cabo la operación)
|
||||
try:
|
||||
from services.cfdi_queue import cancel_cfdi
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id FROM cfdi_queue WHERE sale_id = %s AND status = 'stamped' "
|
||||
"ORDER BY id DESC LIMIT 1", (sale_id,))
|
||||
cfdi_row = cur.fetchone()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key IN "
|
||||
"('cfdi_facturapi_key', 'cfdi_facturapi_org_id', 'facturapi_secret_key')")
|
||||
tcfg = {k: v for k, v in cur.fetchall()}
|
||||
cur.close()
|
||||
if cfdi_row:
|
||||
cancel_cfdi(conn, cfdi_row[0], '03', tenant_config=tcfg)
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback() # CFDI cancel is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
@@ -748,6 +885,8 @@ def create_quotation():
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -844,18 +983,18 @@ def list_quotations():
|
||||
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 = ["1=1"]
|
||||
params = []
|
||||
|
||||
customer_id = request.args.get('customer_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
|
||||
if customer_id:
|
||||
where_clauses.append("q.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("q.status = %s")
|
||||
params.append(status)
|
||||
@@ -1138,6 +1277,8 @@ def update_quotation(quot_id):
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -1454,7 +1595,11 @@ def get_quotation_pdf(quot_id):
|
||||
# Get business info from tenant config
|
||||
business_info = None
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address','phone','email')")
|
||||
cur.execute("""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')
|
||||
UNION ALL SELECT 'phone', value FROM tenant_config WHERE key IN ('tenant_telefono','receipt_phone')
|
||||
UNION ALL SELECT 'email', value FROM tenant_config WHERE key = 'tenant_email' LIMIT 5""")
|
||||
config_rows = cur.fetchall()
|
||||
if config_rows:
|
||||
business_info = {r[0]: r[1] for r in config_rows}
|
||||
@@ -1626,6 +1771,8 @@ def convert_quotation(quot_id):
|
||||
try:
|
||||
_validate_stock_availability(conn, items, g.branch_id)
|
||||
_validate_zero_price(conn, items)
|
||||
_validate_price_floor(conn, items)
|
||||
_validate_max_discount(conn, items)
|
||||
sale = process_sale(conn, sale_data)
|
||||
|
||||
# Mark quotation as converted
|
||||
@@ -1786,6 +1933,8 @@ def create_layaway():
|
||||
enriched = _enrich_items(cur, items, customer_id)
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -1867,6 +2016,23 @@ def create_layaway():
|
||||
'customer_id': customer_id})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): anticipo de cliente
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id,
|
||||
'amount': initial_payment,
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} deposit failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -1892,18 +2058,18 @@ def list_layaways():
|
||||
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 = ["1=1"]
|
||||
params = []
|
||||
|
||||
customer_id = request.args.get('customer_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
|
||||
if customer_id:
|
||||
where_clauses.append("l.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("l.status = %s")
|
||||
params.append(status)
|
||||
@@ -2070,6 +2236,23 @@ def layaway_payment(layaway_id):
|
||||
""", (register_id, amount, f'Apartado #{layaway_id} - abono', g.employee_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): anticipo de cliente
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id,
|
||||
'amount': amount,
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} payment failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
new_remaining = round(total - new_paid, 2)
|
||||
@@ -2117,10 +2300,21 @@ def complete_layaway(layaway_id):
|
||||
return jsonify({'error': f'Layaway is {status}'}), 400
|
||||
|
||||
remaining = round(total - paid, 2)
|
||||
|
||||
# Payment method for the conversion sale / final payment. Defaults to cash
|
||||
# but honours whatever the register sent (tarjeta, transferencia, ...).
|
||||
payment_method = data.get('payment_method', 'efectivo')
|
||||
forma_pago_map = {
|
||||
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
||||
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
register_id = data.get('register_id')
|
||||
|
||||
try:
|
||||
# If there's a remaining balance, accept a final payment with the complete call
|
||||
if remaining > 0:
|
||||
final_method = data.get('payment_method', 'efectivo')
|
||||
final_method = payment_method
|
||||
cur.execute("""
|
||||
INSERT INTO layaway_payments
|
||||
(layaway_id, amount, payment_method, reference, employee_id)
|
||||
@@ -2131,13 +2325,24 @@ def complete_layaway(layaway_id):
|
||||
paid = total
|
||||
|
||||
# Record cash movement for final payment
|
||||
register_id = data.get('register_id')
|
||||
if register_id and final_method == 'efectivo':
|
||||
cur.execute("""
|
||||
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||
VALUES (%s, 'in', %s, %s, %s)
|
||||
""", (register_id, remaining, f'Apartado #{layaway_id} - pago final', g.employee_id))
|
||||
|
||||
# Accounting entry for the final payment (part of the same transaction)
|
||||
try:
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id, 'amount': remaining, 'payment_method': final_method,
|
||||
})
|
||||
except Exception as acc_err:
|
||||
import logging
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} final payment failed: {acc_err}'
|
||||
)
|
||||
|
||||
# Get layaway items
|
||||
cur.execute("""
|
||||
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
|
||||
@@ -2165,12 +2370,14 @@ def complete_layaway(layaway_id):
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, notes)
|
||||
VALUES (%s,%s,%s,%s,'cash','efectivo',%s,%s,%s,%s,%s,0,'PUE','01','completed',%s)
|
||||
VALUES (%s,%s,%s,%s,'cash',%s,%s,%s,%s,%s,%s,0,'PUE',%s,'completed',%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, cust_id, g.employee_id, data.get('register_id'),
|
||||
branch_id, cust_id, g.employee_id, register_id,
|
||||
payment_method,
|
||||
totals_calc['subtotal'], totals_calc['discount_total'],
|
||||
totals_calc['tax_total'], totals_calc['total'], total,
|
||||
forma_pago_sat,
|
||||
f'Completado de apartado #{layaway_id}',
|
||||
))
|
||||
sale_id, sale_created = cur.fetchone()
|
||||
@@ -2201,13 +2408,12 @@ def complete_layaway(layaway_id):
|
||||
))
|
||||
|
||||
# Record payment on register
|
||||
register_id = data.get('register_id')
|
||||
if register_id:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s,%s,'efectivo',%s,%s)
|
||||
""", (sale_id, register_id, total, f'Apartado #{layaway_id} completado'))
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, total, f'Apartado #{layaway_id} completado'))
|
||||
|
||||
sale = {
|
||||
'id': sale_id, 'status': 'completed', 'total': totals_calc['total'],
|
||||
@@ -2225,6 +2431,24 @@ def complete_layaway(layaway_id):
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): apply advances and recognize the sale
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_conversion_entry
|
||||
record_layaway_conversion_entry(conn, {
|
||||
'id': sale['id'],
|
||||
'layaway_id': layaway_id,
|
||||
'subtotal': totals_calc['subtotal'],
|
||||
'tax_total': totals_calc['tax_total'],
|
||||
'total': totals_calc['total'],
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} conversion failed: {acc_err}'
|
||||
)
|
||||
|
||||
# WhatsApp learning hook (non-blocking)
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
@@ -2245,6 +2469,74 @@ def complete_layaway(layaway_id):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
def _record_layaway_cancel_entry(conn, layaway_id):
|
||||
"""Reverse the customer advances (240) booked while collecting a layaway.
|
||||
|
||||
Deposits were journalised as 'Debit Caja/Bancos / Credit 240 Anticipos'
|
||||
when collected; cancelling the layaway refunds them, so the entry is
|
||||
reversed per payment method. Without this, account 240 keeps a phantom
|
||||
liability after cancellation. Mirrors accounting_engine.record_layaway_entry.
|
||||
"""
|
||||
from datetime import date as _date
|
||||
from services.accounting_engine import (
|
||||
_create_entry, _get_account_ids, _ensure_account,
|
||||
_check_period_open, get_next_entry_number,
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT payment_method, COALESCE(SUM(amount), 0)
|
||||
FROM layaway_payments WHERE layaway_id = %s
|
||||
GROUP BY payment_method
|
||||
""", (layaway_id,))
|
||||
payments = cur.fetchall()
|
||||
|
||||
credits = []
|
||||
total = 0.0
|
||||
for method, amount in payments:
|
||||
amt = round(float(amount or 0), 2)
|
||||
if amt <= 0:
|
||||
continue
|
||||
credits.append((method, amt))
|
||||
total = round(total + amt, 2)
|
||||
|
||||
if total <= 0:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
accounts = _get_account_ids(cur, ['110', '111'])
|
||||
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
||||
|
||||
lines = [{
|
||||
'account_id': advance_account,
|
||||
'debit': total,
|
||||
'credit': 0,
|
||||
'description': f'Anticipos de clientes - Cancelacion apartado #{layaway_id}',
|
||||
}]
|
||||
for method, amount in credits:
|
||||
if method in ('transferencia', 'tarjeta'):
|
||||
account_id = accounts['111']
|
||||
desc = f'Bancos - Cancelacion apartado #{layaway_id}'
|
||||
else:
|
||||
account_id = accounts['110']
|
||||
desc = f'Caja - Cancelacion apartado #{layaway_id}'
|
||||
lines.append({
|
||||
'account_id': account_id, 'debit': 0, 'credit': amount,
|
||||
'description': desc,
|
||||
})
|
||||
|
||||
entry_date = _date.today()
|
||||
_check_period_open(cur, entry_date)
|
||||
entry_number = get_next_entry_number(conn)
|
||||
entry_id = _create_entry(
|
||||
cur, entry_number, entry_date, 'egreso',
|
||||
f'Apartado #{layaway_id} - cancelacion (reversa anticipos)',
|
||||
'layaway', layaway_id, lines
|
||||
)
|
||||
cur.close()
|
||||
return entry_id
|
||||
|
||||
|
||||
@pos_bp.route('/layaways/<int:layaway_id>/cancel', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
def cancel_layaway(layaway_id):
|
||||
@@ -2298,6 +2590,19 @@ def cancel_layaway(layaway_id):
|
||||
'items_unreserved': len(layaway_items)})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): reverse the customer advances collected
|
||||
# for this layaway, otherwise account 240 keeps a phantom liability.
|
||||
try:
|
||||
import logging
|
||||
_record_layaway_cancel_entry(conn, layaway_id)
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting reversal for cancelled layaway #{layaway_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -2311,14 +2616,19 @@ def cancel_layaway(layaway_id):
|
||||
# ─── Returns / Warranty ───────────────────────────
|
||||
|
||||
@pos_bp.route('/returns', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth('pos.sell', 'pos.cancel')
|
||||
def create_return():
|
||||
"""Process a product return with warranty support.
|
||||
|
||||
Requires supervisor-level authorisation (pos.cancel) in addition to
|
||||
pos.sell, since a return moves stock back and refunds money.
|
||||
|
||||
Body: {
|
||||
sale_id: int,
|
||||
items: [{sale_item_id: int, quantity: int, reason: str}],
|
||||
notes: str
|
||||
notes: str,
|
||||
register_id: int (optional; cash register refunding the money),
|
||||
refund_method: str (optional; defaults to the sale's payment method)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
@@ -2337,7 +2647,7 @@ def create_return():
|
||||
try:
|
||||
# Validate sale exists and is completed
|
||||
cur.execute("""
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type, payment_method
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
sale = cur.fetchone()
|
||||
@@ -2349,6 +2659,7 @@ def create_return():
|
||||
sale_customer_id = sale[1]
|
||||
sale_branch_id = sale[4] or g.branch_id
|
||||
sale_type = sale[5]
|
||||
refund_method = data.get('refund_method') or sale[6] or 'efectivo'
|
||||
|
||||
# Validate each return item against original sale items
|
||||
total_refund = 0
|
||||
@@ -2433,6 +2744,27 @@ def create_return():
|
||||
notes=f'Devolucion #{return_id} de venta #{sale_id}: {vi["reason"]}'
|
||||
)
|
||||
|
||||
# Refund through the cash register: a cash refund leaves the drawer, so
|
||||
# it is recorded as an 'out' movement (see _compute_register_summary,
|
||||
# which subtracts these refunds from the expected cash). Credit sales
|
||||
# instead lower the customer's balance (handled below), and non-cash
|
||||
# refunds (tarjeta/transferencia) never touch the drawer.
|
||||
register_id = data.get('register_id')
|
||||
if not register_id:
|
||||
cur.execute("""
|
||||
SELECT id FROM cash_registers
|
||||
WHERE employee_id = %s AND status = 'open'
|
||||
""", (g.employee_id,))
|
||||
reg = cur.fetchone()
|
||||
register_id = reg[0] if reg else None
|
||||
|
||||
if register_id and refund_method == 'efectivo' and sale_type != 'credit':
|
||||
cur.execute("""
|
||||
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||
VALUES (%s, 'out', %s, %s, %s)
|
||||
""", (register_id, total_refund,
|
||||
f'Devolucion #{return_id} de venta #{sale_id}', g.employee_id))
|
||||
|
||||
# Update sale status if all items returned
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(ri2.quantity), 0), COALESCE(SUM(si2.quantity), 0)
|
||||
@@ -2466,6 +2798,19 @@ def create_return():
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): devolucion sobre ventas + IVA + inventario
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_return_entry
|
||||
record_return_entry(conn, {'id': return_id, 'sale_id': sale_id})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for return #{return_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -2494,20 +2839,20 @@ def list_returns():
|
||||
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 = ["1=1"]
|
||||
params = []
|
||||
|
||||
sale_id = request.args.get('sale_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
sale_id = _int_arg('sale_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
if sale_id:
|
||||
where_clauses.append("r.sale_id = %s")
|
||||
params.append(int(sale_id))
|
||||
params.append(sale_id)
|
||||
if customer_id:
|
||||
where_clauses.append("r.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
@@ -2655,7 +3000,9 @@ def print_ticket(sale_id):
|
||||
# Fetch business info from config
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
cur.execute("""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')""")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
@@ -2736,7 +3083,9 @@ def print_remission(sale_id):
|
||||
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
cur.execute("""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')""")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
@@ -2765,6 +3114,67 @@ def print_remission(sale_id):
|
||||
})
|
||||
|
||||
|
||||
# ─── Cross-sell recommendations (A10) ───────────────────────────────────────
|
||||
|
||||
@pos_bp.route('/recommendations', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def recommendations():
|
||||
"""Suggest complementary parts for the items currently in the cart.
|
||||
|
||||
Body: {"inventory_ids": [1,2,3], "limit": 5}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_ids = data.get('inventory_ids') or data.get('items') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return jsonify({'error': 'inventory_ids must be a list'}), 400
|
||||
try:
|
||||
inventory_ids = [int(x) for x in raw_ids][:50]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'inventory_ids must be a list of integers'}), 400
|
||||
|
||||
try:
|
||||
limit = int(data.get('limit') or 5)
|
||||
except (TypeError, ValueError):
|
||||
limit = 5
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({'recommendations': []})
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
recs = get_cross_sell(conn, inventory_ids, limit=limit)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({'recommendations': recs})
|
||||
|
||||
|
||||
# ─── Business anomaly report (A5) ───────────────────────────────────────────
|
||||
|
||||
@pos_bp.route('/reports/anomalies', methods=['GET'])
|
||||
@require_auth('reports.view')
|
||||
def report_anomalies():
|
||||
"""Robust anomaly scan over discounts, below-cost sales, cancellations
|
||||
and off-hours tickets.
|
||||
|
||||
Query params: days (90), min_samples (5), z (3.5), limit (50).
|
||||
"""
|
||||
days = _int_arg('days', 90, 1, 3650)
|
||||
min_samples = _int_arg('min_samples', _ANOM_MIN_SAMPLES, 1, 1000)
|
||||
z_threshold = _float_arg('z', _ANOM_Z, 0.5, 20.0)
|
||||
limit = _int_arg('limit', 50, 1, 200)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = detect_anomalies(
|
||||
conn, days=days, min_samples=min_samples,
|
||||
z_threshold=z_threshold, limit=limit,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
|
||||
Reference in New Issue
Block a user