feat(workshop): allow admin/owner to delete service orders
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-06-30 17:51:09 +00:00
parent 2863cf8e9f
commit e9cfb2e756
5 changed files with 68 additions and 7 deletions

View File

@@ -13,6 +13,7 @@ from services.service_order_engine import (
create_service_catalog_item,
create_service_order,
delete_service_catalog_item,
delete_service_order,
get_kanban_summary,
get_service_order,
list_service_catalog,
@@ -150,6 +151,23 @@ def update_order(so_id):
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'])
@require_auth('workshop.edit')
def change_status(so_id):

View 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).

View File

@@ -115,7 +115,7 @@ def get_service_order(conn, so_id):
LEFT JOIN employees creator ON so.created_by = creator.id
LEFT JOIN branches b ON so.branch_id = b.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,))
row = cur.fetchone()
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)")
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"""
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):
"""Update service order status with validation."""
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()
if not row:
cur.close()
@@ -486,7 +487,7 @@ def get_kanban_summary(conn, branch_id=None):
cur.execute(f"""
SELECT status, COUNT(*) as cnt
FROM service_orders
WHERE status != 'cancelled' {branch_filter}
WHERE status != 'cancelled' AND is_deleted = false {branch_filter}
GROUP BY status
""", params)
@@ -499,6 +500,7 @@ def get_kanban_summary(conn, branch_id=None):
SELECT count(*) FROM service_orders
WHERE estimated_completion < NOW()
AND status NOT IN ('ready', 'delivered', 'cancelled')
AND is_deleted = false
{branch_filter}
""", params)
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):
"""Assign a mechanic/technician to a service order."""
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():
cur.close()
raise ValueError("Service order not found")
@@ -827,6 +829,21 @@ def assign_mechanic(conn, so_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) ───────────────────────────────

View File

@@ -27,6 +27,7 @@ var Workshop = (function() {
var hidePrices = role === 'workshop' || role === 'mechanic';
var perms = user.permissions || [];
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 COLUMNS = [
@@ -413,6 +414,7 @@ var Workshop = (function() {
'</div>' : '';
footer.innerHTML = statusHtml +
'<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()">' +
'<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>' +
@@ -699,6 +701,18 @@ var Workshop = (function() {
}).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() {
if (!currentOrderId) return;
if (!window.NexusPrinter || !window.NexusPrinter.isConnected()) {
@@ -907,6 +921,7 @@ var Workshop = (function() {
addSelectedItem: addSelectedItem,
addLabor: addLabor,
convertToSale: convertToSale,
deleteOrder: deleteOrder,
printOrder: printOrder,
openNewOrderModal: openNewOrderModal,
closeNewOrderModal: closeNewOrderModal,

View File

@@ -15,7 +15,7 @@
<meta name="theme-color" content="#F5A623" />
<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>
.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); }
@@ -300,7 +300,7 @@
<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/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 src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>