Files
Autoparts-DB/pos/blueprints/service_order_bp.py
consultoria-as e201dce290 feat(pos/workshop): add 80mm thermal ticket printing for service orders
- Add generate_service_order_ticket() in thermal_printer.py with ESC/POS commands for 58mm and 80mm printers.

- Add POST /pos/api/service-orders/:id/print endpoint returning raw bytes or JSON for browser rendering.

- Extend printer.js with printServiceOrder() using WebUSB/Web Serial.

- Add Imprimir orden button in workshop.js detail modal.

- Update FASES_IMPLEMENTADAS.md.
2026-06-15 06:18:33 +00:00

430 lines
13 KiB
Python

"""Service Order Blueprint: workshop Kanban management.
Prefix: /pos/api/service-orders
"""
from flask import Blueprint, g, jsonify, request
from middleware import require_auth
from services.service_order_engine import (
add_item,
add_labor,
assign_mechanic,
convert_to_sale,
create_service_catalog_item,
create_service_order,
delete_service_catalog_item,
get_kanban_summary,
get_service_order,
list_service_catalog,
list_service_orders,
release_item,
remove_item,
remove_labor,
reserve_item,
update_item,
update_labor,
update_service_catalog_item,
update_service_order,
update_status,
)
from tenant_db import get_tenant_conn
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
@service_order_bp.route('', methods=['GET'])
@require_auth()
def list_orders():
status = request.args.get('status')
priority = request.args.get('priority')
customer_id = request.args.get('customer_id', type=int)
employee_id = request.args.get('employee_id', type=int)
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
conn = get_tenant_conn(g.tenant_id)
try:
result = list_service_orders(
conn, status=status, branch_id=g.branch_id,
customer_id=customer_id, priority=priority,
employee_id=employee_id, page=page, per_page=per_page
)
return jsonify(result)
finally:
conn.close()
@service_order_bp.route('', methods=['POST'])
@require_auth()
def create_order():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
result = create_service_order(conn, {
'tenant_id': g.tenant_id,
'branch_id': data.get('branch_id', g.branch_id),
'customer_id': data.get('customer_id'),
'vehicle_id': data.get('vehicle_id'),
'priority': data.get('priority', 'normal'),
'reception_notes': data.get('reception_notes'),
'estimated_cost': data.get('estimated_cost'),
'estimated_completion': data.get('estimated_completion'),
'employee_id': data.get('employee_id'),
'mileage_in': data.get('mileage_in'),
'fuel_level': data.get('fuel_level'),
'created_by': getattr(g, 'employee_id', None),
})
return jsonify(result), 201
finally:
conn.close()
@service_order_bp.route('/<int:so_id>', methods=['GET'])
@require_auth()
def get_order(so_id):
conn = get_tenant_conn(g.tenant_id)
try:
order = get_service_order(conn, so_id)
if not order:
return jsonify({'error': 'Service order not found'}), 404
return jsonify(order)
finally:
conn.close()
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
@require_auth()
def update_order(so_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_service_order(conn, so_id, data)
if not ok:
return jsonify({'error': 'No fields to update'}), 400
return jsonify({'message': 'Service order updated'})
finally:
conn.close()
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
@require_auth()
def change_status(so_id):
data = request.get_json() or {}
new_status = data.get('status')
if not new_status:
return jsonify({'error': 'status is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = update_status(
conn, so_id, new_status,
changed_by=getattr(g, 'employee_id', None),
notes=data.get('notes'),
)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Items (Parts) ─────────────────────────────
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
@require_auth()
def add_order_item(so_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
item_id = add_item(conn, so_id, data)
return jsonify({'id': item_id, 'message': 'Item added'}), 201
finally:
conn.close()
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_auth()
def update_order_item(item_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_item(conn, item_id, data)
if not ok:
return jsonify({'error': 'No fields to update'}), 400
return jsonify({'message': 'Item updated'})
finally:
conn.close()
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_auth()
def delete_order_item(item_id):
conn = get_tenant_conn(g.tenant_id)
try:
remove_item(conn, item_id)
return jsonify({'message': 'Item removed'})
finally:
conn.close()
# ─── Labor ─────────────────────────────
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
@require_auth()
def add_order_labor(so_id):
data = request.get_json() or {}
if not data.get('description'):
return jsonify({'error': 'description is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
labor_id = add_labor(conn, so_id, data)
return jsonify({'id': labor_id, 'message': 'Labor added'}), 201
finally:
conn.close()
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
@require_auth()
def update_order_labor(labor_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_labor(conn, labor_id, data)
if not ok:
return jsonify({'error': 'No fields to update'}), 400
return jsonify({'message': 'Labor updated'})
finally:
conn.close()
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
@require_auth()
def delete_order_labor(labor_id):
conn = get_tenant_conn(g.tenant_id)
try:
remove_labor(conn, labor_id)
return jsonify({'message': 'Labor removed'})
finally:
conn.close()
# ─── Kanban Summary ─────────────────────────────
@service_order_bp.route('/kanban/summary', methods=['GET'])
@require_auth()
def kanban_summary():
conn = get_tenant_conn(g.tenant_id)
try:
summary = get_kanban_summary(conn, branch_id=g.branch_id)
return jsonify(summary)
finally:
conn.close()
# ─── Inventory reservation ────────────────────────
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_auth()
def reserve_order_item(so_id, item_id):
"""Reserve inventory for a service order item."""
conn = get_tenant_conn(g.tenant_id)
try:
result = reserve_item(conn, item_id, branch_id=g.branch_id, employee_id=g.employee_id)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
@require_auth()
def release_order_item(so_id, item_id):
"""Release a previous inventory reservation."""
conn = get_tenant_conn(g.tenant_id)
try:
result = release_item(conn, item_id, employee_id=g.employee_id)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Convert to sale ──────────────────────────────
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
@require_auth('pos.sell')
def convert_order_to_sale(so_id):
"""Convert a service order into a POS sale.
Body: {
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
sale_type: 'cash' | 'credit' | 'mixed',
register_id: int (optional),
amount_paid: float (optional),
payment_details: [...] (optional),
notes: str (optional)
}
"""
data = request.get_json() or {}
sale_payload = {
'payment_method': data.get('payment_method', 'efectivo'),
'sale_type': data.get('sale_type', 'cash'),
'register_id': data.get('register_id'),
'amount_paid': data.get('amount_paid'),
'payment_details': data.get('payment_details', []),
'notes': data.get('notes'),
}
conn = get_tenant_conn(g.tenant_id)
try:
result = convert_to_sale(
conn, so_id, sale_payload, employee_id=g.employee_id
)
return jsonify(result), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Mechanic assignment ──────────────────────────
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
@require_auth()
def assign_mechanic_endpoint(so_id):
"""Assign a mechanic/technician to a service order."""
data = request.get_json() or {}
employee_id = data.get('employee_id')
if not employee_id:
return jsonify({'error': 'employee_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = assign_mechanic(conn, so_id, employee_id)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Service catalog (reusable labor) ─────────────
@service_order_bp.route('/service-catalog', methods=['GET'])
@require_auth()
def list_catalog():
"""List reusable labor/service concepts."""
active_only = request.args.get('active_only', 'true').lower() != 'false'
conn = get_tenant_conn(g.tenant_id)
try:
items = list_service_catalog(conn, active_only=active_only)
return jsonify({'data': items})
finally:
conn.close()
@service_order_bp.route('/service-catalog', methods=['POST'])
@require_auth()
def create_catalog_item():
"""Create a reusable labor concept."""
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'name is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = create_service_catalog_item(conn, g.tenant_id, data)
return jsonify(result), 201
finally:
conn.close()
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
@require_auth()
def update_catalog_item(item_id):
"""Update a reusable labor concept."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_service_catalog_item(conn, item_id, data)
if not ok:
return jsonify({'error': 'No fields to update'}), 400
return jsonify({'message': 'Catalog item updated'})
finally:
conn.close()
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
@require_auth()
def delete_catalog_item(item_id):
"""Soft-delete a reusable labor concept."""
conn = get_tenant_conn(g.tenant_id)
try:
delete_service_catalog_item(conn, item_id)
return jsonify({'message': 'Catalog item deactivated'})
finally:
conn.close()
# ─── Thermal printing ─────────────────────────────
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_auth()
def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order.
Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80}
- escpos_raw: returns raw ESC/POS bytes (application/octet-stream)
- browser: returns the data dict as JSON for browser-side rendering
"""
from flask import Response
from services.thermal_printer import generate_service_order_ticket
body = request.get_json(silent=True) or {}
printer_type = body.get('printer_type', 'escpos_raw')
width = int(body.get('width', 80))
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
order = get_service_order(conn, so_id)
if not order:
cur.close()
conn.close()
return jsonify({'error': 'Service order not found'}), 404
# Fetch business info from config
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try:
cur.execute(
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
)
for rw in cur.fetchall():
if rw[0] == 'business_name':
business_info['name'] = rw[1]
else:
business_info[rw[0]] = rw[1]
except Exception:
pass
cur.close()
conn.close()
if printer_type == 'browser':
return jsonify(order)
raw = generate_service_order_ticket(order, business_info, width=width)
return Response(
raw,
mimetype='application/octet-stream',
headers={
'Content-Disposition': f'attachment; filename=orden_{order.get("order_number", so_id)}.bin'
},
)