diff --git a/pos/blueprints/service_order_bp.py b/pos/blueprints/service_order_bp.py
index df7687f..85dc91d 100644
--- a/pos/blueprints/service_order_bp.py
+++ b/pos/blueprints/service_order_bp.py
@@ -356,6 +356,60 @@ def assign_mechanic_endpoint(so_id):
conn.close()
+@service_order_bp.route('/inventory-search', methods=['GET'])
+@require_auth('workshop.view')
+def inventory_search():
+ """Search active inventory for attaching parts to a service order.
+
+ Does not require inventory.view so workshop users can pick parts.
+ """
+ q = request.args.get('q', '').strip()
+ if not q:
+ return jsonify({'data': []})
+
+ branch_id = request.args.get('branch_id', g.branch_id)
+ conn = get_tenant_conn(g.tenant_id)
+ try:
+ cur = conn.cursor()
+ like = f'%{q}%'
+ if branch_id:
+ cur.execute("""
+ SELECT i.id, i.part_number, i.name, i.brand, i.unit,
+ COALESCE((SELECT stock FROM inventory_stock
+ WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock,
+ i.cost, i.price_1
+ FROM inventory i
+ WHERE i.is_active = true
+ AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
+ ORDER BY i.name
+ LIMIT 20
+ """, (branch_id, like, like, like))
+ else:
+ cur.execute("""
+ SELECT i.id, i.part_number, i.name, i.brand, i.unit,
+ COALESCE((SELECT stock FROM inventory_stock_summary
+ WHERE inventory_id = i.id), 0) AS stock,
+ i.cost, i.price_1
+ FROM inventory i
+ WHERE i.is_active = true
+ AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
+ ORDER BY i.name
+ LIMIT 20
+ """, (like, like, like))
+ items = []
+ for r in cur.fetchall():
+ items.append({
+ 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4],
+ 'stock': float(r[5]) if r[5] else 0,
+ 'cost': float(r[6]) if r[6] else 0,
+ 'price_1': float(r[7]) if r[7] else 0,
+ })
+ cur.close()
+ return jsonify({'data': items})
+ finally:
+ conn.close()
+
+
# ─── Service catalog (reusable labor) ─────────────
diff --git a/pos/services/service_order_engine.py b/pos/services/service_order_engine.py
index 5c96389..ffb55e8 100644
--- a/pos/services/service_order_engine.py
+++ b/pos/services/service_order_engine.py
@@ -13,7 +13,7 @@ VALID_TRANSITIONS = {
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
'waiting_parts': ['repair', 'cancelled'],
'repair': ['quality_check', 'cancelled'],
- 'quality_check': ['ready', 'repair', 'cancelled'],
+ 'quality_check': ['ready', 'cancelled'],
'ready': ['delivered', 'cancelled'],
'delivered': [],
'cancelled': [],
diff --git a/pos/static/js/workshop.js b/pos/static/js/workshop.js
index 55a0816..4da383a 100644
--- a/pos/static/js/workshop.js
+++ b/pos/static/js/workshop.js
@@ -16,6 +16,8 @@ var Workshop = (function() {
var branches = [];
var currentOrderId = null;
var currentOrder = null;
+ var selectedInventoryItem = null;
+ var itemSearchTimer = null;
var currentView = 'list';
var currentPage = 1;
var perPage = 25;
@@ -48,6 +50,17 @@ var Workshop = (function() {
cancelled: 'Cancelado'
};
+ var VALID_NEXT = {
+ received: ['diagnosis', 'cancelled'],
+ diagnosis: ['waiting_parts', 'repair', 'cancelled'],
+ waiting_parts: ['repair', 'cancelled'],
+ repair: ['quality_check', 'cancelled'],
+ quality_check: ['ready', 'cancelled'],
+ ready: ['delivered', 'cancelled'],
+ delivered: [],
+ cancelled: []
+ };
+
var DELIVERY_LABELS = {
pickup: 'Pasa cliente',
delivery: 'Envío a domicilio',
@@ -362,8 +375,18 @@ var Workshop = (function() {
'
' +
'
' +
- '
Observaciones
' +
- '
' + esc(o.reception_notes || 'Sin observaciones') + '
' +
+ '
Notas
' +
+ '
' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
' +
+ (canEdit ? '
' : '') +
'
' +
'
' +
'
Bitácora
' +
@@ -379,10 +402,12 @@ var Workshop = (function() {
// Footer actions
var footer = document.getElementById('detailFooter');
- var statusHtml = canEdit ?
+ var allowedNext = VALID_NEXT[o.status] || [];
+ var statusHtml = canEdit && allowedNext.length ?
'
' +
' ' +
' ' +
'
' : '';
@@ -442,9 +467,13 @@ var Workshop = (function() {
var laborHeader = '
| Concepto | Horas | ' + (hidePrices ? '' : 'Precio/hr | Total | ') + 'Estado |
|---|
';
var addParts = canEdit ?
- '
' +
- '
' +
- '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
'
' : '';
var addLabor = canEdit ?
@@ -502,6 +531,10 @@ var Workshop = (function() {
function changeStatus() {
if (!currentOrderId) return;
var newStatus = document.getElementById('statusSelect').value;
+ if (currentOrder && newStatus === currentOrder.status) {
+ alert('Selecciona un estado diferente al actual');
+ return;
+ }
api('PUT', '/' + currentOrderId + '/status', {status: newStatus})
.then(function() {
closeDetailModal();
@@ -511,6 +544,27 @@ var Workshop = (function() {
.catch(function(e) { alert('Error: ' + e.message); });
}
+ function saveNotes() {
+ if (!currentOrderId) return;
+ var payload = {
+ reception_notes: document.getElementById('noteReception').value,
+ diagnosis_notes: document.getElementById('noteDiagnosis').value,
+ repair_notes: document.getElementById('noteRepair').value,
+ delivery_notes: document.getElementById('noteDelivery').value
+ };
+ api('PUT', '/' + currentOrderId, payload)
+ .then(function() {
+ alert('Notas guardadas');
+ if (currentOrder) {
+ currentOrder.reception_notes = payload.reception_notes;
+ currentOrder.diagnosis_notes = payload.diagnosis_notes;
+ currentOrder.repair_notes = payload.repair_notes;
+ currentOrder.delivery_notes = payload.delivery_notes;
+ }
+ })
+ .catch(function(e) { alert('Error: ' + e.message); });
+ }
+
function reserveItem(itemId) {
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
.then(function() {
@@ -521,15 +575,94 @@ var Workshop = (function() {
.catch(function(e) { alert('Error: ' + e.message); });
}
- function addItemPlaceholder() {
+ function hideItemSearchResults() {
+ var box = document.getElementById('itemSearchResults');
+ if (box) box.style.display = 'none';
+ }
+
+ function searchItemsForSO() {
+ var input = document.getElementById('newItemSearch');
+ var box = document.getElementById('itemSearchResults');
+ var q = input.value.trim();
+ selectedInventoryItem = null;
+ if (!q || q.length < 2) {
+ box.style.display = 'none';
+ box.innerHTML = '';
+ return;
+ }
+ clearTimeout(itemSearchTimer);
+ itemSearchTimer = setTimeout(function() {
+ var branchId = currentOrder && currentOrder.branch_id ? currentOrder.branch_id : '';
+ fetch('/pos/api/service-orders/inventory-search?q=' + encodeURIComponent(q) + '&branch_id=' + encodeURIComponent(branchId), {headers: headers()})
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ var items = d.data || [];
+ if (!items.length) {
+ box.innerHTML = '
Sin resultados
';
+ box.style.display = 'block';
+ return;
+ }
+ box.innerHTML = items.map(function(it) {
+ return '
' +
+ '
' + esc(it.name) + '
' +
+ '
' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(it.price_1)) + '' +
+ '
';
+ }).join('');
+ box.style.display = 'block';
+ })
+ .catch(function() {
+ box.style.display = 'none';
+ });
+ }, 250);
+ }
+
+ function selectInventoryItem(id) {
+ var box = document.getElementById('itemSearchResults');
+ var div = box.querySelector('[data-id="' + id + '"]');
+ if (!div) return;
+ selectedInventoryItem = {
+ id: id,
+ name: div.dataset.name || '',
+ part_number: div.dataset.part || '',
+ unit_price: parseFloat(div.dataset.price) || 0,
+ unit_cost: parseFloat(div.dataset.cost) || 0
+ };
+ document.getElementById('newItemSearch').value = selectedInventoryItem.name;
+ box.style.display = 'none';
+ }
+
+ function addSelectedItem() {
+ if (!currentOrderId) return;
+ var qty = parseInt(document.getElementById('newItemQty').value, 10) || 1;
+ if (selectedInventoryItem) {
+ api('POST', '/' + currentOrderId + '/items', {
+ inventory_id: selectedInventoryItem.id,
+ part_number: selectedInventoryItem.part_number,
+ name: selectedInventoryItem.name,
+ quantity: qty,
+ unit_cost: selectedInventoryItem.unit_cost,
+ unit_price: selectedInventoryItem.unit_price,
+ status: 'pending'
+ }).then(function() {
+ selectedInventoryItem = null;
+ document.getElementById('newItemSearch').value = '';
+ document.getElementById('newItemQty').value = '1';
+ openDetail(currentOrderId);
+ }).catch(function(e) { alert('Error: ' + e.message); });
+ return;
+ }
+ // Fallback: manual placeholder (no inventory link)
var name = document.getElementById('newItemSearch').value.trim();
if (!name) return;
+ if (!confirm('No seleccionaste una refacción del inventario. ¿Agregar como concepto manual? No podrá reservarse.')) return;
api('POST', '/' + currentOrderId + '/items', {
name: name,
- quantity: 1,
+ quantity: qty,
unit_price: 0,
status: 'pending'
}).then(function() {
+ document.getElementById('newItemSearch').value = '';
+ document.getElementById('newItemQty').value = '1';
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
}
@@ -768,7 +901,10 @@ var Workshop = (function() {
switchTab: switchTab,
changeStatus: changeStatus,
reserveItem: reserveItem,
- addItemPlaceholder: addItemPlaceholder,
+ saveNotes: saveNotes,
+ searchItemsForSO: searchItemsForSO,
+ selectInventoryItem: selectInventoryItem,
+ addSelectedItem: addSelectedItem,
addLabor: addLabor,
convertToSale: convertToSale,
printOrder: printOrder,
diff --git a/pos/templates/workshop.html b/pos/templates/workshop.html
index 3f208a8..3234038 100644
--- a/pos/templates/workshop.html
+++ b/pos/templates/workshop.html
@@ -15,7 +15,12 @@
-
+
+
@@ -295,7 +300,7 @@
-
+