feat: cashier/counter reports, service-order & remission flows, Rached migration utils
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- Add "Mis cortes de caja" report for cashiers/counters with sales detail.
- Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint.
- Remove dashboard from cashier menu; add Reports to cashier/counter.
- Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission.
- Remission notes module (UI, CSS, courier, counter remissions).
- Customer hard-delete and vehicle/customer linkage in workshop.
- POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio.
- Inventory: CSV template with sku_secondary, alias import.
- Rached migration scripts and DB migrations.
- Version-bump cached JS/CSS query strings.

Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
This commit is contained in:
2026-07-02 12:51:56 +00:00
parent 483498cfcc
commit f42910f4f6
71 changed files with 5388 additions and 626 deletions

View File

@@ -30,7 +30,12 @@ const POS = (() => {
let canEditPrice = false;
let canCreateWorkshopOrder = false;
let canCreateLayaway = false;
let canCreateRemission = false;
let counterRemissionEnabled = false;
let currentPerms = [];
let receiptConfig = {};
let couriers = [];
let selectedCourierId = null;
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -38,6 +43,16 @@ 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) {
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;
return p;
}
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
@@ -66,6 +81,10 @@ const POS = (() => {
const el = document.querySelector(selector);
if (el) el.style.display = 'none';
}
function show(selector) {
const el = document.querySelector(selector);
if (el) el.style.display = '';
}
if (!canCancel) {
hide('#btnCancelSale');
hide('#fkeyEsc');
@@ -77,6 +96,25 @@ const POS = (() => {
hide('[title="Orden de servicio"]');
}
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
// Counter remission workflow
if (canCreateRemission) {
hide('#btnCobrar');
hide('.fkey[onclick="POS.checkout()"]');
hide('[onclick="POS.createLayaway()"]');
hide('[onclick="POS.saveQuotation()"]');
show('#btnRemission');
show('#courierSelectField');
} else {
hide('#btnRemission');
hide('#courierSelectField');
}
if (!currentPerms.includes('pos.sell')) {
hide('#btnPayRemission');
} else {
show('#btnPayRemission');
}
}
// ─── Init ────────────────────────────
@@ -87,14 +125,26 @@ const POS = (() => {
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
document.getElementById('branchName').textContent = payload.branch_name || '';
const perms = payload.permissions || [];
currentPerms = perms;
const employeeRole = payload.role || '';
canViewCost = perms.includes('pos.view_cost');
canCancel = perms.includes('pos.cancel');
canDiscount = perms.includes('pos.discount');
canEditPrice = perms.includes('config.edit_prices');
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
canCreateWorkshopOrder = perms.includes('workshop.edit');
canCreateLayaway = perms.includes('pos.sell');
employeeMaxDiscount = payload.max_discount_pct || 100;
// Counter remission feature
try {
const crCfg = await api('/pos/api/config/counter-remission');
counterRemissionEnabled = crCfg.enabled === true;
} catch (e) {
counterRemissionEnabled = false;
}
// Counter remission workflow applies only to the counter role.
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
// Show cost/margin columns and toggle button if permission
if (canViewCost) {
document.getElementById('thCost').style.display = '';
@@ -138,6 +188,7 @@ const POS = (() => {
// Load current register and receipt config
await loadRegister();
await loadReceiptConfig();
await loadCouriers();
// Setup event listeners
setupKeyboard();
@@ -157,8 +208,10 @@ const POS = (() => {
currentRegister = null;
document.getElementById('registerInfo').innerHTML =
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>';
// Force open register modal on first load
showOpenRegisterModal();
// Force open register modal on first load only for users who can sell
if (currentPerms.includes('pos.sell')) {
showOpenRegisterModal();
}
}
} catch (e) {
console.warn('Register check failed:', e);
@@ -174,6 +227,24 @@ const POS = (() => {
}
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('remissionCourier');
if (sel) {
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.addEventListener('change', () => {
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
});
}
} catch (e) {
console.warn('Could not load couriers:', e);
couriers = [];
}
}
function rc(key, fallback) {
const v = receiptConfig[key];
return v !== undefined && v !== null && v !== '' ? v : fallback;
@@ -570,26 +641,11 @@ const POS = (() => {
if (data.data.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 if (data.data.length === 1 && q.length >= 8) {
// Auto-select single result on barcode scan (long codes)
const item = data.data[0];
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
addFromSearch(item, price);
input.value = '';
hideSearchResults();
return;
} else {
let html = '';
data.data.forEach(item => {
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
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})'>
<div>
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
@@ -684,7 +740,7 @@ const POS = (() => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => {
if (item.price_1 > 0) {
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
item.unit_price = priceForTier(item.price_1, tier);
}
});
}
@@ -880,9 +936,12 @@ const POS = (() => {
transferencia: 'refPayment',
tarjeta: 'refPayment',
mixto: 'mixedPayment',
credito: 'creditPayment',
cheque: 'chequePayment',
pendiente: 'pendingPayment',
};
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
const el = document.getElementById(id);
if (el) {
const isActive = el.id === tabs[method];
@@ -896,6 +955,12 @@ const POS = (() => {
const ref = document.getElementById('paymentRef');
if (ref) ref.focus();
}
if (method === 'cheque') {
const chequeAmount = document.getElementById('chequeAmount');
if (chequeAmount) chequeAmount.value = fmt(getTotal());
const chequeRef = document.getElementById('chequeRef');
if (chequeRef) chequeRef.focus();
}
}
function updateChange() {
@@ -933,6 +998,7 @@ const POS = (() => {
let amountPaid = 0;
let paymentDetails = [];
let reference = '';
let saleType = 'cash';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
@@ -952,6 +1018,20 @@ const POS = (() => {
}
});
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
} else if (paymentMethod === 'credito') {
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (total > available) {
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
return;
}
saleType = 'credit';
amountPaid = 0;
} else if (paymentMethod === 'cheque') {
amountPaid = total;
reference = document.getElementById('chequeRef').value.trim();
} else if (paymentMethod === 'pendiente') {
amountPaid = 0;
}
const saleData = {
@@ -964,7 +1044,7 @@ const POS = (() => {
})),
customer_id: currentCustomer ? currentCustomer.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
@@ -983,9 +1063,10 @@ const POS = (() => {
const convertData = {
register_id: currentRegister ? currentRegister.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
amount_paid: amountPaid,
payment_details: paymentDetails,
reference: reference,
};
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
method: 'POST',
@@ -1020,22 +1101,13 @@ const POS = (() => {
}
}
// ─── Credit Sale ─────────────────────
async function creditSale() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
if (!currentRegister) { alert('No hay caja abierta.'); return; }
// ─── Counter Remission Note ────────────
async function createRemissionNote() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
const total = getTotal();
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (currentCustomer.credit_limit > 0 && total > available) {
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
return;
}
}
const saleData = {
const noteData = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
@@ -1043,19 +1115,21 @@ const POS = (() => {
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer.id,
payment_method: 'credito',
sale_type: 'credit',
customer_id: currentCustomer ? currentCustomer.id : null,
notes: 'Nota de remision generada desde mostrador',
register_id: currentRegister ? currentRegister.id : null,
amount_paid: 0,
courier_id: selectedCourierId,
};
try {
const sale = await api('/pos/api/sales', {
const sale = await api('/pos/api/sales/remission', {
method: 'POST',
body: JSON.stringify(saleData),
body: JSON.stringify(noteData),
});
if (selectedCourierId) {
const courier = couriers.find(c => c.id === selectedCourierId);
sale.courier_name = courier ? courier.name : '';
}
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
@@ -1063,12 +1137,128 @@ const POS = (() => {
cart = [];
selectedRow = -1;
clearCustomer();
selectedCourierId = null;
const sel = document.getElementById('remissionCourier');
if (sel) sel.value = '';
renderCart();
showToast(`Nota de remision NR-${sale.id} generada`);
} catch (e) {
alert('Error: ' + e.message);
alert('Error al generar nota de remision: ' + e.message);
}
}
async function openPayRemissionModal() {
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
document.getElementById('payRemissionFolio').value = '';
document.getElementById('payRemissionDetail').innerHTML = '';
document.getElementById('payRemissionResult').innerHTML = '';
document.getElementById('payRemissionActions').style.display = 'none';
document.getElementById('payRemissionModal').classList.add('open');
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
}
function closePayRemissionModal() {
document.getElementById('payRemissionModal').classList.remove('open');
}
let pendingRemissionToPay = null;
async function searchRemissionToPay() {
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
if (!folio) { showToast('Ingresa un folio valido'); return; }
pendingRemissionToPay = null;
try {
const sale = await api('/pos/api/sales/' + folio);
if (sale.status !== 'pending_payment') {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
return;
}
pendingRemissionToPay = sale;
let itemsHtml = (sale.items || []).map(it => `
<div class="ticket-line">
<span class="qty">${it.quantity}</span>
<span class="name">${it.name || ''}</span>
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
</div>
`).join('');
document.getElementById('payRemissionDetail').innerHTML = `
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
<hr class="divider">
${itemsHtml}
`;
document.getElementById('payRemissionActions').style.display = '';
} catch (e) {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
}
}
async function confirmPayRemission() {
if (!pendingRemissionToPay) return;
const sale = pendingRemissionToPay;
const paymentMethod = document.getElementById('payRemissionMethod').value;
let amountPaid = parseFloat(sale.total);
let paymentDetails = [];
let reference = '';
if (paymentMethod === 'efectivo') {
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
if (received < sale.total) { alert('Monto insuficiente'); return; }
amountPaid = received;
} else if (paymentMethod === 'mixto') {
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
let sum = 0;
rows.forEach(row => {
const method = row.querySelector('select').value;
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
const ref = row.querySelectorAll('input')[1]?.value || '';
if (amount > 0) {
paymentDetails.push({ method, amount, reference: ref });
sum += amount;
}
});
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
amountPaid = sum;
} else {
reference = document.getElementById('payRemissionReference').value.trim();
}
try {
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: paymentMethod,
amount_paid: amountPaid,
payment_details: paymentDetails,
register_id: currentRegister ? currentRegister.id : null,
reference: reference,
}),
});
closePayRemissionModal();
showToast(`Nota NR-${sale.id} pagada`);
// Refresh sale object to print paid ticket
const updated = await api('/pos/api/sales/' + sale.id);
lastSaleId = updated.id;
lastSaleData = updated;
showTicket(updated);
} catch (e) {
alert('Error al cobrar nota: ' + e.message);
}
}
function updatePayRemissionMethod() {
const method = document.getElementById('payRemissionMethod').value;
const cashEl = document.getElementById('payRemissionCash');
const refEl = document.getElementById('payRemissionRef');
const mixedEl = document.getElementById('payRemissionMixed');
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
}
// ─── Quotation ───────────────────────
async function saveQuotation() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
@@ -1284,8 +1474,9 @@ const POS = (() => {
hour: '2-digit', minute: '2-digit'
});
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
const isRemission = sale.status === 'pending_payment';
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
let itemsHtml = '';
(sale.items || []).forEach(item => {
@@ -1333,13 +1524,14 @@ const POS = (() => {
</div>
<hr class="divider-double">
<div class="folio-line">
<span>VENTA: V-${sale.id}</span>
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<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>` : ''}
<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>
@@ -1363,6 +1555,12 @@ const POS = (() => {
${showPayment ? `
<hr class="divider">
<div class="payment-section">
${isRemission ? `
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
</div>
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
` : `
<div class="ticket-row">
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
</div>
@@ -1373,11 +1571,13 @@ const POS = (() => {
<div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''}
`}
</div>` : ''}
<hr class="divider">
<div class="footer-section">
<div class="thanks">${thanksMsg}</div>
${footerMsg ? `<div>${footerMsg}</div>` : ''}
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
</div>
`;
@@ -1387,7 +1587,7 @@ const POS = (() => {
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = 'Ticket de Venta';
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
document.getElementById('ticketModal').classList.add('open');
}
@@ -1592,7 +1792,9 @@ const POS = (() => {
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
selectPaymentMethod, updateChange, updateMixedTotal,
creditSale, saveQuotation, createLayaway,
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
saveQuotation, createLayaway,
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,