- Add "Mis cortes de caja" report for cashiers/counters with sales detail. - Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint. - Remove dashboard from cashier menu; add Reports to cashier/counter. - Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission. - Remission notes module (UI, CSS, courier, counter remissions). - Customer hard-delete and vehicle/customer linkage in workshop. - POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio. - Inventory: CSV template with sku_secondary, alias import. - Rached migration scripts and DB migrations. - Version-bump cached JS/CSS query strings. Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
777 lines
27 KiB
Python
777 lines
27 KiB
Python
"""Service Order Blueprint: workshop Kanban management.
|
|
|
|
Prefix: /pos/api/service-orders
|
|
"""
|
|
|
|
import json
|
|
from functools import wraps
|
|
|
|
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_remission,
|
|
convert_to_sale,
|
|
create_service_catalog_item,
|
|
create_service_order,
|
|
delete_service_catalog_item,
|
|
delete_service_order,
|
|
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')
|
|
|
|
|
|
# Roles allowed to view or edit service orders.
|
|
# Counter (mostrador) creates/edits; workshop/mechanic only view restricted info.
|
|
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
|
_WORKSHOP_EDIT_ROLES = {'owner', 'admin', 'counter', 'cashier'}
|
|
|
|
# Statuses that mechanics are not allowed to see (single shared mechanic account).
|
|
_MECHANIC_HIDDEN_STATUSES = {
|
|
'cotizada', 'por_autorizar', 'autorizada', 'autorizacion_parcial',
|
|
'por_facturar', 'facturada'
|
|
}
|
|
|
|
|
|
def _can_view_workshop():
|
|
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
|
|
|
|
|
|
def _can_edit_workshop():
|
|
return g.employee_role in _WORKSHOP_EDIT_ROLES
|
|
|
|
|
|
def _is_restricted_workshop_viewer():
|
|
return g.employee_role in ('workshop', 'mechanic')
|
|
|
|
|
|
def _redact_order_for_mechanic(order):
|
|
"""Remove customer/commercial data from the order payload for mechanics."""
|
|
if not _is_restricted_workshop_viewer():
|
|
return order
|
|
sensitive = {
|
|
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
|
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
|
|
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
|
|
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
|
|
'final_cost', 'total_parts', 'total_labor', 'total',
|
|
}
|
|
|
|
def redact_value(v):
|
|
if isinstance(v, str):
|
|
return '—'
|
|
if isinstance(v, (int, float)):
|
|
return 0
|
|
return None
|
|
|
|
redacted = {}
|
|
for k, v in order.items():
|
|
if k in sensitive:
|
|
redacted[k] = redact_value(v)
|
|
elif k == 'items' and isinstance(v, list):
|
|
redacted[k] = [_redact_item_for_mechanic(it) for it in v]
|
|
else:
|
|
redacted[k] = v
|
|
return redacted
|
|
|
|
|
|
def _redact_item_for_mechanic(item):
|
|
"""Hide mechanic name from item rows for mechanics."""
|
|
item = dict(item)
|
|
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
|
if k in item:
|
|
item[k] = None if not isinstance(item[k], (int, float)) else 0
|
|
return item
|
|
|
|
|
|
def require_workshop_view(f):
|
|
@wraps(f)
|
|
@require_auth()
|
|
def decorated(*args, **kwargs):
|
|
if not _can_view_workshop():
|
|
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
|
return f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
def require_workshop_edit(f):
|
|
@wraps(f)
|
|
@require_auth()
|
|
def decorated(*args, **kwargs):
|
|
if not _can_edit_workshop():
|
|
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
|
return f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
@service_order_bp.route('', methods=['GET'])
|
|
@require_workshop_view
|
|
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)
|
|
delivery_method = request.args.get('delivery_method')
|
|
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
|
|
q = request.args.get('q')
|
|
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, delivery_method=delivery_method,
|
|
is_direct=is_direct, q=q, page=page, per_page=per_page
|
|
)
|
|
if _is_restricted_workshop_viewer():
|
|
result['data'] = [_redact_order_for_mechanic(o) for o in result.get('data', [])]
|
|
# Shared mechanic account can see all orders except commercial/closed statuses.
|
|
if g.employee_role == 'mechanic':
|
|
result['data'] = [
|
|
o for o in result.get('data', [])
|
|
if o.get('status') not in _MECHANIC_HIDDEN_STATUSES
|
|
]
|
|
return jsonify(result)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@service_order_bp.route('/from-pos', methods=['POST'])
|
|
@require_auth('pos.sell')
|
|
def create_order_from_pos():
|
|
"""Create a service order directly from the POS cart (quotation)."""
|
|
data = request.get_json() or {}
|
|
items = data.get('items', [])
|
|
estimated_cost = sum(
|
|
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
|
|
for it in items
|
|
)
|
|
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': estimated_cost or None,
|
|
'estimated_completion': data.get('estimated_completion'),
|
|
'employee_id': data.get('employee_id'),
|
|
'mileage_in': data.get('mileage_in'),
|
|
'created_by': getattr(g, 'employee_id', None),
|
|
'delivery_method': data.get('delivery_method'),
|
|
'courier_id': data.get('courier_id'),
|
|
'is_direct': data.get('is_direct', False),
|
|
'requires_invoice': data.get('requires_invoice', False),
|
|
'workshop_name': data.get('workshop_name'),
|
|
'customer_address': data.get('customer_address'),
|
|
'customer_phone': data.get('customer_phone'),
|
|
'vehicle_description': data.get('vehicle_description'),
|
|
})
|
|
so_id = result['service_order_id']
|
|
for item in items:
|
|
add_item(conn, so_id, item)
|
|
order = get_service_order(conn, so_id)
|
|
return jsonify(order), 201
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@service_order_bp.route('', methods=['POST'])
|
|
@require_workshop_edit
|
|
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),
|
|
'delivery_method': data.get('delivery_method'),
|
|
'courier_id': data.get('courier_id'),
|
|
'is_direct': data.get('is_direct', False),
|
|
'requires_invoice': data.get('requires_invoice', False),
|
|
'workshop_name': data.get('workshop_name'),
|
|
'customer_address': data.get('customer_address'),
|
|
'customer_phone': data.get('customer_phone'),
|
|
'vehicle_description': data.get('vehicle_description'),
|
|
})
|
|
return jsonify(result), 201
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
|
@require_workshop_view
|
|
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
|
|
# Shared mechanic account cannot view commercial/closed statuses.
|
|
if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES:
|
|
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
|
return jsonify(_redact_order_for_mechanic(order))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
|
@require_workshop_view
|
|
def update_order(so_id):
|
|
data = request.get_json() or {}
|
|
# Full edit remains for owner/admin/counter/cashier.
|
|
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
|
|
if g.employee_role == 'mechanic':
|
|
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
|
|
if not data or any(k not in allowed for k in data.keys()):
|
|
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
|
|
elif not _can_edit_workshop():
|
|
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
|
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>', methods=['DELETE'])
|
|
@require_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_workshop_view
|
|
def change_status(so_id):
|
|
if not (_can_edit_workshop() or g.employee_role == 'mechanic'):
|
|
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
|
data = request.get_json() or {}
|
|
new_status = data.get('status')
|
|
if not new_status:
|
|
return jsonify({'error': 'status is required'}), 400
|
|
# Mechanics cannot move orders into commercial/closed statuses.
|
|
if g.employee_role == 'mechanic' and new_status in _MECHANIC_HIDDEN_STATUSES:
|
|
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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_workshop_view
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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()
|
|
|
|
|
|
# ─── Convert to remission note ────────────────────
|
|
|
|
|
|
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
|
@require_auth('pos.remission')
|
|
def convert_order_to_remission(so_id):
|
|
"""Convert a service order into a counter remission note (pending payment).
|
|
|
|
Body: {
|
|
register_id: int (optional),
|
|
notes: str (optional)
|
|
}
|
|
"""
|
|
data = request.get_json() or {}
|
|
sale_payload = {
|
|
'register_id': data.get('register_id'),
|
|
'notes': data.get('notes'),
|
|
}
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
result = convert_to_remission(
|
|
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_workshop_edit
|
|
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_order_bp.route('/customers', methods=['POST'])
|
|
@require_workshop_edit
|
|
def create_customer_for_workshop():
|
|
"""Create a customer directly from the workshop flow."""
|
|
data = request.get_json() or {}
|
|
if not data.get('name'):
|
|
return jsonify({'error': 'El nombre es obligatorio'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
try:
|
|
cur.execute("""
|
|
INSERT INTO customers
|
|
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
|
cp, email, phone, address, price_tier, credit_limit, max_discount_pct, vehicle_info)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
RETURNING id
|
|
""", (
|
|
data.get('branch_id', g.branch_id), data['name'], data.get('rfc'),
|
|
data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
|
|
data.get('cp'), data.get('email'), data.get('phone'), data.get('address'),
|
|
data.get('price_tier', 1), data.get('credit_limit', 0), data.get('max_discount_pct', 0),
|
|
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
|
|
))
|
|
customer_id = cur.fetchone()[0]
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@service_order_bp.route('/vehicles', methods=['POST'])
|
|
@require_workshop_edit
|
|
def create_vehicle_for_workshop():
|
|
"""Create a fleet vehicle directly from the workshop flow.
|
|
|
|
Vehicles must be assigned to a customer. Brand and model are required;
|
|
plate is optional and VIN is not used.
|
|
"""
|
|
data = request.get_json() or {}
|
|
if not data.get('make') or not data.get('model'):
|
|
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
|
|
if not data.get('customer_id'):
|
|
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
try:
|
|
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
|
|
cust = cur.fetchone()
|
|
if not cust:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Cliente no encontrado'}), 404
|
|
|
|
cur.execute("""
|
|
INSERT INTO fleet_vehicles
|
|
(branch_id, customer_id, plate, make, model, year,
|
|
current_mileage, fuel_type, color, owner_name, notes)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
RETURNING id
|
|
""", (
|
|
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'),
|
|
data.get('make'), data.get('model'), data.get('year'),
|
|
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
|
data.get('color'), cust[0], data.get('notes'),
|
|
))
|
|
vehicle_id = cur.fetchone()[0]
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@service_order_bp.route('/inventory-search', methods=['GET'])
|
|
@require_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) ─────────────
|
|
|
|
|
|
@service_order_bp.route('/service-catalog', methods=['GET'])
|
|
@require_workshop_view
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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_workshop_edit
|
|
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'
|
|
},
|
|
)
|