feat(inventory): list operations in Entradas/Salidas/Traspasos/Ajustes tabs
- Add GET /operations endpoint with filtering by type, pagination, date range - Join with inventory, employees, branches for rich display - Add tbody IDs and footer/pagination IDs to operation tables in HTML - Add loadOperations() JS function with renderOperationRow() per type - Integrate loadOperations into switchTab for auto-load on tab change - Update recordPurchase/Adjustment/Transfer to refresh respective lists - Expose loadOperations globally for HTML inline script access
This commit is contained in:
@@ -678,6 +678,110 @@ def api_return():
|
||||
return jsonify({'operation_id': op_id, 'message': 'Return recorded'})
|
||||
|
||||
|
||||
@inventory_bp.route('/operations', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def api_operations():
|
||||
"""List inventory operations (purchases, sales, transfers, adjustments).
|
||||
Supports filtering by operation_type, pagination, and date range.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
op_type = request.args.get('type')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 50))
|
||||
branch_id = request.args.get('branch_id', g.branch_id)
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Build query dynamically
|
||||
where_clauses = ['1=1']
|
||||
params = []
|
||||
|
||||
if op_type:
|
||||
where_clauses.append('io.operation_type = %s')
|
||||
params.append(op_type)
|
||||
if branch_id:
|
||||
where_clauses.append('(io.branch_id = %s OR io.branch_id IS NULL)')
|
||||
params.append(branch_id)
|
||||
if date_from:
|
||||
where_clauses.append('io.created_at >= %s')
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where_clauses.append('io.created_at <= %s')
|
||||
params.append(date_to + ' 23:59:59')
|
||||
|
||||
where_sql = ' AND '.join(where_clauses)
|
||||
|
||||
# Get total count
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM inventory_operations io
|
||||
WHERE {where_sql}
|
||||
""", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Get operations with product and employee info
|
||||
cur.execute(f"""
|
||||
SELECT
|
||||
io.id,
|
||||
io.operation_type,
|
||||
io.quantity,
|
||||
io.cost_at_time,
|
||||
io.notes,
|
||||
io.created_at,
|
||||
io.employee_id,
|
||||
e.name as employee_name,
|
||||
i.id as inventory_id,
|
||||
i.part_number,
|
||||
i.name as product_name,
|
||||
i.barcode,
|
||||
io.branch_id,
|
||||
b.name as branch_name
|
||||
FROM inventory_operations io
|
||||
LEFT JOIN inventory i ON io.inventory_id = i.id
|
||||
LEFT JOIN employees e ON io.employee_id = e.id
|
||||
LEFT JOIN branches b ON io.branch_id = b.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY io.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, offset])
|
||||
|
||||
rows = cur.fetchall()
|
||||
operations = []
|
||||
for row in rows:
|
||||
operations.append({
|
||||
'id': row[0],
|
||||
'operation_type': row[1],
|
||||
'quantity': row[2],
|
||||
'cost_at_time': float(row[3]) if row[3] else None,
|
||||
'notes': row[4],
|
||||
'created_at': row[5].isoformat() if row[5] else None,
|
||||
'employee_id': row[6],
|
||||
'employee_name': row[7],
|
||||
'inventory_id': row[8],
|
||||
'part_number': row[9],
|
||||
'product_name': row[10],
|
||||
'barcode': row[11],
|
||||
'branch_id': row[12],
|
||||
'branch_name': row[13],
|
||||
'total': float(row[3] * row[2]) if row[3] and row[2] else None
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'data': operations,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'total_pages': (total + per_page - 1) // per_page
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ─── Physical Count (two-phase: start → approve) ──────────
|
||||
|
||||
@inventory_bp.route('/physical-count/start', methods=['POST'])
|
||||
|
||||
@@ -298,9 +298,19 @@
|
||||
return;
|
||||
}
|
||||
apiFetch(API + '/adjustment', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
|
||||
document.getElementById('adjustResult').innerHTML = result && result.operation_id
|
||||
? '<span style="color:var(--color-success);">Ajuste registrado (op #' + result.operation_id + ')</span>'
|
||||
: '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
if (result && result.operation_id) {
|
||||
document.getElementById('adjustResult').innerHTML = '<span style="color:var(--color-success);">Ajuste registrado (op #' + result.operation_id + ')</span>';
|
||||
closeAdjustmentModal();
|
||||
['adjustItemId','adjustQty','adjustReason'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
if (window.loadInventoryStats) window.loadInventoryStats();
|
||||
if (window.loadOperations) window.loadOperations('ajustes', 1);
|
||||
loadItems(currentPage);
|
||||
} else {
|
||||
document.getElementById('adjustResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,12 +339,119 @@
|
||||
return;
|
||||
}
|
||||
apiFetch(API + '/transfer', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
|
||||
document.getElementById('transferResult').innerHTML = result && result.out_operation_id
|
||||
? '<span style="color:var(--color-success);">Transferencia registrada</span>'
|
||||
: '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
if (result && result.out_operation_id) {
|
||||
document.getElementById('transferResult').innerHTML = '<span style="color:var(--color-success);">Transferencia registrada</span>';
|
||||
closeTransferModal();
|
||||
['transferItemId','transferFrom','transferTo','transferQty','transferNotes'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
if (window.loadInventoryStats) window.loadInventoryStats();
|
||||
if (window.loadOperations) window.loadOperations('traspasos', 1);
|
||||
loadItems(currentPage);
|
||||
} else {
|
||||
document.getElementById('transferResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// OPERATIONS LIST (Entradas, Salidas, Traspasos, Ajustes)
|
||||
// =====================================================================
|
||||
|
||||
var opTypeMap = {
|
||||
'entradas': 'PURCHASE',
|
||||
'salidas': 'SALE',
|
||||
'traspasos': 'TRANSFER',
|
||||
'ajustes': 'ADJUST'
|
||||
};
|
||||
|
||||
function loadOperations(type, page) {
|
||||
var opType = opTypeMap[type];
|
||||
if (!opType) return;
|
||||
page = page || 1;
|
||||
var params = new URLSearchParams({ type: opType, page: page, per_page: 50 });
|
||||
apiFetch(API + '/operations?' + params.toString()).then(function (data) {
|
||||
if (!data) return;
|
||||
var tbodyId = type + 'TableBody';
|
||||
var footerId = type + 'Footer';
|
||||
var pagId = type + 'Pagination';
|
||||
var tbody = document.getElementById(tbodyId);
|
||||
var ops = data.data || [];
|
||||
if (!ops.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--color-text-muted);">Sin registros</td></tr>';
|
||||
document.getElementById(pagId).innerHTML = '';
|
||||
document.getElementById(footerId).textContent = '';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = ops.map(function (op) { return renderOperationRow(op, type); }).join('');
|
||||
var pg = data.pagination || {};
|
||||
if (pg.total_pages > 1) {
|
||||
document.getElementById(pagId).innerHTML =
|
||||
'<div class="pagination">' +
|
||||
'<button class="page-btn" ' + (pg.page <= 1 ? 'disabled' : 'onclick="window._loadOperations(\'' + type + '\',' + (pg.page - 1) + ')"') + '>‹</button>' +
|
||||
'<span style="padding:0 var(--space-2);font-size:var(--text-body-sm);color:var(--color-text-muted);">' + pg.page + ' / ' + pg.total_pages + ' (' + pg.total + ' registros)</span>' +
|
||||
'<button class="page-btn" ' + (pg.page >= pg.total_pages ? 'disabled' : 'onclick="window._loadOperations(\'' + type + '\',' + (pg.page + 1) + ')"') + '>›</button>' +
|
||||
'</div>';
|
||||
} else {
|
||||
document.getElementById(pagId).innerHTML = '';
|
||||
}
|
||||
document.getElementById(footerId).textContent = (pg.total || 0) + ' registros';
|
||||
});
|
||||
}
|
||||
window.loadOperations = loadOperations;
|
||||
window._loadOperations = loadOperations;
|
||||
|
||||
function renderOperationRow(op, type) {
|
||||
var dateStr = op.created_at ? new Date(op.created_at).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
|
||||
var productInfo = esc(op.part_number || op.barcode || '') + ' — ' + esc(op.product_name || '');
|
||||
if (type === 'entradas') {
|
||||
return '<tr>' +
|
||||
'<td class="td--mono">#' + op.id + '</td>' +
|
||||
'<td>' + dateStr + '</td>' +
|
||||
'<td>' + productInfo + '</td>' +
|
||||
'<td style="text-align:right">' + (op.quantity || 0) + '</td>' +
|
||||
'<td style="text-align:right" class="td--amount">$' + fmt(op.cost_at_time || 0) + '</td>' +
|
||||
'<td style="text-align:right" class="td--amount">$' + fmt(op.total || 0) + '</td>' +
|
||||
'<td>' + esc(op.notes || '-') + '</td>' +
|
||||
'<td>' + esc(op.employee_name || '-') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
if (type === 'salidas') {
|
||||
return '<tr>' +
|
||||
'<td class="td--mono">#' + op.id + '</td>' +
|
||||
'<td>' + dateStr + '</td>' +
|
||||
'<td>' + productInfo + '</td>' +
|
||||
'<td style="text-align:right">' + (op.quantity || 0) + '</td>' +
|
||||
'<td style="text-align:right" class="td--amount">$' + fmt(op.total || 0) + '</td>' +
|
||||
'<td>' + esc(op.notes || '-') + '</td>' +
|
||||
'<td>' + esc(op.employee_name || '-') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
if (type === 'traspasos') {
|
||||
return '<tr>' +
|
||||
'<td class="td--mono">#' + op.id + '</td>' +
|
||||
'<td>' + dateStr + '</td>' +
|
||||
'<td>' + productInfo + '</td>' +
|
||||
'<td style="text-align:right">' + (op.quantity || 0) + '</td>' +
|
||||
'<td>' + esc(op.branch_name || '-') + '</td>' +
|
||||
'<td>' + esc(op.notes || '-') + '</td>' +
|
||||
'<td>' + esc(op.employee_name || '-') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
if (type === 'ajustes') {
|
||||
return '<tr>' +
|
||||
'<td class="td--mono">#' + op.id + '</td>' +
|
||||
'<td>' + dateStr + '</td>' +
|
||||
'<td>' + productInfo + '</td>' +
|
||||
'<td style="text-align:right">' + (op.quantity || 0) + '</td>' +
|
||||
'<td>' + esc(op.notes || '-') + '</td>' +
|
||||
'<td>' + esc(op.employee_name || '-') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
return '<tr><td colspan="8">-</td></tr>';
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// PHYSICAL COUNT / CONTEO (countModal)
|
||||
// =====================================================================
|
||||
|
||||
@@ -377,13 +377,13 @@
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="entradasTableBody">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<span></span>
|
||||
<div class="pagination"></div>
|
||||
<span id="entradasFooter"></span>
|
||||
<div class="pagination" id="entradasPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -422,13 +422,13 @@
|
||||
<th>Autorizó</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="salidasTableBody">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<span></span>
|
||||
<div class="pagination"></div>
|
||||
<span id="salidasFooter"></span>
|
||||
<div class="pagination" id="salidasPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,13 +472,13 @@
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="traspasosTableBody">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<span></span>
|
||||
<div class="pagination"></div>
|
||||
<span id="traspasosFooter"></span>
|
||||
<div class="pagination" id="traspasosPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -517,13 +517,13 @@
|
||||
<th>Autorizó</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="ajustesTableBody">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<span></span>
|
||||
<div class="pagination"></div>
|
||||
<span id="ajustesFooter"></span>
|
||||
<div class="pagination" id="ajustesPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -652,6 +652,11 @@
|
||||
|
||||
// Scroll panels container back to top
|
||||
document.getElementById('tab-panels').scrollTop = 0;
|
||||
|
||||
// Load operations data for non-stock tabs
|
||||
if (name !== 'stock' && name !== 'alertas' && name !== 'conteos' && window.loadOperations) {
|
||||
window.loadOperations(name, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user