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_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):
|
||||
|
||||
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 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) ───────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user