Add allow_zero_price_sales toggle: config UI, POS enforcement and backend validation
This commit is contained in:
@@ -1130,6 +1130,39 @@ def update_counter_remission_config():
|
|||||||
return jsonify({'enabled': enabled == 'true'})
|
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'])
|
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_onboarding_status():
|
def get_onboarding_status():
|
||||||
|
|||||||
@@ -32,6 +32,29 @@ def _tenant_allows_negative_stock(conn):
|
|||||||
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
|
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):
|
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.
|
||||||
|
|
||||||
@@ -129,6 +152,7 @@ def create_sale():
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_validate_zero_price(conn, data.get('items', []))
|
||||||
sale = process_sale(conn, data)
|
sale = process_sale(conn, data)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -164,6 +188,7 @@ def create_remission():
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_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,
|
||||||
'branch_id': branch_id,
|
'branch_id': branch_id,
|
||||||
@@ -728,6 +753,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_zero_price(conn, enriched)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
@@ -1116,6 +1142,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_zero_price(conn, enriched)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
@@ -1602,6 +1629,7 @@ def convert_quotation(quot_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_validate_zero_price(conn, items)
|
||||||
sale = process_sale(conn, sale_data)
|
sale = process_sale(conn, sale_data)
|
||||||
|
|
||||||
# Mark quotation as converted
|
# Mark quotation as converted
|
||||||
@@ -1760,6 +1788,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_zero_price(conn, enriched)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|||||||
@@ -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')
|
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.
|
# Roles allowed to access the workshop module at all.
|
||||||
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
_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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
|
_validate_zero_price(conn, items)
|
||||||
result = create_service_order(conn, {
|
result = create_service_order(conn, {
|
||||||
'tenant_id': g.tenant_id,
|
'tenant_id': g.tenant_id,
|
||||||
'branch_id': data.get('branch_id', g.branch_id),
|
'branch_id': data.get('branch_id', g.branch_id),
|
||||||
|
|||||||
@@ -625,6 +625,7 @@ const Config = (() => {
|
|||||||
await saveVehicleCompatSource();
|
await saveVehicleCompatSource();
|
||||||
await saveAllowedBrands();
|
await saveAllowedBrands();
|
||||||
await saveModules();
|
await saveModules();
|
||||||
|
await saveSalesSettings();
|
||||||
await saveReceiptConfig();
|
await saveReceiptConfig();
|
||||||
toast('Configuración guardada', 'ok');
|
toast('Configuración guardada', 'ok');
|
||||||
} catch (e) {
|
} 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
|
// Tab navigation
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -1277,6 +1309,7 @@ const Config = (() => {
|
|||||||
loadVehicleCompatSource();
|
loadVehicleCompatSource();
|
||||||
loadAllowedBrands();
|
loadAllowedBrands();
|
||||||
loadModules();
|
loadModules();
|
||||||
|
loadSalesSettings();
|
||||||
loadReceiptConfig();
|
loadReceiptConfig();
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
loadRolePermissions();
|
loadRolePermissions();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const POS = (() => {
|
|||||||
let canCreateLayaway = false;
|
let canCreateLayaway = false;
|
||||||
let canCreateRemission = false;
|
let canCreateRemission = false;
|
||||||
let counterRemissionEnabled = false;
|
let counterRemissionEnabled = false;
|
||||||
|
let allowZeroPriceSales = true;
|
||||||
let currentPerms = [];
|
let currentPerms = [];
|
||||||
let receiptConfig = {};
|
let receiptConfig = {};
|
||||||
let couriers = [];
|
let couriers = [];
|
||||||
@@ -142,6 +143,13 @@ const POS = (() => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
counterRemissionEnabled = false;
|
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.
|
// 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');
|
||||||
|
|
||||||
@@ -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() {
|
function modifyPrice() {
|
||||||
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
|
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
|
||||||
if (selectedRow < 0 || selectedRow >= cart.length) {
|
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||||
@@ -451,12 +471,15 @@ const POS = (() => {
|
|||||||
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
|
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
|
||||||
if (p !== null) {
|
if (p !== null) {
|
||||||
const n = parseFloat(p);
|
const n = parseFloat(p);
|
||||||
if (n >= 0) {
|
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;
|
cart[selectedRow].unit_price = n;
|
||||||
renderCart();
|
renderCart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Wire confirm-cancel button
|
// Wire confirm-cancel button
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -1052,6 +1075,12 @@ const POS = (() => {
|
|||||||
generate_cfdi: document.getElementById('cfdiCheck').checked,
|
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');
|
const confirmBtn = document.getElementById('btnConfirmPayment');
|
||||||
confirmBtn.disabled = true;
|
confirmBtn.disabled = true;
|
||||||
confirmBtn.textContent = 'Procesando...';
|
confirmBtn.textContent = 'Procesando...';
|
||||||
@@ -1105,6 +1134,11 @@ const POS = (() => {
|
|||||||
async function createRemissionNote() {
|
async function createRemissionNote() {
|
||||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
||||||
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); 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 total = getTotal();
|
||||||
const noteData = {
|
const noteData = {
|
||||||
@@ -1262,6 +1296,11 @@ const POS = (() => {
|
|||||||
// ─── Quotation ───────────────────────
|
// ─── Quotation ───────────────────────
|
||||||
async function saveQuotation() {
|
async function saveQuotation() {
|
||||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
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 = {
|
const body = {
|
||||||
items: cart.map(item => ({
|
items: cart.map(item => ({
|
||||||
@@ -1302,6 +1341,11 @@ const POS = (() => {
|
|||||||
async function createLayaway() {
|
async function createLayaway() {
|
||||||
if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
|
if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
|
||||||
if (cart.length === 0) { alert('Carrito vacio'); 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; }
|
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
|
||||||
|
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
@@ -1345,6 +1389,11 @@ const POS = (() => {
|
|||||||
function createServiceOrder() {
|
function createServiceOrder() {
|
||||||
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
|
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
|
||||||
if (cart.length === 0) { showToast('Carrito vacio'); 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');
|
const custInput = document.getElementById('soCustomer');
|
||||||
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
|
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
|
||||||
document.getElementById('serviceOrderModal').classList.add('open');
|
document.getElementById('serviceOrderModal').classList.add('open');
|
||||||
|
|||||||
@@ -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 = 43;
|
const VERSION = 44;
|
||||||
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
|
|||||||
@@ -736,6 +736,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 ventas en $0</span>
|
||||||
|
<span class="toggle-row__desc">Permite vender artículos con precio unitario o total de línea igual a $0</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="cfg-allow-zero-price" checked />
|
||||||
|
<span class="toggle__slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user