Compare commits

..

15 Commits

Author SHA1 Message Date
7f753376e3 cambios para corregir bug visual de los temas oscuro y blanco 2026-06-24 10:54:33 -06:00
74118a3247 Merge branch 'desarrollo_hector' into main
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-22 22:51:05 +00:00
14219e7117 feat: migración PZ La Casita, fix motor N/A/RUEDA, cache-buster catálogo y variant_ids 2026-06-22 22:33:59 +00:00
6b80add102 Mejora, ahora los intercambios estan paginados para que no sea una lista larga, muestran de 15 en 15 los intermcambios 2026-06-18 12:11:39 -06:00
ad04572305 correccion del cambio modo oscuro involuntario 2026-06-17 14:52:25 -06:00
ee7e1d49e5 Merge branch 'main' into desarrollo_hector 2026-06-17 14:20:48 -06:00
49bbc37117 Merge branch 'main' into desarrollo_hector 2026-06-15 12:56:54 -06:00
f5711ae22f fix(catalog): unifica modelos duplicados por variante de carroceria/generacion
- catalog_service.get_models ahora agrupa variantes (p. ej. AVEO Saloon,
  AVEO Hatchback) bajo un unico display_name y devuelve variant_ids.
- Se elige el id_model mas bajo como canonico para presentacion.
- /catalog/years y /catalog/engines aceptan model_id como lista separada
  por comas para consultar todos los MYEs de las variantes agrupadas.
- catalog.js usa variant_ids al cargar años/motores y en el selector
  desplegable (incluyendo carga desde VIN).
2026-06-15 18:24:58 +00:00
85ecf52561 feat(customers): habilitar edición de clientes desde la lista
- Hace clickeables las filas de la tabla para seleccionar un cliente y
  mostrar su panel de detalle (donde ya existe el botón Editar).
- Agrega botón de acción rápida con icono de lápiz en cada fila para
  abrir directamente el modal de edición.
- Extrae openEditModal y agrega editCustomer(id) para cargar el cliente
  vía API y abrir el modal sin depender de la selección previa.
- Actualiza colspan del estado vacío de 9 a 11 por la nueva columna.
2026-06-15 18:10:23 +00:00
584b87f82c fix(catalog): descarga de plantilla de precios proveedor con token
El enlace <a> a /pos/api/supplier-catalog/prices/template fallaba con 401
porque la navegación normal no envía el header Authorization. Se reemplaza
por un botón que descarga el blob vía fetch con Bearer token y dispara la
descarga del cliente. También se corrige clase btn-primary -> btn--primary.
2026-06-15 18:01:42 +00:00
b635e44302 style(workshop): alinea UI del taller con el resto del POS
- Usa app-shell/main, page-header con eyebrow, summary-strip y cards con iconos.
- Reemplaza badges personalizados por .badge del design system.
- Unifica tablas con .data-table y .table-wrapper.
- Estandariza modales con .modal-overlay/.modal y formularios con .form-grid.
- Actualiza workshop.js para usar clases del sistema y toggle is-open en modales.
- Corrige tokens rotos (--text-sm, --color-warn, etc.) y usa variables del tema.
2026-06-15 07:17:28 +00:00
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
ce66212223 feat(pos/workshop): add lightweight workshop/taller module
- Add DB migration v4.4_workshop.sql (sale_id, service_catalog,
  reserved_quantity, SO_RESERVE/SO_RELEASE operation types).
- Extend service_order_engine with inventory reservation, release,
  convert-to-sale, mechanic assignment, and service catalog CRUD.
- Extend service_order_bp with /reserve, /convert-to-sale,
  /assign-mechanic, and /service-catalog endpoints.
- Create workshop Kanban UI: workshop.html, workshop.js, workshop.css.
- Add /pos/workshop route and sidebar navigation (sidebar.js + inline
  templates).
- Add 11 unit tests with mocked cursors.
- Update FASES_IMPLEMENTADAS.md with FASE 9 documentation.

Tests: 92 passing (61 console + 20 Facturapi + 11 workshop).
2026-06-15 05:34:35 +00:00
d67887284d feat(pos/facturapi): finalize Horux-to-Facturapi migration
- Normalize Facturapi key/org_id resolution (supports both cfdi_ prefixed
  tenant_config keys and short names used by invoicing_bp).
- Add CSD upload end-to-end (backend + frontend).
- Add helper scripts: setup_facturapi_orgs.py and check_facturapi_tenants.py.
- Add 20 unit tests with mocks (pos/tests/test_facturapi_service.py).
- Add CI workflow for lint + console tests on Python 3.11/3.13.
- Add pyproject.toml and requirements-dev.txt with ruff/pytest config.
- Update FASES_IMPLEMENTADAS.md with FASE 8 documentation.

Tests: 81 passing (61 console + 20 Facturapi).
2026-06-15 04:58:42 +00:00
71f3b1cdec se hacen modificaciones de catalogo a peticion de observaciones de carlos 24052026 2026-05-24 21:13:11 -07:00
45 changed files with 4889 additions and 622 deletions

67
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.13"]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r pos/requirements.txt
pip install -r requirements-dev.txt
- name: Determine changed Python files
id: changed
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
BASE="HEAD~1"
fi
FILES=$(git diff --name-only --diff-filter=ACMRT "$BASE" HEAD | grep '\.py$' || true)
echo "files=$FILES" >> "$GITHUB_OUTPUT"
echo "Changed Python files:"
echo "$FILES"
- name: Lint changed files with ruff
run: |
FILES="${{ steps.changed.outputs.files }}"
if [ -z "$FILES" ]; then
echo "No Python files changed. Skipping lint."
exit 0
fi
ruff check $FILES
ruff format --check $FILES
- name: Run console unit tests
run: |
python -m pytest console/tests/test_core.py console/tests/test_utils.py -v
# Playwright E2E tests require the full stack (PostgreSQL, Redis, etc.).
# Enable this job once a test environment is available in CI.
# - name: Run E2E tests
# run: |
# npm ci
# npx playwright install --with-deps chromium
# npx playwright test

View File

@@ -1,9 +1,9 @@
# Nexus POS — Resumen de Fases Implementadas # Nexus POS — Resumen de Fases Implementadas
**Fecha:** 2026-06-11 **Fecha:** 2026-06-15
**Versión DB:** v4.1 **Versión DB:** v4.4
**Tests:** 73/73 pasando (pytest) **Tests:** 92/92 pasando (pytest: 61 consola + 20 Facturapi + 11 Taller; POS requieren PostgreSQL)
**Commit:** `2b73c2c` **Commit:** `d678872` (HEAD + cambios sin commitear)
--- ---
@@ -200,6 +200,9 @@ METABASE_URL=http://localhost:3000
| — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` | | — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` |
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` | | — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` | | — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
| — | **Migración CFDI de Horux a Facturapi** | 2026-06-14 | `8796cad` |
| — | **Setup/estado masivo de organizaciones Facturapi** | 2026-06-15 | — |
| — | **Módulo de Taller (Workshop Lite)** | 2026-06-15 | — |
## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global ## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global
@@ -247,6 +250,76 @@ METABASE_URL=http://localhost:3000
--- ---
## FASE 8: Migración CFDI a Facturapi
**Commit:** `8796cad` (2026-06-14)
**Migración DB:** `v4.3_facturapi.sql`
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Timbrado vía Facturapi** | `facturapi_service.py`, `cfdi_facturapi_builder.py`, `cfdi_queue.py` | Payloads JSON para Facturapi en lugar de XML unsigned; timbrado, descarga XML/PDF, cancelación SAT |
| **Organizaciones Facturapi** | `invoicing_bp.py` | `POST /pos/api/invoicing/facturapi/setup` crea/liga organización; `GET /pos/api/invoicing/facturapi/status` muestra estado del PAC |
| **Subida de CSD** | `invoicing_bp.py`, `invoicing.html`, `invoicing.js` | Upload de `.cer` y `.key` con contraseña directo a Facturapi |
| **Migración de datos** | `v4.3_facturapi.sql`, `scripts/apply_facturapi_to_all_tenants.py` | Renombra `xml_unsigned``payload_unsigned`, agrega `external_id`, inserta keys de config |
| **Setup masivo** | `scripts/setup_facturapi_orgs.py` | Crea organizaciones Facturapi para todos los tenants activos usando `FACTURAPI_USER_KEY` |
| **Status masivo** | `scripts/check_facturapi_tenants.py` | Reporte tabular/JSON/CSV del estado de configuración Facturapi por tenant |
| **Tests unitarios** | `pos/tests/test_facturapi_service.py` | 20 tests con mocks; sin llamadas a red ni PostgreSQL |
| **CI** | `.github/workflows/ci.yml` | Lint con ruff sobre archivos cambiados + tests de consola en Python 3.11 y 3.13 |
### Variables de entorno
```bash
# Modo automático (recomendado para multi-tenant)
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
# Modo manual por tenant (sobreescribe lo anterior)
# Se almacena en tenant_config: cfdi_facturapi_key, cfdi_facturapi_org_id
```
### Uso
```bash
# 1. Aplicar migración y key a todos los tenants
export FACTURAPI_SECRET_KEY=sk_user_xxx
python3 scripts/apply_facturapi_to_all_tenants.py
# 2. Crear organizaciones Facturapi
export FACTURAPI_USER_KEY=sk_user_xxx
python3 scripts/setup_facturapi_orgs.py
# 3. Ver estado
python3 scripts/check_facturapi_tenants.py
```
---
## FASE 9: Módulo de Taller (Workshop Lite)
**Commit:** (en progreso)
**Migración DB:** `v4.4_workshop.sql`
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Migración DB** | `v4.4_workshop.sql` | `service_orders.sale_id`, tabla `service_catalog`, columna `reserved_quantity`, tipos `SO_RESERVE`/`SO_RELEASE` en `inventory_operations` |
| **Reserva de inventario** | `service_order_engine.py` | `reserve_item()` y `release_item()` para apartar/liberar refacciones del stock de la sucursal |
| **Conversión a venta** | `service_order_engine.py` | `convert_to_sale()` crea una venta en `sales` con refacciones + mano de obra, descuenta inventario y guarda `sale_id` |
| **Catálogo de servicios** | `service_order_engine.py`, `service_order_bp.py` | Conceptos reutilizables de mano de obra (ej. "Cambio de aceite") |
| **Endpoints taller** | `service_order_bp.py` | `POST /:id/items/:item_id/reserve`, `POST /:id/convert-to-sale`, `PUT /:id/assign-mechanic`, CRUD `/service-catalog` |
| **Interfaz Kanban** | `workshop.html`, `workshop.js`, `workshop.css` | Vista por columnas, tarjetas de orden, modal de detalle, cambio de estado, agregar refacciones/mano de obra |
| **Impresión de orden** | `thermal_printer.py`, `service_order_bp.py`, `printer.js`, `workshop.js` | Ticket ESC/POS optimizado para impresoras térmicas 80 mm (58 mm compatible) |
| **Navegación** | `sidebar.js`, plantillas inline | Entrada "Taller" en el menú de gestión |
| **Tests** | `pos/tests/test_service_order_integration.py` | 11 tests con cursores mocks; validan reserva, liberación, conversión a venta y catálogo |
### Flujo de uso
1. El paquetero crea la orden desde `/pos/workshop` (cliente, vehículo, mecánico, falla).
2. El mecánico diagnostica y agrega refacciones y mano deobra.
3. Se reservan las refacciones del inventario de la sucursal.
4. Cuando el vehículo está listo, se convierte la orden en venta.
5. Desde facturación se timbra el CFDI de la venta generada.
---
## Mejoras Pendientes (Roadmap Actualizado) ## Mejoras Pendientes (Roadmap Actualizado)
### 🔴 Crítico — Deuda Técnica ### 🔴 Crítico — Deuda Técnica

View File

@@ -1,6 +1,7 @@
from flask import Flask from flask import Flask
from json_provider import OrjsonProvider from json_provider import OrjsonProvider
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.json = OrjsonProvider(app) app.json = OrjsonProvider(app)
@@ -124,7 +125,7 @@ def create_app():
def health(): def health():
return {'status': 'ok'} return {'status': 'ok'}
from flask import render_template, send_from_directory, jsonify, g from flask import g, jsonify, render_template, send_from_directory
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():
@@ -181,6 +182,10 @@ def create_app():
def pos_fleet(): def pos_fleet():
return render_template('fleet.html') return render_template('fleet.html')
@app.route('/pos/workshop')
def pos_workshop():
return render_template('workshop.html')
@app.route('/pos/quotations') @app.route('/pos/quotations')
def pos_quotations(): def pos_quotations():
return render_template('quotations.html') return render_template('quotations.html')

View File

@@ -150,12 +150,18 @@ def models():
@catalog_bp.route('/years', methods=['GET']) @catalog_bp.route('/years', methods=['GET'])
@require_auth('catalog.view') @require_auth('catalog.view')
def years(): def years():
model_id = request.args.get('model_id', type=int) model_id_param = request.args.get('model_id', '')
if not model_id: if not model_id_param:
return jsonify({'error': 'model_id required'}), 400
try:
model_ids = [int(x) for x in model_id_param.split(',') if x]
except ValueError:
return jsonify({'error': 'model_id must be a comma-separated list of integers'}), 400
if not model_ids:
return jsonify({'error': 'model_id required'}), 400 return jsonify({'error': 'model_id required'}), 400
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids) data = catalog_service.get_years(master, model_ids, mye_ids=mye_ids)
return jsonify({'data': data}) return jsonify({'data': data})
return _with_conns(_do) return _with_conns(_do)
@@ -176,13 +182,19 @@ def years_all():
@catalog_bp.route('/engines', methods=['GET']) @catalog_bp.route('/engines', methods=['GET'])
@require_auth('catalog.view') @require_auth('catalog.view')
def engines(): def engines():
model_id = request.args.get('model_id', type=int) model_id_param = request.args.get('model_id', '')
year_id = request.args.get('year_id', type=int) year_id = request.args.get('year_id', type=int)
if not model_id or not year_id: if not model_id_param or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400 return jsonify({'error': 'model_id and year_id required'}), 400
try:
model_ids = [int(x) for x in model_id_param.split(',') if x]
except ValueError:
return jsonify({'error': 'model_id must be a comma-separated list of integers'}), 400
if not model_ids:
return jsonify({'error': 'model_id required'}), 400
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids) data = catalog_service.get_engines(master, model_ids, year_id, mye_ids=mye_ids)
return jsonify({'data': data}) return jsonify({'data': data})
return _with_conns(_do) return _with_conns(_do)

View File

@@ -161,8 +161,9 @@ def create_customer():
cur.execute(""" cur.execute("""
INSERT INTO customers INSERT INTO customers
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, (branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, vehicle_info) cp, email, phone, address, price_tier, credit_limit,
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) max_discount_pct, vehicle_info)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id RETURNING id
""", ( """, (
branch_id, data['name'], data.get('rfc'), data.get('razon_social'), branch_id, data['name'], data.get('rfc'), data.get('razon_social'),
@@ -170,6 +171,7 @@ def create_customer():
data.get('cp'), data.get('email'), data.get('phone'), data.get('cp'), data.get('email'), data.get('phone'),
data.get('address'), data.get('price_tier', 1), data.get('address'), data.get('price_tier', 1),
data.get('credit_limit', 0), data.get('credit_limit', 0),
data.get('max_discount_pct', 0),
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
)) ))
customer_id = cur.fetchone()[0] customer_id = cur.fetchone()[0]

View File

