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

@@ -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 = """