fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
- Arregla @require_auth, permisos, race conditions, locks de caja/stock - Elimina N+1 en layaway, flotilla, dashboard y global_invoice - Asegura folios atomicos para CFDI, ordenes de servicio y polizas - Protege client_secret de MercadoLibre en backend - Conecta botones/filtros de config, customers, accounting e invoicing - Mejora accesibilidad (labels/aria-label) y estados de carga/vacio - Limpia accounting.js obsoleto y consolida accounting.v9.js - Actualiza cache busting a v32 y Service Worker a v32 - Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md Tests: 35 passed
This commit is contained in:
@@ -7,8 +7,10 @@ NUMERIC(14,2) in the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.accounting_engine import create_manual_entry
|
||||
@@ -696,7 +698,9 @@ def aging_summary():
|
||||
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
||||
s.id as sale_id, s.total, s.created_at,
|
||||
s.id as sale_id,
|
||||
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
|
||||
s.created_at,
|
||||
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
||||
FROM customers c
|
||||
JOIN sales s ON s.customer_id = c.id
|
||||
@@ -709,6 +713,11 @@ def aging_summary():
|
||||
customers = {}
|
||||
for r in cur.fetchall():
|
||||
cust_id = r[0]
|
||||
balance = round(float(r[6]) if r[6] else 0, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
days = r[8] or 0
|
||||
|
||||
if cust_id not in customers:
|
||||
customers[cust_id] = {
|
||||
'id': r[0], 'name': r[1], 'rfc': r[2],
|
||||
@@ -718,24 +727,22 @@ def aging_summary():
|
||||
'total': 0,
|
||||
}
|
||||
|
||||
amount = float(r[6]) if r[6] else 0
|
||||
days = r[8] or 0
|
||||
|
||||
if days <= 0:
|
||||
customers[cust_id]['corriente'] += amount
|
||||
customers[cust_id]['corriente'] += balance
|
||||
elif days <= 30:
|
||||
customers[cust_id]['d1_30'] += amount
|
||||
customers[cust_id]['d1_30'] += balance
|
||||
elif days <= 60:
|
||||
customers[cust_id]['d31_60'] += amount
|
||||
customers[cust_id]['d31_60'] += balance
|
||||
elif days <= 90:
|
||||
customers[cust_id]['d61_90'] += amount
|
||||
customers[cust_id]['d61_90'] += balance
|
||||
else:
|
||||
customers[cust_id]['d90_plus'] += amount
|
||||
customers[cust_id]['d90_plus'] += balance
|
||||
|
||||
customers[cust_id]['total'] += amount
|
||||
customers[cust_id]['total'] += balance
|
||||
|
||||
result = list(customers.values())
|
||||
for c in result:
|
||||
c['credit_balance'] = round(c['total'], 2)
|
||||
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
||||
c[key] = round(c[key], 2)
|
||||
|
||||
@@ -754,6 +761,161 @@ def aging_summary():
|
||||
return jsonify({'data': result, 'totals': totals})
|
||||
|
||||
|
||||
@accounting_bp.route('/aging/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_aging():
|
||||
"""Export receivables or payables to CSV/PDF."""
|
||||
report_type = request.args.get('type', 'receivable')
|
||||
fmt = request.args.get('format', 'csv').lower()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
if report_type == 'payable':
|
||||
# Payables always CSV for now
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
|
||||
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
|
||||
cur.execute("""
|
||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
||||
po.status, s.name
|
||||
FROM purchase_orders po
|
||||
JOIN suppliers s ON s.id = po.supplier_id
|
||||
WHERE po.status NOT IN ('paid', 'cancelled')
|
||||
ORDER BY po.created_at DESC
|
||||
""")
|
||||
for r in cur.fetchall():
|
||||
po_id = r[0]
|
||||
invoice = r[1] or f'OC-{po_id}'
|
||||
total = float(r[2]) if r[2] else 0
|
||||
created_at = r[3]
|
||||
expected = r[4]
|
||||
status = r[5]
|
||||
vendor = r[6]
|
||||
paid = 0
|
||||
balance = round(total - paid, 2)
|
||||
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
|
||||
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
|
||||
writer.writerow([
|
||||
invoice, vendor,
|
||||
created_at.strftime('%Y-%m-%d') if created_at else '',
|
||||
expected.strftime('%Y-%m-%d') if expected else '',
|
||||
total, paid, balance, days_overdue, status_label
|
||||
])
|
||||
cur.close(); conn.close()
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
return Response(
|
||||
'\ufeff' + csv_data,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
|
||||
)
|
||||
|
||||
# Receivables: CSV or PDF
|
||||
cur.execute("""
|
||||
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
|
||||
c.name,
|
||||
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
||||
FROM sales s
|
||||
JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
ORDER BY s.created_at DESC
|
||||
""")
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
sale_id = r[0]
|
||||
subtotal = float(r[1]) if r[1] else 0
|
||||
tax = float(r[2]) if r[2] else 0
|
||||
total = float(r[3]) if r[3] else 0
|
||||
created_at = r[4]
|
||||
customer_name = r[5]
|
||||
payments_total = float(r[6]) if r[6] else 0
|
||||
balance = round(total - payments_total, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
rows.append({
|
||||
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
|
||||
'customer': customer_name,
|
||||
'subtotal': subtotal,
|
||||
'tax': tax,
|
||||
'total': total,
|
||||
'balance': balance,
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
if fmt == 'pdf':
|
||||
from fpdf import FPDF
|
||||
|
||||
class ReceivablesPDF(FPDF):
|
||||
def header(self):
|
||||
self.set_font('Arial', 'B', 14)
|
||||
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
|
||||
self.set_font('Arial', '', 10)
|
||||
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
|
||||
self.ln(4)
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
self.set_font('Arial', 'I', 8)
|
||||
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
|
||||
|
||||
pdf = ReceivablesPDF('L', 'mm', 'A4')
|
||||
pdf.add_page()
|
||||
pdf.set_font('Arial', 'B', 10)
|
||||
pdf.set_fill_color(230, 230, 230)
|
||||
|
||||
col_widths = [30, 95, 35, 30, 35, 35]
|
||||
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
|
||||
for i, h in enumerate(headers):
|
||||
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
|
||||
pdf.ln()
|
||||
|
||||
pdf.set_font('Arial', '', 9)
|
||||
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
|
||||
for row in rows:
|
||||
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
|
||||
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
|
||||
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
|
||||
totals['subtotal'] += row['subtotal']
|
||||
totals['tax'] += row['tax']
|
||||
totals['total'] += row['total']
|
||||
totals['balance'] += row['balance']
|
||||
|
||||
pdf.set_font('Arial', 'B', 9)
|
||||
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
|
||||
|
||||
pdf_bytes = bytes(pdf.output(dest='S'))
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
|
||||
)
|
||||
|
||||
# CSV fallback
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
|
||||
for row in rows:
|
||||
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
return Response(
|
||||
'\ufeff' + csv_data,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
|
||||
)
|
||||
|
||||
|
||||
# ─── Fiscal Periods ────────────────────────────────
|
||||
|
||||
@accounting_bp.route('/periods', methods=['GET'])
|
||||
@@ -860,7 +1022,7 @@ def close_period():
|
||||
|
||||
|
||||
@accounting_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('accounting.read')
|
||||
@require_auth('accounting.view')
|
||||
def api_accounting_stats():
|
||||
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
@@ -151,7 +151,7 @@ def update_branch(branch_id):
|
||||
|
||||
|
||||
@config_bp.route('/employees', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
@require_auth()
|
||||
def list_employees():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -200,7 +200,7 @@ def create_employee():
|
||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||
|
||||
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
|
||||
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant', 'workshop']
|
||||
if data['role'] not in valid_roles:
|
||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||
|
||||
@@ -223,15 +223,22 @@ def create_employee():
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||
'invoicing.view', 'invoicing.create',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices'],
|
||||
'config.view', 'config.edit', 'config.edit_prices',
|
||||
'workshop.view', 'workshop.edit',
|
||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
|
||||
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
|
||||
'catalog.view', 'customers.view', 'customers.create'],
|
||||
'catalog.view',
|
||||
'inventory.view', 'inventory.create',
|
||||
'customers.view', 'customers.create'],
|
||||
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||
'accountant': ['accounting.view', 'accounting.create',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'customers.view'],
|
||||
'customers.view',
|
||||
'fleet.view'],
|
||||
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items', 'customers.view',
|
||||
'fleet.view'],
|
||||
}
|
||||
|
||||
for perm in role_permissions.get(data['role'], []):
|
||||
|
||||
@@ -30,10 +30,24 @@ def list_customers():
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
search = request.args.get('q', '').strip()
|
||||
branch_id = request.args.get('branch_id')
|
||||
price_tier = request.args.get('price_tier', '').strip()
|
||||
status = request.args.get('status', '').strip().lower()
|
||||
|
||||
where_clauses = ["c.is_active = true"]
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if status == 'inactive':
|
||||
where_clauses.append("c.is_active = false")
|
||||
elif status == 'overdue':
|
||||
where_clauses.append(
|
||||
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
|
||||
)
|
||||
elif status == 'all':
|
||||
pass # no is_active filter
|
||||
else:
|
||||
# Default to active customers for backwards compatibility
|
||||
where_clauses.append("c.is_active = true")
|
||||
|
||||
if branch_id:
|
||||
where_clauses.append("c.branch_id = %s")
|
||||
params.append(int(branch_id))
|
||||
@@ -42,8 +56,20 @@ def list_customers():
|
||||
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
|
||||
)
|
||||
params.extend([f'%{search}%'] * 4)
|
||||
if price_tier:
|
||||
# Support numeric tier or Spanish labels
|
||||
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
|
||||
tier_val = tier_map.get(price_tier.lower())
|
||||
if tier_val is None:
|
||||
try:
|
||||
tier_val = int(price_tier)
|
||||
except ValueError:
|
||||
tier_val = None
|
||||
if tier_val in (1, 2, 3):
|
||||
where_clauses.append("c.price_tier = %s")
|
||||
params.append(tier_val)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
||||
|
||||
# Count
|
||||
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
||||
@@ -54,7 +80,7 @@ def list_customers():
|
||||
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
||||
c.address, c.cp,
|
||||
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
||||
c.branch_id
|
||||
c.branch_id, c.is_active, c.created_at, c.last_purchase
|
||||
FROM customers c
|
||||
WHERE {where}
|
||||
ORDER BY c.name
|
||||
@@ -71,6 +97,9 @@ def list_customers():
|
||||
'credit_balance': float(r[10]) if r[10] else 0,
|
||||
'vehicle_info': r[11],
|
||||
'branch_id': r[12],
|
||||
'is_active': r[13],
|
||||
'created_at': str(r[14]) if r[14] else None,
|
||||
'last_purchase': str(r[15]) if r[15] else None,
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -472,6 +501,32 @@ def record_customer_payment(customer_id):
|
||||
UPDATE customers SET credit_balance = %s WHERE id = %s
|
||||
""", (new_balance, customer_id))
|
||||
|
||||
# Allocate the customer payment to oldest unpaid credit sales so the
|
||||
# aging report and per-sale balances reflect the remaining debt.
|
||||
remaining = amount
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
|
||||
FROM sales s
|
||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
||||
WHERE s.customer_id = %s
|
||||
AND s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
GROUP BY s.id, s.total, s.created_at
|
||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
||||
ORDER BY s.created_at
|
||||
""", (customer_id,))
|
||||
for sale_id, balance in cur.fetchall():
|
||||
if remaining <= 0:
|
||||
break
|
||||
pay = round(min(remaining, float(balance)), 2)
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, payment_method, pay,
|
||||
f'Abono cliente #{customer_id}'))
|
||||
remaining = round(remaining - pay, 2)
|
||||
|
||||
# Record cash movement on register if cash payment
|
||||
if register_id and payment_method == 'efectivo':
|
||||
cur.execute("""
|
||||
|
||||
@@ -118,3 +118,70 @@ def get_employee_stats():
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/credit-alerts', methods=['GET'])
|
||||
@require_auth()
|
||||
def credit_alerts():
|
||||
"""Credit sales that are overdue or due within the next 7 days."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT s.id,
|
||||
c.name as customer_name,
|
||||
s.total,
|
||||
s.created_at,
|
||||
s.created_at + INTERVAL '30 days' as due_date,
|
||||
COALESCE(SUM(sp.amount), 0) as paid
|
||||
FROM sales s
|
||||
JOIN customers c ON c.id = s.customer_id
|
||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
||||
WHERE s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
GROUP BY s.id, c.name, s.total, s.created_at
|
||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
||||
AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days'
|
||||
ORDER BY due_date
|
||||
LIMIT 50
|
||||
""")
|
||||
|
||||
rows = []
|
||||
now = datetime.utcnow()
|
||||
for r in cur.fetchall():
|
||||
sale_id = r[0]
|
||||
customer_name = r[1]
|
||||
total = float(r[2]) if r[2] else 0
|
||||
paid = float(r[5]) if r[5] else 0
|
||||
balance = round(total - paid, 2)
|
||||
created_at = r[3]
|
||||
due_date = r[4]
|
||||
days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None
|
||||
|
||||
if days_until_due is None:
|
||||
continue
|
||||
status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current')
|
||||
label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente')
|
||||
|
||||
rows.append({
|
||||
'sale_id': sale_id,
|
||||
'folio': f'VTA-{sale_id}',
|
||||
'customer_name': customer_name,
|
||||
'issue_date': created_at.isoformat() if created_at else None,
|
||||
'due_date': due_date.isoformat() if due_date else None,
|
||||
'days_until_due': days_until_due,
|
||||
'total': total,
|
||||
'paid': paid,
|
||||
'balance': balance,
|
||||
'status': status,
|
||||
'status_label': label,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'data': rows,
|
||||
'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'),
|
||||
'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'),
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
|
||||
# ─── Vehicles CRUD ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def list_vehicles():
|
||||
"""List fleet vehicles with pagination and search.
|
||||
|
||||
@@ -83,7 +83,7 @@ def list_vehicles():
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def get_vehicle(vehicle_id):
|
||||
"""Vehicle detail with maintenance schedules and recent logs."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -155,8 +155,117 @@ def get_vehicle(vehicle_id):
|
||||
return jsonify(vehicle)
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
def list_all_schedules():
|
||||
"""Return all active maintenance schedules joined with vehicle data.
|
||||
|
||||
Replaces the N+1 pattern of fetching schedules per vehicle.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
where_clauses = ["v.is_active = true", "s.is_active = true"]
|
||||
params = []
|
||||
if g.branch_id:
|
||||
where_clauses.append("v.branch_id = %s")
|
||||
params.append(g.branch_id)
|
||||
|
||||
vehicle_ids = request.args.get('vehicle_ids', '').strip()
|
||||
if vehicle_ids:
|
||||
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
|
||||
if ids:
|
||||
where_clauses.append("v.id = ANY(%s)")
|
||||
params.append(ids)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
|
||||
s.interval_months, s.last_done_at, s.last_done_km,
|
||||
s.next_due_at, s.next_due_km, s.notes,
|
||||
v.plate, v.make, v.model, v.current_mileage, v.color
|
||||
FROM fleet_maintenance_schedules s
|
||||
JOIN fleet_vehicles v ON v.id = s.vehicle_id
|
||||
WHERE {where}
|
||||
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
|
||||
""", params)
|
||||
|
||||
schedules = []
|
||||
for r in cur.fetchall():
|
||||
schedules.append({
|
||||
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
|
||||
'interval_km': r[3], 'interval_months': r[4],
|
||||
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
|
||||
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
|
||||
'notes': r[9],
|
||||
'vehicle': {
|
||||
'plate': r[10], 'make': r[11], 'model': r[12],
|
||||
'current_mileage': r[13], 'color': r[14]
|
||||
}
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': schedules})
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/history', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
def list_all_history():
|
||||
"""Return recent maintenance logs for all vehicles joined with vehicle data.
|
||||
|
||||
Replaces the N+1 pattern of fetching logs per vehicle.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
limit = min(int(request.args.get('limit', 200)), 500)
|
||||
|
||||
where_clauses = ["v.is_active = true"]
|
||||
params = [limit]
|
||||
if g.branch_id:
|
||||
where_clauses.append("v.branch_id = %s")
|
||||
params.append(g.branch_id)
|
||||
|
||||
vehicle_ids = request.args.get('vehicle_ids', '').strip()
|
||||
if vehicle_ids:
|
||||
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
|
||||
if ids:
|
||||
where_clauses.append("v.id = ANY(%s)")
|
||||
params.append(ids)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
|
||||
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
|
||||
e.name as employee_name,
|
||||
v.plate, v.make, v.model, v.color
|
||||
FROM fleet_maintenance_logs l
|
||||
JOIN fleet_vehicles v ON v.id = l.vehicle_id
|
||||
LEFT JOIN employees e ON l.employee_id = e.id
|
||||
WHERE {where}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT %s
|
||||
""", params)
|
||||
|
||||
logs = []
|
||||
for r in cur.fetchall():
|
||||
logs.append({
|
||||
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
|
||||
'maintenance_type': r[3], 'mileage_at': r[4],
|
||||
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
|
||||
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
|
||||
'employee_name': r[9],
|
||||
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': logs})
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.create')
|
||||
def create_vehicle():
|
||||
"""Create a fleet vehicle.
|
||||
|
||||
@@ -201,7 +310,7 @@ def create_vehicle():
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.edit')
|
||||
def update_vehicle(vehicle_id):
|
||||
"""Update vehicle fields including mileage.
|
||||
|
||||
@@ -245,7 +354,7 @@ def update_vehicle(vehicle_id):
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.delete')
|
||||
def deactivate_vehicle(vehicle_id):
|
||||
"""Soft-delete: set is_active = false."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -266,7 +375,7 @@ def deactivate_vehicle(vehicle_id):
|
||||
# ─── Maintenance Schedules ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def list_schedules(vehicle_id):
|
||||
"""Maintenance schedules for a vehicle."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -299,7 +408,7 @@ def list_schedules(vehicle_id):
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.create')
|
||||
def create_schedule(vehicle_id):
|
||||
"""Create maintenance schedule for a vehicle.
|
||||
|
||||
@@ -344,7 +453,7 @@ def create_schedule(vehicle_id):
|
||||
# ─── Maintenance Logs ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.create')
|
||||
def record_maintenance(vehicle_id):
|
||||
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
|
||||
|
||||
@@ -427,7 +536,7 @@ def record_maintenance(vehicle_id):
|
||||
# ─── Alerts ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/alerts', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def fleet_alerts():
|
||||
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -472,7 +581,7 @@ def fleet_alerts():
|
||||
# ─── Stats ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/stats', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def fleet_stats():
|
||||
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
@@ -485,7 +485,7 @@ def get_sale_pdf(sale_id):
|
||||
|
||||
|
||||
@invoicing_bp.route("/stats", methods=["GET"])
|
||||
@require_auth("invoicing.read")
|
||||
@require_auth("invoicing.view")
|
||||
def api_invoicing_stats():
|
||||
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
@@ -24,6 +24,8 @@ Routes:
|
||||
POST /pos/api/marketplace-ext/webhook/meli
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
@@ -81,6 +83,49 @@ def get_config():
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
|
||||
@require_auth()
|
||||
def init_meli_connect():
|
||||
"""Store client credentials server-side and return the MercadoLibre auth URL.
|
||||
|
||||
The frontend no longer keeps the client_secret in localStorage.
|
||||
"""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
client_id = data.get("client_id", "").strip()
|
||||
client_secret = data.get("client_secret", "").strip()
|
||||
category = data.get("category", "").strip()
|
||||
shipping = data.get("shipping", "").strip()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
return jsonify({"error": "client_id and client_secret required"}), 400
|
||||
|
||||
base = _get_public_base_url().rstrip("/")
|
||||
redirect_uri = f"{base}/pos/marketplace-external/callback"
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
meli_svc.save_meli_config(conn, {
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
"meli_default_category_id": category,
|
||||
"meli_shipping_mode": shipping,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
auth_url = (
|
||||
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
|
||||
f"&client_id={urllib.parse.quote(client_id)}"
|
||||
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
|
||||
"&scope=read+write+offline_access"
|
||||
)
|
||||
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
||||
@require_auth()
|
||||
def connect_meli():
|
||||
@@ -90,12 +135,21 @@ def connect_meli():
|
||||
|
||||
data = request.get_json() or {}
|
||||
code = data.get("code")
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
redirect_uri = data.get("redirect_uri", "")
|
||||
|
||||
if not code or not client_id or not client_secret:
|
||||
return jsonify({"error": "code, client_id and client_secret required"}), 400
|
||||
if not code:
|
||||
return jsonify({"error": "code required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
client_id = data.get("client_id") or cfg.get("meli_client_id")
|
||||
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
return jsonify({"error": "ML credentials not configured"}), 400
|
||||
|
||||
try:
|
||||
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||
|
||||
@@ -232,6 +232,75 @@ def list_sales():
|
||||
})
|
||||
|
||||
|
||||
@pos_bp.route('/sales/recent', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def recent_sales():
|
||||
"""Return recent sales with their items in a single response.
|
||||
|
||||
Query params:
|
||||
date_from: YYYY-MM-DD (defaults to today)
|
||||
date_to: YYYY-MM-DD (defaults to today)
|
||||
limit: int (default 10, max 50)
|
||||
"""
|
||||
from datetime import date
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
date_from = request.args.get('date_from') or str(date.today())
|
||||
date_to = request.args.get('date_to') or date_from
|
||||
limit = min(int(request.args.get('limit', 10)), 50)
|
||||
|
||||
where_clauses = [
|
||||
"s.created_at >= %s",
|
||||
"s.created_at < %s::date + interval '1 day'",
|
||||
"s.status != 'cancelled'"
|
||||
]
|
||||
params = [date_from, date_to]
|
||||
|
||||
if g.branch_id:
|
||||
where_clauses.append("s.branch_id = %s")
|
||||
params.append(g.branch_id)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.customer_id, s.payment_method, s.total, s.status, s.created_at,
|
||||
c.name as customer_name
|
||||
FROM sales s
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s
|
||||
""", params + [limit])
|
||||
|
||||
sales = []
|
||||
sale_ids = []
|
||||
for r in cur.fetchall():
|
||||
sale_ids.append(r[0])
|
||||
sales.append({
|
||||
'id': r[0], 'customer_id': r[1], 'payment_method': r[2],
|
||||
'total': float(r[3]) if r[3] else 0, 'status': r[4],
|
||||
'created_at': str(r[5]), 'customer_name': r[6],
|
||||
'items': []
|
||||
})
|
||||
|
||||
if sale_ids:
|
||||
cur.execute("""
|
||||
SELECT sale_id, name, quantity
|
||||
FROM sale_items
|
||||
WHERE sale_id = ANY(%s)
|
||||
ORDER BY sale_id, id
|
||||
""", (sale_ids,))
|
||||
for r in cur.fetchall():
|
||||
for sale in sales:
|
||||
if sale['id'] == r[0]:
|
||||
sale['items'].append({'name': r[1], 'quantity': r[2]})
|
||||
break
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': sales})
|
||||
|
||||
|
||||
@pos_bp.route('/historical-sales', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def list_historical_sales():
|
||||
@@ -310,9 +379,13 @@ def list_historical_sales():
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@require_auth()
|
||||
def get_sale(sale_id):
|
||||
"""Get sale detail with items."""
|
||||
# Allow POS users or accounting users to view receivable/sale detail.
|
||||
if g.employee_role != 'owner' and not ({'pos.view', 'accounting.view'} & g.permissions):
|
||||
return jsonify({'error': 'Missing permissions'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -378,12 +451,17 @@ def get_sale(sale_id):
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def api_cancel_sale(sale_id):
|
||||
"""Cancel a sale. Requires mandatory reason.
|
||||
|
||||
Body: {reason: str}
|
||||
"""
|
||||
# Allow POS sellers or accounting staff to cancel tickets from the
|
||||
# receivables / accounting view.
|
||||
if g.employee_role != 'owner' and not ({'pos.sell', 'accounting.view'} & g.permissions):
|
||||
return jsonify({'error': 'Missing permissions'}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
reason = data.get('reason', '').strip()
|
||||
|
||||
@@ -682,9 +760,23 @@ def list_quotations():
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
|
||||
@require_auth('pos.sell')
|
||||
def delete_quotation(quot_id):
|
||||
"""Delete a quotation and its items."""
|
||||
"""Delete a quotation, release its stock reservations and remove its items."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Release reserved stock before deleting items
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if reservation_items:
|
||||
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
|
||||
except Exception:
|
||||
# Continue with deletion even if release fails (e.g. no reservations)
|
||||
pass
|
||||
|
||||
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
|
||||
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
|
||||
deleted = cur.rowcount
|
||||
@@ -986,6 +1078,8 @@ def patch_quotation(quot_id):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
old_status = row[1]
|
||||
|
||||
fields = []
|
||||
params = []
|
||||
if 'customer_id' in data:
|
||||
@@ -997,9 +1091,10 @@ def patch_quotation(quot_id):
|
||||
if 'valid_until' in data:
|
||||
fields.append('valid_until = %s')
|
||||
params.append(data['valid_until'])
|
||||
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
|
||||
new_status = data.get('status')
|
||||
if new_status and new_status in ('active', 'cancelled', 'expired'):
|
||||
fields.append('status = %s')
|
||||
params.append(data['status'])
|
||||
params.append(new_status)
|
||||
|
||||
if not fields:
|
||||
cur.close(); conn.close()
|
||||
@@ -1007,6 +1102,20 @@ def patch_quotation(quot_id):
|
||||
|
||||
params.append(quot_id)
|
||||
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
|
||||
|
||||
# Release reservations when cancelling or expiring
|
||||
if new_status in ('cancelled', 'expired') and old_status not in ('cancelled', 'expired', 'converted'):
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if reservation_items:
|
||||
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation updated'})
|
||||
@@ -1911,10 +2020,14 @@ def complete_layaway(layaway_id):
|
||||
|
||||
# Create sale_items (no inventory deduction — already reserved)
|
||||
sale_items = []
|
||||
inv_ids = [item['inventory_id'] for item in totals_calc['items']]
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost FROM inventory WHERE id = ANY(%s)
|
||||
""", (inv_ids,))
|
||||
inv_map = {r[0]: (r[1], r[2], r[3]) for r in cur.fetchall()}
|
||||
|
||||
for item in totals_calc['items']:
|
||||
cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
|
||||
(item['inventory_id'],))
|
||||
inv = cur.fetchone()
|
||||
inv = inv_map.get(item['inventory_id'], ('', '', 0))
|
||||
cur.execute("""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
@@ -1923,9 +2036,9 @@ def complete_layaway(layaway_id):
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (
|
||||
sale_id, item['inventory_id'],
|
||||
inv[0] if inv else '', inv[1] if inv else '',
|
||||
inv[0] or '', inv[1] or '',
|
||||
item['quantity'], item['unit_price'],
|
||||
float(inv[2]) if inv and inv[2] else 0,
|
||||
float(inv[2]) if inv[2] else 0,
|
||||
item['discount_pct'], item['discount_amount'],
|
||||
item['tax_rate'], item['tax_amount'], item['subtotal']
|
||||
))
|
||||
@@ -2067,7 +2180,7 @@ def create_return():
|
||||
try:
|
||||
# Validate sale exists and is completed
|
||||
cur.execute("""
|
||||
SELECT id, customer_id, total, status, branch_id
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
sale = cur.fetchone()
|
||||
@@ -2078,6 +2191,7 @@ def create_return():
|
||||
|
||||
sale_customer_id = sale[1]
|
||||
sale_branch_id = sale[4] or g.branch_id
|
||||
sale_type = sale[5]
|
||||
|
||||
# Validate each return item against original sale items
|
||||
total_refund = 0
|
||||
@@ -2179,10 +2293,10 @@ def create_return():
|
||||
new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
|
||||
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
|
||||
|
||||
# Update customer credit if applicable
|
||||
if sale_customer_id:
|
||||
# Update customer credit if the original sale was on credit
|
||||
if sale_customer_id and sale_type == 'credit':
|
||||
cur.execute("""
|
||||
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) + %s
|
||||
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) - %s
|
||||
WHERE id = %s
|
||||
""", (total_refund, sale_customer_id))
|
||||
|
||||
|
||||
@@ -33,12 +33,15 @@ service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/se
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('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)
|
||||
|
||||
@@ -47,15 +50,53 @@ def list_orders():
|
||||
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
|
||||
employee_id=employee_id, delivery_method=delivery_method,
|
||||
is_direct=is_direct, q=q, page=page, per_page=per_page
|
||||
)
|
||||
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),
|
||||
})
|
||||
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_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -73,6 +114,9 @@ def create_order():
|
||||
'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),
|
||||
})
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
@@ -80,7 +124,7 @@ def create_order():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.view')
|
||||
def get_order(so_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -93,7 +137,7 @@ def get_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def update_order(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -107,7 +151,7 @@ def update_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def change_status(so_id):
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
@@ -130,7 +174,7 @@ def change_status(so_id):
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -142,7 +186,7 @@ def add_order_item(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -156,7 +200,7 @@ def update_order_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -169,7 +213,7 @@ def delete_order_item(item_id):
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
@@ -183,7 +227,7 @@ def add_order_labor(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -197,7 +241,7 @@ def update_order_labor(labor_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -210,7 +254,7 @@ def delete_order_labor(labor_id):
|
||||
# ─── Kanban Summary ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.view')
|
||||
def kanban_summary():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -224,7 +268,7 @@ def kanban_summary():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -238,7 +282,7 @@ def reserve_order_item(so_id, item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -294,7 +338,7 @@ def convert_order_to_sale(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
@@ -316,7 +360,7 @@ def assign_mechanic_endpoint(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.view')
|
||||
def list_catalog():
|
||||
"""List reusable labor/service concepts."""
|
||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||
@@ -329,7 +373,7 @@ def list_catalog():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -345,7 +389,7 @@ def create_catalog_item():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -360,7 +404,7 @@ def update_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.edit')
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -375,7 +419,7 @@ def delete_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('workshop.view')
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def enqueue_report():
|
||||
|
||||
|
||||
@tasks_bp.route('/<task_id>/status', methods=['GET'])
|
||||
@require_auth
|
||||
@require_auth()
|
||||
def task_status(task_id):
|
||||
"""Get status of a background task."""
|
||||
from celery_app import celery
|
||||
|
||||
Reference in New Issue
Block a user