Add allow_negative_stock toggle for sales, remissions, quotes, layaways and service orders
This commit is contained in:
@@ -1133,17 +1133,17 @@ def update_counter_remission_config():
|
|||||||
@config_bp.route('/sales-settings', methods=['GET'])
|
@config_bp.route('/sales-settings', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_sales_settings():
|
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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
|
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')")
|
||||||
row = cur.fetchone()
|
rows = {k: v for k, v in cur.fetchall()}
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
# Default to true to avoid breaking existing tenants that sell at $0.
|
# Default allow_zero_price_sales to true to avoid breaking existing tenants.
|
||||||
allow = True
|
allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes')
|
||||||
if row:
|
# Default allow_negative_stock to false (safer).
|
||||||
allow = str(row[0]).lower() in ('true', '1', 'yes')
|
neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes')
|
||||||
return jsonify({'allow_zero_price_sales': allow})
|
return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/sales-settings', methods=['PUT'])
|
@config_bp.route('/sales-settings', methods=['PUT'])
|
||||||
@@ -1152,15 +1152,20 @@ def update_sales_settings():
|
|||||||
"""Update sales-related settings."""
|
"""Update sales-related settings."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
allow = 'true' if data.get('allow_zero_price_sales') else 'false'
|
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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
""", ('allow_zero_price_sales', allow))
|
""", ('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()
|
conn.commit()
|
||||||
cur.close(); conn.close()
|
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'])
|
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ def _validate_zero_price(conn, items):
|
|||||||
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
|
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):
|
def _enrich_items(cur, items, customer_id=None):
|
||||||
"""Look up inventory data for items that lack unit_price/tax_rate.
|
"""Look up inventory data for items that lack unit_price/tax_rate.
|
||||||
|
|
||||||
@@ -136,22 +151,10 @@ def create_sale():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
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)
|
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:
|
try:
|
||||||
|
_validate_stock_availability(conn, data.get('items', []), branch_id)
|
||||||
_validate_zero_price(conn, data.get('items', []))
|
_validate_zero_price(conn, data.get('items', []))
|
||||||
sale = process_sale(conn, data)
|
sale = process_sale(conn, data)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -175,19 +178,9 @@ def create_remission():
|
|||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|
||||||
branch_id = data.get('branch_id', g.branch_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:
|
try:
|
||||||
|
_validate_stock_availability(conn, data.get('items', []), branch_id)
|
||||||
_validate_zero_price(conn, data.get('items', []))
|
_validate_zero_price(conn, data.get('items', []))
|
||||||
sale = create_remission_note(conn, {
|
sale = create_remission_note(conn, {
|
||||||
'tenant_id': g.tenant_id,
|
'tenant_id': g.tenant_id,
|
||||||
@@ -753,6 +746,7 @@ def create_quotation():
|
|||||||
# Enrich items with inventory data (price, tax, etc.)
|
# Enrich items with inventory data (price, tax, etc.)
|
||||||
try:
|
try:
|
||||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||||
|
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||||
_validate_zero_price(conn, enriched)
|
_validate_zero_price(conn, enriched)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
@@ -1142,6 +1136,7 @@ def update_quotation(quot_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||||
|
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||||
_validate_zero_price(conn, enriched)
|
_validate_zero_price(conn, enriched)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
@@ -1629,6 +1624,7 @@ def convert_quotation(quot_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_validate_stock_availability(conn, items, g.branch_id)
|
||||||
_validate_zero_price(conn, items)
|
_validate_zero_price(conn, items)
|
||||||
sale = process_sale(conn, sale_data)
|
sale = process_sale(conn, sale_data)
|
||||||
|
|
||||||
@@ -1788,6 +1784,7 @@ def create_layaway():
|
|||||||
# Enrich items with inventory data
|
# Enrich items with inventory data
|
||||||
try:
|
try:
|
||||||
enriched = _enrich_items(cur, items, customer_id)
|
enriched = _enrich_items(cur, items, customer_id)
|
||||||
|
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||||
_validate_zero_price(conn, enriched)
|
_validate_zero_price(conn, enriched)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ from datetime import datetime
|
|||||||
|
|
||||||
from services import inventory_engine
|
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).
|
# Rached workshop statuses (applies to all tenants).
|
||||||
ORDER_STATUSES = [
|
ORDER_STATUSES = [
|
||||||
'por_revisar',
|
'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")
|
raise ValueError("Item has no inventory linked")
|
||||||
|
|
||||||
qty = int(quantity)
|
qty = int(quantity)
|
||||||
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
|
if not _tenant_allows_negative_stock(conn):
|
||||||
if available < qty:
|
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
|
||||||
cur.close()
|
if available < qty:
|
||||||
raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
|
cur.close()
|
||||||
|
raise ValueError(f"Sin stock suficiente. Disponible: {available}, solicitado: {qty}")
|
||||||
|
|
||||||
inventory_engine.record_operation(
|
inventory_engine.record_operation(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -1021,21 +1021,27 @@ const Config = (() => {
|
|||||||
var res = await fetch(API + '/sales-settings', { headers: headers() });
|
var res = await fetch(API + '/sales-settings', { headers: headers() });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
var cb = document.getElementById('cfg-allow-zero-price');
|
var cbZero = document.getElementById('cfg-allow-zero-price');
|
||||||
if (cb) cb.checked = data.allow_zero_price_sales !== false;
|
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) {
|
} catch (e) {
|
||||||
console.error('Config.loadSalesSettings:', e);
|
console.error('Config.loadSalesSettings:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSalesSettings() {
|
async function saveSalesSettings() {
|
||||||
var cb = document.getElementById('cfg-allow-zero-price');
|
var cbZero = document.getElementById('cfg-allow-zero-price');
|
||||||
if (!cb) return;
|
var cbNeg = document.getElementById('cfg-allow-negative-stock');
|
||||||
|
if (!cbZero && !cbNeg) return;
|
||||||
try {
|
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', {
|
var res = await fetch(API + '/sales-settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
body: JSON.stringify({ allow_zero_price_sales: cb.checked })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const POS = (() => {
|
|||||||
let canCreateRemission = false;
|
let canCreateRemission = false;
|
||||||
let counterRemissionEnabled = false;
|
let counterRemissionEnabled = false;
|
||||||
let allowZeroPriceSales = true;
|
let allowZeroPriceSales = true;
|
||||||
|
let allowNegativeStock = false;
|
||||||
let currentPerms = [];
|
let currentPerms = [];
|
||||||
let receiptConfig = {};
|
let receiptConfig = {};
|
||||||
let couriers = [];
|
let couriers = [];
|
||||||
@@ -143,12 +144,14 @@ const POS = (() => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
counterRemissionEnabled = false;
|
counterRemissionEnabled = false;
|
||||||
}
|
}
|
||||||
// Sales settings (zero-price sales toggle)
|
// Sales settings (zero-price and negative-stock toggles)
|
||||||
try {
|
try {
|
||||||
const ssCfg = await api('/pos/api/config/sales-settings');
|
const ssCfg = await api('/pos/api/config/sales-settings');
|
||||||
allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false;
|
allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false;
|
||||||
|
allowNegativeStock = ssCfg.allow_negative_stock === true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
allowZeroPriceSales = true;
|
allowZeroPriceSales = true;
|
||||||
|
allowNegativeStock = false;
|
||||||
}
|
}
|
||||||
// Counter remission workflow applies only to the counter role.
|
// Counter remission workflow applies only to the counter role.
|
||||||
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
|
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||||
// so templates can use cache-busting query params freely.
|
// so templates can use cache-busting query params freely.
|
||||||
|
|
||||||
const VERSION = 44;
|
const VERSION = 45;
|
||||||
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
|
|||||||
@@ -746,6 +746,16 @@
|
|||||||
<span class="toggle__slider"></span>
|
<span class="toggle__slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-row__info">
|
||||||
|
<span class="toggle-row__label">Permitir venta sin stock</span>
|
||||||
|
<span class="toggle-row__desc">Permite vender aunque no haya existencias suficientes en la sucursal (stock negativo)</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="cfg-allow-negative-stock" />
|
||||||
|
<span class="toggle__slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user