@@ -5,22 +5,27 @@ All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
This blueprint is the HTTP layer that validates input and returns JSON. This blueprint is the HTTP layer that validates input and returns JSON.
""" """
import json import base64
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, g
from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn
from services.cfdi_facturapi_builder import (
build_ingreso_payload, build_egreso_payload, build_pago_payload,
)
from services.cfdi_queue import (
enqueue_cfdi, process_queue, retry_failed,
cancel_cfdi, get_queue_status,
)
from services import facturapi_service from services import facturapi_service
from services.audit import log_action from services.audit import log_action
from services.cfdi_facturapi_builder import (
build_egreso_payload,
build_ingreso_payload,
)
from services.cfdi_queue import (
cancel_cfdi,
enqueue_cfdi,
get_queue_status,
process_queue,
retry_failed,
)
from tenant_db import get_tenant_conn
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') invoicing_bp = Blueprint("invoicing", __name__, url_prefix="/pos/api/invoicing")
def _get_issuer_config(cur, branch_id=None): def _get_issuer_config(cur, branch_id=None):
@@ -36,80 +41,97 @@ def _get_issuer_config(cur, branch_id=None):
config[row[0]] = row[1] config[row[0]] = row[1]
result = { result = {
'rfc': config.get('tenant_rfc', ''), "rfc": config.get("tenant_rfc", ""),
'razon_social': config.get('tenant_razon_social', ''), "razon_social": config.get("tenant_razon_social", ""),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), "regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
'cp': config.get('tenant_cp', '00000'), "cp": config.get("tenant_cp", "00000"),
'serie': config.get('cfdi_serie', 'A'), "serie": config.get("cfdi_serie", "A"),
'facturapi_key': config.get('cfdi_facturapi_key', ''), "facturapi_key": config.get("cfdi_facturapi_key", ""),
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''), "facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
} }
# Branch-level override # Branch-level override
if branch_id: if branch_id:
cur.execute(""" cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s FROM branches WHERE id = %s
""", (branch_id,)) """,
(branch_id,),
)
row = cur.fetchone() row = cur.fetchone()
if row and row[0]: if row and row[0]:
result['rfc'] = row[0] or result['rfc'] result["rfc"] = row[0] or result["rfc"]
result['razon_social'] = row[1] or result['razon_social'] result["razon_social"] = row[1] or result["razon_social"]
result['regimen_fiscal'] = row[2] or result['regimen_fiscal'] result["regimen_fiscal"] = row[2] or result["regimen_fiscal"]
result['cp'] = row[3] or result['cp'] result["cp"] = row[3] or result["cp"]
result['serie'] = row[4] or result['serie'] result["serie"] = row[4] or result["serie"]
return result return result
def _get_sale_with_items(cur, sale_id): def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation.""" """Load a sale with its items for CFDI generation."""
cur.execute(""" cur.execute(
"""
SELECT id, branch_id, customer_id, employee_id, sale_type, SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total, payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales WHERE id = %s FROM sales WHERE id = %s
""", (sale_id,)) """,
(sale_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None return None
sale = { sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2], "id": row[0],
'employee_id': row[3], 'sale_type': row[4], "branch_id": row[1],
'payment_method': row[5], "customer_id": row[2],
'subtotal': float(row[6]) if row[6] else 0, "employee_id": row[3],
'discount_total': float(row[7]) if row[7] else 0, "sale_type": row[4],
'tax_total': float(row[8]) if row[8] else 0, "payment_method": row[5],
'total': float(row[9]) if row[9] else 0, "subtotal": float(row[6]) if row[6] else 0,
'metodo_pago_sat': row[10] or 'PUE', "discount_total": float(row[7]) if row[7] else 0,
'forma_pago_sat': row[11] or '01', "tax_total": float(row[8]) if row[8] else 0,
'status': row[12], "total": float(row[9]) if row[9] else 0,
'created_at': str(row[13]), "metodo_pago_sat": row[10] or "PUE",
"forma_pago_sat": row[11] or "01",
"status": row[12],
"created_at": str(row[13]),
} }
cur.execute(""" cur.execute(
"""
SELECT id, inventory_id, part_number, name, quantity, unit_price, SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad subtotal, clave_prod_serv, clave_unidad
FROM sale_items WHERE sale_id = %s ORDER BY id FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,)) """,
(sale_id,),
)
sale['items'] = [] sale["items"] = []
for r in cur.fetchall(): for r in cur.fetchall():
sale['items'].append({ sale["items"].append(
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], {
'name': r[3], 'quantity': r[4], "id": r[0],
'unit_price': float(r[5]) if r[5] else 0, "inventory_id": r[1],
'unit_cost': float(r[6]) if r[6] else 0, "part_number": r[2],
'discount_pct': float(r[7]) if r[7] else 0, "name": r[3],
'discount_amount': float(r[8]) if r[8] else 0, "quantity": r[4],
'tax_rate': float(r[9]) if r[9] else 0.16, "unit_price": float(r[5]) if r[5] else 0,
'tax_amount': float(r[10]) if r[10] else 0, "unit_cost": float(r[6]) if r[6] else 0,
'subtotal': float(r[11]) if r[11] else 0, "discount_pct": float(r[7]) if r[7] else 0,
'clave_prod_serv': r[12] or '25174800', "discount_amount": float(r[8]) if r[8] else 0,
'clave_unidad': r[13] or 'H87', "tax_rate": float(r[9]) if r[9] else 0.16,
}) "tax_amount": float(r[10]) if r[10] else 0,
"subtotal": float(r[11]) if r[11] else 0,
"clave_prod_serv": r[12] or "25174800",
"clave_unidad": r[13] or "H87",
}
)
return sale return sale
@@ -118,24 +140,32 @@ def _get_customer(cur, customer_id):
"""Load customer data for CFDI receptor.""" """Load customer data for CFDI receptor."""
if not customer_id: if not customer_id:
return None return None
cur.execute(""" cur.execute(
"""
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
FROM customers WHERE id = %s FROM customers WHERE id = %s
""", (customer_id,)) """,
(customer_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None return None
return { return {
'id': row[0], 'name': row[1], 'rfc': row[2], "id": row[0],
'razon_social': row[3], 'regimen_fiscal': row[4], "name": row[1],
'uso_cfdi': row[5] or 'G03', 'cp': row[6], "rfc": row[2],
"razon_social": row[3],
"regimen_fiscal": row[4],
"uso_cfdi": row[5] or "G03",
"cp": row[6],
} }
# ─── Generate CFDI ───────────────────────────────── # ─── Generate CFDI ─────────────────────────────────
@invoicing_bp.route('/invoice', methods=['POST'])
@require_auth('invoicing.create') @invoicing_bp.route("/invoice", methods=["POST"])
@require_auth("invoicing.create")
def generate_invoice(): def generate_invoice():
"""Generate a CFDI for a sale and enqueue for timbrado. """Generate a CFDI for a sale and enqueue for timbrado.
@@ -146,11 +176,11 @@ def generate_invoice():
} }
""" """
data = request.get_json() or {} data = request.get_json() or {}
sale_id = data.get('sale_id') sale_id = data.get("sale_id")
cfdi_type = data.get('type', 'ingreso') cfdi_type = data.get("type", "ingreso")
if not sale_id: if not sale_id:
return jsonify({'error': 'sale_id is required'}), 400 return jsonify({"error": "sale_id is required"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -158,45 +188,54 @@ def generate_invoice():
try: try:
sale = _get_sale_with_items(cur, sale_id) sale = _get_sale_with_items(cur, sale_id)
if not sale: if not sale:
return jsonify({'error': 'Sale not found'}), 404 return jsonify({"error": "Sale not found"}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id')) tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
if not tenant_config['rfc']: if not tenant_config["rfc"]:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400 return jsonify({"error": "Tenant RFC not configured. Set tenant_rfc in config."}), 400
if sale['status'] == 'cancelled': if sale["status"] == "cancelled":
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400 return jsonify({"error": "Cannot invoice a cancelled sale"}), 400
customer = _get_customer(cur, sale.get('customer_id')) customer = _get_customer(cur, sale.get("customer_id"))
# Check if this sale already has a stamped CFDI # Check if this sale already has a stamped CFDI
cur.execute(""" cur.execute(
"""
SELECT id, status FROM cfdi_queue SELECT id, status FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed') WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
""", (sale_id, cfdi_type)) """,
(sale_id, cfdi_type),
)
existing = cur.fetchone() existing = cur.fetchone()
if existing: if existing:
return jsonify({ return jsonify(
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' {
}), 409 "error": f"Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})"
}
), 409
# Build Facturapi payload # Build Facturapi payload
if cfdi_type == 'ingreso': if cfdi_type == "ingreso":
payload = build_ingreso_payload(sale, tenant_config, customer) payload = build_ingreso_payload(sale, tenant_config, customer)
elif cfdi_type == 'egreso': elif cfdi_type == "egreso":
original_uuid = data.get('original_uuid') original_uuid = data.get("original_uuid")
if not original_uuid: if not original_uuid:
return jsonify({'error': 'original_uuid required for egreso'}), 400 return jsonify({"error": "original_uuid required for egreso"}), 400
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid) payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
else: else:
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 return jsonify({"error": f"Invalid CFDI type: {cfdi_type}"}), 400
# Enqueue # Enqueue
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload) result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'], log_action(
new_value={'sale_id': sale_id, 'type': cfdi_type, conn,
'folio': result['provisional_folio']}) "CFDI_GENERATED",
"cfdi_queue",
result["id"],
new_value={"sale_id": sale_id, "type": cfdi_type, "folio": result["provisional_folio"]},
)
conn.commit() conn.commit()
cur.close() cur.close()
@@ -207,18 +246,19 @@ def generate_invoice():
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── Queue Management ────────────────────────────── # ─── Queue Management ──────────────────────────────
@invoicing_bp.route('/queue', methods=['GET'])
@require_auth('invoicing.view') @invoicing_bp.route("/queue", methods=["GET"])
@require_auth("invoicing.view")
def list_queue(): def list_queue():
"""List CFDI queue items. """List CFDI queue items.
@@ -227,11 +267,11 @@ def list_queue():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
filters = { filters = {
'status': request.args.get('status'), "status": request.args.get("status"),
'sale_id': request.args.get('sale_id'), "sale_id": request.args.get("sale_id"),
'type': request.args.get('type'), "type": request.args.get("type"),
'page': request.args.get('page', 1), "page": request.args.get("page", 1),
'per_page': request.args.get('per_page', 50), "per_page": request.args.get("per_page", 50),
} }
result = get_queue_status(conn, filters) result = get_queue_status(conn, filters)
@@ -239,36 +279,46 @@ def list_queue():
return jsonify(result) return jsonify(result)
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET']) @invoicing_bp.route("/queue/<int:cfdi_id>", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def get_queue_item(cfdi_id): def get_queue_item(cfdi_id):
"""Get CFDI queue item detail (includes XML).""" """Get CFDI queue item detail (includes XML)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
"""
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed, SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
q.error_message, q.cancel_motive, q.cancel_replacement_uuid, q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
q.created_at, q.stamped_at, q.external_id q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q WHERE q.id = %s FROM cfdi_queue q WHERE q.id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'CFDI queue item not found'}), 404 conn.close()
return jsonify({"error": "CFDI queue item not found"}), 404
item = { item = {
'id': row[0], 'sale_id': row[1], 'type': row[2], "id": row[0],
'payload_unsigned': row[3], 'xml_signed': row[4], "sale_id": row[1],
'uuid_fiscal': row[5], 'status': row[6], "type": row[2],
'retry_count': row[7], 'provisional_folio': row[8], "payload_unsigned": row[3],
'error_message': row[9], 'cancel_motive': row[10], "xml_signed": row[4],
'cancel_replacement_uuid': row[11], "uuid_fiscal": row[5],
'created_at': str(row[12]) if row[12] else None, "status": row[6],
'stamped_at': str(row[13]) if row[13] else None, "retry_count": row[7],
'external_id': row[14], "provisional_folio": row[8],
"error_message": row[9],
"cancel_motive": row[10],
"cancel_replacement_uuid": row[11],
"created_at": str(row[12]) if row[12] else None,
"stamped_at": str(row[13]) if row[13] else None,
"external_id": row[14],
} }
cur.close() cur.close()
@@ -276,8 +326,8 @@ def get_queue_item(cfdi_id):
return jsonify(item) return jsonify(item)
@invoicing_bp.route('/queue/process', methods=['POST']) @invoicing_bp.route("/queue/process", methods=["POST"])
@require_auth('invoicing.create') @require_auth("invoicing.create")
def trigger_process_queue(): def trigger_process_queue():
"""Manually trigger processing of pending CFDI queue items.""" """Manually trigger processing of pending CFDI queue items."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -285,17 +335,17 @@ def trigger_process_queue():
try: try:
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
if not tenant_config.get('facturapi_key'): if not tenant_config.get("facturapi_key"):
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': 'Facturapi key not configured'}), 400 return jsonify({"error": "Facturapi key not configured"}), 400
# Reset eligible failed items first # Reset eligible failed items first
reset_count = retry_failed(conn) reset_count = retry_failed(conn)
# Process the queue # Process the queue
result = process_queue(conn, tenant_config) result = process_queue(conn, tenant_config)
result['retries_reset'] = reset_count result["retries_reset"] = reset_count
cur.close() cur.close()
conn.close() conn.close()
@@ -305,13 +355,14 @@ def trigger_process_queue():
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── Cancel CFDI ──────────────────────────────────── # ─── Cancel CFDI ────────────────────────────────────
@invoicing_bp.route('/cancel/<int:cfdi_id>', methods=['POST'])
@require_auth('invoicing.delete') @invoicing_bp.route("/cancel/<int:cfdi_id>", methods=["POST"])
@require_auth("invoicing.delete")
def cancel_invoice(cfdi_id): def cancel_invoice(cfdi_id):
"""Cancel a CFDI with SAT motive code. """Cancel a CFDI with SAT motive code.
@@ -322,15 +373,15 @@ def cancel_invoice(cfdi_id):
Only owner and admin can cancel CFDIs. Only owner and admin can cancel CFDIs.
""" """
if g.employee_role not in ('owner', 'admin'): if g.employee_role not in ("owner", "admin"):
return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403 return jsonify({"error": "Only owner or admin can cancel CFDIs"}), 403
data = request.get_json() or {} data = request.get_json() or {}
motive = data.get('motive') motive = data.get("motive")
replacement_uuid = data.get('replacement_uuid') replacement_uuid = data.get("replacement_uuid")
if not motive: if not motive:
return jsonify({'error': 'motive is required'}), 400 return jsonify({"error": "motive is required"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -338,12 +389,20 @@ def cancel_invoice(cfdi_id):
try: try:
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
result = cancel_cfdi( result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid, conn,
cfdi_id,
motive,
replacement_uuid,
tenant_config=tenant_config, tenant_config=tenant_config,
) )
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, log_action(
new_value={'motive': motive, 'replacement_uuid': replacement_uuid}) conn,
"CFDI_CANCELLED",
"cfdi_queue",
cfdi_id,
new_value={"motive": motive, "replacement_uuid": replacement_uuid},
)
conn.commit() conn.commit()
cur.close() cur.close()
@@ -354,18 +413,19 @@ def cancel_invoice(cfdi_id):
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── PDF Generation ───────────────────────────────── # ─── PDF Generation ─────────────────────────────────
@invoicing_bp.route('/<int:sale_id>/pdf', methods=['GET'])
@require_auth('invoicing.view') @invoicing_bp.route("/<int:sale_id>/pdf", methods=["GET"])
@require_auth("invoicing.view")
def get_sale_pdf(sale_id): def get_sale_pdf(sale_id):
"""Generate a PDF representation of the sale/CFDI. """Generate a PDF representation of the sale/CFDI.
@@ -378,48 +438,54 @@ def get_sale_pdf(sale_id):
sale = _get_sale_with_items(cur, sale_id) sale = _get_sale_with_items(cur, sale_id)
if not sale: if not sale:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'Sale not found'}), 404 conn.close()
return jsonify({"error": "Sale not found"}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id')) tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
customer = _get_customer(cur, sale.get('customer_id')) customer = _get_customer(cur, sale.get("customer_id"))
# Check if there's a stamped CFDI # Check if there's a stamped CFDI
cur.execute(""" cur.execute(
"""
SELECT uuid_fiscal, provisional_folio, status, stamped_at SELECT uuid_fiscal, provisional_folio, status, stamped_at
FROM cfdi_queue FROM cfdi_queue
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped' WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
ORDER BY stamped_at DESC LIMIT 1 ORDER BY stamped_at DESC LIMIT 1
""", (sale_id,)) """,
(sale_id,),
)
cfdi_row = cur.fetchone() cfdi_row = cur.fetchone()
cfdi_info = None cfdi_info = None
if cfdi_row: if cfdi_row:
cfdi_info = { cfdi_info = {
'uuid_fiscal': cfdi_row[0], "uuid_fiscal": cfdi_row[0],
'provisional_folio': cfdi_row[1], "provisional_folio": cfdi_row[1],
'status': cfdi_row[2], "status": cfdi_row[2],
'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None, "stamped_at": str(cfdi_row[3]) if cfdi_row[3] else None,
} }
cur.close() cur.close()
conn.close() conn.close()
return jsonify({ return jsonify(
'sale': sale, {
'tenant': { "sale": sale,
'rfc': tenant_config.get('rfc', ''), "tenant": {
'razon_social': tenant_config.get('razon_social', ''), "rfc": tenant_config.get("rfc", ""),
'regimen_fiscal': tenant_config.get('regimen_fiscal', ''), "razon_social": tenant_config.get("razon_social", ""),
'cp': tenant_config.get('cp', ''), "regimen_fiscal": tenant_config.get("regimen_fiscal", ""),
}, "cp": tenant_config.get("cp", ""),
'customer': customer, },
'cfdi': cfdi_info, "customer": customer,
}) "cfdi": cfdi_info,
}
)
@invoicing_bp.route('/stats', methods=['GET']) @invoicing_bp.route("/stats", methods=["GET"])
@require_auth('invoicing.read') @require_auth("invoicing.read")
def api_invoicing_stats(): def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations.""" """Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -437,16 +503,18 @@ def api_invoicing_stats():
cur.close() cur.close()
conn.close() conn.close()
return jsonify({ return jsonify(
'facturas': row[0] or 0, {
'notas_credito': row[1] or 0, "facturas": row[0] or 0,
'complementos': row[2] or 0, "notas_credito": row[1] or 0,
'cancelaciones': row[3] or 0, "complementos": row[2] or 0,
}) "cancelaciones": row[3] or 0,
}
)
@invoicing_bp.route('/global-invoice', methods=['POST']) @invoicing_bp.route("/global-invoice", methods=["POST"])
@require_auth('invoicing.create') @require_auth("invoicing.create")
def generate_global_invoice(): def generate_global_invoice():
"""Generate a monthly global invoice for cash sales. """Generate a monthly global invoice for cash sales.
@@ -458,39 +526,45 @@ def generate_global_invoice():
""" """
data = request.get_json() or {} data = request.get_json() or {}
now = datetime.now() now = datetime.now()
year = data.get('year', now.year) year = data.get("year", now.year)
month = data.get('month', now.month) month = data.get("month", now.month)
branch_id = data.get('branch_id') branch_id = data.get("branch_id")
try: try:
year = int(year) year = int(year)
month = int(month) month = int(month)
if month < 1 or month > 12: if month < 1 or month > 12:
return jsonify({'error': 'month must be 1-12'}), 400 return jsonify({"error": "month must be 1-12"}), 400
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'error': 'year and month must be integers'}), 400 return jsonify({"error": "year and month must be integers"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
tenant_config = _get_issuer_config(cur, branch_id) tenant_config = _get_issuer_config(cur, branch_id)
if not tenant_config['rfc']: if not tenant_config["rfc"]:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'Tenant RFC not configured'}), 400 conn.close()
return jsonify({"error": "Tenant RFC not configured"}), 400
from services.global_invoice import generate_global_invoice from services.global_invoice import generate_global_invoice
result = generate_global_invoice( result = generate_global_invoice(
conn, tenant_config, year, month, conn, tenant_config, year, month, branch_id=branch_id, employee_id=getattr(g, "employee_id", None)
branch_id=branch_id,
employee_id=getattr(g, 'employee_id', None)
) )
if 'error' in result: if "error" in result:
cur.close(); conn.close() cur.close()
conn.close()
return jsonify(result), 400 return jsonify(result), 400
log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'], log_action(
new_value={'year': year, 'month': month, 'sales_count': result['sales_count']}) conn,
"GLOBAL_INVOICE_CREATE",
"cfdi_queue",
result["id"],
new_value={"year": year, "month": month, "sales_count": result["sales_count"]},
)
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() conn.close()
@@ -498,56 +572,62 @@ def generate_global_invoice():
return jsonify(result), 201 return jsonify(result), 201
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET']) @invoicing_bp.route("/global-invoice/<int:cfdi_id>", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def get_global_invoice(cfdi_id): def get_global_invoice(cfdi_id):
"""Get status and linked sales of a global invoice.""" """Get status and linked sales of a global invoice."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
from services.global_invoice import get_global_invoice_status from services.global_invoice import get_global_invoice_status
result = get_global_invoice_status(conn, cfdi_id) result = get_global_invoice_status(conn, cfdi_id)
cur.close() cur.close()
conn.close() conn.close()
if not result: if not result:
return jsonify({'error': 'Global invoice not found'}), 404 return jsonify({"error": "Global invoice not found"}), 404
return jsonify(result) return jsonify(result)
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET']) @invoicing_bp.route("/global-invoice/eligible-sales", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def get_eligible_sales_for_global(): def get_eligible_sales_for_global():
"""Preview sales that would be included in a global invoice. """Preview sales that would be included in a global invoice.
Query params: year, month, branch_id Query params: year, month, branch_id
""" """
now = datetime.now() now = datetime.now()
year = request.args.get('year', now.year, type=int) year = request.args.get("year", now.year, type=int)
month = request.args.get('month', now.month, type=int) month = request.args.get("month", now.month, type=int)
branch_id = request.args.get('branch_id', type=int) branch_id = request.args.get("branch_id", type=int)
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
from services.global_invoice import get_eligible_sales from services.global_invoice import get_eligible_sales
sales = get_eligible_sales(conn, year, month, branch_id) sales = get_eligible_sales(conn, year, month, branch_id)
conn.close() conn.close()
return jsonify({ return jsonify(
'year': year, 'month': month, {
'count': len(sales), "year": year,
'total': sum(s['total'] for s in sales), "month": month,
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales], "count": len(sales),
}) "total": sum(s["total"] for s in sales),
"sales": [{"id": s["id"], "total": s["total"], "created_at": s["created_at"]} for s in sales],
}
)
# ─── Facturapi extras ─────────────────────────────── # ─── Facturapi extras ───────────────────────────────
@invoicing_bp.route('/facturapi/status', methods=['GET'])
@require_auth('invoicing.view') @invoicing_bp.route("/facturapi/status", methods=["GET"])
@require_auth("invoicing.view")
def facturapi_status(): def facturapi_status():
"""Return Facturapi organization status for the tenant.""" """Return Facturapi organization status for the tenant."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -560,8 +640,8 @@ def facturapi_status():
return jsonify(status) return jsonify(status)
@invoicing_bp.route('/facturapi/setup', methods=['POST']) @invoicing_bp.route("/facturapi/setup", methods=["POST"])
@require_auth('invoicing.create') @require_auth("invoicing.create")
def facturapi_setup(): def facturapi_setup():
"""Create or link a Facturapi organization for this tenant. """Create or link a Facturapi organization for this tenant.
@@ -573,92 +653,166 @@ def facturapi_setup():
try: try:
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
if not tenant_config.get('rfc'): if not tenant_config.get("rfc"):
return jsonify({'error': 'Tenant RFC not configured'}), 400 return jsonify({"error": "Tenant RFC not configured"}), 400
result = facturapi_service.create_organization(tenant_config) result = facturapi_service.create_organization(tenant_config)
cur.execute(""" cur.execute(
"""
INSERT INTO tenant_config (key, value) INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_org_id', %s) VALUES ('cfdi_facturapi_org_id', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (result['org_id'],)) """,
(result["org_id"],),
)
cur.execute(""" cur.execute(
"""
INSERT INTO tenant_config (key, value) INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_key', %s) VALUES ('cfdi_facturapi_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (result['api_key'],)) """,
(result["api_key"],),
)
log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None, log_action(conn, "FACTURAPI_SETUP", "tenant_config", None, new_value={"org_id": result["org_id"]})
new_value={'org_id': result['org_id']})
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({ return jsonify(
'org_id': result['org_id'], {
'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.', "org_id": result["org_id"],
}) "message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
}
)
except ValueError as e: except ValueError as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET']) @invoicing_bp.route("/facturapi/download/<int:cfdi_id>/<doc_type>", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def facturapi_download(cfdi_id, doc_type): def facturapi_download(cfdi_id, doc_type):
"""Download PDF or XML for a stamped CFDI from Facturapi. """Download PDF or XML for a stamped CFDI from Facturapi.
doc_type: 'pdf' | 'xml' doc_type: 'pdf' | 'xml'
""" """
if doc_type not in ('pdf', 'xml'): if doc_type not in ("pdf", "xml"):
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400 return jsonify({"error": "doc_type must be 'pdf' or 'xml'"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
"""
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'CFDI not found'}), 404 conn.close()
return jsonify({"error": "CFDI not found"}), 404
external_id, uuid_fiscal, status = row external_id, uuid_fiscal, status = row
if status != 'stamped' or not external_id: if status != "stamped" or not external_id:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400 conn.close()
return jsonify({"error": "CFDI is not stamped or has no external id"}), 400
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
cur.close() cur.close()
conn.close() conn.close()
try: try:
if doc_type == 'pdf': if doc_type == "pdf":
content = facturapi_service.download_pdf(tenant_config, external_id) content = facturapi_service.download_pdf(tenant_config, external_id)
mime = 'application/pdf' mime = "application/pdf"
filename = f'cfdi_{uuid_fiscal or external_id}.pdf' filename = f"cfdi_{uuid_fiscal or external_id}.pdf"
else: else:
content = facturapi_service.download_xml(tenant_config, external_id) content = facturapi_service.download_xml(tenant_config, external_id)
mime = 'application/xml' mime = "application/xml"
filename = f'cfdi_{uuid_fiscal or external_id}.xml' filename = f"cfdi_{uuid_fiscal or external_id}.xml"
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
from flask import Response from flask import Response
return Response( return Response(
content, content,
mimetype=mime, mimetype=mime,
headers={'Content-Disposition': f'attachment; filename="{filename}"'}, headers={"Content-Disposition": f'attachment; filename="{filename}"'},
) )
@invoicing_bp.route("/facturapi/csd", methods=["POST"])
@require_auth("invoicing.create")
def facturapi_upload_csd():
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
Multipart form with:
- certificate: .cer file
- private_key: .key file
- password: CSD password
"""
if "certificate" not in request.files or "private_key" not in request.files:
return jsonify({"error": "certificate and private_key files are required"}), 400
password = (request.form.get("password") or "").strip()
if not password:
return jsonify({"error": "password is required"}), 400
cer_file = request.files["certificate"]
key_file = request.files["private_key"]
if not cer_file.filename or not key_file.filename:
return jsonify({"error": "certificate and private_key files are required"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
cer_b64 = base64.b64encode(cer_file.read()).decode("ascii")
key_b64 = base64.b64encode(key_file.read()).decode("ascii")
result = facturapi_service.upload_csd(tenant_config, cer_b64, key_b64, password)
log_action(
conn,
"FACTURAPI_CSD_UPLOAD",
"tenant_config",
None,
new_value={"org_id": tenant_config.get("facturapi_org_id")},
)
conn.commit()
cur.close()
conn.close()
return jsonify(
{
"success": True,
"message": "CSD uploaded successfully",
"certificate": result.get("certificate"),
}
)
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 500

View File

@@ -3,15 +3,31 @@
Prefix: /pos/api/service-orders Prefix: /pos/api/service-orders
""" """
from flask import Blueprint, request, jsonify, g from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn
from services.service_order_engine import ( from services.service_order_engine import (
create_service_order, get_service_order, list_service_orders, add_item,
update_status, add_item, update_item, remove_item, add_labor,
add_labor, update_labor, remove_labor, assign_mechanic,
update_service_order, get_kanban_summary, 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 = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
@@ -202,3 +218,212 @@ def kanban_summary():
return jsonify(summary) return jsonify(summary)
finally: finally:
conn.close() 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'
},
)

View File

@@ -4,6 +4,7 @@
import os import os
import sys import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tenant_db import get_master_conn, get_tenant_conn_by_dbname from tenant_db import get_master_conn, get_tenant_conn_by_dbname
@@ -12,43 +13,45 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
# Migration registry: version -> filename # Migration registry: version -> filename
MIGRATIONS = { MIGRATIONS = {
'v1.0': 'v1.0_initial.sql', "v1.0": "v1.0_initial.sql",
'v1.1': 'v1.1_pos_tables.sql', "v1.1": "v1.1_pos_tables.sql",
'v1.2': 'v1.2_subdomain.sql', "v1.2": "v1.2_subdomain.sql",
'v1.3': 'v1.3_fleet.sql', "v1.3": "v1.3_fleet.sql",
'v1.4': 'v1.4_whatsapp.sql', "v1.4": "v1.4_whatsapp.sql",
'v1.5': 'v1.5_returns.sql', "v1.5": "v1.5_returns.sql",
'v1.6': 'v1.6_marketplace.sql', "v1.6": "v1.6_marketplace.sql",
'v1.7': 'v1.7_plates.sql', "v1.7": "v1.7_plates.sql",
'v1.8': 'v1.8_performance_indexes.sql', "v1.8": "v1.8_performance_indexes.sql",
'v1.9': 'v1.9_redis_cache.sql', "v1.9": "v1.9_redis_cache.sql",
'v2.0': 'v2.0_multi_currency.sql', "v2.0": "v2.0_multi_currency.sql",
'v2.1': 'v2.1_suppliers.sql', "v2.1": "v2.1_suppliers.sql",
'v2.2': 'v2.2_alerts_warranty.sql', "v2.2": "v2.2_alerts_warranty.sql",
'v2.3': 'v2.3_metabase.sql', "v2.3": "v2.3_metabase.sql",
'v2.4': 'v2.4_crm_enhanced.sql', "v2.4": "v2.4_crm_enhanced.sql",
'v2.5': 'v2.5_service_orders.sql', "v2.5": "v2.5_service_orders.sql",
'v2.6': 'v2.6_bnpl_erp.sql', "v2.6": "v2.6_bnpl_erp.sql",
'v2.7': 'v2.7_notifications.sql', "v2.7": "v2.7_notifications.sql",
'v2.8': 'v2.8_savings.sql', "v2.8": "v2.8_savings.sql",
'v2.9': 'v2.9_logistics.sql', "v2.9": "v2.9_logistics.sql",
'v3.0': 'v3.0_public_api.sql', "v3.0": "v3.0_public_api.sql",
'v3.1': 'v3.1_inventory_vehicle_compat.sql', "v3.1": "v3.1_inventory_vehicle_compat.sql",
'v3.2': 'v3.2_db_performance.sql', "v3.2": "v3.2_db_performance.sql",
'v3.2.1': 'v3.2_qwen_vehicle_compat.sql', "v3.2.1": "v3.2_qwen_vehicle_compat.sql",
'v3.3': 'v3.3_marketplace_any_part.sql', "v3.3": "v3.3_marketplace_any_part.sql",
'v3.3.1': 'v3.3_materialized_view.sql', "v3.3.1": "v3.3_materialized_view.sql",
'v3.4': 'v3.4_meli_integration.sql', "v3.4": "v3.4_meli_integration.sql",
'v3.5': 'v3.5_meli_questions.sql', "v3.5": "v3.5_meli_questions.sql",
'v3.5.1': 'v3.5_whatsapp_state_machine.sql', "v3.5.1": "v3.5_whatsapp_state_machine.sql",
'v3.6': 'v3.6_dropshipping.sql', "v3.6": "v3.6_dropshipping.sql",
'v3.7': 'v3.7_sku_aliases.sql', "v3.7": "v3.7_sku_aliases.sql",
'v3.8': 'v3.8_supplier_catalog.sql', "v3.8": "v3.8_supplier_catalog.sql",
'v3.9': 'v3.9_supplier_catalog_prices.sql', "v3.9": "v3.9_supplier_catalog_prices.sql",
'v4.0': 'v4.0_multi_branch.sql', "v4.0": "v4.0_multi_branch.sql",
'v4.1': 'v4.1_global_invoice.sql', "v4.1": "v4.1_global_invoice.sql",
'v4.2': 'v4.2_meli_sync_queue.sql', "v4.2": "v4.2_meli_sync_queue.sql",
'v4.3': 'v4.3_facturapi.sql', "v4.3": "v4.3_facturapi.sql",
"v4.4": "v4.4_workshop.sql",
"v4.5": "v4.5_customer_max_discount.sql",
} }
@@ -81,9 +84,9 @@ def apply_migration(db_name, version):
sql = f.read() sql = f.read()
# Skip migrations marked for manual/non-tenant execution # Skip migrations marked for manual/non-tenant execution
first_line = sql.splitlines()[0].strip() if sql.strip() else '' first_line = sql.splitlines()[0].strip() if sql.strip() else ""
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'): if first_line.startswith(": SKIP") or first_line.startswith("-- : SKIP"):
print(f" SKIP (manual/non-tenant migration)") print(" SKIP (manual/non-tenant migration)")
return True return True
conn = get_tenant_conn_by_dbname(db_name) conn = get_tenant_conn_by_dbname(db_name)
@@ -116,16 +119,19 @@ def run_migrations():
if version <= current_version: if version <= current_version:
continue continue
print(f" Applying {version}...", end=' ') print(f" Applying {version}...", end=" ")
if apply_migration(db_name, version): if apply_migration(db_name, version):
# Update version in master # Update version in master
master_conn = get_master_conn() master_conn = get_master_conn()
master_cur = master_conn.cursor() master_cur = master_conn.cursor()
master_cur.execute(""" master_cur.execute(
"""
INSERT INTO tenant_schema_version (tenant_id, version) INSERT INTO tenant_schema_version (tenant_id, version)
VALUES (%s, %s) VALUES (%s, %s)
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW() ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
""", (tenant_id, version, version)) """,
(tenant_id, version, version),
)
master_conn.commit() master_conn.commit()
master_cur.close() master_cur.close()
master_conn.close() master_conn.close()
@@ -137,5 +143,5 @@ def run_migrations():
print("\nDone.") print("\nDone.")
if __name__ == '__main__': if __name__ == "__main__":
run_migrations() run_migrations()

View File

@@ -0,0 +1,66 @@
-- v4.4 Workshop Lite
-- Extends service orders with inventory reservation, sale linking and a labor catalog.
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. SERVICE_ORDERS: link to the sale generated from the order
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS sale_id INTEGER REFERENCES sales(id);
COMMENT ON COLUMN service_orders.sale_id IS 'Sale/invoice generated from this service order';
CREATE INDEX IF NOT EXISTS idx_service_orders_sale_id ON service_orders(sale_id);
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. SERVICE_CATALOG: reusable labor/work concepts for mechanics
-- ═════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS service_catalog (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
suggested_hours NUMERIC(6,2) DEFAULT 0,
suggested_rate NUMERIC(12,2) DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_service_catalog_tenant ON service_catalog(tenant_id);
CREATE INDEX IF NOT EXISTS idx_service_catalog_active ON service_catalog(is_active);
COMMENT ON TABLE service_catalog IS 'Reusable labor concepts for workshop service orders';
-- Trigger to auto-update updated_at on service_catalog
CREATE OR REPLACE FUNCTION update_service_catalog_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_service_catalog_updated_at ON service_catalog;
CREATE TRIGGER trg_service_catalog_updated_at
BEFORE UPDATE ON service_catalog
FOR EACH ROW
EXECUTE FUNCTION update_service_catalog_updated_at();
-- ═════════════════════════════════════════════════════════════════════════════
-- 3. SERVICE_ORDER_ITEMS: track reserved quantity separately
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_order_items
ADD COLUMN IF NOT EXISTS reserved_quantity NUMERIC(10,2) DEFAULT 0;
COMMENT ON COLUMN service_order_items.reserved_quantity IS 'Quantity currently reserved from inventory';
-- ═════════════════════════════════════════════════════════════════════════════
-- 4. INVENTORY_OPERATIONS: new operation types for service orders
-- ═════════════════════════════════════════════════════════════════════════════
-- operation_type is VARCHAR(20) without a constraint, so no ALTER is needed.
-- New types used by the workshop module:
-- SO_RESERVE : negative quantity, reserves stock when item is added to SO
-- SO_RELEASE : positive quantity, releases a previous reservation
-- SO_CONSUME : negative quantity, final deduction when SO is converted to sale
COMMENT ON COLUMN inventory_operations.operation_type IS
'SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL, QUOTE_RESERVE, QUOTE_RELEASE, SO_RESERVE, SO_RELEASE, SO_CONSUME';

View File

@@ -0,0 +1,5 @@
-- /home/Autopartes/pos/migrations/v4.5_customer_max_discount.sql
-- Tenant DB schema v4.5 — add per-customer maximum discount percentage.
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS max_discount_pct NUMERIC(5,2) DEFAULT 0;

View File

@@ -285,20 +285,24 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
# Filter to North America models only, add clean display name, deduplicate # Filter to North America models only, add clean display name, deduplicate
filtered = [r for r in rows if is_na_model(brand_name, r[1])] filtered = [r for r in rows if is_na_model(brand_name, r[1])]
# Group by (display_name, raw name) so distinct body-style variants # Group by display_name so body-style/generation variants
# (e.g. AVEO vs AVEO SALOON) remain selectable. # (e.g. AVEO Saloon, AVEO Hatchback) are shown as a single model.
seen = set() groups = {}
results = []
for r in filtered: for r in filtered:
display = _clean_model_name(r[1]) display = _clean_model_name(r[1])
key = (display, r[1]) groups.setdefault(display, []).append(r)
if key not in seen:
seen.add(key) results = []
results.append({ for display, variants in groups.items():
'id_model': r[0], # Sort by raw model id ascending; first becomes the canonical id.
'name_model': r[1], variants.sort(key=lambda x: x[0])
'display_name': display, canonical = variants[0]
}) results.append({
'id_model': canonical[0],
'name_model': canonical[1],
'display_name': display,
'variant_ids': [v[0] for v in variants],
})
# Sort by display_name # Sort by display_name
results.sort(key=lambda x: x['display_name']) results.sort(key=lambda x: x['display_name'])
@@ -306,34 +310,37 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
def get_years(master_conn, model_id, mye_ids=None): def get_years(master_conn, model_id, mye_ids=None):
"""Get distinct years for a model via MYE (fast, no vehicle_parts scan). Ordered DESC.""" """Get distinct years for a model (or list of model variants) via MYE.
Ordered DESC."""
cur = master_conn.cursor() cur = master_conn.cursor()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
if mye_ids: if mye_ids:
cur.execute(""" cur.execute("""
SELECT DISTINCT y.id_year, y.year_car SELECT DISTINCT y.id_year, y.year_car
FROM years y FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = %s AND mye.id_mye = ANY(%s) WHERE mye.model_id = ANY(%s) AND mye.id_mye = ANY(%s)
ORDER BY y.year_car DESC ORDER BY y.year_car DESC
""", (model_id, mye_ids)) """, (list(model_ids), mye_ids))
else: else:
cur.execute(""" cur.execute("""
SELECT DISTINCT y.id_year, y.year_car SELECT DISTINCT y.id_year, y.year_car
FROM years y FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = %s WHERE mye.model_id = ANY(%s)
ORDER BY y.year_car DESC ORDER BY y.year_car DESC
""", (model_id,)) """, (list(model_ids),))
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
return [{'id_year': r[0], 'year_car': r[1]} for r in rows] return [{'id_year': r[0], 'year_car': r[1]} for r in rows]
def get_engines(master_conn, model_id, year_id, mye_ids=None): def get_engines(master_conn, model_id, year_id, mye_ids=None):
"""Get MYE entries (engine + trim) for a model+year combo.""" """Get MYE entries (engine + trim) for a model (or list of variants) + year combo."""
cur = master_conn.cursor() cur = master_conn.cursor()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
mye_filter = "" mye_filter = ""
params = [model_id, year_id] params = [list(model_ids), year_id]
if mye_ids: if mye_ids:
mye_filter = " AND mye.id_mye = ANY(%s)" mye_filter = " AND mye.id_mye = ANY(%s)"
params.append(mye_ids) params.append(mye_ids)
@@ -341,12 +348,29 @@ def get_engines(master_conn, model_id, year_id, mye_ids=None):
SELECT mye.id_mye, e.name_engine, mye.trim_level SELECT mye.id_mye, e.name_engine, mye.trim_level
FROM model_year_engine mye FROM model_year_engine mye
JOIN engines e ON e.id_engine = mye.engine_id JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.model_id = %s AND mye.year_id = %s{mye_filter} WHERE mye.model_id = ANY(%s) AND mye.year_id = %s{mye_filter}
ORDER BY e.name_engine, mye.trim_level ORDER BY e.name_engine, mye.trim_level, mye.id_mye
""", tuple(params)) """, tuple(params))
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows]
def _clean_engine_name(name):
if not name or name.strip().upper() in ('N/A', 'RUEDA', ''):
return 'Sin especificar'
return name.strip()
# Deduplicate identical (name, trim) entries so the user doesn't see
# multiple indistinguishable "Sin especificar" options.
seen = set()
results = []
for id_mye, name_engine, trim_level in rows:
clean_name = _clean_engine_name(name_engine)
key = (clean_name, trim_level or '')
if key in seen:
continue
seen.add(key)
results.append({'id_mye': id_mye, 'name_engine': clean_name, 'trim_level': key[1]})
return results
def get_categories(master_conn, mye_id, allowed_brands=None): def get_categories(master_conn, mye_id, allowed_brands=None):

View File

@@ -9,8 +9,8 @@ generates those payloads for:
- Factura global mensual - Factura global mensual
""" """
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
# SAT defaults # SAT defaults
RFC_PUBLICO_GENERAL = "XAXX010101000" RFC_PUBLICO_GENERAL = "XAXX010101000"
@@ -148,9 +148,7 @@ def build_egreso_payload(sale, tenant_config, customer, original_uuid):
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso).""" """Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
payload = build_ingreso_payload(sale, tenant_config, customer) payload = build_ingreso_payload(sale, tenant_config, customer)
payload["type"] = "E" payload["type"] = "E"
payload["related_documents"] = [ payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
{"relationship": "01", "documents": [original_uuid]}
]
payload["payment_method"] = "PUE" payload["payment_method"] = "PUE"
return payload return payload
@@ -162,15 +160,12 @@ def build_pago_payload(payment, tenant_config, customer, original_uuid):
amount = _to_dec(payment.get("amount", 0)) amount = _to_dec(payment.get("amount", 0))
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP) base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
iva = (amount - base).quantize(TWO, ROUND_HALF_UP)
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
if "T" not in str(payment_date): if "T" not in str(payment_date):
payment_date = f"{payment_date}T12:00:00" payment_date = f"{payment_date}T12:00:00"
forma_pago = FORMA_PAGO_MAP.get( forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
(payment.get("payment_method") or "").lower().strip(), "01"
)
payload = { payload = {
"type": "P", "type": "P",

View File

@@ -17,7 +17,7 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime
from services import facturapi_service from services import facturapi_service
@@ -34,7 +34,7 @@ def _generate_provisional_folio(conn):
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0] seq = cur.fetchone()[0]
cur.close() cur.close()
return f'PRE-{seq:05d}' return f"PRE-{seq:05d}"
def enqueue_cfdi(conn, sale_id, cfdi_type, payload): def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
@@ -54,22 +54,25 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
payload_json = payload if isinstance(payload, str) else json.dumps(payload) payload_json = payload if isinstance(payload, str) else json.dumps(payload)
cur.execute(""" cur.execute(
"""
INSERT INTO cfdi_queue INSERT INTO cfdi_queue
(sale_id, type, payload_unsigned, status, provisional_folio) (sale_id, type, payload_unsigned, status, provisional_folio)
VALUES (%s, %s, %s, 'pending', %s) VALUES (%s, %s, %s, 'pending', %s)
RETURNING id, created_at RETURNING id, created_at
""", (sale_id, cfdi_type, payload_json, provisional_folio)) """,
(sale_id, cfdi_type, payload_json, provisional_folio),
)
cfdi_id, created_at = cur.fetchone() cfdi_id, created_at = cur.fetchone()
cur.close() cur.close()
return { return {
'id': cfdi_id, "id": cfdi_id,
'sale_id': sale_id, "sale_id": sale_id,
'type': cfdi_type, "type": cfdi_type,
'status': 'pending', "status": "pending",
'provisional_folio': provisional_folio, "provisional_folio": provisional_folio,
'created_at': str(created_at), "created_at": str(created_at),
} }
@@ -90,34 +93,40 @@ def process_queue(conn, tenant_config, dry_run=False):
""" """
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
"""
SELECT id, sale_id, type, payload_unsigned, retry_count SELECT id, sale_id, type, payload_unsigned, retry_count
FROM cfdi_queue FROM cfdi_queue
WHERE status IN ('pending', 'failed') WHERE status IN ('pending', 'failed')
AND retry_count < %s AND retry_count < %s
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 50 LIMIT 50
""", (MAX_RETRIES,)) """,
(MAX_RETRIES,),
)
items = cur.fetchall() items = cur.fetchall()
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []} results = {"processed": 0, "stamped": 0, "failed": 0, "details": []}
api_key = tenant_config.get('facturapi_key') api_key = tenant_config.get("facturapi_key")
if not api_key: if not api_key:
cur.close() cur.close()
raise ValueError("Facturapi key not configured for tenant") raise ValueError("Facturapi key not configured for tenant")
for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items: for cfdi_id, _sale_id, _cfdi_type, payload_unsigned, _retry_count in items:
results['processed'] += 1 results["processed"] += 1
# Update status to 'sending' # Update status to 'sending'
cur.execute(""" cur.execute(
"""
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
conn.commit() conn.commit()
try: try:
payload = json.loads(payload_unsigned or '{}') payload = json.loads(payload_unsigned or "{}")
if not payload: if not payload:
raise ValueError("Empty payload in queue item") raise ValueError("Empty payload in queue item")
@@ -127,18 +136,19 @@ def process_queue(conn, tenant_config, dry_run=False):
raise ValueError("dry_run is not supported with Facturapi") raise ValueError("dry_run is not supported with Facturapi")
invoice = facturapi_service.create_invoice(tenant_config, payload) invoice = facturapi_service.create_invoice(tenant_config, payload)
invoice_id = invoice.get('id') invoice_id = invoice.get("id")
uuid_fiscal = invoice.get('uuid') uuid_fiscal = invoice.get("uuid")
# Download signed XML for storage # Download signed XML for storage
try: try:
xml_signed = facturapi_service.download_xml(tenant_config, invoice_id) xml_signed = facturapi_service.download_xml(tenant_config, invoice_id)
xml_signed_str = xml_signed.decode('utf-8') if isinstance(xml_signed, bytes) else str(xml_signed) xml_signed_str = xml_signed.decode("utf-8") if isinstance(xml_signed, bytes) else str(xml_signed)
except Exception as xml_err: except Exception as xml_err:
logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err) logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
xml_signed_str = '' xml_signed_str = ""
cur.execute(""" cur.execute(
"""
UPDATE cfdi_queue UPDATE cfdi_queue
SET status = 'stamped', SET status = 'stamped',
xml_signed = %s, xml_signed = %s,
@@ -147,30 +157,37 @@ def process_queue(conn, tenant_config, dry_run=False):
stamped_at = NOW(), stamped_at = NOW(),
error_message = NULL error_message = NULL
WHERE id = %s WHERE id = %s
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id)) """,
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
)
conn.commit() conn.commit()
results['stamped'] += 1 results["stamped"] += 1
results['details'].append({ results["details"].append(
'id': cfdi_id, 'status': 'stamped', {
'uuid': uuid_fiscal, 'external_id': invoice_id, "id": cfdi_id,
}) "status": "stamped",
"uuid": uuid_fiscal,
"external_id": invoice_id,
}
)
except Exception as e: except Exception as e:
error_msg = f'{type(e).__name__}: {str(e)[:500]}' error_msg = f"{type(e).__name__}: {str(e)[:500]}"
cur.execute(""" cur.execute(
"""
UPDATE cfdi_queue UPDATE cfdi_queue
SET status = 'failed', SET status = 'failed',
retry_count = retry_count + 1, retry_count = retry_count + 1,
error_message = %s error_message = %s
WHERE id = %s WHERE id = %s
""", (error_msg, cfdi_id)) """,
(error_msg, cfdi_id),
)
conn.commit() conn.commit()
results['failed'] += 1 results["failed"] += 1
results['details'].append({ results["details"].append({"id": cfdi_id, "status": "failed", "error": error_msg})
'id': cfdi_id, 'status': 'failed', 'error': error_msg
})
cur.close() cur.close()
return results return results
@@ -184,30 +201,33 @@ def retry_failed(conn):
""" """
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
"""
SELECT id, retry_count, created_at SELECT id, retry_count, created_at
FROM cfdi_queue FROM cfdi_queue
WHERE status = 'failed' AND retry_count < %s WHERE status = 'failed' AND retry_count < %s
ORDER BY created_at ASC ORDER BY created_at ASC
""", (MAX_RETRIES,)) """,
(MAX_RETRIES,),
)
items = cur.fetchall() items = cur.fetchall()
reset_count = 0 reset_count = 0
now = datetime.utcnow() now = datetime.utcnow()
for cfdi_id, retry_count, created_at in items: for cfdi_id, retry_count, created_at in items:
if retry_count < len(BACKOFF_INTERVALS): wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1]
wait_seconds = BACKOFF_INTERVALS[retry_count]
else:
wait_seconds = BACKOFF_INTERVALS[-1]
# Use created_at as approximation for last attempt. # Use created_at as approximation for last attempt.
# In production, track last_attempt_at separately. # In production, track last_attempt_at separately.
elapsed = (now - created_at).total_seconds() elapsed = (now - created_at).total_seconds()
if elapsed >= wait_seconds: if elapsed >= wait_seconds:
cur.execute(""" cur.execute(
"""
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
reset_count += 1 reset_count += 1
conn.commit() conn.commit()
@@ -215,8 +235,7 @@ def retry_failed(conn):
return reset_count return reset_count
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None):
tenant_config=None):
"""Cancel a stamped CFDI via Facturapi. """Cancel a stamped CFDI via Facturapi.
SAT cancellation motives: SAT cancellation motives:
@@ -238,38 +257,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
Raises: Raises:
ValueError: on validation errors ValueError: on validation errors
""" """
if motive not in ('01', '02', '03', '04'): if motive not in ("01", "02", "03", "04"):
raise ValueError(f"Invalid SAT cancellation motive: {motive}") raise ValueError(f"Invalid SAT cancellation motive: {motive}")
if motive == '01' and not replacement_uuid: if motive == "01" and not replacement_uuid:
raise ValueError("Motive 01 requires a replacement UUID") raise ValueError("Motive 01 requires a replacement UUID")
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
"""
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise ValueError(f"CFDI queue item {cfdi_id} not found") raise ValueError(f"CFDI queue item {cfdi_id} not found")
_, uuid_fiscal, external_id, current_status = row _, uuid_fiscal, external_id, current_status = row
if current_status == 'cancelled': if current_status == "cancelled":
raise ValueError("CFDI is already cancelled") raise ValueError("CFDI is already cancelled")
if current_status != 'stamped': if current_status != "stamped":
# If not stamped, we can just mark as cancelled locally # If not stamped, we can just mark as cancelled locally
cur.execute(""" cur.execute(
"""
UPDATE cfdi_queue UPDATE cfdi_queue
SET status = 'cancelled', cancel_motive = %s SET status = 'cancelled', cancel_motive = %s
WHERE id = %s WHERE id = %s
""", (motive, cfdi_id)) """,
(motive, cfdi_id),
)
conn.commit() conn.commit()
cur.close() cur.close()
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'} return {"id": cfdi_id, "status": "cancelled", "message": "Cancelled locally (was not stamped)"}
if not tenant_config or not tenant_config.get('facturapi_key'): if not tenant_config or not tenant_config.get("facturapi_key"):
cur.close() cur.close()
raise ValueError("Facturapi key not configured for tenant") raise ValueError("Facturapi key not configured for tenant")
@@ -279,36 +304,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
try: try:
facturapi_service.cancel_invoice( facturapi_service.cancel_invoice(
tenant_config, external_id, motive, tenant_config,
external_id,
motive,
replacement_uuid=replacement_uuid, replacement_uuid=replacement_uuid,
) )
cur.execute(""" cur.execute(
"""
UPDATE cfdi_queue UPDATE cfdi_queue
SET status = 'cancelled', SET status = 'cancelled',
cancel_motive = %s, cancel_motive = %s,
cancel_replacement_uuid = %s, cancel_replacement_uuid = %s,
error_message = NULL error_message = NULL
WHERE id = %s WHERE id = %s
""", (motive, replacement_uuid, cfdi_id)) """,
(motive, replacement_uuid, cfdi_id),
)
conn.commit() conn.commit()
cur.close() cur.close()
return { return {
'id': cfdi_id, "id": cfdi_id,
'status': 'cancelled', "status": "cancelled",
'message': f'Cancelled with SAT (motive {motive})', "message": f"Cancelled with SAT (motive {motive})",
} }
except Exception as e: except Exception as e:
error_msg = f'Cancel failed: {str(e)[:500]}' error_msg = f"Cancel failed: {str(e)[:500]}"
cur.execute(""" cur.execute(
"""
UPDATE cfdi_queue UPDATE cfdi_queue
SET error_message = %s SET error_message = %s
WHERE id = %s WHERE id = %s
""", (error_msg, cfdi_id)) """,
(error_msg, cfdi_id),
)
conn.commit() conn.commit()
cur.close() cur.close()
raise ValueError(error_msg) raise ValueError(error_msg) from e
def get_queue_status(conn, filters=None): def get_queue_status(conn, filters=None):
@@ -316,30 +349,31 @@ def get_queue_status(conn, filters=None):
filters = filters or {} filters = filters or {}
cur = conn.cursor() cur = conn.cursor()
page = int(filters.get('page', 1)) page = int(filters.get("page", 1))
per_page = min(int(filters.get('per_page', 50)), 200) per_page = min(int(filters.get("per_page", 50)), 200)
where_clauses = ["1=1"] where_clauses = ["1=1"]
params = [] params = []
if filters.get('status'): if filters.get("status"):
where_clauses.append("q.status = %s") where_clauses.append("q.status = %s")
params.append(filters['status']) params.append(filters["status"])
if filters.get('sale_id'): if filters.get("sale_id"):
where_clauses.append("q.sale_id = %s") where_clauses.append("q.sale_id = %s")
params.append(int(filters['sale_id'])) params.append(int(filters["sale_id"]))
if filters.get('type'): if filters.get("type"):
where_clauses.append("q.type = %s") where_clauses.append("q.type = %s")
params.append(filters['type']) params.append(filters["type"])
where = " AND ".join(where_clauses) where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params) cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
total = cur.fetchone()[0] total = cur.fetchone()[0]
cur.execute(f""" cur.execute(
f"""
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status, SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
q.retry_count, q.provisional_folio, q.error_message, q.retry_count, q.provisional_folio, q.error_message,
q.cancel_motive, q.created_at, q.stamped_at, q.external_id q.cancel_motive, q.created_at, q.stamped_at, q.external_id
@@ -347,26 +381,37 @@ def get_queue_status(conn, filters=None):
WHERE {where} WHERE {where}
ORDER BY q.created_at DESC ORDER BY q.created_at DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page]) """,
params + [per_page, (page - 1) * per_page],
)
items = [] items = []
for r in cur.fetchall(): for r in cur.fetchall():
items.append({ items.append(
'id': r[0], 'sale_id': r[1], 'type': r[2], {
'uuid_fiscal': r[3], 'status': r[4], "id": r[0],
'retry_count': r[5], 'provisional_folio': r[6], "sale_id": r[1],
'error_message': r[7], 'cancel_motive': r[8], "type": r[2],
'created_at': str(r[9]) if r[9] else None, "uuid_fiscal": r[3],
'stamped_at': str(r[10]) if r[10] else None, "status": r[4],
'external_id': r[11], "retry_count": r[5],
}) "provisional_folio": r[6],
"error_message": r[7],
"cancel_motive": r[8],
"created_at": str(r[9]) if r[9] else None,
"stamped_at": str(r[10]) if r[10] else None,
"external_id": r[11],
}
)
cur.close() cur.close()
total_pages = (total + per_page - 1) // per_page total_pages = (total + per_page - 1) // per_page
return { return {
'data': items, "data": items,
'pagination': { "pagination": {
'page': page, 'per_page': per_page, "page": page,
'total': total, 'total_pages': total_pages, "per_page": per_page,
} "total": total,
"total_pages": total_pages,
},
} }

