Compare commits
15 Commits
6aff32f93b
...
desarrollo
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f753376e3 | |||
| 74118a3247 | |||
| 14219e7117 | |||
| 6b80add102 | |||
| ad04572305 | |||
| ee7e1d49e5 | |||
| 49bbc37117 | |||
| f5711ae22f | |||
| 85ecf52561 | |||
| 584b87f82c | |||
| b635e44302 | |||
| e201dce290 | |||
| ce66212223 | |||
| d67887284d | |||
| 71f3b1cdec |
67
.github/workflows/ci.yml
vendored
Normal file
67
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
"customer": customer,
|
||||||
'cfdi': cfdi_info,
|
"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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
66
pos/migrations/v4.4_workshop.sql
Normal file
66
pos/migrations/v4.4_workshop.sql
Normal 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';
|
||||||
5
pos/migrations/v4.5_customer_max_discount.sql
Normal file
5
pos/migrations/v4.5_customer_max_discount.sql
Normal 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;
|
||||||
@@ -285,19 +285,23 @@ 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 = []
|
||||||
|
for display, variants in groups.items():
|
||||||
|
# Sort by raw model id ascending; first becomes the canonical id.
|
||||||
|
variants.sort(key=lambda x: x[0])
|
||||||
|
canonical = variants[0]
|
||||||
results.append({
|
results.append({
|
||||||
'id_model': r[0],
|
'id_model': canonical[0],
|
||||||
'name_model': r[1],
|
'name_model': canonical[1],
|
||||||
'display_name': display,
|
'display_name': display,
|
||||||
|
'variant_ids': [v[0] for v in variants],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by display_name
|
# Sort by 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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
tenant_key = (tenant_config.get(key) or "").strip()
|
||||||
if tenant_key.startswith("sk_user_"):
|
if tenant_key.startswith("sk_user_"):
|
||||||
return tenant_key
|
return tenant_key
|
||||||
raise FacturapiError(
|
raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
|
||||||
"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,
|
"configured": True,
|
||||||
"has_csd": bool(cert.get("has_certificate")),
|
"has_csd": bool(cert.get("has_certificate")),
|
||||||
"legal_name": legal.get("name") or legal.get("legal_name"),
|
"legal_name": legal.get("name") or legal.get("legal_name"),
|
||||||
"tax_id": legal.get("tax_id"),
|
"tax_id": legal.get("tax_id"),
|
||||||
"pending_steps": org.get("pending_steps", []),
|
"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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) ── */
|
||||||
@@ -369,6 +370,22 @@
|
|||||||
.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
769
pos/static/css/workshop.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
|
|
||||||
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>';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 + '">← Atrá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('');
|
||||||
@@ -1260,8 +1288,6 @@
|
|||||||
|
|
||||||
// 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' : '') + '>‹ 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 ›</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;
|
||||||
|
|
||||||
|
// Si hay año carga motores usando todas las variantes del modelo
|
||||||
|
if (yearVal) {
|
||||||
vsEngine.disabled = false;
|
vsEngine.disabled = false;
|
||||||
apiFetch(API + '/engines?model_id=' + modelId + '&year_id=' + yearVal).then(function (data) {
|
apiFetch(API + '/engines?model_id=' + variantIds.join(',') + '&year_id=' + yearVal).then(function (data) {
|
||||||
var engines = data.data || data;
|
var engines = data.data || data;
|
||||||
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 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 '<option value="' + e.id_mye + '">' + esc(label) + '</option>';
|
return '<option value="' + e.id_mye + '">' + esc(label) + '</option>';
|
||||||
}).join('');
|
}).join('');
|
||||||
// If only 1 engine, auto-select
|
|
||||||
if (engines.length === 1) {
|
if (engines.length === 1) {
|
||||||
vsEngine.value = engines[0].id_mye;
|
vsEngine.value = engines[0].id_mye;
|
||||||
vsEngineChanged();
|
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) {
|
||||||
|
// 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);
|
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 ───
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|
||||||
|
|||||||
@@ -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
537
pos/static/js/workshop.js
Normal 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);
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,53 +814,55 @@
|
|||||||
</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 <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 · 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>
|
||||||
|
|
||||||
|
<form id="csd-form" enctype="multipart/form-data">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">Archivo .cer</label>
|
<label class="form-label">Archivo .cer</label>
|
||||||
<button class="btn btn--secondary" style="width:100%;justify-content:center;">
|
<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">
|
<svg viewBox="0 0 24 24">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<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"/>
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
Subir certificado .cer
|
<span id="csd-cer-label">Subir certificado .cer</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="form-hint">Certificado público del SAT</span>
|
<span class="form-hint">Certificado público del SAT</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">Archivo .key</label>
|
<label class="form-label">Archivo .key</label>
|
||||||
<button class="btn btn--secondary" style="width:100%;justify-content:center;">
|
<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">
|
<svg viewBox="0 0 24 24">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<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"/>
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
Subir llave privada .key
|
<span id="csd-key-label">Subir llave privada .key</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="form-hint">Llave privada del CSD</span>
|
<span class="form-hint">Llave privada del CSD</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-field form-field--span2">
|
<div class="form-field form-field--span2">
|
||||||
<label class="form-label" for="contrasena-csd">Contraseña del CSD</label>
|
<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" />
|
<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>
|
<span class="form-hint">Contraseña asignada al generar el CSD en el SAT</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
|
<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 type="button" class="btn btn--ghost" onclick="Invoicing.resetCsdForm()">Cancelar</button>
|
||||||
<button class="btn btn--primary">
|
<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">
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
@@ -862,6 +870,7 @@
|
|||||||
Actualizar CSD
|
Actualizar CSD
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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
217
pos/templates/workshop.html
Normal 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()">×</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()">×</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()">×</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>
|
||||||
235
pos/tests/test_facturapi_service.py
Normal file
235
pos/tests/test_facturapi_service.py
Normal 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"
|
||||||
238
pos/tests/test_service_order_integration.py
Normal file
238
pos/tests/test_service_order_integration.py
Normal 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
47
pyproject.toml
Normal 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
16
requirements-dev.txt
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
241
scripts/check_facturapi_tenants.py
Normal file
241
scripts/check_facturapi_tenants.py
Normal 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 "-",
|
||||||
|
"Sí" if r["has_key"] else "No",
|
||||||
|
"Sí" if r["has_org_id"] else "No",
|
||||||
|
"Sí" 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()
|
||||||
229
scripts/migrate_pz_to_tenant.py
Normal file
229
scripts/migrate_pz_to_tenant.py
Normal 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()
|
||||||
181
scripts/setup_facturapi_orgs.py
Normal file
181
scripts/setup_facturapi_orgs.py
Normal 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()
|
||||||
Reference in New Issue
Block a user