feat(workshop): allow admin/owner to delete service orders
- Soft-delete service orders via is_deleted flag. - Added DELETE /pos/api/service-orders/<id> restricted to owner/admin. - Updated list, detail, summary, status and assignment queries to exclude deleted orders. - Added 'Eliminar orden' button in the detail modal for administrators.
This commit is contained in:
@@ -13,6 +13,7 @@ from services.service_order_engine import (
|
|||||||
create_service_catalog_item,
|
create_service_catalog_item,
|
||||||
create_service_order,
|
create_service_order,
|
||||||
delete_service_catalog_item,
|
delete_service_catalog_item,
|
||||||
|
delete_service_order,
|
||||||
get_kanban_summary,
|
get_kanban_summary,
|
||||||
get_service_order,
|
get_service_order,
|
||||||
list_service_catalog,
|
list_service_catalog,
|
||||||
@@ -150,6 +151,23 @@ def update_order(so_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
||||||
|
@require_auth('workshop.edit')
|
||||||
|
def delete_order(so_id):
|
||||||
|
"""Soft-delete a service order. Restricted to owner/admin."""
|
||||||
|
if g.employee_role not in ('owner', 'admin'):
|
||||||
|
return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
delete_service_order(conn, so_id)
|
||||||
|
return jsonify({'message': 'Orden eliminada'})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 404
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||||
@require_auth('workshop.edit')
|
@require_auth('workshop.edit')
|
||||||
def change_status(so_id):
|
def change_status(so_id):
|
||||||
|
|||||||
11
pos/migrations/v4.12_service_order_soft_delete.sql
Normal file
11
pos/migrations/v4.12_service_order_soft_delete.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- v4.12_service_order_soft_delete.sql
|
||||||
|
-- Soft-delete support for service orders.
|
||||||
|
|
||||||
|
ALTER TABLE service_orders
|
||||||
|
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_orders_is_deleted
|
||||||
|
ON service_orders (is_deleted);
|
||||||
|
|
||||||
|
-- Exclude deleted orders from common queries by default via partial index helpers
|
||||||
|
-- (application filters explicitly).
|
||||||
@@ -115,7 +115,7 @@ def get_service_order(conn, so_id):
|
|||||||
LEFT JOIN employees creator ON so.created_by = creator.id
|
LEFT JOIN employees creator ON so.created_by = creator.id
|
||||||
LEFT JOIN branches b ON so.branch_id = b.id
|
LEFT JOIN branches b ON so.branch_id = b.id
|
||||||
LEFT JOIN couriers co ON so.courier_id = co.id
|
LEFT JOIN couriers co ON so.courier_id = co.id
|
||||||
WHERE so.id = %s
|
WHERE so.id = %s AND so.is_deleted = false
|
||||||
""", (so_id,))
|
""", (so_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -242,7 +242,8 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
where_clauses.append("(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s)")
|
where_clauses.append("(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s)")
|
||||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
|
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||||
|
|
||||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
where_clauses.append("so.is_deleted = false")
|
||||||
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT count(*) FROM service_orders so
|
SELECT count(*) FROM service_orders so
|
||||||
@@ -309,7 +310,7 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
||||||
"""Update service order status with validation."""
|
"""Update service order status with validation."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT status FROM service_orders WHERE id = %s", (so_id,))
|
cur.execute("SELECT status FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -486,7 +487,7 @@ def get_kanban_summary(conn, branch_id=None):
|
|||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT status, COUNT(*) as cnt
|
SELECT status, COUNT(*) as cnt
|
||||||
FROM service_orders
|
FROM service_orders
|
||||||
WHERE status != 'cancelled' {branch_filter}
|
WHERE status != 'cancelled' AND is_deleted = false {branch_filter}
|
||||||
GROUP BY status
|
GROUP BY status
|
||||||
""", params)
|
""", params)
|
||||||
|
|
||||||
@@ -499,6 +500,7 @@ def get_kanban_summary(conn, branch_id=None):
|
|||||||
SELECT count(*) FROM service_orders
|
SELECT count(*) FROM service_orders
|
||||||
WHERE estimated_completion < NOW()
|
WHERE estimated_completion < NOW()
|
||||||
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
||||||
|
AND is_deleted = false
|
||||||
{branch_filter}
|
{branch_filter}
|
||||||
""", params)
|
""", params)
|
||||||
overdue = cur.fetchone()[0]
|
overdue = cur.fetchone()[0]
|
||||||
@@ -813,7 +815,7 @@ def convert_to_sale(conn, so_id, sale_data, employee_id=None):
|
|||||||
def assign_mechanic(conn, so_id, employee_id):
|
def assign_mechanic(conn, so_id, employee_id):
|
||||||
"""Assign a mechanic/technician to a service order."""
|
"""Assign a mechanic/technician to a service order."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT id FROM service_orders WHERE id = %s", (so_id,))
|
cur.execute("SELECT id FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
cur.close()
|
cur.close()
|
||||||
raise ValueError("Service order not found")
|
raise ValueError("Service order not found")
|
||||||
@@ -827,6 +829,21 @@ def assign_mechanic(conn, so_id, employee_id):
|
|||||||
return {"employee_id": employee_id}
|
return {"employee_id": employee_id}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_service_order(conn, so_id):
|
||||||
|
"""Soft-delete a service order."""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE service_orders SET is_deleted = true WHERE id = %s AND is_deleted = false",
|
||||||
|
(so_id,),
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
if deleted == 0:
|
||||||
|
raise ValueError("Service order not found")
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
# ─── Service catalog (reusable labor concepts) ───────────────────────────────
|
# ─── Service catalog (reusable labor concepts) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ var Workshop = (function() {
|
|||||||
var hidePrices = role === 'workshop' || role === 'mechanic';
|
var hidePrices = role === 'workshop' || role === 'mechanic';
|
||||||
var perms = user.permissions || [];
|
var perms = user.permissions || [];
|
||||||
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
|
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
|
||||||
|
var canDelete = role === 'owner' || role === 'admin';
|
||||||
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
|
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
|
||||||
|
|
||||||
var COLUMNS = [
|
var COLUMNS = [
|
||||||
@@ -413,6 +414,7 @@ var Workshop = (function() {
|
|||||||
'</div>' : '';
|
'</div>' : '';
|
||||||
footer.innerHTML = statusHtml +
|
footer.innerHTML = statusHtml +
|
||||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
||||||
|
(canDelete ? '<button class="btn btn--danger" onclick="Workshop.deleteOrder()">Eliminar orden</button>' : '') +
|
||||||
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
||||||
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
||||||
'Imprimir orden</button>' +
|
'Imprimir orden</button>' +
|
||||||
@@ -699,6 +701,18 @@ var Workshop = (function() {
|
|||||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteOrder() {
|
||||||
|
if (!currentOrderId) return;
|
||||||
|
if (!confirm('¿Eliminar esta orden de servicio? Se ocultará del taller pero las reservas de inventario y la venta asociada (si existe) no se verán afectadas.')) return;
|
||||||
|
api('DELETE', '/' + currentOrderId, {})
|
||||||
|
.then(function() {
|
||||||
|
closeDetailModal();
|
||||||
|
loadSummary();
|
||||||
|
loadOrders();
|
||||||
|
})
|
||||||
|
.catch(function(e) { alert('Error: ' + e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
function printOrder() {
|
function printOrder() {
|
||||||
if (!currentOrderId) return;
|
if (!currentOrderId) return;
|
||||||
if (!window.NexusPrinter || !window.NexusPrinter.isConnected()) {
|
if (!window.NexusPrinter || !window.NexusPrinter.isConnected()) {
|
||||||
@@ -907,6 +921,7 @@ var Workshop = (function() {
|
|||||||
addSelectedItem: addSelectedItem,
|
addSelectedItem: addSelectedItem,
|
||||||
addLabor: addLabor,
|
addLabor: addLabor,
|
||||||
convertToSale: convertToSale,
|
convertToSale: convertToSale,
|
||||||
|
deleteOrder: deleteOrder,
|
||||||
printOrder: printOrder,
|
printOrder: printOrder,
|
||||||
openNewOrderModal: openNewOrderModal,
|
openNewOrderModal: openNewOrderModal,
|
||||||
closeNewOrderModal: closeNewOrderModal,
|
closeNewOrderModal: closeNewOrderModal,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=34">
|
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=35">
|
||||||
<style>
|
<style>
|
||||||
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
|
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
|
||||||
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
|
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=33" defer></script>
|
<script src="/pos/static/js/sidebar.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/workshop.js?v=34" defer></script>
|
<script src="/pos/static/js/workshop.js?v=35" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user