View File

@@ -12,11 +12,10 @@ Authentication modes:
Reference: https://docs.facturapi.io/ Reference: https://docs.facturapi.io/
""" """
import os
import base64 import base64
import logging import logging
import os
from decimal import Decimal from decimal import Decimal
from typing import Optional
import requests import requests
@@ -35,8 +34,8 @@ class FacturapiError(Exception):
# ─── HTTP helpers ─────────────────────────────────────────────────────────── # ─── HTTP helpers ───────────────────────────────────────────────────────────
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None,
extra_headers=None, timeout=60): def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None, extra_headers=None, timeout=60):
"""Make a request to Facturapi REST API with Basic Auth.""" """Make a request to Facturapi REST API with Basic Auth."""
url = f"{BASE_URL}{endpoint}" url = f"{BASE_URL}{endpoint}"
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@@ -54,7 +53,7 @@ def _request(method: str, endpoint: str, api_key: str, json_payload=None, params
timeout=timeout, timeout=timeout,
) )
except requests.RequestException as e: except requests.RequestException as e:
raise FacturapiError(f"Connection error: {e}", status_code=0) raise FacturapiError(f"Connection error: {e}", status_code=0) from e
if not resp.ok: if not resp.ok:
raise FacturapiError( raise FacturapiError(
@@ -88,15 +87,24 @@ def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60)
# ─── Tenant config helpers ────────────────────────────────────────────────── # ─── Tenant config helpers ──────────────────────────────────────────────────
def _get_secret_key(tenant_config: dict) -> Optional[str]:
for key in ("facturapi_key", "facturapi_secret_key"): def _get_secret_key(tenant_config: dict) -> str | None:
for key in ("facturapi_secret_key", "facturapi_key", "cfdi_facturapi_key"):
val = (tenant_config.get(key) or "").strip() val = (tenant_config.get(key) or "").strip()
if val: if val:
return val return val
return None return None
def _get_user_key() -> Optional[str]: def _get_org_id(tenant_config: dict) -> str | None:
for key in ("facturapi_org_id", "cfdi_facturapi_org_id"):
val = (tenant_config.get(key) or "").strip()
if val:
return val
return None
def _get_user_key() -> str | None:
return USER_KEY.strip() or None return USER_KEY.strip() or None
@@ -117,42 +125,11 @@ def get_api_key(tenant_config: dict) -> str:
user = _get_user_key() user = _get_user_key()
if user: if user:
return user return user
raise FacturapiError( raise FacturapiError("Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key")
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
)
# ─── Organizations ────────────────────────────────────────────────────────── # ─── Organizations ──────────────────────────────────────────────────────────
def create_organization(tenant_config: dict) -> dict:
"""Create a new Facturapi organization for the tenant.
Requires FACTURAPI_USER_KEY.
Returns dict with id, api_key.
"""
user_key = _get_user_key()
if not user_key:
raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations")
payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))}
legal = tenant_config.get("legal_name") or tenant_config.get("razon_social")
if legal:
payload["legal"] = {"name": legal}
if tenant_config.get("rfc"):
payload["legal"] = payload.get("legal", {})
payload["legal"]["tax_id"] = tenant_config["rfc"]
org = _request("POST", "/organizations", user_key, json_payload=payload)
org_id = org.get("id")
# Generate live secret key
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={})
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key}
def get_organization(org_id: str, api_key: str) -> dict: def get_organization(org_id: str, api_key: str) -> dict:
return _request("GET", f"/organizations/{org_id}", api_key) return _request("GET", f"/organizations/{org_id}", api_key)
@@ -164,7 +141,7 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
cer_b64 and key_b64 are base64-encoded strings. cer_b64 and key_b64 are base64-encoded strings.
""" """
api_key = get_api_key(tenant_config) api_key = get_api_key(tenant_config)
org_id = tenant_config.get("facturapi_org_id") org_id = _get_org_id(tenant_config)
if not org_id: if not org_id:
raise FacturapiError("No Facturapi organization configured for tenant") raise FacturapiError("No Facturapi organization configured for tenant")
@@ -196,15 +173,14 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
user_key = _get_user_key() user_key = _get_user_key()
if user_key: if user_key:
return user_key return user_key
tenant_key = (tenant_config.get("facturapi_key") or "").strip() for key in ("facturapi_key", "cfdi_facturapi_key"):
if tenant_key.startswith("sk_user_"): tenant_key = (tenant_config.get(key) or "").strip()
return tenant_key if tenant_key.startswith("sk_user_"):
raise FacturapiError( return tenant_key
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required" raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
)
def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]: def find_organization_by_rfc(tenant_config: dict) -> dict | None:
"""Search for an existing Facturapi organization by tenant RFC. """Search for an existing Facturapi organization by tenant RFC.
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key). Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key).
@@ -252,9 +228,7 @@ def create_organization(tenant_config: dict) -> dict:
raise FacturapiError("Could not create organization: no id returned") raise FacturapiError("Could not create organization: no id returned")
# Generate live secret key # Generate live secret key
key_resp = _request( key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
"PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60
)
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp) live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key: if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}") raise FacturapiError(f"Could not generate live key for org {org_id}")
@@ -282,7 +256,7 @@ def get_org_status(tenant_config: dict) -> dict:
result["error"] = str(e) result["error"] = str(e)
return result return result
org_id = tenant_config.get("facturapi_org_id") org_id = _get_org_id(tenant_config)
if not org_id: if not org_id:
result["error"] = "No Facturapi organization configured" result["error"] = "No Facturapi organization configured"
return result return result
@@ -294,13 +268,15 @@ def get_org_status(tenant_config: dict) -> dict:
org = get_organization(org_id, api_key) org = get_organization(org_id, api_key)
legal = org.get("legal", {}) legal = org.get("legal", {})
cert = org.get("certificate", {}) cert = org.get("certificate", {})
result.update({ result.update(
"configured": True, {
"has_csd": bool(cert.get("has_certificate")), "configured": True,
"legal_name": legal.get("name") or legal.get("legal_name"), "has_csd": bool(cert.get("has_certificate")),
"tax_id": legal.get("tax_id"), "legal_name": legal.get("name") or legal.get("legal_name"),
"pending_steps": org.get("pending_steps", []), "tax_id": legal.get("tax_id"),
}) "pending_steps": org.get("pending_steps", []),
}
)
except FacturapiError as e: except FacturapiError as e:
result["error"] = str(e) result["error"] = str(e)
@@ -309,6 +285,7 @@ def get_org_status(tenant_config: dict) -> dict:
# ─── Customers ────────────────────────────────────────────────────────────── # ─── Customers ──────────────────────────────────────────────────────────────
def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str: def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
"""Create or update a customer in Facturapi and return its id. """Create or update a customer in Facturapi and return its id.
@@ -364,6 +341,7 @@ def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
# ─── Invoices ─────────────────────────────────────────────────────────────── # ─── Invoices ───────────────────────────────────────────────────────────────
def create_invoice(tenant_config: dict, payload: dict) -> dict: def create_invoice(tenant_config: dict, payload: dict) -> dict:
"""Create and stamp an invoice in Facturapi. """Create and stamp an invoice in Facturapi.
@@ -373,8 +351,7 @@ def create_invoice(tenant_config: dict, payload: dict) -> dict:
return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90) return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, replacement_uuid: str | None = None) -> dict:
replacement_uuid: Optional[str] = None) -> dict:
"""Cancel an invoice in Facturapi. """Cancel an invoice in Facturapi.
Motive codes: Motive codes:
@@ -402,6 +379,7 @@ def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
# ─── Helpers ───────────────────────────────────────────────────────────────── # ─── Helpers ─────────────────────────────────────────────────────────────────
def is_lco_rejection(message: str) -> bool: def is_lco_rejection(message: str) -> bool:
"""Detect SAT LCO rejection (CSD not yet propagated).""" """Detect SAT LCO rejection (CSD not yet propagated)."""
if not message: if not message:

