From c90012ca37ea479a5bbd125bd048f0c0c1fd1218 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Wed, 15 Jul 2026 18:02:43 +0000 Subject: [PATCH] Add allow_negative_stock toggle for sales, remissions, quotes, layaways and service orders --- pos/blueprints/config_bp.py | 23 ++++++++------ pos/blueprints/pos_bp.py | 45 +++++++++++++--------------- pos/services/service_order_engine.py | 19 +++++++++--- pos/static/js/config.js | 16 ++++++---- pos/static/js/pos.js | 5 +++- pos/static/pwa/sw.js | 2 +- pos/templates/config.html | 10 +++++++ 7 files changed, 76 insertions(+), 44 deletions(-) diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 64bced0..51c087b 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -1133,17 +1133,17 @@ def update_counter_remission_config(): @config_bp.route('/sales-settings', methods=['GET']) @require_auth('pos.view') def get_sales_settings(): - """Get sales-related settings (zero-price sales, etc.).""" + """Get sales-related settings (zero-price sales, negative stock).""" 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.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')") + rows = {k: v for k, v in cur.fetchall()} 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}) + # Default allow_zero_price_sales to true to avoid breaking existing tenants. + allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes') + # Default allow_negative_stock to false (safer). + neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes') + return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg}) @config_bp.route('/sales-settings', methods=['PUT']) @@ -1152,15 +1152,20 @@ def update_sales_settings(): """Update sales-related settings.""" data = request.get_json() or {} allow = 'true' if data.get('allow_zero_price_sales') else 'false' + neg = 'true' if data.get('allow_negative_stock') 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)) + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, ('allow_negative_stock', neg)) conn.commit() cur.close(); conn.close() - return jsonify({'allow_zero_price_sales': allow == 'true'}) + return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'}) @config_bp.route('/onboarding-status', methods=['GET']) diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 48b1ce7..b28e149 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -55,6 +55,21 @@ def _validate_zero_price(conn, items): raise ValueError(f"No está permitido vender artículos en $0 ({name})") +def _validate_stock_availability(conn, items, branch_id): + """Raise ValueError if stock is insufficient and the tenant forbids negative stock.""" + if _tenant_allows_negative_stock(conn): + return + for item in items: + inv_id = item.get('inventory_id') + qty = int(item.get('quantity', 1) or 1) + if not inv_id: + continue + available = get_stock(conn, inv_id, branch_id) + if available < qty: + name = item.get('name') or item.get('part_number') or inv_id + raise ValueError(f'Sin stock suficiente para {name}. Disponible: {available}, solicitado: {qty}') + + def _enrich_items(cur, items, customer_id=None): """Look up inventory data for items that lack unit_price/tax_rate. @@ -136,22 +151,10 @@ def create_sale(): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) - # Verify stock availability per item for the active branch - # (skipped for tenants configured to allow negative stock / oversell) branch_id = data.get('branch_id', g.branch_id) - if not _tenant_allows_negative_stock(conn): - for item in data.get('items', []): - inv_id = item.get('inventory_id') - qty = int(item.get('quantity', 1)) - if inv_id: - available = get_stock(conn, inv_id, branch_id) - if available < qty: - conn.close() - return jsonify({ - 'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}' - }), 400 try: + _validate_stock_availability(conn, data.get('items', []), branch_id) _validate_zero_price(conn, data.get('items', [])) sale = process_sale(conn, data) conn.commit() @@ -175,19 +178,9 @@ def create_remission(): conn = get_tenant_conn(g.tenant_id) branch_id = data.get('branch_id', g.branch_id) - if not _tenant_allows_negative_stock(conn): - for item in data.get('items', []): - inv_id = item.get('inventory_id') - qty = int(item.get('quantity', 1)) - if inv_id: - available = get_stock(conn, inv_id, branch_id) - if available < qty: - conn.close() - return jsonify({ - 'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}' - }), 400 try: + _validate_stock_availability(conn, data.get('items', []), branch_id) _validate_zero_price(conn, data.get('items', [])) sale = create_remission_note(conn, { 'tenant_id': g.tenant_id, @@ -753,6 +746,7 @@ def create_quotation(): # Enrich items with inventory data (price, tax, etc.) try: enriched = _enrich_items(cur, items, data.get('customer_id')) + _validate_stock_availability(conn, enriched, g.branch_id) _validate_zero_price(conn, enriched) except ValueError as e: cur.close(); conn.close() @@ -1142,6 +1136,7 @@ def update_quotation(quot_id): try: enriched = _enrich_items(cur, items, data.get('customer_id')) + _validate_stock_availability(conn, enriched, g.branch_id) _validate_zero_price(conn, enriched) except ValueError as e: cur.close(); conn.close() @@ -1629,6 +1624,7 @@ def convert_quotation(quot_id): } try: + _validate_stock_availability(conn, items, g.branch_id) _validate_zero_price(conn, items) sale = process_sale(conn, sale_data) @@ -1788,6 +1784,7 @@ def create_layaway(): # Enrich items with inventory data try: enriched = _enrich_items(cur, items, customer_id) + _validate_stock_availability(conn, enriched, g.branch_id) _validate_zero_price(conn, enriched) except ValueError as e: cur.close(); conn.close() diff --git a/pos/services/service_order_engine.py b/pos/services/service_order_engine.py index c4c35e2..6989c0e 100644 --- a/pos/services/service_order_engine.py +++ b/pos/services/service_order_engine.py @@ -8,6 +8,16 @@ from datetime import datetime from services import inventory_engine + +def _tenant_allows_negative_stock(conn): + """Return True if the tenant explicitly allows selling below zero stock.""" + cur = conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'") + row = cur.fetchone() + cur.close() + return row is not None and str(row[0]).lower() in ('true', '1', 'yes') + + # Rached workshop statuses (applies to all tenants). ORDER_STATUSES = [ 'por_revisar', @@ -612,10 +622,11 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None): raise ValueError("Item has no inventory linked") qty = int(quantity) - available = inventory_engine.get_stock(conn, inventory_id, branch_id) - if available < qty: - cur.close() - raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}") + if not _tenant_allows_negative_stock(conn): + available = inventory_engine.get_stock(conn, inventory_id, branch_id) + if available < qty: + cur.close() + raise ValueError(f"Sin stock suficiente. Disponible: {available}, solicitado: {qty}") inventory_engine.record_operation( conn, diff --git a/pos/static/js/config.js b/pos/static/js/config.js index 9a1524f..ddbc70f 100644 --- a/pos/static/js/config.js +++ b/pos/static/js/config.js @@ -1021,21 +1021,27 @@ const Config = (() => { 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; + var cbZero = document.getElementById('cfg-allow-zero-price'); + if (cbZero) cbZero.checked = data.allow_zero_price_sales !== false; + var cbNeg = document.getElementById('cfg-allow-negative-stock'); + if (cbNeg) cbNeg.checked = data.allow_negative_stock === true; } catch (e) { console.error('Config.loadSalesSettings:', e); } } async function saveSalesSettings() { - var cb = document.getElementById('cfg-allow-zero-price'); - if (!cb) return; + var cbZero = document.getElementById('cfg-allow-zero-price'); + var cbNeg = document.getElementById('cfg-allow-negative-stock'); + if (!cbZero && !cbNeg) return; try { + var body = {}; + if (cbZero) body.allow_zero_price_sales = cbZero.checked; + if (cbNeg) body.allow_negative_stock = cbNeg.checked; var res = await fetch(API + '/sales-settings', { method: 'PUT', headers: headers(), - body: JSON.stringify({ allow_zero_price_sales: cb.checked }) + body: JSON.stringify(body) }); if (!res.ok) { var err = await res.json().catch(function() { return { error: res.statusText }; }); diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js index bee0cf2..6b496af 100644 --- a/pos/static/js/pos.js +++ b/pos/static/js/pos.js @@ -33,6 +33,7 @@ const POS = (() => { let canCreateRemission = false; let counterRemissionEnabled = false; let allowZeroPriceSales = true; + let allowNegativeStock = false; let currentPerms = []; let receiptConfig = {}; let couriers = []; @@ -143,12 +144,14 @@ const POS = (() => { } catch (e) { counterRemissionEnabled = false; } - // Sales settings (zero-price sales toggle) + // Sales settings (zero-price and negative-stock toggles) try { const ssCfg = await api('/pos/api/config/sales-settings'); allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false; + allowNegativeStock = ssCfg.allow_negative_stock === true; } catch (e) { allowZeroPriceSales = true; + allowNegativeStock = false; } // Counter remission workflow applies only to the counter role. canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission'); diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index d5e13cf..593e5fd 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 = 44; +const VERSION = 45; const CACHE_NAME = 'nexus-pos-v' + VERSION; const APP_SHELL = [ diff --git a/pos/templates/config.html b/pos/templates/config.html index 03643d0..22b5e72 100644 --- a/pos/templates/config.html +++ b/pos/templates/config.html @@ -746,6 +746,16 @@ +
+
+ Permitir venta sin stock + Permite vender aunque no haya existencias suficientes en la sucursal (stock negativo) +
+ +