From 116d4452daaaba1a8c2c6b360c4ff14cd4d97245 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Wed, 15 Jul 2026 17:50:02 +0000 Subject: [PATCH] Add allow_zero_price_sales toggle: config UI, POS enforcement and backend validation --- pos/blueprints/config_bp.py | 33 ++++++++++++++++++ pos/blueprints/pos_bp.py | 29 ++++++++++++++++ pos/blueprints/service_order_bp.py | 24 +++++++++++++ pos/static/js/config.js | 33 ++++++++++++++++++ pos/static/js/pos.js | 55 ++++++++++++++++++++++++++++-- pos/static/pwa/sw.js | 2 +- pos/templates/config.html | 10 ++++++ 7 files changed, 182 insertions(+), 4 deletions(-) diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 0af84d7..64bced0 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -1130,6 +1130,39 @@ def update_counter_remission_config(): return jsonify({'enabled': enabled == 'true'}) +@config_bp.route('/sales-settings', methods=['GET']) +@require_auth('pos.view') +def get_sales_settings(): + """Get sales-related settings (zero-price sales, etc.).""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'") + row = cur.fetchone() + cur.close(); conn.close() + # Default to true to avoid breaking existing tenants that sell at $0. + allow = True + if row: + allow = str(row[0]).lower() in ('true', '1', 'yes') + return jsonify({'allow_zero_price_sales': allow}) + + +@config_bp.route('/sales-settings', methods=['PUT']) +@require_auth('config.edit') +def update_sales_settings(): + """Update sales-related settings.""" + data = request.get_json() or {} + allow = 'true' if data.get('allow_zero_price_sales') else 'false' + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, ('allow_zero_price_sales', allow)) + conn.commit() + cur.close(); conn.close() + return jsonify({'allow_zero_price_sales': allow == 'true'}) + + @config_bp.route('/onboarding-status', methods=['GET']) @require_auth('pos.view') def get_onboarding_status(): diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 9341677..48b1ce7 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -32,6 +32,29 @@ def _tenant_allows_negative_stock(conn): return row is not None and str(row[0]).lower() in ('true', '1', 'yes') +def _tenant_allows_zero_price(conn): + """Return True if the tenant allows selling items at $0. Defaults to True.""" + cur = conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'") + row = cur.fetchone() + cur.close() + return str(row[0]).lower() in ('true', '1', 'yes') if row else True + + +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): + return + for item in items: + unit_price = float(item.get('unit_price', 0) or 0) + quantity = float(item.get('quantity', 1) or 1) + discount_pct = float(item.get('discount_pct', 0) or 0) + line_total = unit_price * quantity * (1 - discount_pct / 100) + if line_total <= 0: + name = item.get('name') or item.get('part_number') or item.get('inventory_id') + raise ValueError(f"No está permitido vender artículos en $0 ({name})") + + def _enrich_items(cur, items, customer_id=None): """Look up inventory data for items that lack unit_price/tax_rate. @@ -129,6 +152,7 @@ def create_sale(): }), 400 try: + _validate_zero_price(conn, data.get('items', [])) sale = process_sale(conn, data) conn.commit() conn.close() @@ -164,6 +188,7 @@ def create_remission(): }), 400 try: + _validate_zero_price(conn, data.get('items', [])) sale = create_remission_note(conn, { 'tenant_id': g.tenant_id, 'branch_id': branch_id, @@ -728,6 +753,7 @@ def create_quotation(): # Enrich items with inventory data (price, tax, etc.) try: enriched = _enrich_items(cur, items, data.get('customer_id')) + _validate_zero_price(conn, enriched) except ValueError as e: cur.close(); conn.close() return jsonify({'error': str(e)}), 400 @@ -1116,6 +1142,7 @@ def update_quotation(quot_id): try: enriched = _enrich_items(cur, items, data.get('customer_id')) + _validate_zero_price(conn, enriched) except ValueError as e: cur.close(); conn.close() return jsonify({'error': str(e)}), 400 @@ -1602,6 +1629,7 @@ def convert_quotation(quot_id): } try: + _validate_zero_price(conn, items) sale = process_sale(conn, sale_data) # Mark quotation as converted @@ -1760,6 +1788,7 @@ def create_layaway(): # Enrich items with inventory data try: enriched = _enrich_items(cur, items, customer_id) + _validate_zero_price(conn, enriched) except ValueError as e: cur.close(); conn.close() return jsonify({'error': str(e)}), 400 diff --git a/pos/blueprints/service_order_bp.py b/pos/blueprints/service_order_bp.py index 65d7605..f37496b 100644 --- a/pos/blueprints/service_order_bp.py +++ b/pos/blueprints/service_order_bp.py @@ -38,6 +38,29 @@ from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PE service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders') +def _tenant_allows_zero_price(conn): + """Return True if the tenant allows selling items at $0. Defaults to True.""" + cur = conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'") + row = cur.fetchone() + cur.close() + return str(row[0]).lower() in ('true', '1', 'yes') if row else True + + +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): + return + for item in items: + unit_price = float(item.get('unit_price', 0) or 0) + quantity = float(item.get('quantity', 1) or 1) + discount_pct = float(item.get('discount_pct', 0) or 0) + line_total = unit_price * quantity * (1 - discount_pct / 100) + if line_total <= 0: + name = item.get('name') or item.get('part_number') or item.get('inventory_id') + raise ValueError(f"No está permitido vender artículos en $0 ({name})") + + # Roles allowed to access the workshop module at all. _WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'} @@ -174,6 +197,7 @@ def create_order_from_pos(): ) conn = get_tenant_conn(g.tenant_id) try: + _validate_zero_price(conn, items) result = create_service_order(conn, { 'tenant_id': g.tenant_id, 'branch_id': data.get('branch_id', g.branch_id), diff --git a/pos/static/js/config.js b/pos/static/js/config.js index 428c193..9a1524f 100644 --- a/pos/static/js/config.js +++ b/pos/static/js/config.js @@ -625,6 +625,7 @@ const Config = (() => { await saveVehicleCompatSource(); await saveAllowedBrands(); await saveModules(); + await saveSalesSettings(); await saveReceiptConfig(); toast('Configuración guardada', 'ok'); } catch (e) { @@ -1015,6 +1016,37 @@ const Config = (() => { } } + async function loadSalesSettings() { + try { + var res = await fetch(API + '/sales-settings', { headers: headers() }); + if (!res.ok) return; + var data = await res.json(); + var cb = document.getElementById('cfg-allow-zero-price'); + if (cb) cb.checked = data.allow_zero_price_sales !== false; + } catch (e) { + console.error('Config.loadSalesSettings:', e); + } + } + + async function saveSalesSettings() { + var cb = document.getElementById('cfg-allow-zero-price'); + if (!cb) return; + try { + var res = await fetch(API + '/sales-settings', { + method: 'PUT', + headers: headers(), + body: JSON.stringify({ allow_zero_price_sales: cb.checked }) + }); + if (!res.ok) { + var err = await res.json().catch(function() { return { error: res.statusText }; }); + throw new Error(err.error || 'Save failed'); + } + } catch (e) { + toast(e.message, 'error'); + throw e; + } + } + // ------------------------------------------------------------------------- // Tab navigation // ------------------------------------------------------------------------- @@ -1277,6 +1309,7 @@ const Config = (() => { loadVehicleCompatSource(); loadAllowedBrands(); loadModules(); + loadSalesSettings(); loadReceiptConfig(); if (isAdmin) { loadRolePermissions(); diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js index 2ecd5b0..bee0cf2 100644 --- a/pos/static/js/pos.js +++ b/pos/static/js/pos.js @@ -32,6 +32,7 @@ const POS = (() => { let canCreateLayaway = false; let canCreateRemission = false; let counterRemissionEnabled = false; + let allowZeroPriceSales = true; let currentPerms = []; let receiptConfig = {}; let couriers = []; @@ -142,6 +143,13 @@ const POS = (() => { } catch (e) { counterRemissionEnabled = false; } + // Sales settings (zero-price sales toggle) + try { + const ssCfg = await api('/pos/api/config/sales-settings'); + allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false; + } catch (e) { + allowZeroPriceSales = true; + } // Counter remission workflow applies only to the counter role. canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission'); @@ -442,6 +450,18 @@ const POS = (() => { } } + function isZeroPriceItem(item) { + const unitPrice = parseFloat(item.unit_price || 0); + const qty = parseFloat(item.quantity || 1); + const discount = parseFloat(item.discount_pct || 0); + return (unitPrice * qty * (1 - discount / 100)) <= 0; + } + + function findZeroPriceItem() { + if (allowZeroPriceSales) return null; + return cart.find(isZeroPriceItem) || null; + } + function modifyPrice() { if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; } if (selectedRow < 0 || selectedRow >= cart.length) { @@ -451,10 +471,13 @@ const POS = (() => { const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price); if (p !== null) { const n = parseFloat(p); - if (n >= 0) { - cart[selectedRow].unit_price = n; - renderCart(); + if (n < 0) { showToast('Precio no válido'); return; } + if (!allowZeroPriceSales && n === 0) { + showToast('No está permitido dejar el precio en $0'); + return; } + cart[selectedRow].unit_price = n; + renderCart(); } } @@ -1052,6 +1075,12 @@ const POS = (() => { generate_cfdi: document.getElementById('cfdiCheck').checked, }; + const zeroItem = findZeroPriceItem(); + if (zeroItem) { + showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); + return; + } + const confirmBtn = document.getElementById('btnConfirmPayment'); confirmBtn.disabled = true; confirmBtn.textContent = 'Procesando...'; @@ -1105,6 +1134,11 @@ const POS = (() => { async function createRemissionNote() { if (cart.length === 0) { showToast('Carrito vacio'); return; } if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; } + const zeroItem = findZeroPriceItem(); + if (zeroItem) { + showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); + return; + } const total = getTotal(); const noteData = { @@ -1262,6 +1296,11 @@ const POS = (() => { // ─── Quotation ─────────────────────── async function saveQuotation() { if (cart.length === 0) { showToast('Carrito vacio'); return; } + const zeroItem = findZeroPriceItem(); + if (zeroItem) { + showToast('No está permitido cotizar artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); + return; + } const body = { items: cart.map(item => ({ @@ -1302,6 +1341,11 @@ const POS = (() => { async function createLayaway() { if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; } if (cart.length === 0) { alert('Carrito vacio'); return; } + const zeroItem = findZeroPriceItem(); + if (zeroItem) { + alert('No está permitido apartar artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); + return; + } if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; } const total = getTotal(); @@ -1345,6 +1389,11 @@ const POS = (() => { function createServiceOrder() { if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; } if (cart.length === 0) { showToast('Carrito vacio'); return; } + const zeroItem = findZeroPriceItem(); + if (zeroItem) { + showToast('No está permitido generar orden con artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); + return; + } const custInput = document.getElementById('soCustomer'); if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General'; document.getElementById('serviceOrderModal').classList.add('open'); diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index 0d5bb88..d5e13cf 100644 --- a/pos/static/pwa/sw.js +++ b/pos/static/pwa/sw.js @@ -6,7 +6,7 @@ // The fetch handler normalizes static asset URLs (strips ?v= query strings) // so templates can use cache-busting query params freely. -const VERSION = 43; +const VERSION = 44; const CACHE_NAME = 'nexus-pos-v' + VERSION; const APP_SHELL = [ diff --git a/pos/templates/config.html b/pos/templates/config.html index 3574d1d..03643d0 100644 --- a/pos/templates/config.html +++ b/pos/templates/config.html @@ -736,6 +736,16 @@ +
+
+ Permitir ventas en $0 + Permite vender artículos con precio unitario o total de línea igual a $0 +
+ +