View File

@@ -3,8 +3,11 @@
States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered
""" """
import contextlib
from datetime import datetime from datetime import datetime
from services import inventory_engine
VALID_TRANSITIONS = { VALID_TRANSITIONS = {
'received': ['diagnosis', 'cancelled'], 'received': ['diagnosis', 'cancelled'],
'diagnosis': ['waiting_parts', 'repair', 'cancelled'], 'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
@@ -30,10 +33,8 @@ def _generate_order_number(conn):
row = cur.fetchone() row = cur.fetchone()
last_num = 0 last_num = 0
if row and row[0]: if row and row[0]:
try: with contextlib.suppress(ValueError):
last_num = int(row[0].split('-')[-1]) last_num = int(row[0].split('-')[-1])
except ValueError:
pass
new_num = last_num + 1 new_num = last_num + 1
cur.close() cur.close()
return f"{prefix}{new_num:04d}" return f"{prefix}{new_num:04d}"
@@ -422,7 +423,7 @@ def get_kanban_summary(conn, branch_id=None):
GROUP BY status GROUP BY status
""", params) """, params)
summary = {status: 0 for status in VALID_TRANSITIONS.keys() if status != 'cancelled'} summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
for r in cur.fetchall(): for r in cur.fetchall():
summary[r[0]] = r[1] summary[r[0]] = r[1]
@@ -438,3 +439,413 @@ def get_kanban_summary(conn, branch_id=None):
cur.close() cur.close()
summary['overdue'] = overdue summary['overdue'] = overdue
return summary return summary
# ─── Workshop inventory integration ─────────────────────────────────────────
def reserve_item(conn, so_item_id, branch_id, employee_id=None):
"""Reserve inventory for a service order item.
Records a negative SO_RESERVE operation and updates reserved_quantity.
Raises ValueError if stock is insufficient.
"""
cur = conn.cursor()
cur.execute(
"""
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
so.order_number
FROM service_order_items soi
JOIN service_orders so ON so.id = soi.service_order_id
WHERE soi.id = %s
""",
(so_item_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Service order item not found")
so_id, inventory_id, quantity, status, order_number = row
if status == "cancelled":
cur.close()
raise ValueError("Cannot reserve a cancelled item")
if not inventory_id:
cur.close()
raise ValueError("Item has no inventory linked")
qty = int(quantity)
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
if available < qty:
cur.close()
raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SO_RESERVE",
-qty,
reference_id=so_id,
reference_type="service_order_item",
notes=f"Reserva orden {order_number}",
employee_id=employee_id,
)
cur.execute(
"UPDATE service_order_items SET reserved_quantity = %s WHERE id = %s",
(qty, so_item_id),
)
conn.commit()
cur.close()
return {"reserved": qty}
def release_item(conn, so_item_id, employee_id=None):
"""Release a previous reservation for a service order item.
Records a positive SO_RELEASE operation and resets reserved_quantity.
"""
cur = conn.cursor()
cur.execute(
"""
SELECT soi.service_order_id, soi.inventory_id, soi.reserved_quantity,
so.branch_id, so.order_number
FROM service_order_items soi
JOIN service_orders so ON so.id = soi.service_order_id
WHERE soi.id = %s
""",
(so_item_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Service order item not found")
so_id, inventory_id, reserved_qty, branch_id, order_number = row
if not inventory_id or not reserved_qty:
cur.close()
return {"released": 0}
qty = int(reserved_qty)
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SO_RELEASE",
qty,
reference_id=so_id,
reference_type="service_order_item",
notes=f"Liberacion reserva orden {order_number}",
employee_id=employee_id,
)
cur.execute(
"UPDATE service_order_items SET reserved_quantity = 0 WHERE id = %s",
(so_item_id,),
)
conn.commit()
cur.close()
return {"released": qty}
def _consume_item_inventory(conn, so_item, sale_id, order_number, branch_id, employee_id=None):
"""Release reservation and record final SALE for a service order item."""
inventory_id = so_item.get("inventory_id")
reserved_qty = so_item.get("reserved_quantity", 0)
qty = int(so_item.get("quantity", 0))
if not inventory_id or qty <= 0:
return
if reserved_qty:
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SO_RELEASE",
int(reserved_qty),
reference_id=so_item.get("service_order_id"),
reference_type="service_order",
notes=f"Liberacion para venta orden {order_number}",
employee_id=employee_id,
)
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SALE",
-qty,
reference_id=sale_id,
reference_type="sale",
notes=f"Venta desde orden {order_number}",
employee_id=employee_id,
)
def convert_to_sale(conn, so_id, sale_data, employee_id=None):
"""Convert a service order into a POS sale.
sale_data keys:
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto'
sale_type: 'cash' | 'credit' | 'mixed'
register_id: int (optional)
amount_paid: float (optional)
payment_details: list (optional)
notes: str (optional)
Returns dict with sale_id, total, items_count.
"""
cur = conn.cursor()
so = get_service_order(conn, so_id)
if not so:
cur.close()
raise ValueError("Service order not found")
if so["status"] == "cancelled":
cur.close()
raise ValueError("Cannot convert a cancelled service order")
if so.get("sale_id"):
cur.close()
raise ValueError("Service order already converted to sale")
branch_id = so["branch_id"]
customer_id = so["customer_id"]
# Build sale items from SO parts and labor
sale_items = []
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
qty = int(item.get("quantity", 1))
unit_price = float(item.get("unit_price") or 0)
unit_cost = float(item.get("unit_cost") or 0)
sale_items.append(
{
"inventory_id": item.get("inventory_id"),
"part_number": item.get("part_number") or "PART",
"name": item.get("name") or "Refaccion",
"quantity": qty,
"unit_price": unit_price,
"unit_cost": unit_cost,
"tax_rate": 0.16,
}
)
for labor in so.get("labor", []):
if labor.get("status") == "cancelled":
continue
sale_items.append(
{
"inventory_id": None,
"part_number": "SERV",
"name": labor.get("description") or "Mano de obra",
"quantity": 1,
"unit_price": float(labor.get("total_cost") or 0),
"unit_cost": 0,
"tax_rate": 0.16,
}
)
if not sale_items:
cur.close()
raise ValueError("No items or labor to invoice")
# Calculate totals
subtotal = 0.0
tax_total = 0.0
for item in sale_items:
item_subtotal = item["quantity"] * item["unit_price"]
item_tax = item_subtotal * item["tax_rate"]
item["subtotal"] = item_subtotal
item["tax_amount"] = item_tax
subtotal += item_subtotal
tax_total += item_tax
total = subtotal + tax_total
payment_method = sale_data.get("payment_method", "efectivo")
sale_type = sale_data.get("sale_type", "cash")
register_id = sale_data.get("register_id")
amount_paid = float(sale_data.get("amount_paid", total if sale_type == "cash" else 0))
change_given = max(amount_paid - total, 0) if sale_type == "cash" and payment_method == "efectivo" else 0
notes = sale_data.get("notes") or f"Orden de servicio {so['order_number']}"
metodo_pago_sat = "PPD" if sale_type == "credit" else "PUE"
forma_pago_map = {"efectivo": "01", "transferencia": "03", "tarjeta": "04", "mixto": "99"}
forma_pago_sat = forma_pago_map.get(payment_method, "99")
cur.execute(
"""
INSERT INTO sales
(branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s)
RETURNING id, created_at
""",
(
branch_id,
customer_id,
employee_id,
register_id,
sale_type,
payment_method,
subtotal,
0,
tax_total,
total,
amount_paid,
change_given,
metodo_pago_sat,
forma_pago_sat,
notes,
),
)
sale_id, _created_at = cur.fetchone()
# Insert sale_items
for item in sale_items:
cur.execute(
"""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
sale_id,
item["inventory_id"],
item["part_number"],
item["name"],
item["quantity"],
item["unit_price"],
item["unit_cost"],
item["tax_rate"],
item["tax_amount"],
item["subtotal"],
),
)
# Consume inventory for parts
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
_consume_item_inventory(
conn, item, sale_id, so["order_number"], branch_id, employee_id
)
# Link order to sale
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
conn.commit()
cur.close()
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
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,))
if not cur.fetchone():
cur.close()
raise ValueError("Service order not found")
cur.execute(
"UPDATE service_orders SET employee_id = %s WHERE id = %s",
(employee_id, so_id),
)
conn.commit()
cur.close()
return {"employee_id": employee_id}
# ─── Service catalog (reusable labor concepts) ───────────────────────────────
def list_service_catalog(conn, active_only=True):
"""List reusable labor/service concepts."""
cur = conn.cursor()
where = "WHERE is_active = true" if active_only else ""
cur.execute(
f"""
SELECT id, tenant_id, name, description, suggested_hours, suggested_rate,
is_active, created_at, updated_at
FROM service_catalog
{where}
ORDER BY name
"""
)
items = []
for r in cur.fetchall():
items.append(
{
"id": r[0],
"tenant_id": r[1],
"name": r[2],
"description": r[3],
"suggested_hours": float(r[4]) if r[4] else 0,
"suggested_rate": float(r[5]) if r[5] else 0,
"is_active": r[6],
"created_at": str(r[7]) if r[7] else None,
"updated_at": str(r[8]) if r[8] else None,
}
)
cur.close()
return items
def create_service_catalog_item(conn, tenant_id, data):
"""Create a reusable labor concept."""
cur = conn.cursor()
cur.execute(
"""
INSERT INTO service_catalog
(tenant_id, name, description, suggested_hours, suggested_rate, is_active)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
tenant_id,
data.get("name"),
data.get("description"),
data.get("suggested_hours", 0),
data.get("suggested_rate", 0),
data.get("is_active", True),
),
)
item_id = cur.fetchone()[0]
conn.commit()
cur.close()
return {"id": item_id}
def update_service_catalog_item(conn, item_id, data):
"""Update a reusable labor concept."""
cur = conn.cursor()
allowed = ["name", "description", "suggested_hours", "suggested_rate", "is_active"]
sets = []
vals = []
for field in allowed:
if field in data:
sets.append(f"{field} = %s")
vals.append(data[field])
if not sets:
cur.close()
return False
vals.append(item_id)
cur.execute(f"UPDATE service_catalog SET {', '.join(sets)} WHERE id = %s", vals)
conn.commit()
cur.close()
return True
def delete_service_catalog_item(conn, item_id):
"""Soft-delete a reusable labor concept by setting is_active = false."""
cur = conn.cursor()
cur.execute(
"UPDATE service_catalog SET is_active = false WHERE id = %s", (item_id,)
)
conn.commit()
cur.close()
return True

View File

@@ -204,3 +204,140 @@ def _total_line(label, amount, width):
"""Format a totals line like 'Subtotal: $1,234.56'.""" """Format a totals line like 'Subtotal: $1,234.56'."""
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}' val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
return _format_line(label, val, width) + '\n' return _format_line(label, val, width) + '\n'
def generate_service_order_ticket(so_data, business_info, width=80):
"""Generate ESC/POS bytes for a workshop service order ticket.
Args:
so_data: dict with service order info:
order_number, status, customer_name, vehicle_plate, vehicle_make,
vehicle_model, mileage_in, fuel_level, reception_notes,
employee_name, created_at, items[{name, part_number, quantity,
unit_price}], labor[{description, hours, hourly_rate, total_cost}],
estimated_cost, total
business_info: dict with name, rfc, address
width: 58 or 80 (mm)
Returns: bytes ready to send to printer
"""
chars = 32 if width == 58 else 48
buf = bytearray()
buf += INIT
# Header
buf += ALIGN_CENTER
buf += LARGE_SIZE
buf += (business_info.get("name", "NEXUS POS") + "\n").encode("cp437", errors="replace")
buf += NORMAL_SIZE
if business_info.get("rfc"):
buf += (business_info["rfc"] + "\n").encode("cp437", errors="replace")
if business_info.get("address"):
buf += (business_info["address"] + "\n").encode("cp437", errors="replace")
buf += b"\n"
# Title
buf += BOLD_ON + DOUBLE_HEIGHT
buf += "ORDEN DE SERVICIO\n".encode("cp437", errors="replace")
buf += NORMAL_SIZE + BOLD_OFF
buf += b"\n"
# Order info
buf += ALIGN_LEFT
buf += BOLD_ON
buf += f"Folio: {so_data.get('order_number', 'N/A')}\n".encode("cp437", errors="replace")
buf += BOLD_OFF
buf += f"Estado: {so_data.get('status', '')}\n".encode("cp437", errors="replace")
buf += f"Fecha: {str(so_data.get('created_at', ''))[:19]}\n".encode("cp437", errors="replace")
if so_data.get("employee_name"):
buf += f"Mecanico: {so_data['employee_name']}\n".encode("cp437", errors="replace")
buf += ("-" * chars + "\n").encode()
# Customer / vehicle
if so_data.get("customer_name"):
buf += BOLD_ON
buf += f"Cliente: {so_data['customer_name']}\n".encode("cp437", errors="replace")
buf += BOLD_OFF
vehicle = " ".join(
str(v) for v in [
so_data.get("vehicle_plate", ""),
so_data.get("vehicle_make", ""),
so_data.get("vehicle_model", ""),
] if v
).strip()
if vehicle:
buf += f"Vehiculo: {vehicle}\n".encode("cp437", errors="replace")
if so_data.get("mileage_in"):
buf += f"Kilometraje: {so_data['mileage_in']}\n".encode("cp437", errors="replace")
if so_data.get("fuel_level"):
buf += f"Gasolina: {so_data['fuel_level']}\n".encode("cp437", errors="replace")
buf += ("-" * chars + "\n").encode()
# Reception notes
if so_data.get("reception_notes"):
buf += BOLD_ON
buf += "Falla / Observaciones:\n".encode("cp437", errors="replace")
buf += BOLD_OFF
for line in str(so_data["reception_notes"]).splitlines():
buf += (line[:chars] + "\n").encode("cp437", errors="replace")
buf += ("-" * chars + "\n").encode()
# Parts
items = so_data.get("items", [])
if items:
buf += BOLD_ON
buf += "REFACCIONES\n".encode("cp437", errors="replace")
buf += BOLD_OFF
for item in items:
name = item.get("name", "")[:chars - 10]
part_no = item.get("part_number", "")
qty = item.get("quantity", 1)
unit_price = item.get("unit_price", 0)
line_total = qty * unit_price
buf += f"{qty}x {name}\n".encode("cp437", errors="replace")
if part_no:
buf += f" #{part_no}\n".encode("cp437", errors="replace")
buf += ALIGN_RIGHT
buf += f"${line_total:,.2f}\n".encode("cp437", errors="replace")
buf += ALIGN_LEFT
buf += ("-" * chars + "\n").encode()
# Labor
labor_items = so_data.get("labor", [])
if labor_items:
buf += BOLD_ON
buf += "MANO DE OBRA\n".encode("cp437", errors="replace")
buf += BOLD_OFF
for labor in labor_items:
desc = labor.get("description", "")[:chars - 10]
hours = labor.get("hours", 0)
rate = labor.get("hourly_rate", 0)
total = labor.get("total_cost", hours * rate)
buf += f"{desc}\n".encode("cp437", errors="replace")
buf += f" {hours} hrs x ${rate:,.2f}\n".encode("cp437", errors="replace")
buf += ALIGN_RIGHT
buf += f"${total:,.2f}\n".encode("cp437", errors="replace")
buf += ALIGN_LEFT
buf += ("-" * chars + "\n").encode()
# Totals
buf += ALIGN_RIGHT
if items or labor_items:
total = so_data.get("total") or sum(
i.get("quantity", 1) * i.get("unit_price", 0) for i in items
) + sum(labor.get("total_cost", 0) for labor in labor_items)
buf += BOLD_ON + DOUBLE_HEIGHT
buf += _total_line("TOTAL ESTIMADO:", total, chars).encode("cp437", errors="replace")
buf += NORMAL_SIZE + BOLD_OFF
if so_data.get("estimated_cost"):
buf += _total_line("Costo estimado:", so_data["estimated_cost"], chars).encode("cp437", errors="replace")
# Footer
buf += b"\n"
buf += ALIGN_CENTER
buf += "No es comprobante fiscal\n".encode("cp437", errors="replace")
buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace")
buf += b"\n\n\n"
buf += PARTIAL_CUT
return bytes(buf)

View File

@@ -88,7 +88,8 @@
.breadcrumb__link:hover { color: var(--color-primary); } .breadcrumb__link:hover { color: var(--color-primary); }
.breadcrumb__sep { color: var(--color-text-disabled); } .breadcrumb__sep { color: var(--color-text-disabled); }
.breadcrumb__current { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } .breadcrumb__current { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); }
.breadcrumb__back { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); color: var(--color-text-muted); font-size: var(--text-body-sm); cursor: pointer; transition: var(--transition-fast); }
.breadcrumb__back:hover { background: var(--color-primary-muted); color: var(--color-primary); }
.header-actions { display: flex; align-items: center; gap: var(--space-3); } .header-actions { display: flex; align-items: center; gap: var(--space-3); }
/* ── Catalog mode toggle (OEM / Local) ── */ /* ── Catalog mode toggle (OEM / Local) ── */
@@ -362,13 +363,29 @@
.bodega-table th { text-align: left; font-weight: var(--font-weight-semibold); color: var(--color-text-muted); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: var(--tracking-wider); padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border); } .bodega-table th { text-align: left; font-weight: var(--font-weight-semibold); color: var(--color-text-muted); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: var(--tracking-wider); padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border); }
.bodega-table td { padding: var(--space-2); border-bottom: 1px solid var(--color-border); color: var(--color-text-primary); } .bodega-table td { padding: var(--space-2); border-bottom: 1px solid var(--color-border); color: var(--color-text-primary); }
/* Alternatives list */ /* Alternatives list */
.alt-item { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border); } .alt-item { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border); }
.alt-item:last-child { border-bottom: none; } .alt-item:last-child { border-bottom: none; }
.alt-item__pn { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); font-size: var(--text-body-sm); } .alt-item__pn { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); font-size: var(--text-body-sm); }
.alt-item__mfr { font-size: var(--text-caption); color: var(--color-text-muted); } .alt-item__mfr { font-size: var(--text-caption); color: var(--color-text-muted); }
.alt-item__stock { font-size: var(--text-caption); } .alt-item__stock { font-size: var(--text-caption); }
/* Compatible vehicles pagination */
.compat-pager {
display: flex; align-items: center; justify-content: space-between;
gap: var(--space-2); margin-top: var(--space-4); padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.compat-pager__btn {
font-family: inherit; font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
color: var(--color-text-primary); background: var(--color-surface-2);
border: 1px solid var(--color-border); border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3); cursor: pointer; white-space: nowrap;
}
.compat-pager__btn:hover:not(:disabled) { background: var(--color-surface-3, var(--color-surface-2)); }
.compat-pager__btn:disabled { opacity: 0.4; cursor: not-allowed; }
.compat-pager__info { font-size: var(--text-caption); color: var(--color-text-muted); text-align: center; flex: 1; }
/* Add to cart section */ /* Add to cart section */
.detail-footer { .detail-footer {
padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border); padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border);

769
pos/static/css/workshop.css Normal file
View File

