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

@@ -39,6 +39,8 @@ const POS = (() => {
let receiptConfig = {};
let couriers = [];
let selectedCourierId = null;
let crossSellTimer = null;
let crossSellItems = [];
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -46,13 +48,29 @@ const POS = (() => {
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Price based on customer tier: base price (price_1) with tier discount.
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
function priceForTier(basePrice, tier) {
function esc(s) {
if (s == null) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
// Price based on customer tier.
// Prefers the tenant's real tier prices (price_2/price_3, materialized
// from the tier discounts config) and falls back to the legacy formula.
function priceForTier(basePrice, tier, item) {
const p = parseFloat(basePrice) || 0;
const t = parseInt(tier, 10) || 1;
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
if (t === 2) {
const p2 = item ? parseFloat(item.price_2) : 0;
if (p2 > 0) return p2;
return Math.round(p * 0.95 * 100) / 100;
}
if (t === 3) {
const p3 = item ? parseFloat(item.price_3) : 0;
if (p3 > 0) return p3;
return Math.round(p * 0.90 * 100) / 100;
}
return p;
}
@@ -209,6 +227,10 @@ const POS = (() => {
await loadRegister();
await loadReceiptConfig();
await loadCouriers();
await restoreCartState();
replayOfflineSales();
scheduleCrossSell();
if (navigator.onLine === false && typeof showOfflineBanner === 'function') showOfflineBanner(true);
// Setup event listeners
setupKeyboard();
@@ -259,6 +281,11 @@ const POS = (() => {
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
});
}
const saleSel = document.getElementById('saleCourier');
if (saleSel) {
saleSel.innerHTML = '<option value="">— Sin repartidor —</option>' +
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
}
} catch (e) {
console.warn('Could not load couriers:', e);
couriers = [];
@@ -402,18 +429,82 @@ const POS = (() => {
renderCart();
showToast(`${item.name || 'Articulo'} agregado`);
scheduleCrossSell();
}
function removeFromCart(index) {
cart.splice(index, 1);
if (selectedRow >= cart.length) selectedRow = cart.length - 1;
renderCart();
scheduleCrossSell();
}
function clearCart() {
cart.length = 0;
selectedRow = -1;
renderCart();
hideCrossSell();
}
// ─── Cross-sell (A10) ────────────────
// Debounced call to /pos/api/recommendations. The panel lives in
// pos.html (#crossSellPanel / #crossSellList).
function scheduleCrossSell() {
if (crossSellTimer) clearTimeout(crossSellTimer);
if (!cart.length) { hideCrossSell(); return; }
crossSellTimer = setTimeout(refreshCrossSell, 350);
}
async function refreshCrossSell() {
const panel = document.getElementById('crossSellPanel');
const list = document.getElementById('crossSellList');
if (!panel || !list) return;
const ids = cart.map(c => c.inventory_id).filter(Boolean);
if (!ids.length) { hideCrossSell(); return; }
try {
const data = await api('/pos/api/recommendations', {
method: 'POST',
body: JSON.stringify({ inventory_ids: ids, limit: 4 })
});
crossSellItems = data.recommendations || [];
} catch (e) {
hideCrossSell();
return;
}
const inCart = new Set(ids);
const items = crossSellItems.filter(r => !inCart.has(r.inventory_id));
if (!items.length) { hideCrossSell(); return; }
list.innerHTML = items.map(r => `
<button type="button" onclick="POS.addCrossSellItem(${r.inventory_id})"
title="${esc(r.reason || '')}"
style="display:flex;justify-content:space-between;align-items:center;gap:8px;text-align:left;width:100%;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:5px 8px;cursor:pointer;color:var(--color-text-primary);">
<span style="display:flex;flex-direction:column;overflow:hidden;">
<span style="font-size:var(--text-body-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(r.name || '')}</span>
<span style="font-size:var(--text-caption);color:var(--color-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(r.reason || '')}</span>
</span>
<span style="font-family:var(--font-mono);font-size:var(--text-body-sm);white-space:nowrap;">${fmt(r.price)}</span>
</button>`).join('');
panel.style.display = '';
}
function hideCrossSell() {
if (crossSellTimer) { clearTimeout(crossSellTimer); crossSellTimer = null; }
const panel = document.getElementById('crossSellPanel');
if (panel) panel.style.display = 'none';
}
function addCrossSellItem(inventoryId) {
const rec = crossSellItems.find(r => r.inventory_id === inventoryId);
if (!rec) return;
addToCart({
inventory_id: rec.inventory_id,
name: rec.name,
part_number: rec.part_number,
quantity: 1,
unit_price: rec.price || 0,
price_1: rec.price || 0,
stock: rec.stock || 0,
});
}
function openCancelModal() {
@@ -504,6 +595,32 @@ const POS = (() => {
}
});
function saveCartState() {
try {
sessionStorage.setItem('pos_cart_state', JSON.stringify({
cart, currentCustomerId: currentCustomer ? currentCustomer.id : null,
}));
} catch (e) { /* storage lleno o bloqueado */ }
}
async function restoreCartState() {
try {
const raw = sessionStorage.getItem('pos_cart_state');
if (!raw) return;
const state = JSON.parse(raw);
if (!state || !Array.isArray(state.cart) || !state.cart.length) return;
cart = state.cart;
if (state.currentCustomerId) {
try {
const c = await api('/pos/api/customers/' + state.currentCustomerId);
if (c && c.data) currentCustomer = c.data;
} catch (e) { /* cliente ya no existe */ }
}
renderCart();
showToast('Carrito restaurado (' + cart.length + ' partidas)', 'ok');
} catch (e) { /* ignore */ }
}
function renderCart() {
const tbody = document.getElementById('cartBody');
const table = document.getElementById('cartTable');
@@ -558,6 +675,7 @@ const POS = (() => {
tbody.innerHTML = html;
updateTotals();
updateAvgMargin();
saveCartState();
}
function updateQty(index, val) {
@@ -646,6 +764,17 @@ const POS = (() => {
}
// ─── Search ──────────────────────────
let lastSearchResults = [];
let lastSearchTier = 1;
let searchActiveIndex = -1;
function highlightSearchResult() {
const rows = document.querySelectorAll('#searchResults > div[data-search-idx]');
rows.forEach((el, i) => {
el.style.background = i === searchActiveIndex ? 'var(--color-primary-muted)' : '';
});
}
function setupSearch() {
const input = document.getElementById('itemSearch');
if (!input) return;
@@ -654,9 +783,35 @@ const POS = (() => {
searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (!lastSearchResults.length) return;
e.preventDefault();
searchActiveIndex = e.key === 'ArrowDown'
? Math.min(searchActiveIndex + 1, lastSearchResults.length - 1)
: Math.max(searchActiveIndex - 1, 0);
highlightSearchResult();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
searchItems(input.value.trim());
const q = input.value.trim();
if (!q) return;
const norm = (s) => (s || '').toUpperCase().replace(/[\s\-\._]/g, '');
// 0-click scanner: exact barcode/part_number match adds directly
const exact = lastSearchResults.find(r =>
norm(r.part_number) === norm(q) || norm(r.barcode || '') === norm(q));
if (exact) {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : lastSearchTier;
addFromSearch(exact, priceForTier(exact.price_1, tier, exact));
return;
}
if (searchActiveIndex >= 0 && lastSearchResults[searchActiveIndex]) {
const item = lastSearchResults[searchActiveIndex];
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : lastSearchTier;
addFromSearch(item, priceForTier(item.price_1, tier, item));
return;
}
searchItems(q);
}
if (e.key === 'Escape') {
input.value = '';
@@ -665,23 +820,74 @@ const POS = (() => {
});
}
function cacheSearch(q, items) {
try {
const cache = JSON.parse(localStorage.getItem('pos_search_cache') || '{}');
cache[q.toLowerCase()] = { items: items.slice(0, 12), ts: Date.now() };
const keys = Object.keys(cache);
if (keys.length > 40) keys.slice(0, 5).forEach(k => delete cache[k]);
localStorage.setItem('pos_search_cache', JSON.stringify(cache));
} catch (e) {}
}
function searchOffline(q) {
try {
const cache = JSON.parse(localStorage.getItem('pos_search_cache') || '{}');
const ql = q.toLowerCase();
let hits = [];
Object.keys(cache).forEach(function (k) {
if (ql.indexOf(k) !== -1 || k.indexOf(ql) !== -1) hits = hits.concat(cache[k].items);
});
const seen = new Set();
return hits.filter(function (i) {
if (seen.has(i.id)) return false; seen.add(i.id); return true;
}).slice(0, 12);
} catch (e) { return []; }
}
async function searchItems(q) {
if (!q || q.length < 2) { hideSearchResults(); return; }
if (navigator.onLine === false) {
const cached = searchOffline(q);
const container = document.getElementById('searchResults');
const totals = document.getElementById('totalsPanel');
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
lastSearchResults = cached;
lastSearchTier = tier;
searchActiveIndex = -1;
container.innerHTML = '<div style="padding:8px var(--space-4);background:var(--color-warning-muted,#fef3c7);font-size:var(--text-caption);">Sin conexión — resultados guardados</div>'
+ (cached.length ? cached.map(function (item, idx) {
const price = priceForTier(item.price_1, tier, item);
return `<div data-search-idx="${idx}" style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div><div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | Stock: ${item.stock}</div></div>
<div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div></div>`;
}).join('') : '<div style="padding:16px;text-align:center;color:var(--color-text-muted);">Sin resultados guardados para esta búsqueda</div>');
container.style.display = '';
totals.style.display = 'none';
return;
}
try {
const data = await api(`/pos/api/inventory/items?q=${encodeURIComponent(q)}&per_page=20`);
const container = document.getElementById('searchResults');
const totals = document.getElementById('totalsPanel');
if (data.data.length === 0) {
searchActiveIndex = -1;
lastSearchResults = data.data || [];
lastSearchTier = 1;
if (lastSearchResults.length) cacheSearch(q, lastSearchResults);
if (lastSearchResults.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
if (window.BarcodeFeedback) BarcodeFeedback.error();
} else {
let html = '';
data.data.forEach(item => {
lastSearchResults.forEach((item, idx) => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
const price = priceForTier(item.price_1, tier);
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
const price = priceForTier(item.price_1, tier, item);
html += `<div data-search-idx="${idx}" style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="POS_searchHover(${idx})" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div>
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
@@ -719,6 +925,290 @@ const POS = (() => {
document.getElementById('itemSearch').focus();
}
window.highlightSearchResult = highlightSearchResult;
window.POS_searchHover = function (idx) { searchActiveIndex = idx; highlightSearchResult(); };
// ─── Venta offline (cola + replay) ─────────────────────
function offlineQueue() {
try { return JSON.parse(localStorage.getItem('pos_offline_sales') || '[]'); }
catch (e) { return []; }
}
function offlineSave(queue) {
try { localStorage.setItem('pos_offline_sales', JSON.stringify(queue)); } catch (e) {}
const el = document.getElementById('offlineQueueBadge');
if (el) el.textContent = queue.length;
}
function queueOfflineSale(saleData) {
const queue = offlineQueue();
saleData.client_uuid = (crypto.randomUUID ? crypto.randomUUID() : 'off-' + Date.now() + '-' + Math.random().toString(36).slice(2));
saleData._queued_at = new Date().toISOString();
queue.push(saleData);
offlineSave(queue);
return saleData.client_uuid;
}
async function replayOfflineSales() {
const queue = offlineQueue();
if (!queue.length) return 0;
const remaining = [];
let synced = 0;
for (const saleData of queue) {
try {
await api('/pos/api/sales', { method: 'POST', body: JSON.stringify(saleData) });
synced++;
} catch (e) {
remaining.push(saleData); // aún sin conexión: se reintenta luego
}
}
offlineSave(remaining);
if (synced) showToast(synced + ' venta(s) offline sincronizada(s)', 'ok');
return synced;
}
window.addEventListener('online', function () {
if (typeof showOfflineBanner === 'function') showOfflineBanner(false);
replayOfflineSales();
});
window.addEventListener('offline', function () {
if (typeof showOfflineBanner === 'function') showOfflineBanner(true);
});
// ─── Devoluciones UI ──────────────────
let returnSale = null;
function openReturnModal() {
if (!currentPerms.includes('pos.sell')) { showToast('Sin permiso para devoluciones'); return; }
returnSale = null;
document.getElementById('returnSaleId').value = '';
document.getElementById('returnSaleInfo').style.display = 'none';
document.getElementById('returnItemsList').style.display = 'none';
document.getElementById('returnItemsList').innerHTML = '';
document.getElementById('returnFooter').style.display = 'none';
document.getElementById('returnResult').innerHTML = '';
document.getElementById('returnModal').classList.add('open');
setTimeout(() => document.getElementById('returnSaleId').focus(), 100);
}
function closeReturnModal() {
document.getElementById('returnModal').classList.remove('open');
}
async function searchReturnSale() {
const id = parseInt(document.getElementById('returnSaleId').value, 10);
if (!id) return;
try {
const sale = await api('/pos/api/sales/' + id);
if (!sale || !sale.items || !sale.items.length) throw new Error('Venta sin partidas');
if (sale.status === 'cancelled') throw new Error('La venta está cancelada');
returnSale = sale;
document.getElementById('returnSaleInfo').style.display = 'block';
document.getElementById('returnSaleInfo').innerHTML =
`Venta #${sale.id} — ${sale.customer_name || 'Público General'} — Total: ${fmt(sale.total)} — Estado: ${sale.status}`;
const list = document.getElementById('returnItemsList');
list.style.display = 'block';
list.innerHTML = sale.items.map(it => `
<div style="display:flex;align-items:center;gap:8px;padding:8px var(--space-3);border-bottom:1px solid var(--color-border);">
<input type="checkbox" id="ret-chk-${it.id}" checked style="width:auto;" />
<span style="flex:1;font-size:var(--text-body-sm);">${it.name || it.part_number} (${it.part_number})</span>
<span style="font-size:var(--text-caption);color:var(--color-text-muted);">cant: ${it.quantity}</span>
<input type="number" id="ret-qty-${it.id}" class="form-input" min="1" max="${it.quantity}" value="${it.quantity}" style="width:70px;" />
<input type="text" id="ret-reason-${it.id}" class="form-input" placeholder="Motivo" style="width:160px;" />
</div>`).join('');
document.getElementById('returnFooter').style.display = 'flex';
} catch (e) {
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
}
}
async function submitReturn() {
if (!returnSale) return;
const items = [];
returnSale.items.forEach(it => {
const chk = document.getElementById('ret-chk-' + it.id);
if (chk && chk.checked) {
const qty = parseInt(document.getElementById('ret-qty-' + it.id).value, 10) || 0;
const reason = (document.getElementById('ret-reason-' + it.id).value || '').trim();
if (qty > 0) items.push({ sale_item_id: it.id, quantity: qty, reason: reason || 'Devolución' });
}
});
if (!items.length) {
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">Selecciona al menos una partida</span>';
return;
}
try {
const res = await api('/pos/api/returns', {
method: 'POST',
body: JSON.stringify({
sale_id: returnSale.id,
items,
notes: document.getElementById('returnNotes').value,
register_id: currentRegister ? currentRegister.id : null,
refund_method: returnSale.payment_method || 'efectivo',
}),
});
document.getElementById('returnResult').innerHTML =
'<span style="color:var(--color-success);">Devolución registrada: reembolso ' + fmt(res.total_refund || 0) + '</span>';
renderCart();
} catch (e) {
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
}
}
// ─── Apartados UI ─────────────────────
let currentLayawayId = null;
function openLayawaysModal() {
document.getElementById('layawaysModal').classList.add('open');
document.getElementById('layawayActions').style.display = 'none';
loadLayaways();
}
function closeLayawaysModal() {
document.getElementById('layawaysModal').classList.remove('open');
}
async function loadLayaways() {
const el = document.getElementById('layawaysList');
try {
const data = await api('/pos/api/layaways?status=active&per_page=50');
const rows = data.data || data.layaways || [];
if (!rows.length) {
el.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">No hay apartados activos</span>';
return;
}
el.innerHTML = rows.map(l => {
const balance = (l.total || 0) - (l.amount_paid || 0);
return `<div style="display:flex;align-items:center;gap:10px;padding:10px var(--space-3);border-bottom:1px solid var(--color-border);cursor:pointer;"
onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''"
onclick="POS.selectLayaway(${l.id}, ${l.total}, ${l.amount_paid || 0}, '${(l.customer_name || '').replace(/'/g, "\\'")}')">
<div style="flex:1;">
<div style="font-weight:var(--font-weight-semibold);">Apartado #${l.id} — ${l.customer_name || 'Sin cliente'}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Vence: ${l.expires_at || '—'}</div>
</div>
<div style="text-align:right;">
<div style="font-weight:var(--font-weight-bold);">${fmt(balance)}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">de ${fmt(l.total)}</div>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = '<span style="color:var(--color-error);">Error cargando apartados</span>';
}
}
function selectLayaway(id, total, paid, customerName) {
currentLayawayId = id;
document.getElementById('layawayActions').style.display = 'block';
document.getElementById('layawayDetail').innerHTML =
`<strong>Apartado #${id}</strong> — ${customerName} — Saldo: <strong>${fmt(total - paid)}</strong>`;
document.getElementById('layawayPayAmount').value = (Math.round((total - paid) * 100) / 100) || '';
document.getElementById('layawayResult').innerHTML = '';
}
async function layawayPay() {
if (!currentLayawayId) return;
const amount = parseFloat(document.getElementById('layawayPayAmount').value) || 0;
if (amount <= 0) return;
try {
await api(`/pos/api/layaways/${currentLayawayId}/payment`, {
method: 'POST',
body: JSON.stringify({ amount, payment_method: document.getElementById('layawayPayMethod').value }),
});
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-success);">Abono registrado</span>';
loadLayaways();
} catch (e) {
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
}
}
async function layawayComplete() {
if (!currentLayawayId) return;
try {
const res = await api(`/pos/api/layaways/${currentLayawayId}/complete`, {
method: 'POST',
body: JSON.stringify({
payment_method: document.getElementById('layawayPayMethod').value,
register_id: currentRegister ? currentRegister.id : null,
}),
});
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-success);">Apartado liquidado' + (res.id ? ' — Venta #' + res.id : '') + '</span>';
loadLayaways();
} catch (e) {
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
}
}
async function layawayCancel() {
if (!currentLayawayId) return;
if (!confirm('¿Cancelar el apartado? El stock regresará a inventario.')) return;
try {
await api(`/pos/api/layaways/${currentLayawayId}/cancel`, { method: 'PUT', body: '{}' });
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-success);">Apartado cancelado</span>';
loadLayaways();
} catch (e) {
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
}
}
// ─── Vehículo: placa/VIN → piezas compatibles ─────────────────
async function vehicleLookup() {
const input = document.getElementById('vehicleLookup');
const raw = (input.value || '').trim();
if (!raw) return;
const compact = raw.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
const container = document.getElementById('searchResults');
const totals = document.getElementById('totalsPanel');
container.innerHTML = '<div style="padding:14px;text-align:center;color:var(--color-text-muted);">Buscando vehículo...</div>';
container.style.display = '';
totals.style.display = 'none';
let myeId = null, label = '';
try {
if (compact.length === 17) {
const d = await api('/pos/api/catalog/vin/' + encodeURIComponent(compact));
const m = d.match || d.catalog_match || d;
myeId = d.mye_id || m.mye_id || m.id_mye || null;
label = [m.make || d.make, m.model || d.model, m.year || d.year].filter(Boolean).join(' ');
} else {
const d = await api('/pos/api/catalog/plate/' + encodeURIComponent(raw));
if (!d.found) throw new Error('Placa no registrada — regístrala en el cliente');
const m = d.catalog_match || {};
myeId = m.mye_id || m.id_mye || null;
label = [d.make, d.model, d.year].filter(Boolean).join(' ');
if (d.customer_id) {
try {
const cust = await api('/pos/api/customers/' + d.customer_id);
if (cust && cust.data && !currentCustomer) currentCustomer = cust.data;
} catch (e) { /* opcional */ }
}
}
if (!myeId) {
throw new Error('Vehículo encontrado pero sin motor en catálogo; usa el catálogo por modelo/año');
}
const data = await api('/pos/api/catalog/vehicle-inventory?mye_id=' + myeId);
const items = data.data || [];
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
let html = '<div style="padding:10px var(--space-4);background:var(--color-primary-muted);font-weight:var(--font-weight-semibold);">🚗 Piezas compatibles: ' + esc(label || 'vehículo') + ' (' + items.length + ')</div>';
if (!items.length) {
html += '<div style="padding:16px;text-align:center;color:var(--color-text-muted);">Sin piezas compatibles registradas aún</div>';
} else {
items.forEach(item => {
const price = priceForTier(item.price_1, tier, item);
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div>
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
<div style="font-size:var(--text-caption);color:${item.stock <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};">Stock: ${item.stock}</div>
</div>
<div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div>
</div>`;
});
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div style="padding:14px;text-align:center;color:var(--color-error);">' + esc(e.message) + '</div>';
}
}
function hideSearchResults() {
const sr = document.getElementById('searchResults');
const tp = document.getElementById('totalsPanel');
@@ -923,6 +1413,8 @@ const POS = (() => {
document.getElementById('changeDisplay').className = 'cambio-amount positive';
const payRef = document.getElementById('paymentRef');
if (payRef) payRef.value = '';
const saleCourierSel = document.getElementById('saleCourier');
if (saleCourierSel) saleCourierSel.value = '';
// Populate quick amounts
const quickContainer = document.getElementById('quickAmounts');
@@ -998,9 +1490,22 @@ const POS = (() => {
}
}
function getPayCurrency() {
const sel = document.getElementById('payCurrency');
return sel ? sel.value : 'MXN';
}
function getUsdRate() {
const r = parseFloat(localStorage.getItem('pos_exchange_rate')) || 17.5;
return r > 0 ? r : 17.5;
}
function updateChange() {
const total = getTotal();
const received = parseFloat(document.getElementById('cashReceived').value) || 0;
let received = parseFloat(document.getElementById('cashReceived').value) || 0;
const hint = document.getElementById('usdRateHint');
if (hint) hint.textContent = getPayCurrency() === 'USD' ? ('TC: ' + getUsdRate()) : '';
if (getPayCurrency() === 'USD') received = received * getUsdRate();
const change = received - total;
const el = document.getElementById('changeDisplay');
if (change >= 0) {
@@ -1035,8 +1540,14 @@ const POS = (() => {
let reference = '';
let saleType = 'cash';
let payCurrencyNote = '';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
if (getPayCurrency() === 'USD') {
const usd = amountPaid;
amountPaid = Math.round(usd * getUsdRate() * 100) / 100;
payCurrencyNote = `Pago en USD: $${usd.toFixed(2)} @ TC ${getUsdRate()}`;
}
if (amountPaid < total) { alert(`Monto insuficiente. Total: ${fmt(total)}`); return; }
} else if (paymentMethod === 'transferencia' || paymentMethod === 'tarjeta') {
amountPaid = total;
@@ -1083,8 +1594,10 @@ const POS = (() => {
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
reference: reference,
reference: [reference, payCurrencyNote].filter(Boolean).join(' | '),
generate_cfdi: document.getElementById('cfdiCheck').checked,
courier_id: (document.getElementById('saleCourier') || {}).value
? parseInt(document.getElementById('saleCourier').value, 10) : null,
};
const zeroItem = findZeroPriceItem();
@@ -1126,16 +1639,42 @@ const POS = (() => {
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
if (saleData.courier_id) {
const courier = couriers.find(c => c.id === saleData.courier_id);
sale.courier_name = courier ? courier.name : '';
}
closePaymentModal();
showTicket(sale);
if (rc('auto_print', '') === true || rc('auto_print', '') === 'true') {
try { printTicket(); } catch (e) { /* impresión no bloquea */ }
}
// Clear cart
cart = [];
selectedRow = -1;
clearCustomer();
const saleCourierSel2 = document.getElementById('saleCourier');
if (saleCourierSel2) saleCourierSel2.value = '';
renderCart();
} catch (e) {
alert('Error al procesar venta: ' + e.message);
// Sin conexión: encolar la venta para sincronizar después
if (navigator.onLine === false || /network|failed to fetch/i.test(e.message)) {
const uuid = queueOfflineSale(saleData);
closePaymentModal();
showTicket({
id: 'OFF-' + uuid.slice(0, 8),
total: total,
items: saleData.items.map(it => ({ name: it.part_number || it.inventory_id, quantity: it.quantity, unit_price: it.unit_price, subtotal: it.unit_price * it.quantity })),
customer_name: currentCustomer ? currentCustomer.name : 'Público General',
payment_method: paymentMethod,
offline: true,
created_at: new Date().toISOString(),
});
cart = []; selectedRow = -1; renderCart();
showToast('Sin conexión — venta en cola (' + offlineQueue().length + ' pendientes). Se sincronizará automáticamente.', 'ok');
} else {
alert('Error al procesar venta: ' + e.message);
}
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Confirmar Pago';
@@ -1593,7 +2132,7 @@ const POS = (() => {
<span>Cliente: ${customerName}</span>
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
</div>
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
${sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
<hr class="divider">
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
<span class="qty">Cant</span>
@@ -1792,7 +2331,15 @@ const POS = (() => {
// ─── Drawer ──────────────────────────
function openDrawer() {
showToast('Comando enviado al cajon de efectivo.');
// Kick ESC p 0 25 250 via connected thermal printer (USB/Serial)
try {
if (window.NexusPrinter && typeof window.NexusPrinter.sendRaw === 'function') {
window.NexusPrinter.sendRaw(new Uint8Array([27, 112, 48, 25, 250]))
.then(ok => showToast(ok ? 'Cajón abierto.' : 'Sin impresora conectada — no se pudo abrir el cajón.', ok ? 'ok' : 'error'));
return;
}
} catch (e) { /* fallthrough */ }
showToast('Conecta una impresora térmica (Config → Impresora) para abrir el cajón.', 'error');
}
// ─── Keyboard Shortcuts ──────────────
@@ -1924,6 +2471,9 @@ const POS = (() => {
addToCart, removeFromCart, clearCart, selectRow,
updateQty, updateDiscount,
addFromSearch, hideSearchResults,
openReturnModal, closeReturnModal, searchReturnSale, submitReturn, vehicleLookup,
openLayawaysModal, closeLayawaysModal, loadLayaways, selectLayaway,
layawayPay, layawayComplete, layawayCancel,
selectCustomer, clearCustomer,
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
@@ -1938,6 +2488,7 @@ const POS = (() => {
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
refreshCrossSell, hideCrossSell, addCrossSellItem,
};
})();