@@ -0,0 +1,769 @@
/* workshop.css — Taller / Service Orders Kanban (design-system aligned) */
/* ═══════════════════════════════════════════════════════════════
BASE RESET & SHELL
═══════════════════════════════════════════════════════════════ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
body {
font-family: var(--font-body);
font-size: var(--text-body-sm);
color: var(--color-text-primary);
background-color: var(--color-bg-base);
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
[data-theme="modern"] body {
background-color: var(--color-bg-base);
background-image: radial-gradient(
circle,
var(--dot-grid-color) 1px,
transparent 1px
);
background-size: var(--dot-grid-size) var(--dot-grid-size);
}
.app-shell {
display: flex;
height: 100vh;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* ═══════════════════════════════════════════════════════════════
PAGE HEADER
═══════════════════════════════════════════════════════════════ */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-6);
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
[data-theme="industrial"] .page-header {
background: var(--color-surface-1);
}
.page-header__title-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.page-header__eyebrow {
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
color: var(--color-text-muted);
}
.page-header__title {
font-family: var(--font-heading);
font-weight: var(--heading-weight-primary);
font-size: var(--text-h4);
letter-spacing: var(--heading-tracking-h4);
color: var(--color-text-primary);
line-height: 1.2;
}
[data-theme="industrial"] .page-header__title {
text-transform: uppercase;
}
.page-header__actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.page-header__subtitle {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
margin-top: var(--space-1);
}
/* ═══════════════════════════════════════════════════════════════
SUMMARY CARDS
═══════════════════════════════════════════════════════════════ */
.summary-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
padding: var(--space-4) var(--space-6);
background: var(--color-bg-base);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
[data-theme="modern"] .summary-strip {
background: transparent;
}
.summary-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-5);
display: flex;
align-items: flex-start;
gap: var(--space-3);
box-shadow: var(--shadow-sm);
transition: var(--transition-normal);
}
.summary-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-strong);
}
[data-theme="industrial"] .summary-card {
border-left: 3px solid var(--color-primary);
}
[data-theme="modern"] .summary-card {
background: var(--color-bg-overlay);
}
.summary-card__icon {
width: 38px;
height: 38px;
border-radius: var(--radius-md);
background: var(--color-primary-muted);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.summary-card__icon svg {
width: 20px;
height: 20px;
stroke: var(--color-primary);
fill: none;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.summary-card__icon--ok {
background: rgba(34, 197, 94, 0.12);
}
.summary-card__icon--ok svg {
stroke: var(--color-success);
}
.summary-card__icon--alert {
background: rgba(239, 68, 68, 0.12);
}
.summary-card__icon--alert svg {
stroke: var(--color-error);
}
.summary-card__body {
flex: 1;
min-width: 0;
}
.summary-card__label {
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.summary-card__value {
font-family: var(--font-heading);
font-weight: var(--heading-weight-primary);
font-size: 1.5rem;
color: var(--color-text-primary);
line-height: 1.1;
}
.summary-card__value--danger {
color: var(--color-error);
}
/* ═══════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
height: 36px;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-wide);
cursor: pointer;
border: 1px solid transparent;
transition: var(--transition-fast);
text-decoration: none;
white-space: nowrap;
}
.btn svg {
width: 15px;
height: 15px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
.btn--primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-border);
}
.btn--primary:hover {
background: var(--btn-primary-bg-hover);
}
.btn--secondary {
background: var(--btn-secondary-bg);
color: var(--btn-secondary-text);
border-color: var(--btn-secondary-border);
}
.btn--secondary:hover {
background: var(--btn-secondary-bg-hover);
}
.btn--ghost {
background: var(--btn-ghost-bg);
color: var(--btn-ghost-text);
border-color: var(--btn-ghost-border);
}
.btn--ghost:hover {
background: var(--color-surface-2);
border-color: var(--color-border-strong);
color: var(--color-text-primary);
}
.btn--sm {
height: 28px;
padding: 0 var(--space-3);
font-size: var(--text-caption);
}
/* ═══════════════════════════════════════════════════════════════
DATA TABLE
═══════════════════════════════════════════════════════════════ */
.table-wrapper {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
[data-theme="modern"] .table-wrapper {
background: var(--color-bg-overlay);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.data-table thead {
position: sticky;
top: 0;
background: var(--color-surface-2);
border-bottom: 1px solid var(--color-border);
z-index: 10;
}
[data-theme="industrial"] .data-table thead {
background: var(--color-surface-3);
}
.data-table th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-text-muted);
white-space: nowrap;
}
.data-table th:first-child { padding-left: var(--space-5); }
.data-table th:last-child { padding-right: var(--space-5); }
.data-table tbody tr {
border-bottom: 1px solid var(--color-border);
transition: background var(--duration-fast) var(--ease-in-out);
}
.data-table tbody tr:last-child {
border-bottom: none;
}
.data-table tbody tr:hover {
background: var(--color-surface-2);
}
.data-table td {
padding: var(--space-3) var(--space-4);
color: var(--color-text-secondary);
vertical-align: middle;
}
.data-table td:first-child { padding-left: var(--space-5); }
.data-table td:last-child { padding-right: var(--space-5); }
.data-table .td--primary {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.data-table .td--mono {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-text-accent);
}
.data-table .td--amount {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
/* ═══════════════════════════════════════════════════════════════
BADGES
═══════════════════════════════════════════════════════════════ */
.badge--reserved {
background: rgba(99, 102, 241, 0.12);
color: #818cf8;
}
.badge--installed {
background: rgba(34, 197, 94, 0.12);
color: var(--color-success);
}
.badge--normal {
background: var(--color-primary-muted);
color: var(--color-primary);
}
.badge--high {
background: rgba(234, 179, 8, 0.12);
color: var(--color-warning);
}
.badge--urgent {
background: rgba(239, 68, 68, 0.12);
color: var(--color-error);
}
/* ═══════════════════════════════════════════════════════════════
FORMS
═══════════════════════════════════════════════════════════════ */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.form-field--span2,
.form-field--span3,
.form-field--span4 {
grid-column: 1 / -1;
}
.form-label {
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.form-input,
.form-select {
padding: var(--space-2) var(--space-3);
background: var(--color-surface-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-body-sm);
outline: none;
transition: var(--transition-fast);
width: 100%;
}
.form-input:focus,
.form-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted);
}
.form-field textarea.form-input {
min-height: 80px;
resize: vertical;
}
/* ═══════════════════════════════════════════════════════════════
MODALS
═══════════════════════════════════════════════════════════════ */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 9000;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-overlay.is-open {
display: flex;
}
.modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 520px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
}
.modal--lg {
width: 720px;
max-width: 90vw;
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.modal__header h2,
.modal__header h3 {
font-family: var(--font-heading);
font-weight: var(--heading-weight-primary);
font-size: var(--text-h5);
color: var(--color-text-primary);
margin: 0;
}
.modal__close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--color-text-muted);
cursor: pointer;
padding: 0 var(--space-1);
line-height: 1;
}
.modal__close:hover {
color: var(--color-text-primary);
}
.modal__body {
padding: var(--space-4) var(--space-5);
overflow-y: auto;
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
/* ═══════════════════════════════════════════════════════════════
KANBAN BOARD
═══════════════════════════════════════════════════════════════ */
.kanban-board {
flex: 1;
display: flex;
gap: var(--space-4);
overflow-x: auto;
padding: var(--space-4) var(--space-6);
min-height: 0;
}
.kanban-column {
flex: 0 0 280px;
display: flex;
flex-direction: column;
max-height: 100%;
}
.kanban-column__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
font-family: var(--font-heading);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
[data-theme="modern"] .kanban-column__header {
background: var(--color-bg-overlay);
}
.kanban-column__count {
background: var(--color-primary);
color: var(--color-text-inverse);
font-size: var(--text-caption);
padding: 2px 8px;
border-radius: var(--radius-full);
}
.kanban-column__body {
flex: 1;
overflow-y: auto;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-md) var(--radius-md);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kanban-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.kanban-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--color-border-strong);
}
[data-theme="modern"] .kanban-card {
background: var(--color-bg-overlay);
}
.kanban-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-2);
gap: var(--space-2);
}
.kanban-card__id {
font-family: var(--font-mono);
font-size: var(--text-caption);
color: var(--color-primary);
font-weight: var(--font-weight-bold);
}
.kanban-card__priority {
font-size: var(--text-caption);
}
.kanban-card__customer {
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-1);
color: var(--color-text-primary);
}
.kanban-card__vehicle {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.kanban-card__meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-caption);
color: var(--color-text-muted);
}
.kanban-card__mechanic {
display: flex;
align-items: center;
gap: var(--space-1);
}
/* ═══════════════════════════════════════════════════════════════
SERVICE ORDER DETAIL
═══════════════════════════════════════════════════════════════ */
.so-detail {
display: grid;
gap: var(--space-4);
}
.so-detail__section {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
}
[data-theme="modern"] .so-detail__section {
background: var(--color-bg-overlay);
}
.so-detail__section h3 {
font-family: var(--font-heading);
font-size: var(--text-body-sm);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
margin-bottom: var(--space-3);
color: var(--color-text-primary);
}
.so-detail__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.so-detail__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.so-detail__label {
font-size: var(--text-caption);
color: var(--color-text-muted);
text-transform: uppercase;
}
.so-detail__value {
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.so-detail__actions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
/* ═══════════════════════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════════════════════ */
@media (max-width: 1024px) {
.summary-strip {
grid-template-columns: repeat(2, 1fr);
}
.kanban-board {
flex-direction: column;
overflow-x: visible;
overflow-y: auto;
}
.kanban-column {
flex: 1 1 auto;
max-height: none;
}
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.page-header__actions {
width: 100%;
flex-wrap: wrap;
}
.summary-strip {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-field--span2,
.form-field--span3,
.form-field--span4 {
grid-column: span 1;
}
.so-detail__grid {
grid-template-columns: 1fr;
}
}

View File

@@ -14,6 +14,7 @@
brandId: null, brandId: null,
model: null, model: null,
modelId: null, modelId: null,
modelVariantIds: null, // array of TecDoc model ids for grouped variants
year: null, year: null,
yearId: null, yearId: null,
engine: null, engine: null,
@@ -51,7 +52,7 @@
return; return;
} }
this.el('brandCatalogOverlay').style.display = 'block'; this.el('brandCatalogOverlay').style.display = 'block';
document.body.style.overflow = 'hidden'; //document.body.style.overflow = 'hidden';
this.loadBrands(); this.loadBrands();
}, },
@@ -60,10 +61,10 @@
document.body.style.overflow = ''; document.body.style.overflow = '';
this.reset(); this.reset();
}, },
reset: function() { reset: function() {
this.state = 'brands'; this.state = 'brands';
this.nav = { brand: null, brandId: null, model: null, modelId: null, year: null, yearId: null, engine: null, myeId: null, category: null, categoryId: null }; this.nav = { brand: null, brandId: null, model: null, modelId: null, modelVariantIds: null, year: null, yearId: null, engine: null, myeId: null, category: null, categoryId: null };
this._allBrands = []; this._allBrands = [];
this._lastItems = []; this._lastItems = [];
this._offset = 0; this._offset = 0;
@@ -96,7 +97,7 @@
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')\'>' + escapeHtml(this.nav.brand) + '</a>'); parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')\'>' + escapeHtml(this.nav.brand) + '</a>');
} }
if (this.nav.model) { if (this.nav.model) {
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')\'>' + escapeHtml(this.nav.model) + '</a>'); parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ',' + JSON.stringify(this.nav.modelVariantIds || [this.nav.modelId]) + ')\'>' + escapeHtml(this.nav.model) + '</a>');
} }
if (this.nav.year) { if (this.nav.year) {
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')\'>' + this.nav.year + '</a>'); parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')\'>' + this.nav.year + '</a>');
@@ -202,7 +203,8 @@
renderModelList: function(models) { renderModelList: function(models) {
var html = '<div class="nav-grid">'; var html = '<div class="nav-grid">';
models.forEach(function(m) { models.forEach(function(m) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')\'>' + var variantIds = (m.variant_ids && m.variant_ids.length) ? m.variant_ids : [m.id_model];
html += '<div class="nav-card" onclick=\'BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ',' + JSON.stringify(variantIds) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(m.display_name || m.name_model) + '</div>' + '<div class="nav-card__name">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
'</div>'; '</div>';
}); });
@@ -210,20 +212,22 @@
this.setContent(html); this.setContent(html);
}, },
selectModel: function(modelId, modelName) { selectModel: function(modelId, modelName, variantIds) {
this.nav.model = modelName; this.nav.model = modelName;
this.nav.modelId = modelId; this.nav.modelId = modelId;
this.loadYears(modelId); this.nav.modelVariantIds = variantIds && variantIds.length ? variantIds : [modelId];
this.loadYears(this.nav.modelVariantIds);
}, },
// ---------- YEARS ---------- // ---------- YEARS ----------
loadYears: function(modelId) { loadYears: function(modelIds) {
this.loading(true); this.loading(true);
this.state = 'years'; this.state = 'years';
this.setSearch(''); this.setSearch('');
this.buildBreadcrumb(); this.buildBreadcrumb();
var self = this; var self = this;
fetch('/pos/api/catalog/years?model_id=' + encodeURIComponent(modelId), { headers: this._headers() }) var modelIdParam = Array.isArray(modelIds) ? modelIds.join(',') : String(modelIds);
fetch('/pos/api/catalog/years?model_id=' + encodeURIComponent(modelIdParam), { headers: this._headers() })
.then(function(r) { .then(function(r) {
if (!self._checkAuth(r)) return null; if (!self._checkAuth(r)) return null;
return r.json(); return r.json();
@@ -258,17 +262,18 @@
selectYear: function(yearId, yearCar) { selectYear: function(yearId, yearCar) {
this.nav.year = yearCar; this.nav.year = yearCar;
this.nav.yearId = yearId; this.nav.yearId = yearId;
this.loadEngines(this.nav.modelId, yearId); this.loadEngines(this.nav.modelVariantIds || [this.nav.modelId], yearId);
}, },
// ---------- ENGINES ---------- // ---------- ENGINES ----------
loadEngines: function(modelId, yearId) { loadEngines: function(modelIds, yearId) {
this.loading(true); this.loading(true);
this.state = 'engines'; this.state = 'engines';
this.setSearch(''); this.setSearch('');
this.buildBreadcrumb(); this.buildBreadcrumb();
var self = this; var self = this;
fetch('/pos/api/catalog/engines?model_id=' + encodeURIComponent(modelId) + '&year_id=' + encodeURIComponent(yearId), { headers: this._headers() }) var modelIdParam = Array.isArray(modelIds) ? modelIds.join(',') : String(modelIds);
fetch('/pos/api/catalog/engines?model_id=' + encodeURIComponent(modelIdParam) + '&year_id=' + encodeURIComponent(yearId), { headers: this._headers() })
.then(function(r) { .then(function(r) {
if (!self._checkAuth(r)) return null; if (!self._checkAuth(r)) return null;
return r.json(); return r.json();
@@ -292,8 +297,9 @@
renderEngineList: function(engines) { renderEngineList: function(engines) {
var html = '<div class="nav-grid">'; var html = '<div class="nav-grid">';
engines.forEach(function(e) { engines.forEach(function(e) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')\'>' + var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
'<div class="nav-card__name">' + escapeHtml(e.name_engine) + '</div>' + html += '<div class="nav-card" onclick=\'BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(name) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(name) + '</div>' +
'<div class="nav-card__sub">' + escapeHtml(e.trim_level || '') + '</div>' + '<div class="nav-card__sub">' + escapeHtml(e.trim_level || '') + '</div>' +
'</div>'; '</div>';
}); });

View File

@@ -276,7 +276,20 @@
if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null }); if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null });
else if (nav.partType) parts.push({ label: nav.partType.name, action: null }); else if (nav.partType) parts.push({ label: nav.partType.name, action: null });
//Botón para retroceder
var backAction = null;
if (parts.length > 1) {
var prev = parts[parts.length - 2];
backAction = prev ? prev.action : 'loadBrands';
}
var html = ''; var html = '';
//Botón para retroceder
if (backAction) {
html += '<button class="breadcrumb__back" data-bc-action="' + backAction + '">&#8592; Atr&aacute;s</button>';
html += '<span class="breadcrumb__sep" aria-hidden="true">|</span>';
}
//--------
for (var i = 0; i < parts.length; i++) { for (var i = 0; i < parts.length; i++) {
if (i > 0) html += '<span class="breadcrumb__sep" aria-hidden="true">/</span>'; if (i > 0) html += '<span class="breadcrumb__sep" aria-hidden="true">/</span>';
if (i < parts.length - 1 && parts[i].action) { if (i < parts.length - 1 && parts[i].action) {
@@ -303,6 +316,7 @@
else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); } else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); }
}); });
}); });
} }
function resetNav() { function resetNav() {
@@ -427,6 +441,12 @@
}); });
} }
function modelIdsParam(model) {
if (!model) return '';
if (model.variant_ids && model.variant_ids.length) return model.variant_ids.join(',');
return String(model.id);
}
function loadModels() { function loadModels() {
nav.level = 'models'; nav.level = 'models';
pushNavState(); pushNavState();
@@ -440,14 +460,15 @@
if (!data || !data.data || !data.data.length) { showEmpty('Sin modelos', 'No hay modelos con partes para ' + nav.brand.name); return; } if (!data || !data.data || !data.data.length) { showEmpty('Sin modelos', 'No hay modelos con partes para ' + nav.brand.name); return; }
navGrid.className = 'nav-grid'; navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (m) { navGrid.innerHTML = data.data.map(function (m) {
return '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-name="' + esc(m.display_name || m.name_model) + '">' + return '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-variant-ids="' + esc((m.variant_ids || [m.id_model]).join(',')) + '" data-name="' + esc(m.display_name || m.name_model) + '">' +
'<div class="nav-card__name">' + esc(m.display_name || m.name_model) + '</div>' + '<div class="nav-card__name">' + esc(m.display_name || m.name_model) + '</div>' +
'</div>'; '</div>';
}).join(''); }).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) { navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () { card.addEventListener('click', function () {
nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name }; var variantIds = (this.dataset.variantIds || this.dataset.modelId).split(',').map(function(x){ return parseInt(x); });
nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name, variant_ids: variantIds };
loadYears(); loadYears();
}); });
}); });
@@ -462,7 +483,7 @@
setupLevelFilter(false); setupLevelFilter(false);
showLoading(); showLoading();
apiFetch(API + '/years?model_id=' + nav.model.id).then(function (data) { apiFetch(API + '/years?model_id=' + modelIdsParam(nav.model)).then(function (data) {
hideLoading(); hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin anios', 'No hay anios con partes para este modelo.'); return; } if (!data || !data.data || !data.data.length) { showEmpty('Sin anios', 'No hay anios con partes para este modelo.'); return; }
navGrid.className = 'nav-grid nav-grid--years'; navGrid.className = 'nav-grid nav-grid--years';
@@ -489,23 +510,30 @@
setupLevelFilter(false); setupLevelFilter(false);
showLoading(); showLoading();
apiFetch(API + '/engines?model_id=' + nav.model.id + '&year_id=' + nav.year.id).then(function (data) { apiFetch(API + '/engines?model_id=' + modelIdsParam(nav.model) + '&year_id=' + nav.year.id).then(function (data) {
hideLoading(); hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin motores', 'No hay configuraciones de motor para esta combinacion.'); return; } if (!data || !data.data || !data.data.length) { showEmpty('Sin motores', 'No hay configuraciones de motor para esta combinacion.'); return; }
// Helper: avoid showing raw "N/A" as engine name
function engineLabel(e) {
var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
return name + (e.trim_level ? ' — ' + e.trim_level : '');
}
// If only one engine, auto-select it // If only one engine, auto-select it
if (data.data.length === 1) { if (data.data.length === 1) {
var e = data.data[0]; var e = data.data[0];
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') }; nav.engine = { id_mye: e.id_mye, name: engineLabel(e) };
loadCategoriesForMode(); loadCategoriesForMode();
return; return;
} }
navGrid.className = 'nav-grid'; navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (e) { navGrid.innerHTML = data.data.map(function (e) {
var label = e.name_engine + (e.trim_level ? ' — ' + e.trim_level : ''); var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
var label = name + (e.trim_level ? ' — ' + e.trim_level : '');
return '<div class="nav-card" role="listitem" data-mye-id="' + e.id_mye + '" data-name="' + esc(label) + '">' + return '<div class="nav-card" role="listitem" data-mye-id="' + e.id_mye + '" data-name="' + esc(label) + '">' +
'<div class="nav-card__name">' + esc(e.name_engine) + '</div>' + '<div class="nav-card__name">' + esc(name) + '</div>' +
(e.trim_level ? '<div class="nav-card__sub">' + esc(e.trim_level) + '</div>' : '') + (e.trim_level ? '<div class="nav-card__sub">' + esc(e.trim_level) + '</div>' : '') +
'</div>'; '</div>';
}).join(''); }).join('');
@@ -1258,10 +1286,8 @@
html += '</div>'; html += '</div>';
} }
// Compatibilities — deduplicate by (make, model, year, engine) // Compatibilities — deduplicate by (make, model, year, engine)
if (p.compatibilities && p.compatibilities.length) { if (p.compatibilities && p.compatibilities.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Vehiculos compatibles</div>';
var seenCompat = {}; var seenCompat = {};
var uniqCompat = []; var uniqCompat = [];
p.compatibilities.forEach(function(c) { p.compatibilities.forEach(function(c) {
@@ -1270,22 +1296,69 @@
seenCompat[key] = true; seenCompat[key] = true;
uniqCompat.push(c); uniqCompat.push(c);
}); });
var currentMake = ''; html += '<div class="detail-section">';
uniqCompat.forEach(function(c) { html += '<div class="detail-section__title">Vehiculos compatibles</div>';
if (c.make !== currentMake) { html += '<div id="compat-list"></div>';
currentMake = c.make; html += '<div id="compat-pager" class="compat-pager"></div>';
html += '<div style="font-weight:600;margin-top:8px;">' + esc(c.make) + '</div>';
}
html += '<div style="padding-left:12px;color:var(--color-text-muted);font-size:var(--text-body-sm);">' +
esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '</div>';
});
html += '</div>'; html += '</div>';
compatFullList = uniqCompat;
} else {
compatFullList = [];
} }
detailBody.innerHTML = html; detailBody.innerHTML = html;
if (compatFullList.length) {
renderCompatPage(1);
}
}); });
} }
// --- Pagination for "Vehiculos compatibles" ---
var compatFullList = [];
var compatPageSize = 15;
var compatCurrentPage = 1;
function renderCompatPage(page) {
var listEl = document.getElementById('compat-list');
var pagerEl = document.getElementById('compat-pager');
if (!listEl || !pagerEl) return;
var totalItems = compatFullList.length;
var totalPages = Math.max(1, Math.ceil(totalItems / compatPageSize));
page = Math.min(Math.max(1, page), totalPages);
compatCurrentPage = page;
var start = (page - 1) * compatPageSize;
var pageItems = compatFullList.slice(start, start + compatPageSize);
var listHtml = '';
var currentMake = '';
pageItems.forEach(function(c) {
if (c.make !== currentMake) {
currentMake = c.make;
listHtml += '<div style="font-weight:600;margin-top:8px;">' + esc(c.make) + '</div>';
}
listHtml += '<div style="padding-left:12px;color:var(--color-text-muted);font-size:var(--text-body-sm);">' +
esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '</div>';
});
listEl.innerHTML = listHtml;
if (totalPages > 1) {
pagerEl.innerHTML =
'<button type="button" id="compat-prev" class="compat-pager__btn"' + (page <= 1 ? ' disabled' : '') + '>&lsaquo; Anterior</button>' +
'<span class="compat-pager__info">Pagina ' + page + ' de ' + totalPages + ' (' + totalItems + ' vehiculos)</span>' +
'<button type="button" id="compat-next" class="compat-pager__btn"' + (page >= totalPages ? ' disabled' : '') + '>Siguiente &rsaquo;</button>';
var prevBtn = document.getElementById('compat-prev');
var nextBtn = document.getElementById('compat-next');
if (prevBtn) prevBtn.addEventListener('click', function () { renderCompatPage(compatCurrentPage - 1); });
if (nextBtn) nextBtn.addEventListener('click', function () { renderCompatPage(compatCurrentPage + 1); });
} else {
pagerEl.innerHTML = '';
}
}
function closeDetail() { function closeDetail() {
detailPanel.classList.remove('is-open'); detailPanel.classList.remove('is-open');
detailOverlay.classList.remove('is-visible'); detailOverlay.classList.remove('is-visible');
@@ -1788,20 +1861,22 @@
}); });
} }
function vsYearChanged() { function vsYearChanged() {
var yearId = vsYear.value; var yearId = vsYear.value;
vsBrand.innerHTML = '<option value="">Marca...</option>'; vsClear.style.display = (yearId || vsBrand.value || vsModel.value) ? '' : 'none';
vsModel.innerHTML = '<option value="">Modelo...</option>';
vsEngine.innerHTML = '<option value="">Motor...</option>';
vsBrand.disabled = true;
vsModel.disabled = true;
vsEngine.disabled = true;
vsClear.style.display = yearId ? '' : 'none';
if (!yearId) return; // Resetear marca solo si no hay nada seleccionado en ella
// Load brands filtered by year
vsBrand.disabled = false; vsBrand.disabled = false;
vsEngine.innerHTML = '<option value="">Motor...</option>';
vsEngine.disabled = true;
if (!yearId) {
//carga todo
vsLoadAllBrands();
return;
}
// Filtrar marcas por año
apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) { apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) {
var brands = data.data || data; var brands = data.data || data;
if (!brands) return; if (!brands) return;
@@ -1809,54 +1884,99 @@
brands.map(function (b) { brands.map(function (b) {
return '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>'; return '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>';
}).join(''); }).join('');
// si hay marca seleccionada despliega modelos
if (vsBrand.value) vsBrandChanged();
});
}
function vsLoadAllBrands() {
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
var brands = data.data || data;
if (!brands) return;
var current = vsBrand.value;
vsBrand.innerHTML = '<option value="">Marca...</option>' +
brands.map(function (b) {
return '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>';
}).join('');
if (current) vsBrand.value = current;
}); });
} }
function vsBrandChanged() { function vsBrandChanged() {
var brandId = vsBrand.value; var brandId = vsBrand.value;
var yearId = vsYear.value; var yearId = vsYear.value;
vsClear.style.display = (yearId || brandId) ? '' : 'none';
vsModel.innerHTML = '<option value="">Modelo...</option>'; vsModel.innerHTML = '<option value="">Modelo...</option>';
vsEngine.innerHTML = '<option value="">Motor...</option>'; vsEngine.innerHTML = '<option value="">Motor...</option>';
vsModel.disabled = true; vsModel.disabled = false; // cambio
vsEngine.disabled = true; vsEngine.disabled = true;
if (!brandId) return; if (!brandId) {
vsModel.disabled = true;
return;
}
// Load models filtered by brand AND year // Cargar modelos con o sin año
vsModel.disabled = false;
apiFetch(API + '/models?brand_id=' + brandId + (yearId ? '&year_id=' + yearId : '')).then(function (data) { apiFetch(API + '/models?brand_id=' + brandId + (yearId ? '&year_id=' + yearId : '')).then(function (data) {
var models = data.data || data; var models = data.data || data;
if (!models) return; if (!models) return;
vsModel.innerHTML = '<option value="">Modelo...</option>' + vsModel.innerHTML = '<option value="">Modelo...</option>' +
models.map(function (m) { models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>'; var variants = (m.variant_ids || [m.id_model]).join(',');
return '<option value="' + m.id_model + '" data-variant-ids="' + esc(variants) + '">' + esc(m.display_name || m.name_model) + '</option>';
}).join(''); }).join('');
// si hay modelo seleccionado despliega año
if (vsModel.value) vsModelChanged();
}); });
} }
function vsModelChanged() { function vsModelChanged() {
var modelId = vsModel.value; var modelId = vsModel.value;
var yearVal = vsYear.value; var yearVal = vsYear.value;
var selectedOption = vsModel.options[vsModel.selectedIndex];
var variantIds = selectedOption && selectedOption.dataset.variantIds
? selectedOption.dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: (modelId ? [parseInt(modelId)] : []);
vsEngine.innerHTML = '<option value="">Motor...</option>'; vsEngine.innerHTML = '<option value="">Motor...</option>';
vsEngine.disabled = true; vsEngine.disabled = true;
if (!modelId || !yearVal) return; if (!modelId || !variantIds.length) return;
vsEngine.disabled = false; // Si hay año carga motores usando todas las variantes del modelo
apiFetch(API + '/engines?model_id=' + modelId + '&year_id=' + yearVal).then(function (data) { if (yearVal) {
var engines = data.data || data; vsEngine.disabled = false;
if (!engines) return; apiFetch(API + '/engines?model_id=' + variantIds.join(',') + '&year_id=' + yearVal).then(function (data) {
vsEngine.innerHTML = '<option value="">Motor...</option>' + var engines = data.data || data;
engines.map(function (e) { if (!engines) return;
var label = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); vsEngine.innerHTML = '<option value="">Motor...</option>' +
return '<option value="' + e.id_mye + '">' + esc(label) + '</option>'; engines.map(function (e) {
}).join(''); var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
// If only 1 engine, auto-select var label = name + (e.trim_level ? ' (' + e.trim_level + ')' : '');
if (engines.length === 1) { return '<option value="' + e.id_mye + '">' + esc(label) + '</option>';
vsEngine.value = engines[0].id_mye; }).join('');
vsEngineChanged(); if (engines.length === 1) {
} vsEngine.value = engines[0].id_mye;
}); vsEngineChanged();
}
});
} else {
// Sin año: cargar categorías a nivel modelo
var brandId = vsBrand.value;
if (!brandId) return;
nav.brand = { id: parseInt(brandId), name: vsBrand.options[vsBrand.selectedIndex].text };
nav.model = { id: parseInt(modelId), name: vsModel.options[vsModel.selectedIndex].text };
nav.year = null;
nav.engine = null;
nav.level = 'categories';
pushNavState();
loadCategoriesForMode();
setTimeout(function () {
var body = document.getElementById('pageBody');
if (body) body.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 300);
}
} }
function vsEngineChanged() { function vsEngineChanged() {
@@ -1869,8 +1989,12 @@
var modelText = vsModel.options[vsModel.selectedIndex].text; var modelText = vsModel.options[vsModel.selectedIndex].text;
var engineText = vsEngine.options[vsEngine.selectedIndex].text; var engineText = vsEngine.options[vsEngine.selectedIndex].text;
var selectedModelOption = vsModel.options[vsModel.selectedIndex];
var modelVariantIds = selectedModelOption && selectedModelOption.dataset.variantIds
? selectedModelOption.dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: [parseInt(vsModel.value)];
nav.brand = { id: parseInt(vsBrand.value), name: brandText }; nav.brand = { id: parseInt(vsBrand.value), name: brandText };
nav.model = { id: parseInt(vsModel.value), name: modelText }; nav.model = { id: parseInt(vsModel.value), name: modelText, variant_ids: modelVariantIds };
nav.year = { id: parseInt(vsYear.value), year: yearText }; nav.year = { id: parseInt(vsYear.value), year: yearText };
nav.engine = { id_mye: parseInt(myeId), name: engineText }; nav.engine = { id_mye: parseInt(myeId), name: engineText };
nav.level = 'categories'; nav.level = 'categories';
@@ -2058,19 +2182,35 @@
if (!models) return; if (!models) return;
vsModel.innerHTML = '<option value="">Modelo...</option>' + vsModel.innerHTML = '<option value="">Modelo...</option>' +
models.map(function (m) { models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>'; var variants = (m.variant_ids || [m.id_model]).join(',');
return '<option value="' + m.id_model + '" data-variant-ids="' + esc(variants) + '">' + esc(m.display_name || m.name_model) + '</option>';
}).join(''); }).join('');
vsModel.disabled = false; vsModel.disabled = false;
if (match.model_id) { if (match.model_id) {
vsModel.value = String(match.model_id); // The VIN match may point to a variant that is now grouped under
// a canonical model; select the option whose variants include it.
var matchedOption = Array.from(vsModel.options).find(function (opt) {
if (!opt.value) return false;
var vids = (opt.dataset.variantIds || opt.value).split(',').map(function(x){ return parseInt(x); });
return vids.indexOf(match.model_id) !== -1;
});
if (matchedOption) {
vsModel.value = matchedOption.value;
} else {
vsModel.value = String(match.model_id);
}
var selectedVariantIds = vsModel.selectedIndex >= 0 && vsModel.options[vsModel.selectedIndex].dataset.variantIds
? vsModel.options[vsModel.selectedIndex].dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: [match.model_id];
// Load engines // Load engines
apiFetch(API + '/engines?model_id=' + match.model_id + '&year_id=' + match.year_id).then(function (engData) { apiFetch(API + '/engines?model_id=' + selectedVariantIds.join(',') + '&year_id=' + match.year_id).then(function (engData) {
var engines = engData && (engData.data || engData); var engines = engData && (engData.data || engData);
if (!engines) return; if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' + vsEngine.innerHTML = '<option value="">Motor...</option>' +
engines.map(function (e) { engines.map(function (e) {
var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); var ename = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
var elabel = ename + (e.trim_level ? ' (' + e.trim_level + ')' : '');
return '<option value="' + e.id_mye + '">' + esc(elabel) + '</option>'; return '<option value="' + e.id_mye + '">' + esc(elabel) + '</option>';
}).join(''); }).join('');
vsEngine.disabled = false; vsEngine.disabled = false;
@@ -2148,6 +2288,31 @@
} }
} }
async function downloadPriceTemplate() {
try {
var res = await fetch('/pos/api/supplier-catalog/prices/template', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) {
var data = await res.json().catch(function() { return {}; });
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">' + esc(data.error || 'Error al descargar plantilla') + '</span>';
return;
}
var blob = await res.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'supplier_prices_template.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-success);">✓ Plantilla descargada.</span>';
} catch (e) {
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(e.message) + '</span>';
}
}
function shouldShowUploadPricesButton() { function shouldShowUploadPricesButton() {
try { try {
var user = JSON.parse(localStorage.getItem('pos_employee') || '{}'); var user = JSON.parse(localStorage.getItem('pos_employee') || '{}');
@@ -2180,6 +2345,7 @@
openUploadPricesModal: openUploadPricesModal, openUploadPricesModal: openUploadPricesModal,
closeUploadPricesModal: closeUploadPricesModal, closeUploadPricesModal: closeUploadPricesModal,
submitUploadPrices: submitUploadPrices, submitUploadPrices: submitUploadPrices,
downloadPriceTemplate: downloadPriceTemplate,
}; };
// ─── INIT ─── // ─── INIT ───

View File

@@ -55,9 +55,25 @@ const Config = (() => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Theme switcher // Theme switcher
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function setTheme(theme) { /*function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {} try { localStorage.setItem('pos_theme', theme); } catch(e) {}
document.querySelectorAll('.theme-btn').forEach(function(btn) {
btn.classList.toggle('is-active', btn.dataset.themeTarget === theme);
});
document.querySelectorAll('.theme-option').forEach(function(opt) {
opt.classList.remove('is-selected');
});
var idx = theme === 'industrial' ? 0 : 1;
var opts = document.querySelectorAll('.theme-option');
if (opts[idx]) opts[idx].classList.add('is-selected');
}
window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
document.querySelectorAll('.theme-btn').forEach(function(btn) { document.querySelectorAll('.theme-btn').forEach(function(btn) {
btn.classList.toggle('is-active', btn.dataset.themeTarget === theme); btn.classList.toggle('is-active', btn.dataset.themeTarget === theme);
@@ -70,7 +86,6 @@ const Config = (() => {
var opts = document.querySelectorAll('.theme-option'); var opts = document.querySelectorAll('.theme-option');
if (opts[idx]) opts[idx].classList.add('is-selected'); if (opts[idx]) opts[idx].classList.add('is-selected');
} }
window.setTheme = setTheme;
function selectThemeOption(theme) { function selectThemeOption(theme) {
setTheme(theme); setTheme(theme);
@@ -802,7 +817,7 @@ const Config = (() => {
// Restore theme // Restore theme
try { try {
var saved = localStorage.getItem('nexus-theme'); var saved = localStorage.getItem('pos_theme');
if (saved === 'industrial' || saved === 'modern') { if (saved === 'industrial' || saved === 'modern') {
setTheme(saved); setTheme(saved);
} }

View File

@@ -107,7 +107,7 @@ const Customers = (() => {
const num = String(c.id).padStart(5, '0'); const num = String(c.id).padStart(5, '0');
const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : ''; const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : '';
const isChecked = selectedCustomers.has(c.id) ? 'checked' : ''; const isChecked = selectedCustomers.has(c.id) ? 'checked' : '';
return '<tr class="' + selClass + '">' + return '<tr class="' + selClass + '" onclick="Customers.selectCustomer(' + c.id + ')">' +
'<td onclick="event.stopPropagation();"><input type="checkbox" ' + isChecked + ' onchange="Customers.toggleCustomerSelection(' + c.id + ')"></td>' + '<td onclick="event.stopPropagation();"><input type="checkbox" ' + isChecked + ' onchange="Customers.toggleCustomerSelection(' + c.id + ')"></td>' +
'<td class="cell-num">' + num + '</td>' + '<td class="cell-num">' + num + '</td>' +
'<td>' + '<td>' +
@@ -121,6 +121,7 @@ const Customers = (() => {
'<td class="cell-credit ' + creditClass + '">' + fmt(available) + '</td>' + '<td class="cell-credit ' + creditClass + '">' + fmt(available) + '</td>' +
'<td class="cell-date hide-mobile">' + formatDate(c.last_purchase || c.created_at) + '</td>' + '<td class="cell-date hide-mobile">' + formatDate(c.last_purchase || c.created_at) + '</td>' +
'<td>' + statusBadge(c) + '</td>' + '<td>' + statusBadge(c) + '</td>' +
'<td onclick="event.stopPropagation();"><button class="btn btn-sm btn-ghost" title="Editar cliente" onclick="Customers.editCustomer(' + c.id + ')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button></td>' +
'</tr>'; '</tr>';
} }
@@ -131,7 +132,7 @@ const Customers = (() => {
if (!tbody) return; if (!tbody) return;
if (!customers || customers.length === 0) { if (!customers || customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="9">' + renderEmptyState({ tbody.innerHTML = '<tr><td colspan="11">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
title: 'Sin clientes', title: 'Sin clientes',
subtitle: 'No se encontraron clientes registrados.', subtitle: 'No se encontraron clientes registrados.',
@@ -146,7 +147,7 @@ const Customers = (() => {
rowHeight: 52, rowHeight: 52,
buffer: 3, buffer: 3,
renderRow: renderCustomerRow, renderRow: renderCustomerRow,
emptyHtml: '<tr><td colspan="9">' + renderEmptyState({ title: 'Sin clientes', subtitle: 'No hay clientes registrados.' }) + '</td></tr>' emptyHtml: '<tr><td colspan="11">' + renderEmptyState({ title: 'Sin clientes', subtitle: 'No hay clientes registrados.' }) + '</td></tr>'
}); });
} }
customersVS.setData(customers); customersVS.setData(customers);
@@ -415,9 +416,22 @@ const Customers = (() => {
function editCurrent() { function editCurrent() {
if (!currentCustomer) return; if (!currentCustomer) return;
const c = currentCustomer; openEditModal(currentCustomer);
}
async function editCustomer(id) {
try {
const c = await api(`/pos/api/customers/${id}`);
currentCustomer = c;
openEditModal(c);
} catch (e) {
alert('Error: ' + e.message);
}
}
function openEditModal(c) {
const modal = document.getElementById('customerModal'); const modal = document.getElementById('customerModal');
if (!modal) return; if (!modal || !c) return;
document.getElementById('modalTitle').textContent = 'Editar Cliente'; document.getElementById('modalTitle').textContent = 'Editar Cliente';
document.getElementById('editId').value = c.id; document.getElementById('editId').value = c.id;
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; }; const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
@@ -802,7 +816,7 @@ const Customers = (() => {
const publicApi = { const publicApi = {
search, goToPage, loadCustomers, search, goToPage, loadCustomers,
showDetail, selectCustomer, closeDetail, showDetail, selectCustomer, closeDetail,
showCreateModal, editCurrent, closeModal, save, showCreateModal, editCurrent, editCustomer, closeModal, save,
showStatement, closeStatement, showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment, showPaymentModal, closePayment, recordPayment,
}; };

View File

@@ -46,15 +46,23 @@ const Dashboard = (() => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Theme switcher // Theme switcher
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function setTheme(theme) { /*function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {} try { localStorage.setItem('pos_theme', theme); } catch(e) {}
const btnInd = document.getElementById('btn-industrial');
const btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
}
window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
const btnInd = document.getElementById('btn-industrial'); const btnInd = document.getElementById('btn-industrial');
const btnMod = document.getElementById('btn-modern'); const btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial'); if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('active', theme === 'modern'); if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
} }
window.setTheme = setTheme;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Sidebar toggle (mobile) // Sidebar toggle (mobile)
@@ -689,7 +697,7 @@ const Dashboard = (() => {
// Restore theme // Restore theme
try { try {
const saved = localStorage.getItem('nexus-theme'); const saved = localStorage.getItem('pos_theme');
if (saved === 'industrial' || saved === 'modern') { if (saved === 'industrial' || saved === 'modern') {
setTheme(saved); setTheme(saved);
} }

View File

@@ -329,6 +329,73 @@ const Invoicing = (() => {
} }
} }
function resetCsdForm() {
document.getElementById('csd-form').reset();
document.getElementById('csd-cer-label').textContent = 'Subir certificado .cer';
document.getElementById('csd-key-label').textContent = 'Subir llave privada .key';
}
function updateFileLabels() {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
if (cer && cer.files.length) {
document.getElementById('csd-cer-label').textContent = cer.files[0].name;
}
if (key && key.files.length) {
document.getElementById('csd-key-label').textContent = key.files[0].name;
}
}
async function uploadCsd(btn) {
if (!btn) return;
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
const password = document.getElementById('contrasena-csd').value.trim();
if (!cer || !cer.files.length || !key || !key.files.length) {
alert('Selecciona el archivo .cer y .key');
return;
}
if (!password) {
alert('Escribe la contraseña del CSD');
return;
}
const formData = new FormData();
formData.append('certificate', cer.files[0]);
formData.append('private_key', key.files[0]);
formData.append('password', password);
btn.disabled = true;
const originalText = btn.innerHTML;
btn.textContent = 'Subiendo...';
try {
const res = await fetch(`${API}/facturapi/csd`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token()}` },
body: formData,
});
const data = await res.json().catch(() => ({ error: res.statusText }));
if (!res.ok) throw new Error(data.error || 'Upload failed');
alert('CSD actualizado correctamente');
resetCsdForm();
loadFacturapiStatus();
} catch (e) {
alert('Error al subir CSD: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// Wire file input change listeners
setTimeout(() => {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
if (cer) cer.addEventListener('change', updateFileLabels);
if (key) key.addEventListener('change', updateFileLabels);
}, 0);
// ---- Detail modal (uses modalDetalleOverlay) ---- // ---- Detail modal (uses modalDetalleOverlay) ----
async function showDetail(cfdiId) { async function showDetail(cfdiId) {
const overlay = document.getElementById('modalDetalleOverlay'); const overlay = document.getElementById('modalDetalleOverlay');
@@ -612,6 +679,7 @@ const Invoicing = (() => {
showDetail, showCancelModal, confirmCancel, processQueue, showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder, showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi, openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm,
}; };
// Register Cmd+K items // Register Cmd+K items
if (typeof registerCmdKItem === "function") { if (typeof registerCmdKItem === "function") {

View File

@@ -134,6 +134,31 @@ window.NexusPrinter = (function () {
return sendRaw(new Uint8Array(buf)); return sendRaw(new Uint8Array(buf));
} }
/**
* Print a workshop service order ticket.
* @param {number} soId
* @param {number} [width=80] — 58 or 80 mm
* @returns {Promise<boolean>}
*/
async function printServiceOrder(soId, width) {
width = width || 80;
const token = localStorage.getItem('pos_token');
const resp = await fetch('/pos/api/service-orders/' + soId + '/print', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ printer_type: 'escpos_raw', width: width })
});
if (!resp.ok) {
console.error('[NexusPrinter] backend error', resp.status);
return false;
}
const buf = await resp.arrayBuffer();
return sendRaw(new Uint8Array(buf));
}
// ── Persistence helpers ──────────────────────── // ── Persistence helpers ────────────────────────
function _save() { function _save() {
@@ -147,6 +172,7 @@ window.NexusPrinter = (function () {
disconnect: disconnect, disconnect: disconnect,
isConnected: isConnected, isConnected: isConnected,
sendRaw: sendRaw, sendRaw: sendRaw,
printSale: printSale printSale: printSale,
printServiceOrder: printServiceOrder
}; };
})(); })();

View File

@@ -59,15 +59,23 @@ const Reports = (() => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Theme switcher // Theme switcher
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function setTheme(theme) { /* function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {} try { localStorage.setItem('pos_theme', theme); } catch(e) {}
var btnInd = document.getElementById('btn-industrial');
var btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
}
window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
var btnInd = document.getElementById('btn-industrial'); var btnInd = document.getElementById('btn-industrial');
var btnMod = document.getElementById('btn-modern'); var btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial'); if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern'); if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
} }
window.setTheme = setTheme;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Tab switcher with lazy loading // Tab switcher with lazy loading
@@ -745,7 +753,7 @@ const Reports = (() => {
// Restore theme // Restore theme
try { try {
var saved = localStorage.getItem('nexus-theme') || 'industrial'; var saved = localStorage.getItem('pos_theme') || 'industrial';
setTheme(saved); setTheme(saved);
} catch(e) {} } catch(e) {}

View File

@@ -40,6 +40,7 @@ window.renderSidebar = function(modulesOverride) {
].filter(Boolean)}, ].filter(Boolean)},
{ label: _t('nav_management'), items: [ { label: _t('nav_management'), items: [
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' }, { name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' }, { name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null, moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null, moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,

537
pos/static/js/workshop.js Normal file
View File

@@ -0,0 +1,537 @@
/**
* workshop.js — Taller / Service Orders Kanban for Nexus POS
*/
var Workshop = (function() {
'use strict';
var API = '/pos/api/service-orders';
var token = localStorage.getItem('pos_token');
var orders = [];
var catalog = [];
var customers = [];
var vehicles = [];
var employees = [];
var currentOrderId = null;
var COLUMNS = [
{key: 'received', label: 'Recibido'},
{key: 'diagnosis', label: 'Diagnóstico'},
{key: 'waiting_parts', label: 'Espera refacciones'},
{key: 'repair', label: 'En reparación'},
{key: 'quality_check', label: 'Control calidad'},
{key: 'ready', label: 'Listo'},
{key: 'delivered', label: 'Entregado'},
];
function headers() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
}
function fmt(n) {
if (n == null) return '0';
return parseFloat(n).toLocaleString('es-MX');
}
function fmtMoney(n) {
if (n == null) return '$0.00';
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function fmtDate(d) {
if (!d) return '—';
var dt = new Date(d);
return dt.toLocaleDateString('es-MX', {day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'});
}
function esc(s) {
if (!s) return '';
var el = document.createElement('div');
el.textContent = s;
return el.innerHTML;
}
function api(method, url, body) {
var opts = {method: method, headers: headers()};
if (body) opts.body = JSON.stringify(body);
return fetch(API + url, opts).then(function(r) {
return r.json().then(function(data) {
if (!r.ok) throw new Error(data.error || r.statusText);
return data;
});
});
}
// ─── Init ───
function init() {
loadSummary();
loadOrders();
loadCatalog();
loadReferenceData();
}
// ─── Summary / Kanban ───
function loadSummary() {
fetch(API + '/kanban/summary', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
document.getElementById('statReceived').textContent = fmt(d.received || 0);
document.getElementById('statRepair').textContent = fmt((d.repair || 0) + (d.diagnosis || 0) + (d.waiting_parts || 0) + (d.quality_check || 0));
document.getElementById('statReady').textContent = fmt(d.ready || 0);
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
})
.catch(function() {});
}
function loadOrders() {
fetch(API + '?per_page=200', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
orders = d.data || [];
renderKanban();
})
.catch(function(e) {
console.error(e);
document.getElementById('kanbanBoard').innerHTML = '<div class="empty-state"><div class="empty-state__title">Error cargando órdenes</div><div class="empty-state__subtitle">No se pudieron cargar las órdenes de servicio.</div></div>';
});
}
function renderKanban() {
var board = document.getElementById('kanbanBoard');
board.innerHTML = '';
COLUMNS.forEach(function(col) {
var colOrders = orders.filter(function(o) { return o.status === col.key; });
var colEl = document.createElement('div');
colEl.className = 'kanban-column';
colEl.innerHTML =
'<div class="kanban-column__header">' +
' <span>' + esc(col.label) + '</span>' +
' <span class="kanban-column__count">' + colOrders.length + '</span>' +
'</div>' +
'<div class="kanban-column__body" id="col-' + col.key + '"></div>';
board.appendChild(colEl);
var body = colEl.querySelector('.kanban-column__body');
if (!colOrders.length) {
body.innerHTML = '<div class="empty-state" style="padding:var(--space-4);"><div class="empty-state__subtitle">Sin órdenes</div></div>';
} else {
colOrders.forEach(function(o) {
body.appendChild(renderCard(o));
});
}
});
}
function priorityLabel(p) {
var map = {normal: 'Normal', high: 'Alta', urgent: 'Urgente'};
return map[p] || p;
}
function statusBadgeClass(status) {
var map = {
pending: 'badge--pending',
reserved: 'badge--reserved',
installed: 'badge--installed',
cancelled: 'badge--cancelled',
complete: 'badge--complete'
};
return map[status] || 'badge--pending';
}
function statusLabel(status) {
var map = {
pending: 'Pendiente',
reserved: 'Reservado',
installed: 'Instalado',
cancelled: 'Cancelado',
complete: 'Completado'
};
return map[status] || status;
}
function renderCard(o) {
var card = document.createElement('div');
card.className = 'kanban-card';
card.onclick = function() { openDetail(o.id); };
card.innerHTML =
'<div class="kanban-card__header">' +
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
' <span class="kanban-card__priority badge badge--' + esc(o.priority) + '">' + esc(priorityLabel(o.priority)) + '</span>' +
'</div>' +
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
'<div class="kanban-card__meta">' +
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
' <span>' + fmtMoney(o.estimated_cost) + '</span>' +
'</div>';
return card;
}
// ─── Detail modal ───
function openDetail(id) {
currentOrderId = id;
fetch(API + '/' + id, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(o) {
document.getElementById('detailTitle').textContent = 'Orden ' + esc(o.order_number);
renderDetailBody(o);
document.getElementById('detailModal').classList.add('is-open');
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function renderDetailBody(o) {
var html =
'<div class="so-detail">' +
' <div class="so-detail__section">' +
' <h3>Información general</h3>' +
' <div class="so-detail__grid">' +
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Estado</span><span class="so-detail__value">' + esc(o.status) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
' </div>' +
' <div style="margin-top:var(--space-3);"><span class="so-detail__label">Notas recepción</span><p>' + esc(o.reception_notes || '—') + '</p></div>' +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Refacciones</h3>' +
' <table class="data-table"><thead><tr><th>Concepto</th><th>Cant.</th><th>Precio</th><th>Estado</th><th></th></tr></thead><tbody>' +
(o.items || []).map(function(it) {
var itemStatus = it.reserved_quantity >= it.quantity ? 'reserved' : it.status;
return '<tr>' +
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
'<td>' + fmt(it.quantity) + '</td>' +
'<td>' + fmtMoney(it.unit_price) + '</td>' +
'<td><span class="badge ' + statusBadgeClass(itemStatus) + '">' + statusLabel(itemStatus) + '</span></td>' +
'<td>' + (it.reserved_quantity < it.quantity && it.status !== 'cancelled' ? '<button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button>' : '') + '</td>' +
'</tr>';
}).join('') +
'</tbody></table>' +
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/numero" style="flex:1;" />' +
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
' </div>' +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Mano de obra</h3>' +
' <table class="data-table"><thead><tr><th>Concepto</th><th>Horas</th><th>Precio/hr</th><th>Total</th><th>Estado</th></tr></thead><tbody>' +
(o.labor || []).map(function(l) {
return '<tr>' +
'<td>' + esc(l.description) + '</td>' +
'<td>' + fmt(l.hours) + '</td>' +
'<td>' + fmtMoney(l.hourly_rate) + '</td>' +
'<td>' + fmtMoney(l.total_cost) + '</td>' +
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + statusLabel(l.status) + '</span></td>' +
'</tr>';
}).join('') +
'</tbody></table>' +
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;" />' +
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
' <input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />' +
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
' </div>' +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Cambiar estado</h3>' +
' <div class="so-detail__actions">' +
' <select class="form-input" id="statusSelect" style="width:auto;">' +
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
' </select>' +
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
' </div>' +
' </div>' +
'</div>';
document.getElementById('detailBody').innerHTML = html;
// Populate labor catalog select
var sel = document.getElementById('laborCatalogSelect');
if (sel) {
catalog.forEach(function(c) {
var opt = document.createElement('option');
opt.value = JSON.stringify(c);
opt.textContent = c.name + ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')';
sel.appendChild(opt);
});
sel.onchange = function() {
if (!sel.value) return;
var c = JSON.parse(sel.value);
document.getElementById('laborDesc').value = c.name;
document.getElementById('laborHours').value = c.suggested_hours;
document.getElementById('laborRate').value = c.suggested_rate;
};
}
// Footer actions
var footer = document.getElementById('detailFooter');
footer.innerHTML =
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</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>' +
(o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
}
function closeDetailModal() {
document.getElementById('detailModal').classList.remove('is-open');
currentOrderId = null;
}
// ─── Actions ───
function changeStatus() {
if (!currentOrderId) return;
var newStatus = document.getElementById('statusSelect').value;
api('PUT', '/' + currentOrderId + '/status', {status: newStatus})
.then(function() {
closeDetailModal();
loadSummary();
loadOrders();
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function reserveItem(itemId) {
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
.then(function() {
alert('Refacción reservada');
openDetail(currentOrderId);
loadSummary();
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function addItemPlaceholder() {
var name = document.getElementById('newItemSearch').value.trim();
if (!name) return;
api('POST', '/' + currentOrderId + '/items', {
name: name,
quantity: 1,
unit_price: 0,
status: 'pending'
}).then(function() {
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
}
function addLabor() {
var desc = document.getElementById('laborDesc').value.trim();
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
var rate = parseFloat(document.getElementById('laborRate').value) || 0;
if (!desc) return alert('Escribe una descripción');
api('POST', '/' + currentOrderId + '/labor', {
description: desc,
hours: hours,
hourly_rate: rate,
status: 'pending'
}).then(function() {
document.getElementById('laborDesc').value = '';
document.getElementById('laborHours').value = '';
document.getElementById('laborRate').value = '';
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
}
function convertToSale() {
if (!currentOrderId) return;
if (!confirm('¿Convertir esta orden en una venta? Se descontarán las refacciones reservadas del inventario.')) return;
api('POST', '/' + currentOrderId + '/convert-to-sale', {
payment_method: 'efectivo',
sale_type: 'cash'
}).then(function(r) {
alert('Venta creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total));
closeDetailModal();
loadSummary();
loadOrders();
}).catch(function(e) { alert('Error: ' + e.message); });
}
function printOrder() {
if (!currentOrderId) return;
if (!window.NexusPrinter || !window.NexusPrinter.isConnected()) {
var connect = confirm('No hay impresora conectada. ¿Conectar ahora?');
if (connect) {
window.NexusPrinter.connect().then(function(r) {
if (r.ok) doPrint();
});
}
return;
}
doPrint();
function doPrint() {
window.NexusPrinter.printServiceOrder(currentOrderId, 80)
.then(function(ok) {
if (ok) alert('Orden enviada a la impresora');
else alert('No se pudo imprimir');
})
.catch(function(e) { alert('Error: ' + e.message); });
}
}
// ─── New order ───
function openNewOrderModal() {
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; });
populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; });
document.getElementById('newOrderModal').classList.add('is-open');
}
function closeNewOrderModal() {
document.getElementById('newOrderModal').classList.remove('is-open');
document.getElementById('newOrderForm').reset();
}
function submitNewOrder() {
var customerId = document.getElementById('noCustomer').value;
if (!customerId) return alert('Selecciona un cliente');
api('POST', '', {
customer_id: parseInt(customerId, 10),
vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null,
employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null,
priority: document.getElementById('noPriority').value,
estimated_completion: document.getElementById('noEstimatedCompletion').value || null,
mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null,
reception_notes: document.getElementById('noNotes').value
}).then(function() {
closeNewOrderModal();
loadSummary();
loadOrders();
}).catch(function(e) { alert('Error: ' + e.message); });
}
// ─── Catalog ───
function openCatalogModal() {
document.getElementById('catalogModal').classList.add('is-open');
renderCatalog();
}
function closeCatalogModal() {
document.getElementById('catalogModal').classList.remove('is-open');
}
function loadCatalog() {
fetch(API + '/service-catalog?active_only=true', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
catalog = d.data || [];
})
.catch(function() {});
}
function renderCatalog() {
var body = document.getElementById('catalogBody');
if (!catalog.length) {
body.innerHTML = '<tr><td colspan="5" style="text-align:center;">Sin conceptos</td></tr>';
return;
}
body.innerHTML = catalog.map(function(c) {
return '<tr>' +
'<td>' + esc(c.name) + (c.description ? '<br><small>' + esc(c.description) + '</small>' : '') + '</td>' +
'<td>' + fmt(c.suggested_hours) + '</td>' +
'<td>' + fmtMoney(c.suggested_rate) + '</td>' +
'<td>' + fmtMoney(c.suggested_hours * c.suggested_rate) + '</td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Workshop.deleteCatalogItem(' + c.id + ')">Desactivar</button></td>' +
'</tr>';
}).join('');
}
function addCatalogItem() {
var name = document.getElementById('catName').value.trim();
if (!name) return alert('Escribe un nombre');
api('POST', '/service-catalog', {
name: name,
description: document.getElementById('catDesc').value,
suggested_hours: parseFloat(document.getElementById('catHours').value) || 0,
suggested_rate: parseFloat(document.getElementById('catRate').value) || 0
}).then(function() {
document.getElementById('catName').value = '';
document.getElementById('catDesc').value = '';
document.getElementById('catHours').value = '';
document.getElementById('catRate').value = '';
loadCatalog();
setTimeout(renderCatalog, 200);
}).catch(function(e) { alert('Error: ' + e.message); });
}
function deleteCatalogItem(id) {
if (!confirm('¿Desactivar este concepto?')) return;
api('DELETE', '/service-catalog/' + id, {})
.then(function() {
loadCatalog();
setTimeout(renderCatalog, 200);
})
.catch(function(e) { alert('Error: ' + e.message); });
}
// ─── Reference data ───
function loadReferenceData() {
// Customers
fetch('/pos/api/customers?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) { customers = (d.data || d.customers || []); })
.catch(function() {});
// Vehicles
fetch('/pos/api/fleet/vehicles?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) { vehicles = (d.data || []); })
.catch(function() { vehicles = []; });
// Employees
fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) { employees = (d.data || d.employees || []); })
.catch(function() { employees = []; });
}
function populateSelect(id, items, mapper) {
var sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = id === 'noCustomer' ? '' : '<option value="">—</option>';
items.forEach(function(it) {
var opt = mapper(it);
var el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.text;
sel.appendChild(el);
});
}
// ─── Public API ───
return {
init: init,
openDetail: openDetail,
closeDetailModal: closeDetailModal,
changeStatus: changeStatus,
reserveItem: reserveItem,
addItemPlaceholder: addItemPlaceholder,
addLabor: addLabor,
convertToSale: convertToSale,
printOrder: printOrder,
openNewOrderModal: openNewOrderModal,
closeNewOrderModal: closeNewOrderModal,
submitNewOrder: submitNewOrder,
openCatalogModal: openCatalogModal,
closeCatalogModal: closeCatalogModal,
addCatalogItem: addCatalogItem,
deleteCatalogItem: deleteCatalogItem,
};
})();
document.addEventListener('DOMContentLoaded', Workshop.init);

View File

@@ -6,7 +6,7 @@
// The fetch handler normalizes static asset URLs (strips ?v= query strings) // The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely. // so templates can use cache-busting query params freely.
const CACHE_NAME = 'nexus-pos-v18'; const CACHE_NAME = 'nexus-pos-v20';
const APP_SHELL = [ const APP_SHELL = [
'/pos/static/css/tokens.css', '/pos/static/css/tokens.css',

View File

@@ -85,6 +85,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Clientes Clientes
</a> </a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Taller
</a>
<div class="nav-section-label">Finanzas</div> <div class="nav-section-label">Finanzas</div>
<a class="nav-item" href="/pos/invoicing"> <a class="nav-item" href="/pos/invoicing">

View File

@@ -145,14 +145,14 @@
<div class="vs-arrow"></div> <div class="vs-arrow"></div>
<div class="vs-group"> <div class="vs-group">
<label class="vs-label">Marca</label> <label class="vs-label">Marca</label>
<select class="vs-select" id="vsBrand" disabled onchange="CatalogApp.vsBrandChanged()"> <select class="vs-select" id="vsBrand" onchange="CatalogApp.vsBrandChanged()">
<option value="">Seleccionar...</option> <option value="">Seleccionar...</option>
</select> </select>
</div> </div>
<div class="vs-arrow"></div> <div class="vs-arrow"></div>
<div class="vs-group"> <div class="vs-group">
<label class="vs-label">Modelo</label> <label class="vs-label">Modelo</label>
<select class="vs-select" id="vsModel" disabled onchange="CatalogApp.vsModelChanged()"> <select class="vs-select" id="vsModel" onchange="CatalogApp.vsModelChanged()">
<option value="">Seleccionar...</option> <option value="">Seleccionar...</option>
</select> </select>
</div> </div>
@@ -294,15 +294,15 @@
<input type="file" id="uploadPricesFile" accept=".csv,.xlsx,.xls" style="width:100%;" /> <input type="file" id="uploadPricesFile" accept=".csv,.xlsx,.xls" style="width:100%;" />
</div> </div>
<div style="display:flex;gap:var(--space-2);justify-content:flex-end;"> <div style="display:flex;gap:var(--space-2);justify-content:flex-end;">
<a href="/pos/api/supplier-catalog/prices/template" class="btn btn--ghost" style="text-decoration:none;">Descargar plantilla</a> <button class="btn btn--ghost" onclick="CatalogApp.downloadPriceTemplate()">Descargar plantilla</button>
<button class="btn btn-primary" onclick="CatalogApp.submitUploadPrices()">Subir precios</button> <button class="btn btn--primary" onclick="CatalogApp.submitUploadPrices()">Subir precios</button>
</div> </div>
<div id="uploadPricesStatus" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div> <div id="uploadPricesStatus" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div>
</div> </div>
</div> </div>
<!-- Brand Catalog Overlay (full-screen overlay for brand-first browsing) --> <!-- Brand Catalog Overlay (full-screen overlay for brand-first browsing) -->
<div id="brandCatalogOverlay" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9000;background:var(--color-bg-base);overflow:auto;padding:var(--space-4);"> <div id="brandCatalogOverlay" style="display:none;position:relative;top:0;left:0;right:0;bottom:0;background:var(--color-bg-base);overflow:auto;padding:var(--space-4);">
<div style="max-width:1200px;margin:0 auto;"> <div style="max-width:1200px;margin:0 auto;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
<h2 style="margin:0;font-family:var(--font-heading);font-size:var(--text-h3);">Catalogo por Marca</h2> <h2 style="margin:0;font-family:var(--font-heading);font-size:var(--text-h3);">Catalogo por Marca</h2>
@@ -321,7 +321,7 @@
<script src="/pos/static/js/splash-loader.js?v=1" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/catalog.js?v=6" defer></script> <script src="/pos/static/js/catalog.js?v=8" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script> <script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script> <script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
@@ -341,6 +341,6 @@
} }
</script> </script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/brand-catalog.js?v=9" defer></script> <script src="/pos/static/js/brand-catalog.js?v=10" defer></script>
</body> </body>
</html> </html>

View File

@@ -85,6 +85,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Clientes Clientes
</a> </a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Taller
</a>
<div class="nav-section-label">Finanzas</div> <div class="nav-section-label">Finanzas</div>
<a class="nav-item" href="/pos/invoicing"> <a class="nav-item" href="/pos/invoicing">

View File

@@ -330,6 +330,7 @@
<th>Crédito Disp.</th> <th>Crédito Disp.</th>
<th class="hide-mobile">Última Compra</th> <th class="hide-mobile">Última Compra</th>
<th>Estado</th> <th>Estado</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody id="customersBody"> <tbody id="customersBody">

View File

@@ -644,13 +644,13 @@
}); });
// Persist preference // Persist preference
try { localStorage.setItem('nexus-theme', theme); } catch(e) {} try { localStorage.setItem('pos_theme', theme); } catch(e) {}
} }
// Restore on load // Restore on load
(function() { (function() {
var saved; var saved;
try { saved = localStorage.getItem('nexus-theme'); } catch(e) {} try { saved = localStorage.getItem('pos_theme'); } catch(e) {}
if (saved === 'industrial' || saved === 'modern') { if (saved === 'industrial' || saved === 'modern') {
setTheme(saved); setTheme(saved);
} }

View File

@@ -118,6 +118,12 @@
</svg> </svg>
<span>Clientes</span> <span>Clientes</span>
</a> </a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<span>Taller</span>
</a>
<a class="nav-item is-active" href="/pos/invoicing" aria-current="page"> <a class="nav-item is-active" href="/pos/invoicing" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> <svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
@@ -800,7 +806,7 @@
</div> </div>
<div class="config-section__body"> <div class="config-section__body">
<div class="cert-status"> <div class="cert-status" id="csd-status">
<div class="cert-status__icon"> <div class="cert-status__icon">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
@@ -808,60 +814,63 @@
</svg> </svg>
</div> </div>
<div class="cert-status__info"> <div class="cert-status__info">
<div class="cert-status__name"> <div class="cert-status__name" id="csd-status-name">
CSD Activo &nbsp;<span class="badge badge--vigente">Vigente</span> CSD — Consultar estado en Facturapi
</div> </div>
<div class="cert-status__detail"> <div class="cert-status__detail" id="csd-status-detail">
No. Certificado: 20001000000300022779 &nbsp;·&nbsp; Vence: 14/07/2026 El estado del certificado se muestra en la sección Facturapi (PAC).
</div> </div>
</div> </div>
<button class="btn btn--ghost btn--sm">Ver</button>
</div> </div>
<div class="form-grid"> <form id="csd-form" enctype="multipart/form-data">
<div class="form-field"> <div class="form-grid">
<label class="form-label">Archivo .cer</label> <div class="form-field">
<button class="btn btn--secondary" style="width:100%;justify-content:center;"> <label class="form-label">Archivo .cer</label>
<input type="file" id="csd-cer" name="certificate" accept=".cer" style="display:none;" />
<button type="button" class="btn btn--secondary" id="csd-cer-btn" style="width:100%;justify-content:center;" onclick="document.getElementById('csd-cer').click()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span id="csd-cer-label">Subir certificado .cer</span>
</button>
<span class="form-hint">Certificado público del SAT</span>
</div>
<div class="form-field">
<label class="form-label">Archivo .key</label>
<input type="file" id="csd-key" name="private_key" accept=".key" style="display:none;" />
<button type="button" class="btn btn--secondary" id="csd-key-btn" style="width:100%;justify-content:center;" onclick="document.getElementById('csd-key').click()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span id="csd-key-label">Subir llave privada .key</span>
</button>
<span class="form-hint">Llave privada del CSD</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="contrasena-csd">Contraseña del CSD</label>
<input class="form-input" id="contrasena-csd" name="password" type="password" placeholder="Contraseña de la llave privada" />
<span class="form-hint">Contraseña asignada al generar el CSD en el SAT</span>
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button type="button" class="btn btn--ghost" onclick="Invoicing.resetCsdForm()">Cancelar</button>
<button type="button" class="btn btn--primary" id="csd-submit-btn" onclick="Invoicing.uploadCsd(this)">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<polyline points="17 8 12 3 7 8"/> <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg> </svg>
Subir certificado .cer Actualizar CSD
</button> </button>
<span class="form-hint">Certificado público del SAT</span>
</div> </div>
</form>
<div class="form-field">
<label class="form-label">Archivo .key</label>
<button class="btn btn--secondary" style="width:100%;justify-content:center;">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Subir llave privada .key
</button>
<span class="form-hint">Llave privada del CSD</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="contrasena-csd">Contraseña del CSD</label>
<input class="form-input" id="contrasena-csd" type="password" placeholder="Contraseña de la llave privada" />
<span class="form-hint">Contraseña asignada al generar el CSD en el SAT</span>
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button class="btn btn--ghost">Cancelar</button>
<button class="btn btn--primary">
<svg viewBox="0 0 24 24">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Actualizar CSD
</button>
</div>
</div> </div>
</div> </div>

View File

@@ -105,6 +105,12 @@
</svg> </svg>
<span>Clientes</span> <span>Clientes</span>
</a> </a>
<a href="/pos/workshop" class="nav-item">
<svg class="nav-item__icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 4.7a.8.8 0 0 0 0 1.1l1.2 1.2a.8.8 0 0 0 1.1 0l2.8-2.8a4.5 4.5 0 0 1-6 6l-5.2 5.2a1.6 1.6 0 0 1-2.2-2.2l5.2-5.2a4.5 4.5 0 0 1 6-6L11 4.7z"/>
</svg>
<span>Taller</span>
</a>
<a href="/pos/invoicing" class="nav-item"> <a href="/pos/invoicing" class="nav-item">
<svg class="nav-item__icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="nav-item__icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5">

217
pos/templates/workshop.html Normal file
View File

@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Taller — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<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=2">
</head>
<body>
<div class="app-shell">
<!-- Sidebar injected by sidebar.js -->
<aside id="sidebar"></aside>
<main class="main" role="main">
<div id="offlineBanner"></div>
<div class="page-header">
<div class="page-header__title-group">
<span class="page-header__eyebrow">Operación · Taller</span>
<h1 class="page-header__title">Taller</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" id="btnCatalog" onclick="Workshop.openCatalogModal()">
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
Catálogo de servicios
</button>
<button class="btn btn--primary" id="btnNewOrder" onclick="Workshop.openNewOrderModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nueva orden
</button>
</div>
</div>
<!-- Summary cards -->
<div class="summary-strip" id="statsRow">
<div class="summary-card">
<div class="summary-card__icon">
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
</div>
<div class="summary-card__body">
<div class="summary-card__label">Recibidos</div>
<div class="summary-card__value" id="statReceived">--</div>
</div>
</div>
<div class="summary-card">
<div class="summary-card__icon">
<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</div>
<div class="summary-card__body">
<div class="summary-card__label">En reparación</div>
<div class="summary-card__value" id="statRepair">--</div>
</div>
</div>
<div class="summary-card">
<div class="summary-card__icon summary-card__icon--ok">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
</div>
<div class="summary-card__body">
<div class="summary-card__label">Listos</div>
<div class="summary-card__value" id="statReady">--</div>
</div>
</div>
<div class="summary-card">
<div class="summary-card__icon summary-card__icon--alert">
<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<div class="summary-card__body">
<div class="summary-card__label">Vencidos</div>
<div class="summary-card__value summary-card__value--danger" id="statOverdue">--</div>
</div>
</div>
</div>
<!-- Kanban board -->
<div class="kanban-board" id="kanbanBoard">
<!-- Columns injected by JS -->
</div>
</main>
</div>
<!-- Detail modal -->
<div class="modal-overlay" id="detailModal">
<div class="modal modal--lg">
<div class="modal__header">
<h2 class="modal__title" id="detailTitle">Orden #0000</h2>
<button class="modal__close" onclick="Workshop.closeDetailModal()">&times;</button>
</div>
<div class="modal__body" id="detailBody">
<!-- Injected by JS -->
</div>
<div class="modal__footer" id="detailFooter">
<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>
</div>
</div>
</div>
<!-- New order modal -->
<div class="modal-overlay" id="newOrderModal">
<div class="modal">
<div class="modal__header">
<h2 class="modal__title">Nueva orden de servicio</h2>
<button class="modal__close" onclick="Workshop.closeNewOrderModal()">&times;</button>
</div>
<div class="modal__body">
<form id="newOrderForm" class="form-grid">
<div class="form-field">
<label class="form-label" for="noCustomer">Cliente</label>
<select class="form-input" id="noCustomer" required></select>
</div>
<div class="form-field">
<label class="form-label" for="noVehicle">Vehículo</label>
<select class="form-input" id="noVehicle"></select>
</div>
<div class="form-field">
<label class="form-label" for="noMechanic">Mecánico asignado</label>
<select class="form-input" id="noMechanic"></select>
</div>
<div class="form-field">
<label class="form-label" for="noPriority">Prioridad</label>
<select class="form-input" id="noPriority">
<option value="normal">Normal</option>
<option value="high">Alta</option>
<option value="urgent">Urgente</option>
</select>
</div>
<div class="form-field">
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
</div>
<div class="form-field">
<label class="form-label" for="noMileage">Kilometraje</label>
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="noNotes">Notas de recepción</label>
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div>
</form>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeNewOrderModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.submitNewOrder()">Crear orden</button>
</div>
</div>
</div>
<!-- Catalog modal -->
<div class="modal-overlay" id="catalogModal">
<div class="modal modal--lg">
<div class="modal__header">
<h2 class="modal__title">Catálogo de servicios</h2>
<button class="modal__close" onclick="Workshop.closeCatalogModal()">&times;</button>
</div>
<div class="modal__body">
<div class="form-grid" id="catalogForm">
<div class="form-field form-field--span2">
<input class="form-input" id="catName" placeholder="Nombre del servicio" />
</div>
<div class="form-field">
<input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" />
</div>
<div class="form-field">
<input class="form-input" id="catRate" type="number" step="0.01" placeholder="Precio/hora" />
</div>
<div class="form-field form-field--span3">
<input class="form-input" id="catDesc" placeholder="Descripción" />
</div>
<div class="form-field">
<button class="btn btn--primary" onclick="Workshop.addCatalogItem()">Agregar</button>
</div>
</div>
<div class="table-wrapper" style="margin-top:var(--space-4);">
<div class="vs-container" style="max-height:40vh;overflow-y:auto;">
<table class="data-table">
<thead>
<tr>
<th>Servicio</th>
<th>Horas</th>
<th>Precio/hora</th>
<th>Total sugerido</th>
<th></th>
</tr>
</thead>
<tbody id="catalogBody">
<tr><td colspan="5" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/workshop.js?v=2" 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>
</body>
</html>

View File

@@ -0,0 +1,235 @@
"""Unit tests for Facturapi service with mocked HTTP calls.
These tests do not require PostgreSQL or network access.
"""
import base64
from unittest import mock
import pytest
# Import must not trigger DB connections.
from pos.services import facturapi_service
@pytest.fixture
def user_key():
"""Patch USER_KEY for the duration of the test."""
with mock.patch.object(facturapi_service, "USER_KEY", "sk_user_abc"):
yield
class TestGetApiKey:
def test_prefers_secret_key(self):
config = {
"facturapi_secret_key": "sk_secret_123",
"facturapi_key": "sk_test_456",
}
assert facturapi_service.get_api_key(config) == "sk_secret_123"
def test_falls_back_to_facturapi_key(self):
config = {"facturapi_key": "sk_test_456"}
assert facturapi_service.get_api_key(config) == "sk_test_456"
def test_falls_back_to_cfdi_prefixed_key(self):
config = {"cfdi_facturapi_key": "sk_test_789"}
assert facturapi_service.get_api_key(config) == "sk_test_789"
def test_falls_back_to_user_key_env(self, user_key):
assert facturapi_service.get_api_key({}) == "sk_user_abc"
def test_raises_when_nothing_configured(self):
with (
mock.patch.object(facturapi_service, "USER_KEY", ""),
pytest.raises(facturapi_service.FacturapiError),
):
facturapi_service.get_api_key({})
class TestGetOrgId:
def test_prefers_short_key(self):
config = {"facturapi_org_id": "org_123", "cfdi_facturapi_org_id": "org_456"}
assert facturapi_service._get_org_id(config) == "org_123"
def test_falls_back_to_cfdi_prefixed_key(self):
config = {"cfdi_facturapi_org_id": "org_789"}
assert facturapi_service._get_org_id(config) == "org_789"
def test_returns_none_when_missing(self):
assert facturapi_service._get_org_id({}) is None
class TestRequest:
@mock.patch("pos.services.facturapi_service.requests.request")
def test_successful_request_returns_json(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.content = b'{"id": "inv_1"}'
mock_response.json.return_value = {"id": "inv_1"}
mock_request.return_value = mock_response
result = facturapi_service._request("GET", "/invoices", "sk_test")
assert result == {"id": "inv_1"}
mock_request.assert_called_once()
_, kwargs = mock_request.call_args
assert kwargs["auth"] == ("sk_test", "")
@mock.patch("pos.services.facturapi_service.requests.request")
def test_failed_request_raises_facturapi_error(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.text = "Bad request"
mock_request.return_value = mock_response
with pytest.raises(facturapi_service.FacturapiError) as exc_info:
facturapi_service._request("POST", "/invoices", "sk_test", json_payload={})
assert exc_info.value.status_code == 400
class TestCreateOrganization:
@mock.patch("pos.services.facturapi_service.find_organization_by_rfc")
@mock.patch("pos.services.facturapi_service._request")
def test_creates_organization_and_generates_live_key(self, mock_request, mock_find, user_key):
mock_find.return_value = None
mock_request.side_effect = [
{"id": "org_123"}, # POST /organizations
{"key": "sk_live_abc"}, # PUT /organizations/org_123/apikeys/live
]
result = facturapi_service.create_organization({"rfc": "ABC010101AAA", "razon_social": "Test SA"})
assert result == {"org_id": "org_123", "api_key": "sk_live_abc"}
assert mock_request.call_count == 2
@mock.patch("pos.services.facturapi_service.find_organization_by_rfc")
@mock.patch("pos.services.facturapi_service._request")
def test_reuses_existing_organization_by_rfc(self, mock_request, mock_find, user_key):
mock_find.return_value = {"id": "org_existing"}
mock_request.return_value = {"key": "sk_live_existing"}
result = facturapi_service.create_organization({"rfc": "ABC010101AAA", "razon_social": "Test SA"})
assert result["org_id"] == "org_existing"
mock_request.assert_called_once()
class TestUploadCsd:
@mock.patch("pos.services.facturapi_service.requests.post")
def test_uploads_csd(self, mock_post):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.json.return_value = {"certificate": {"has_certificate": True}}
mock_post.return_value = mock_response
config = {"facturapi_key": "sk_test", "facturapi_org_id": "org_1"}
cer_b64 = base64.b64encode(b"fake-cer").decode("ascii")
key_b64 = base64.b64encode(b"fake-key").decode("ascii")
result = facturapi_service.upload_csd(config, cer_b64, key_b64, "password")
assert result["certificate"]["has_certificate"] is True
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert kwargs["auth"] == ("sk_test", "")
class TestCreateInvoice:
@mock.patch("pos.services.facturapi_service._request")
def test_creates_invoice(self, mock_request):
mock_request.return_value = {"id": "inv_1", "uuid": "uuid-1"}
config = {"facturapi_key": "sk_test"}
payload = {"customer": {"tax_id": "XAXX010101000"}}
result = facturapi_service.create_invoice(config, payload)
assert result["uuid"] == "uuid-1"
mock_request.assert_called_once_with("POST", "/invoices", "sk_test", json_payload=payload, timeout=90)
class TestCancelInvoice:
@mock.patch("pos.services.facturapi_service._request")
def test_cancel_invoice_with_replacement(self, mock_request):
mock_request.return_value = {"status": "canceled"}
config = {"facturapi_key": "sk_test"}
result = facturapi_service.cancel_invoice(config, "inv_1", "01", replacement_uuid="uuid-2")
assert result["status"] == "canceled"
mock_request.assert_called_once_with(
"DELETE",
"/invoices/inv_1",
"sk_test",
params={"motive": "01", "replacement": "uuid-2"},
timeout=60,
)
class TestDownloadXml:
@mock.patch("pos.services.facturapi_service.requests.request")
def test_downloads_xml(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.content = b"<xml/>"
mock_request.return_value = mock_response
config = {"facturapi_key": "sk_test"}
result = facturapi_service.download_xml(config, "inv_1")
assert result == b"<xml/>"
class TestGetOrgStatus:
@mock.patch("pos.services.facturapi_service.get_organization")
def test_returns_configured_with_csd(self, mock_get_org):
mock_get_org.return_value = {
"legal": {"name": "Test SA", "tax_id": "ABC010101AAA"},
"certificate": {"has_certificate": True},
"pending_steps": [],
}
config = {"facturapi_key": "sk_test", "cfdi_facturapi_org_id": "org_1"}
result = facturapi_service.get_org_status(config)
assert result["configured"] is True
assert result["has_csd"] is True
assert result["has_org_id"] is True
def test_returns_error_without_key(self):
with mock.patch.object(facturapi_service, "USER_KEY", ""):
result = facturapi_service.get_org_status({})
assert result["has_key"] is False
assert "not configured" in result["error"].lower()
def test_returns_error_without_org_id(self):
with mock.patch.object(facturapi_service, "USER_KEY", ""):
result = facturapi_service.get_org_status({"facturapi_key": "sk_test"})
assert result["has_org_id"] is False
assert "organization" in result["error"].lower()
class TestCreateOrUpdateCustomer:
@mock.patch("pos.services.facturapi_service._request")
def test_creates_new_customer_when_not_found(self, mock_request):
mock_request.side_effect = [
{"data": []}, # search
{"id": "cus_1"}, # create
]
config = {"facturapi_key": "sk_test"}
customer_id = facturapi_service.create_or_update_customer(
config,
{
"legal_name": "Test",
"tax_id": "ABC010101AAA",
"tax_system": "601",
"email": "test@example.com",
"zip": "01000",
},
)
assert customer_id == "cus_1"

View File

@@ -0,0 +1,238 @@
"""Unit tests for service order workshop integration.
These tests use mocked DB cursors and inventory_engine so they do not require
PostgreSQL or network access.
"""
import os
import sys
POS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, POS_DIR)
from unittest import mock # noqa: E402
import pytest # noqa: E402
from services import service_order_engine as engine # noqa: E402
class MockCursor:
"""Simple programmable cursor mock."""
def __init__(self, responses=None):
self.responses = responses or []
self._calls = []
self._response_index = 0
def execute(self, sql, params=None):
self._calls.append((sql, params))
def fetchone(self):
if self._response_index < len(self.responses):
resp = self.responses[self._response_index]
self._response_index += 1
return resp
return None
def fetchall(self):
if self._response_index < len(self.responses):
resp = self.responses[self._response_index]
self._response_index += 1
return resp
return []
def close(self):
pass
class MockConn:
def __init__(self, cursor):
self._cursor = cursor
def cursor(self):
return self._cursor
def commit(self):
pass
def rollback(self):
pass
@pytest.fixture
def conn():
return MockConn(MockCursor())
def test_generate_order_number_first_of_year(conn):
conn._cursor.responses = [(None,)]
number = engine._generate_order_number(conn)
assert number.startswith("SO-")
assert number.endswith("-0001")
def test_generate_order_number_increments(conn):
conn._cursor.responses = [("SO-2026-0042",)]
number = engine._generate_order_number(conn)
assert number.endswith("-0043")
@mock.patch("services.inventory_engine.get_stock", return_value=10)
@mock.patch("services.inventory_engine.record_operation", return_value=123)
def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn):
conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001"), # item lookup
None, # update
]
result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9)
assert result["reserved"] == 3
mock_stock.assert_called_once_with(conn, 5, 2)
mock_record.assert_called_once()
args = mock_record.call_args.args
assert args[3] == "SO_RESERVE"
assert args[4] == -3
@mock.patch("services.inventory_engine.get_stock", return_value=1)
def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn):
conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001"),
]
with pytest.raises(ValueError, match="Insufficient stock"):
engine.reserve_item(conn, 7, branch_id=2)
@mock.patch("services.inventory_engine.record_operation", return_value=124)
def test_release_item_restores_stock(mock_record, conn):
conn._cursor.responses = [
(1, 5, 2, 2, "SO-2026-0001"), # item lookup (reserved_quantity=2)
None, # update
]
result = engine.release_item(conn, 7, employee_id=9)
assert result["released"] == 2
args = mock_record.call_args.args
assert args[3] == "SO_RELEASE"
assert args[4] == 2
@mock.patch("services.inventory_engine.record_operation", return_value=125)
def test_convert_to_sale_creates_sale_and_consumes_inventory(mock_record, conn):
# Mock get_service_order response
so = {
"id": 1,
"order_number": "SO-2026-0001",
"status": "ready",
"sale_id": None,
"branch_id": 2,
"customer_id": 3,
"items": [
{
"id": 10,
"inventory_id": 5,
"part_number": "BP-123",
"name": "Bujia",
"quantity": 2,
"unit_price": 150.0,
"unit_cost": 80.0,
"status": "pending",
"reserved_quantity": 2,
}
],
"labor": [
{
"description": "Cambio de bujias",
"hours": 1,
"hourly_rate": 250,
"total_cost": 250,
"status": "completed",
}
],
}
cur = conn._cursor
cur.responses = [
(1, "2026-01-01 10:00:00"), # sale insert -> id=1
]
with mock.patch.object(engine, "get_service_order", return_value=so):
result = engine.convert_to_sale(conn, 1, {"payment_method": "efectivo", "sale_type": "cash"}, employee_id=9)
assert result["sale_id"] == 1
assert result["items_count"] == 2
assert result["total"] == pytest.approx(2 * 150 * 1.16 + 250 * 1.16, 0.01)
# inventory operations: SO_RELEASE + SALE
assert mock_record.call_count == 2
first = mock_record.call_args_list[0].args
second = mock_record.call_args_list[1].args
assert first[3] == "SO_RELEASE"
assert first[4] == 2
assert second[3] == "SALE"
assert second[4] == -2
def test_convert_to_sale_raises_when_already_converted(conn):
so = {"status": "ready", "sale_id": 99, "branch_id": 1, "customer_id": 1, "items": [], "labor": []}
conn._cursor.responses = [(1,)]
with (
mock.patch.object(engine, "get_service_order", return_value=so),
pytest.raises(ValueError, match="already converted"),
):
engine.convert_to_sale(conn, 1, {})
def test_convert_to_sale_raises_when_cancelled(conn):
so = {"status": "cancelled", "sale_id": None, "branch_id": 1, "customer_id": 1, "items": [], "labor": []}
conn._cursor.responses = [(1,)]
with (
mock.patch.object(engine, "get_service_order", return_value=so),
pytest.raises(ValueError, match="cancelled"),
):
engine.convert_to_sale(conn, 1, {})
def test_assign_mechanic_updates_employee(conn):
conn._cursor.responses = [(1,), None]
result = engine.assign_mechanic(conn, 1, 7)
assert result["employee_id"] == 7
def test_assign_mechanic_raises_when_order_missing(conn):
conn._cursor.responses = [None]
with pytest.raises(ValueError, match="not found"):
engine.assign_mechanic(conn, 1, 7)
def test_service_catalog_crud(conn):
# create
conn._cursor.responses = [(1,)]
result = engine.create_service_catalog_item(
conn, 1, {"name": "Afinacion", "suggested_hours": 2, "suggested_rate": 300}
)
assert result["id"] == 1
# list
conn._cursor = MockCursor(
[
[(1, 1, "Afinacion", "", 2, 300, True, None, None)],
]
)
items = engine.list_service_catalog(conn)
assert len(items) == 1
assert items[0]["name"] == "Afinacion"
# update
conn._cursor = MockCursor([None])
ok = engine.update_service_catalog_item(conn, 1, {"name": "Afinacion mayor"})
assert ok is True
# delete
conn._cursor = MockCursor([None])
ok = engine.delete_service_catalog_item(conn, 1)
assert ok is True

47
pyproject.toml Normal file
View File

@@ -0,0 +1,47 @@
[tool.ruff]
target-version = "py311"
line-length = 120
exclude = [
".git",
"__pycache__",
".pytest_cache",
".venv",
".venv_import",
"node_modules",
"backups",
"data",
"vehicle_database",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults
"B905", # zip() without strict= (Python <3.10 compat)
]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["console/tests", "pos/tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"

16
requirements-dev.txt Normal file
View File

@@ -0,0 +1,16 @@
# Development dependencies for Nexus Autoparts
# Install: pip install -r requirements-dev.txt
# Testing
pytest>=8.0
pytest-asyncio>=0.23
pytest-cov>=5.0
# Linting and formatting
ruff>=0.6.0
# Type checking (optional, enable later)
# mypy>=1.10
# E2E testing
# playwright>=1.40 # install via npm: npx playwright install

View File

@@ -14,6 +14,7 @@ Usage:
import os import os
import sys import sys
import psycopg2 import psycopg2
MIGRATION_SQL = """ MIGRATION_SQL = """
@@ -42,9 +43,7 @@ def get_tenant_db_names(master_dsn):
conn = psycopg2.connect(master_dsn) conn = psycopg2.connect(master_dsn)
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
"SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id"
)
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
return rows return rows

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Check Facturapi configuration status for all active tenants.
Usage:
export MASTER_DB_URL=postgresql://user:pass@host/nexus_autoparts
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@host/{db_name}"
export FACTURAPI_USER_KEY=sk_user_xxx # optional, for org auto-discovery
python3 scripts/check_facturapi_tenants.py
Output: table (default), --json, or --csv.
"""
import argparse
import csv
import json
import os
import sys
# Allow importing pos/services
POS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pos")
sys.path.insert(0, POS_DIR)
import psycopg2 # noqa: E402
from services import facturapi_service # noqa: E402
def get_tenants(master_dsn: str):
conn = psycopg2.connect(master_dsn)
try:
cur = conn.cursor()
cur.execute(
"""
SELECT t.id, t.db_name, t.name, t.subdomain, COALESCE(v.version, 'v0.0') AS version
FROM tenants t
LEFT JOIN tenant_schema_version v ON t.id = v.tenant_id
WHERE t.is_active = true
ORDER BY t.id
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
conn.close()
def get_tenant_config(db_name: str, template_dsn: str) -> dict[str, str]:
dsn = template_dsn.format(db_name=db_name)
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
# Business-level fiscal data
cur.execute(
"""
SELECT key, value FROM tenant_config
WHERE key IN (
'tenant_rfc', 'tenant_razon_social', 'tenant_cp',
'cfdi_regimen_fiscal', 'cfdi_serie',
'cfdi_facturapi_key', 'cfdi_facturapi_org_id'
)
"""
)
config = {row[0]: row[1] or "" for row in cur.fetchall()}
# Main branch fiscal data (used as fallback by _get_issuer_config)
cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE is_main = true LIMIT 1
"""
)
branch = cur.fetchone()
if branch:
config["rfc"] = (branch[0] or config.get("tenant_rfc", "")).strip()
config["razon_social"] = (branch[1] or config.get("tenant_razon_social", "")).strip()
config["regimen_fiscal"] = (branch[2] or config.get("cfdi_regimen_fiscal", "")).strip()
config["cp"] = (branch[3] or config.get("tenant_cp", "")).strip()
config["serie"] = (branch[4] or config.get("cfdi_serie", "")).strip()
else:
config["rfc"] = config.get("tenant_rfc", "").strip()
config["razon_social"] = config.get("tenant_razon_social", "").strip()
config["regimen_fiscal"] = config.get("cfdi_regimen_fiscal", "").strip()
config["cp"] = config.get("tenant_cp", "").strip()
config["serie"] = config.get("cfdi_serie", "").strip()
cur.close()
return config
finally:
conn.close()
def check_tenant(tenant_id: int, db_name: str, name: str, version: str, template_dsn: str) -> dict:
result = {
"tenant_id": tenant_id,
"db_name": db_name,
"name": name,
"schema_version": version,
"rfc": "",
"razon_social": "",
"has_key": False,
"has_org_id": False,
"has_csd": False,
"configured": False,
"pending_steps": [],
"error": None,
}
try:
config = get_tenant_config(db_name, template_dsn)
result["rfc"] = config.get("rfc", "")
result["razon_social"] = config.get("razon_social", "")
status = facturapi_service.get_org_status(config)
result.update(
{
"has_key": status.get("has_key", False),
"has_org_id": status.get("has_org_id", False),
"has_csd": status.get("has_csd", False),
"configured": status.get("configured", False),
"pending_steps": status.get("pending_steps", []),
"error": status.get("error"),
}
)
except Exception as e:
result["error"] = f"{type(e).__name__}: {str(e)[:200]}"
return result
def print_table(results: list[dict]):
headers = ["ID", "Tenant", "RFC", "Key", "Org", "CSD", "Status", "Error/Pending"]
rows = []
for r in results:
status = "OK" if r["configured"] and r["has_csd"] else "PENDING"
pending = (
"; ".join((s.get("description") or s.get("type") or str(s)) for s in r["pending_steps"])
if r["pending_steps"]
else ""
)
detail = (r["error"] or pending or "")[:60]
rows.append(
[
str(r["tenant_id"]),
r["name"][:28],
r["rfc"] or "-",
"" if r["has_key"] else "No",
"" if r["has_org_id"] else "No",
"" if r["has_csd"] else "No",
status,
detail,
]
)
widths = [max(len(str(row[i])) for row in [headers] + rows) for i in range(len(headers))]
sep = "+-" + "-+-".join("-" * w for w in widths) + "-+"
def fmt(row):
return "| " + " | ".join(str(row[i]).ljust(widths[i]) for i in range(len(row))) + " |"
print(sep)
print(fmt(headers))
print(sep)
for row in rows:
print(fmt(row))
print(sep)
print(
f"\nTotal: {len(results)} tenants | Listos: {sum(1 for r in results if r['configured'] and r['has_csd'])} | Pendientes: {sum(1 for r in results if not (r['configured'] and r['has_csd']))}"
)
def print_json(results: list[dict]):
print(json.dumps(results, indent=2, default=str))
def print_csv(results: list[dict]):
writer = csv.DictWriter(
sys.stdout,
fieldnames=[
"tenant_id",
"name",
"db_name",
"schema_version",
"rfc",
"has_key",
"has_org_id",
"has_csd",
"configured",
"error",
],
)
writer.writeheader()
for r in results:
writer.writerow(
{
"tenant_id": r["tenant_id"],
"name": r["name"],
"db_name": r["db_name"],
"schema_version": r["schema_version"],
"rfc": r["rfc"],
"has_key": r["has_key"],
"has_org_id": r["has_org_id"],
"has_csd": r["has_csd"],
"configured": r["configured"],
"error": r["error"],
}
)
def main():
parser = argparse.ArgumentParser(description="Check Facturapi status for all tenants")
parser.add_argument("--json", action="store_true", help="Output JSON")
parser.add_argument("--csv", action="store_true", help="Output CSV")
args = parser.parse_args()
master_dsn = os.environ.get("MASTER_DB_URL")
template_dsn = os.environ.get("TENANT_DB_URL_TEMPLATE")
if not master_dsn or not template_dsn:
print("Set MASTER_DB_URL and TENANT_DB_URL_TEMPLATE", file=sys.stderr)
sys.exit(1)
tenants = get_tenants(master_dsn)
if not tenants:
print("No active tenants found.")
return
results = []
for tenant_id, db_name, name, _subdomain, version in tenants:
results.append(check_tenant(tenant_id, db_name, name, version, template_dsn))
if args.json:
print_json(results)
elif args.csv:
print_csv(results)
else:
print_table(results)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Migración de respaldo Punto Zero (MySQL) a tenant PostgreSQL de Nexus.
Uso:
MYSQL_HOST=127.0.0.1 MYSQL_PORT=3307 MYSQL_DB=datos1 \
TENANT_DB_URL=postgresql://postgres@localhost/tenant_refaccionaria_la_casita \
BRANCH_ID=1 \
python3 scripts/migrate_pz_to_tenant.py
Requiere pymysql y psycopg2. Instalar con:
pip3 install --target /tmp/pylibs pymysql psycopg2-binary
"""
import os
import sys
# Librerías instaladas fuera del sistema porque el venv del proyecto no tiene pip
sys.path.insert(0, "/tmp/pylibs")
import pymysql
import psycopg2
from psycopg2.extras import execute_values
MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3307"))
MYSQL_DB = os.getenv("MYSQL_DB", "datos1")
MYSQL_USER = os.getenv("MYSQL_USER", "root")
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
PG_URL = os.getenv(
"TENANT_DB_URL",
"postgresql://postgres@localhost/tenant_refaccionaria_la_casita",
)
BRANCH_ID = int(os.getenv("BRANCH_ID", "1"))
def mysql_conn():
return pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
user=MYSQL_USER,
password=MYSQL_PASS,
db=MYSQL_DB,
charset="latin1",
cursorclass=pymysql.cursors.Cursor,
)
def pg_conn():
return psycopg2.connect(PG_URL)
def clean(s):
if s is None:
return ""
return str(s).strip()
def migrate_customers(mysql, pg):
cur = mysql.cursor()
cur.execute(
"""
SELECT Clave, Nombre, RFC, Domicilio, Colonia, CP, Ciudad, Estado,
Telefono1, Email, LimiteCredito, Precio, Desc1, Suspendido
FROM clientes
"""
)
rows = []
for r in cur.fetchall():
name = clean(r[1])
if not name:
continue
address_parts = [clean(r[3]), clean(r[4]), clean(r[6]), clean(r[7])]
address = ", ".join([p for p in address_parts if p]) or None
phone = clean(r[8]) or None
email = clean(r[9]) or None
rfc = clean(r[2]) or None
cp = clean(r[5]) or None
credit_limit = float(r[10] or 0)
price_tier = int(r[11] if r[11] is not None else 1)
if price_tier not in (1, 2, 3):
price_tier = 1
max_discount = float(r[12] or 0)
is_active = int(r[13] or 0) == 0
rows.append(
(
BRANCH_ID,
name,
rfc,
None, # razon_social
None, # regimen_fiscal
None, # uso_cfdi
cp,
email,
phone,
address,
price_tier,
credit_limit,
0.0, # credit_balance
is_active,
None, # vehicle_info
max_discount,
)
)
cur.close()
if not rows:
print("No hay clientes para migrar.")
return
pgcur = pg.cursor()
execute_values(
pgcur,
"""
INSERT INTO customers (
branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit,
credit_balance, is_active, vehicle_info, max_discount_pct
) VALUES %s
""",
rows,
)
pg.commit()
pgcur.close()
print(f"Migrados {len(rows)} clientes.")
def migrate_inventory(mysql, pg):
cur = mysql.cursor()
cur.execute("SELECT Id, Marca FROM marcas")
brand_map = {row[0]: clean(row[1]) for row in cur.fetchall()}
cur.execute(
"""
SELECT p.Clave, p.Descrip, p.DescripDetallada, p.Costo, p.Precio1,
p.Precio2, p.Precio3, p.IVA, p.Marca, p.Linea, p.SubLinea,
p.UnidadMedida, p.Ubicacion, p.ClaveSAT, p.EnLista, p.Bloqueado
FROM productos p
"""
)
rows = []
for r in cur.fetchall():
sku = clean(r[0])
name = clean(r[1])
if not sku or not name:
continue
cost = float(r[3] or 0)
price_1 = float(r[4] or 0)
price_2 = float(r[5] or 0)
price_3 = float(r[6] or 0)
iva = r[7]
tax_rate = float(iva) / 100.0 if iva else 0.16
brand = brand_map.get(r[8]) or None
is_active = int(r[14] or 0) == 1 and int(r[15] or 0) == 0
location = clean(r[12]) or None
unit = "PZA"
rows.append(
(
BRANCH_ID,
sku,
None, # barcode
name,
None, # description (se omite descripción detallada por instrucción del usuario)
None, # category_id
brand,
None, # vehicle_compatibility
unit,
cost,
price_1,
price_2,
price_3,
tax_rate,
0, # min_stock
0, # max_stock
location,
None, # image_url
is_active,
None, # catalog_part_id
)
)
cur.close()
if not rows:
print("No hay productos para migrar.")
return
pgcur = pg.cursor()
execute_values(
pgcur,
"""
INSERT INTO inventory (
branch_id, part_number, barcode, name, description, category_id,
brand, vehicle_compatibility, unit, cost, price_1, price_2, price_3,
tax_rate, min_stock, max_stock, location, image_url, is_active,
catalog_part_id
) VALUES %s
""",
rows,
)
pg.commit()
pgcur.close()
print(f"Migrados {len(rows)} productos.")
def main():
mysql = mysql_conn()
pg = pg_conn()
pgcur = pg.cursor()
# Tenant está limpio; truncamos para partir de cero
pgcur.execute(
"TRUNCATE TABLE customers, inventory, inventory_stock RESTART IDENTITY CASCADE"
)
pg.commit()
pgcur.close()
print("Tablas destino truncadas.")
migrate_customers(mysql, pg)
migrate_inventory(mysql, pg)
mysql.close()
pg.close()
print("Migración completada.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Create Facturapi organizations for tenants that don't have one.
Requires FACTURAPI_USER_KEY environment variable (user key with permission to
manage organizations).
Usage:
export MASTER_DB_URL=postgresql://user:pass@host/nexus_autoparts
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@host/{db_name}"
export FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
python3 scripts/setup_facturapi_orgs.py
# Preview only
python3 scripts/setup_facturapi_orgs.py --dry-run
"""
import argparse
import os
import sys
POS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pos")
sys.path.insert(0, POS_DIR)
import psycopg2 # noqa: E402
from services import facturapi_service # noqa: E402
def get_tenants(master_dsn: str):
conn = psycopg2.connect(master_dsn)
try:
cur = conn.cursor()
cur.execute(
"""
SELECT t.id, t.db_name, t.name, t.subdomain
FROM tenants t
WHERE t.is_active = true
ORDER BY t.id
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
conn.close()
def get_tenant_fiscal_data(db_name: str, template_dsn: str) -> dict:
dsn = template_dsn.format(db_name=db_name)
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
cur.execute(
"""
SELECT key, value FROM tenant_config
WHERE key IN ('tenant_rfc', 'tenant_razon_social', 'tenant_cp', 'cfdi_regimen_fiscal')
"""
)
config = {row[0]: row[1] or "" for row in cur.fetchall()}
cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal
FROM branches WHERE is_main = true LIMIT 1
"""
)
branch = cur.fetchone()
if branch:
config["rfc"] = (branch[0] or config.get("tenant_rfc", "")).strip()
config["razon_social"] = (branch[1] or config.get("tenant_razon_social", "")).strip()
config["regimen_fiscal"] = (branch[2] or config.get("cfdi_regimen_fiscal", "")).strip()
config["cp"] = (branch[3] or config.get("tenant_cp", "")).strip()
else:
config["rfc"] = config.get("tenant_rfc", "").strip()
config["razon_social"] = config.get("tenant_razon_social", "").strip()
config["regimen_fiscal"] = config.get("cfdi_regimen_fiscal", "").strip()
config["cp"] = config.get("tenant_cp", "").strip()
# Existing facturapi keys
cur.execute(
"""
SELECT key, value FROM tenant_config
WHERE key IN ('cfdi_facturapi_org_id', 'cfdi_facturapi_key')
"""
)
for key, value in cur.fetchall():
config[key] = value or ""
cur.close()
return config
finally:
conn.close()
def save_facturapi_config(db_name: str, template_dsn: str, org_id: str, api_key: str):
dsn = template_dsn.format(db_name=db_name)
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_org_id', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""",
(org_id,),
)
cur.execute(
"""
INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""",
(api_key,),
)
conn.commit()
cur.close()
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="Create Facturapi organizations for tenants")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying")
args = parser.parse_args()
master_dsn = os.environ.get("MASTER_DB_URL")
template_dsn = os.environ.get("TENANT_DB_URL_TEMPLATE")
user_key = os.environ.get("FACTURAPI_USER_KEY")
if not master_dsn or not template_dsn:
print("Set MASTER_DB_URL and TENANT_DB_URL_TEMPLATE", file=sys.stderr)
sys.exit(1)
if not user_key:
print("Set FACTURAPI_USER_KEY (user key required to manage organizations)", file=sys.stderr)
sys.exit(1)
tenants = get_tenants(master_dsn)
if not tenants:
print("No active tenants found.")
return
created = 0
skipped = 0
errors = 0
for tenant_id, db_name, name, _subdomain in tenants:
print(f"\n[{tenant_id}] {name} ({db_name})")
try:
config = get_tenant_fiscal_data(db_name, template_dsn)
if config.get("cfdi_facturapi_org_id") and config.get("cfdi_facturapi_key"):
print(" SKIP: already has org_id and key")
skipped += 1
continue
if not config.get("rfc"):
print(" ERROR: tenant RFC not configured")
errors += 1
continue
print(f" RFC: {config['rfc']}")
print(f" Razon social: {config['razon_social'] or '(will use RFC)'}")
if args.dry_run:
print(" DRY-RUN: would create organization")
continue
result = facturapi_service.create_organization(config)
save_facturapi_config(db_name, template_dsn, result["org_id"], result["api_key"])
print(f" CREATED: org_id={result['org_id']}")
created += 1
except Exception as e:
print(f" ERROR: {type(e).__name__}: {str(e)[:200]}")
errors += 1
print(f"\nDone. Created: {created} | Skipped: {skipped} | Errors: {errors}")
if __name__ == "__main__":
main()