docs: add design and implementation plans
- SaaS + aftermarket design spec - SaaS + aftermarket implementation plan (15 tasks) - Captura partes design - POS + cuentas design and plan Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
docs/plans/2026-03-01-captura-partes-design.md
Normal file
84
docs/plans/2026-03-01-captura-partes-design.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Captura de Partes OEM — Diseño
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
App web de captura de datos para 3 capturistas que trabajan en pipeline:
|
||||||
|
1. **Capturista OEM** — registra partes OEM por vehículo
|
||||||
|
2. **Capturista Intercambios** — agrega aftermarket por pieza OEM
|
||||||
|
3. **Capturista Imágenes** — sube fotos por pieza OEM
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
- Frontend: HTML/CSS/JS vanilla (una sola página con 3 tabs)
|
||||||
|
- Backend: API Flask existente en `server.py` (endpoints `/api/admin/*`)
|
||||||
|
- Base de datos: PostgreSQL `nexus_autoparts`
|
||||||
|
- Almacenamiento imágenes: `/home/Autopartes/dashboard/static/parts/`
|
||||||
|
|
||||||
|
## Sección 1: Captura OEM
|
||||||
|
|
||||||
|
### Flujo
|
||||||
|
1. Capturista ve lista de vehículos pendientes (sin partes OEM)
|
||||||
|
2. Filtra por marca/modelo, elige un vehículo
|
||||||
|
3. Ve tabla con 12 categorías / 63 grupos
|
||||||
|
4. Por cada grupo, puede [+ Agregar pieza]: # OEM, nombre, cantidad
|
||||||
|
5. Guarda fila por fila (POST /api/admin/parts + POST /api/admin/fitment)
|
||||||
|
6. Marca vehículo como "Terminado" → desaparece de pendientes
|
||||||
|
|
||||||
|
### Estado de vehículo
|
||||||
|
- **Pendiente**: 0 partes registradas
|
||||||
|
- **En progreso**: tiene partes pero no marcado terminado
|
||||||
|
- **Terminado**: marcado explícitamente por capturista
|
||||||
|
|
||||||
|
### Lógica de guardado
|
||||||
|
1. POST /api/admin/parts → crea pieza OEM → obtiene part_id
|
||||||
|
2. POST /api/admin/fitment → vincula pieza a vehículo (mye_id + part_id)
|
||||||
|
3. Si OEM ya existe en DB, reutilizar part_id existente
|
||||||
|
|
||||||
|
## Sección 2: Captura Intercambios
|
||||||
|
|
||||||
|
### Flujo
|
||||||
|
1. Ve lista de piezas OEM sin aftermarket
|
||||||
|
2. Selecciona una pieza → ve su info OEM
|
||||||
|
3. Agrega intercambios: fabricante, # aftermarket, calidad, precio, garantía
|
||||||
|
4. POST /api/admin/aftermarket
|
||||||
|
5. Siguiente pieza
|
||||||
|
|
||||||
|
### Campos por intercambio
|
||||||
|
- manufacturer_id (dropdown de fabricantes)
|
||||||
|
- part_number (texto)
|
||||||
|
- name (texto)
|
||||||
|
- quality_tier (economy/standard/oem/premium)
|
||||||
|
- price_usd (número)
|
||||||
|
- warranty_months (número)
|
||||||
|
|
||||||
|
## Sección 3: Captura Imágenes
|
||||||
|
|
||||||
|
### Flujo
|
||||||
|
1. Ve lista de piezas OEM sin imagen
|
||||||
|
2. Selecciona una pieza → ve su info
|
||||||
|
3. Sube archivo de imagen (jpg/png/webp)
|
||||||
|
4. Se guarda en /static/parts/{oem_number}.{ext}
|
||||||
|
5. Se actualiza campo image_url en tabla parts
|
||||||
|
|
||||||
|
### Restricciones
|
||||||
|
- Máximo 2MB por imagen
|
||||||
|
- Formatos: jpg, png, webp
|
||||||
|
- Se redimensiona a 800x800 max en el servidor (si es necesario)
|
||||||
|
|
||||||
|
## Nuevos endpoints necesarios
|
||||||
|
|
||||||
|
### Estado de vehículos
|
||||||
|
- GET /api/captura/vehicles/pending — vehículos sin partes
|
||||||
|
- GET /api/captura/vehicles/in-progress — con partes pero no terminados
|
||||||
|
- POST /api/captura/vehicles/{mye_id}/complete — marcar terminado
|
||||||
|
|
||||||
|
### Piezas para intercambios
|
||||||
|
- GET /api/captura/parts/without-aftermarket — piezas sin intercambio
|
||||||
|
|
||||||
|
### Piezas para imágenes
|
||||||
|
- GET /api/captura/parts/without-image — piezas sin foto
|
||||||
|
- POST /api/captura/parts/{part_id}/image — subir imagen
|
||||||
|
|
||||||
|
## Archivos
|
||||||
|
- `/home/Autopartes/dashboard/captura.html` — página principal
|
||||||
|
- `/home/Autopartes/dashboard/captura.js` — lógica de las 3 secciones
|
||||||
|
- `/home/Autopartes/dashboard/captura.css` — estilos específicos
|
||||||
|
- Ruta en server.py: `/captura` → sirve captura.html
|
||||||
130
docs/plans/2026-03-02-pos-cuentas-design.md
Normal file
130
docs/plans/2026-03-02-pos-cuentas-design.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Punto de Venta + Cuentas por Cobrar — Diseño
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
Sistema de punto de venta integrado al catálogo Nexus Autoparts con:
|
||||||
|
- Ventas de partes OEM y aftermarket
|
||||||
|
- Facturación con datos fiscales (RFC, IVA, folio consecutivo)
|
||||||
|
- Cuentas a crédito para clientes frecuentes
|
||||||
|
- Pagos: abonos parciales y pago al corte
|
||||||
|
- Precios calculados por costo + margen configurable
|
||||||
|
|
||||||
|
## Tablas nuevas
|
||||||
|
|
||||||
|
### customers
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_customer | SERIAL PK | |
|
||||||
|
| name | VARCHAR(200) | Nombre del cliente |
|
||||||
|
| rfc | VARCHAR(13) | RFC fiscal |
|
||||||
|
| business_name | VARCHAR(300) | Razón social |
|
||||||
|
| email | VARCHAR(200) | |
|
||||||
|
| phone | VARCHAR(20) | |
|
||||||
|
| address | TEXT | Dirección fiscal |
|
||||||
|
| credit_limit | DECIMAL(12,2) | Límite de crédito |
|
||||||
|
| balance | DECIMAL(12,2) DEFAULT 0 | Saldo actual (lo que debe) |
|
||||||
|
| payment_terms | INTEGER DEFAULT 30 | Días de crédito |
|
||||||
|
| active | BOOLEAN DEFAULT TRUE | |
|
||||||
|
| created_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
|
||||||
|
### invoices
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_invoice | SERIAL PK | |
|
||||||
|
| customer_id | INTEGER FK customers | |
|
||||||
|
| folio | VARCHAR(20) UNIQUE | Folio consecutivo (ej: NX-000001) |
|
||||||
|
| date_issued | TIMESTAMP DEFAULT NOW() | Fecha emisión |
|
||||||
|
| subtotal | DECIMAL(12,2) | Sin IVA |
|
||||||
|
| tax_rate | DECIMAL(5,4) DEFAULT 0.16 | Tasa IVA |
|
||||||
|
| tax_amount | DECIMAL(12,2) | Monto IVA |
|
||||||
|
| total | DECIMAL(12,2) | Total con IVA |
|
||||||
|
| amount_paid | DECIMAL(12,2) DEFAULT 0 | Total abonado |
|
||||||
|
| status | VARCHAR(20) DEFAULT 'pending' | pending/partial/paid/cancelled |
|
||||||
|
| notes | TEXT | |
|
||||||
|
| created_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
|
||||||
|
### invoice_items
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_invoice_item | SERIAL PK | |
|
||||||
|
| invoice_id | INTEGER FK invoices | |
|
||||||
|
| part_id | INTEGER FK parts (nullable) | Pieza OEM |
|
||||||
|
| aftermarket_id | INTEGER FK aftermarket_parts (nullable) | Pieza aftermarket |
|
||||||
|
| description | VARCHAR(500) | Descripción de la línea |
|
||||||
|
| quantity | INTEGER DEFAULT 1 | |
|
||||||
|
| unit_cost | DECIMAL(12,2) | Costo unitario |
|
||||||
|
| margin_pct | DECIMAL(5,2) | Margen % aplicado |
|
||||||
|
| unit_price | DECIMAL(12,2) | Precio de venta unitario |
|
||||||
|
| line_total | DECIMAL(12,2) | quantity * unit_price |
|
||||||
|
|
||||||
|
### payments
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_payment | SERIAL PK | |
|
||||||
|
| customer_id | INTEGER FK customers | |
|
||||||
|
| invoice_id | INTEGER FK invoices (nullable) | Si aplica a factura específica |
|
||||||
|
| amount | DECIMAL(12,2) | Monto del pago |
|
||||||
|
| payment_method | VARCHAR(20) | efectivo/transferencia/cheque/tarjeta |
|
||||||
|
| reference | VARCHAR(100) | # referencia del pago |
|
||||||
|
| date_payment | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
| notes | TEXT | |
|
||||||
|
| created_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
|
||||||
|
## Columnas nuevas en tablas existentes
|
||||||
|
|
||||||
|
### parts
|
||||||
|
- `cost_usd DECIMAL(12,2)` — costo de la pieza
|
||||||
|
|
||||||
|
### aftermarket_parts
|
||||||
|
- `cost_usd DECIMAL(12,2)` — costo de la pieza aftermarket
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
- Margen default: 30% (configurable)
|
||||||
|
- IVA: 16%
|
||||||
|
- Folio format: NX-XXXXXX (consecutivo)
|
||||||
|
|
||||||
|
## Páginas web nuevas
|
||||||
|
|
||||||
|
### /pos — Punto de Venta
|
||||||
|
- Selector de cliente (buscador + crear nuevo)
|
||||||
|
- Buscador de partes (OEM y aftermarket)
|
||||||
|
- Carrito con líneas editables (costo, margen, precio)
|
||||||
|
- Botón facturar → genera factura con folio
|
||||||
|
|
||||||
|
### /cuentas — Cuentas por Cobrar
|
||||||
|
- Lista de clientes con saldos
|
||||||
|
- Detalle de cliente: facturas pendientes, historial pagos
|
||||||
|
- Registrar pago/abono
|
||||||
|
- Estado de cuenta imprimible
|
||||||
|
|
||||||
|
## Endpoints API nuevos
|
||||||
|
|
||||||
|
### Clientes
|
||||||
|
- GET /api/pos/customers — listar clientes
|
||||||
|
- GET /api/pos/customers/:id — detalle cliente con saldo
|
||||||
|
- POST /api/pos/customers — crear cliente
|
||||||
|
- PUT /api/pos/customers/:id — editar cliente
|
||||||
|
|
||||||
|
### Facturas
|
||||||
|
- GET /api/pos/invoices — listar facturas (filtros: cliente, status, fecha)
|
||||||
|
- GET /api/pos/invoices/:id — detalle factura con líneas
|
||||||
|
- POST /api/pos/invoices — crear factura (con líneas)
|
||||||
|
- PUT /api/pos/invoices/:id/cancel — cancelar factura
|
||||||
|
|
||||||
|
### Pagos
|
||||||
|
- GET /api/pos/payments — listar pagos
|
||||||
|
- POST /api/pos/payments — registrar pago/abono
|
||||||
|
- GET /api/pos/customers/:id/statement — estado de cuenta
|
||||||
|
|
||||||
|
## Flujos
|
||||||
|
|
||||||
|
### Venta
|
||||||
|
1. Seleccionar/crear cliente
|
||||||
|
2. Buscar partes → agregar al carrito
|
||||||
|
3. Ajustar margen si necesario
|
||||||
|
4. Facturar → se crea invoice + items, se suma al balance del cliente
|
||||||
|
|
||||||
|
### Pago
|
||||||
|
1. Buscar cliente → ver saldo y facturas pendientes
|
||||||
|
2. Registrar pago (monto, método, referencia)
|
||||||
|
3. Se aplica a factura o como abono general
|
||||||
|
4. Se actualiza balance del cliente
|
||||||
722
docs/plans/2026-03-02-pos-cuentas-plan.md
Normal file
722
docs/plans/2026-03-02-pos-cuentas-plan.md
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
# POS + Cuentas por Cobrar — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a Point of Sale with credit accounts, invoicing with tax data, and payment tracking to the Nexus Autoparts system.
|
||||||
|
|
||||||
|
**Architecture:** New PostgreSQL tables (customers, invoices, invoice_items, payments) + API endpoints in server.py + two new pages (pos.html, cuentas.html). Prices are cost + configurable margin. Customer balances are maintained via triggers on invoice/payment inserts.
|
||||||
|
|
||||||
|
**Tech Stack:** Flask, PostgreSQL, SQLAlchemy raw SQL via `text()`, vanilla HTML/CSS/JS (same stack as existing app).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Database schema — Create new tables and columns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py` (no changes yet, just DB)
|
||||||
|
|
||||||
|
**Step 1: Add cost_usd columns and create POS tables**
|
||||||
|
|
||||||
|
Run this SQL via Python script:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# /home/Autopartes/setup_pos_tables.py
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
from config import DB_URL
|
||||||
|
|
||||||
|
engine = create_engine(DB_URL)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("""
|
||||||
|
-- Add cost columns
|
||||||
|
ALTER TABLE parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2);
|
||||||
|
ALTER TABLE aftermarket_parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2);
|
||||||
|
|
||||||
|
-- Customers
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id_customer SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
rfc VARCHAR(13),
|
||||||
|
business_name VARCHAR(300),
|
||||||
|
email VARCHAR(200),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
address TEXT,
|
||||||
|
credit_limit DECIMAL(12,2) DEFAULT 0,
|
||||||
|
balance DECIMAL(12,2) DEFAULT 0,
|
||||||
|
payment_terms INTEGER DEFAULT 30,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invoices
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id_invoice SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id_customer),
|
||||||
|
folio VARCHAR(20) UNIQUE NOT NULL,
|
||||||
|
date_issued TIMESTAMP DEFAULT NOW(),
|
||||||
|
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_rate DECIMAL(5,4) DEFAULT 0.16,
|
||||||
|
tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
amount_paid DECIMAL(12,2) DEFAULT 0,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invoice items
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||||
|
id_invoice_item SERIAL PRIMARY KEY,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES invoices(id_invoice) ON DELETE CASCADE,
|
||||||
|
part_id INTEGER REFERENCES parts(id_part),
|
||||||
|
aftermarket_id INTEGER REFERENCES aftermarket_parts(id_aftermarket_parts),
|
||||||
|
description VARCHAR(500) NOT NULL,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
unit_cost DECIMAL(12,2) DEFAULT 0,
|
||||||
|
margin_pct DECIMAL(5,2) DEFAULT 30,
|
||||||
|
unit_price DECIMAL(12,2) NOT NULL,
|
||||||
|
line_total DECIMAL(12,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payments
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id_payment SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id_customer),
|
||||||
|
invoice_id INTEGER REFERENCES invoices(id_invoice),
|
||||||
|
amount DECIMAL(12,2) NOT NULL,
|
||||||
|
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
|
||||||
|
reference VARCHAR(100),
|
||||||
|
date_payment TIMESTAMP DEFAULT NOW(),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_folio ON invoices(folio);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_customer ON payments(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_invoice ON payments(invoice_id);
|
||||||
|
|
||||||
|
-- Folio sequence
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS invoice_folio_seq START 1;
|
||||||
|
"""))
|
||||||
|
conn.commit()
|
||||||
|
print("POS tables created successfully")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run the script**
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes && python3 setup_pos_tables.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify**
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from config import DB_URL
|
||||||
|
engine = create_engine(DB_URL)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for t in ['customers','invoices','invoice_items','payments']:
|
||||||
|
cols = conn.execute(text(f\"SELECT column_name FROM information_schema.columns WHERE table_name='{t}' ORDER BY ordinal_position\")).fetchall()
|
||||||
|
print(f'{t}: {[c[0] for c in cols]}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add setup_pos_tables.py && git commit -m "feat(pos): add POS database schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: API endpoints — Customers CRUD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py` — insert before Main Block (line ~2672)
|
||||||
|
|
||||||
|
**Step 1: Add customer endpoints**
|
||||||
|
|
||||||
|
Insert these endpoints before the `# Main Block` comment in server.py:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ============================================================================
|
||||||
|
# POS (Point of Sale) Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route('/pos')
|
||||||
|
def pos_page():
|
||||||
|
return send_from_directory('.', 'pos.html')
|
||||||
|
|
||||||
|
@app.route('/pos.js')
|
||||||
|
def pos_js():
|
||||||
|
return send_from_directory('.', 'pos.js')
|
||||||
|
|
||||||
|
@app.route('/pos.css')
|
||||||
|
def pos_css():
|
||||||
|
return send_from_directory('.', 'pos.css')
|
||||||
|
|
||||||
|
@app.route('/cuentas')
|
||||||
|
def cuentas_page():
|
||||||
|
return send_from_directory('.', 'cuentas.html')
|
||||||
|
|
||||||
|
@app.route('/cuentas.js')
|
||||||
|
def cuentas_js():
|
||||||
|
return send_from_directory('.', 'cuentas.js')
|
||||||
|
|
||||||
|
@app.route('/cuentas.css')
|
||||||
|
def cuentas_css():
|
||||||
|
return send_from_directory('.', 'cuentas.css')
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Customers ----
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers')
|
||||||
|
def api_pos_customers():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
search = request.args.get('search', '')
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = min(int(request.args.get('per_page', 50)), 100)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
filters = ["active = TRUE"]
|
||||||
|
params = {'limit': per_page, 'offset': offset}
|
||||||
|
if search:
|
||||||
|
filters.append("(name ILIKE :search OR rfc ILIKE :search OR business_name ILIKE :search)")
|
||||||
|
params['search'] = f'%{search}%'
|
||||||
|
|
||||||
|
where = ' AND '.join(filters)
|
||||||
|
total = session.execute(text(f"SELECT COUNT(*) FROM customers WHERE {where}"), params).scalar()
|
||||||
|
|
||||||
|
rows = session.execute(text(f"""
|
||||||
|
SELECT id_customer, name, rfc, business_name, phone, balance, credit_limit, payment_terms
|
||||||
|
FROM customers WHERE {where}
|
||||||
|
ORDER BY name LIMIT :limit OFFSET :offset
|
||||||
|
"""), params).mappings().all()
|
||||||
|
return jsonify({'data': [dict(r) for r in rows], 'pagination': {
|
||||||
|
'page': page, 'per_page': per_page, 'total': total,
|
||||||
|
'total_pages': (total + per_page - 1) // per_page
|
||||||
|
}})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers/<int:customer_id>')
|
||||||
|
def api_pos_customer_detail(customer_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
row = session.execute(text(
|
||||||
|
"SELECT * FROM customers WHERE id_customer = :id"
|
||||||
|
), {'id': customer_id}).mappings().first()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||||
|
return jsonify(dict(row))
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers', methods=['POST'])
|
||||||
|
def api_pos_create_customer():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
result = session.execute(text("""
|
||||||
|
INSERT INTO customers (name, rfc, business_name, email, phone, address, credit_limit, payment_terms)
|
||||||
|
VALUES (:name, :rfc, :business_name, :email, :phone, :address, :credit_limit, :payment_terms)
|
||||||
|
RETURNING id_customer
|
||||||
|
"""), {
|
||||||
|
'name': data['name'], 'rfc': data.get('rfc'),
|
||||||
|
'business_name': data.get('business_name'),
|
||||||
|
'email': data.get('email'), 'phone': data.get('phone'),
|
||||||
|
'address': data.get('address'),
|
||||||
|
'credit_limit': data.get('credit_limit', 0),
|
||||||
|
'payment_terms': data.get('payment_terms', 30)
|
||||||
|
})
|
||||||
|
new_id = result.scalar()
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'id': new_id, 'message': 'Cliente creado'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers/<int:customer_id>', methods=['PUT'])
|
||||||
|
def api_pos_update_customer(customer_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
session.execute(text("""
|
||||||
|
UPDATE customers SET name = :name, rfc = :rfc, business_name = :business_name,
|
||||||
|
email = :email, phone = :phone, address = :address,
|
||||||
|
credit_limit = :credit_limit, payment_terms = :payment_terms
|
||||||
|
WHERE id_customer = :id
|
||||||
|
"""), {
|
||||||
|
'name': data['name'], 'rfc': data.get('rfc'),
|
||||||
|
'business_name': data.get('business_name'),
|
||||||
|
'email': data.get('email'), 'phone': data.get('phone'),
|
||||||
|
'address': data.get('address'),
|
||||||
|
'credit_limit': data.get('credit_limit', 0),
|
||||||
|
'payment_terms': data.get('payment_terms', 30),
|
||||||
|
'id': customer_id
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'message': 'Cliente actualizado'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify routes load**
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes/dashboard && python3 -c "import server; [print(r.rule) for r in server.app.url_map.iter_rules() if 'pos' in r.rule]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/server.py && git commit -m "feat(pos): add customer CRUD endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: API endpoints — Invoices and invoice items
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py`
|
||||||
|
|
||||||
|
**Step 1: Add invoice endpoints** (insert after customer endpoints, before Main Block)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- Invoices ----
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices')
|
||||||
|
def api_pos_invoices():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
customer_id = request.args.get('customer_id', '')
|
||||||
|
status = request.args.get('status', '')
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = min(int(request.args.get('per_page', 50)), 100)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
filters = ["1=1"]
|
||||||
|
params = {'limit': per_page, 'offset': offset}
|
||||||
|
if customer_id:
|
||||||
|
filters.append("i.customer_id = :customer_id")
|
||||||
|
params['customer_id'] = int(customer_id)
|
||||||
|
if status:
|
||||||
|
filters.append("i.status = :status")
|
||||||
|
params['status'] = status
|
||||||
|
|
||||||
|
where = ' AND '.join(filters)
|
||||||
|
total = session.execute(text(f"""
|
||||||
|
SELECT COUNT(*) FROM invoices i WHERE {where}
|
||||||
|
"""), params).scalar()
|
||||||
|
|
||||||
|
rows = session.execute(text(f"""
|
||||||
|
SELECT i.id_invoice, i.folio, i.date_issued, i.subtotal, i.tax_amount,
|
||||||
|
i.total, i.amount_paid, i.status, c.name AS customer_name, c.rfc
|
||||||
|
FROM invoices i
|
||||||
|
JOIN customers c ON i.customer_id = c.id_customer
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY i.date_issued DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""), params).mappings().all()
|
||||||
|
return jsonify({'data': [dict(r) for r in rows], 'pagination': {
|
||||||
|
'page': page, 'per_page': per_page, 'total': total,
|
||||||
|
'total_pages': (total + per_page - 1) // per_page
|
||||||
|
}})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices/<int:invoice_id>')
|
||||||
|
def api_pos_invoice_detail(invoice_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
inv = session.execute(text("""
|
||||||
|
SELECT i.*, c.name AS customer_name, c.rfc, c.business_name, c.address
|
||||||
|
FROM invoices i JOIN customers c ON i.customer_id = c.id_customer
|
||||||
|
WHERE i.id_invoice = :id
|
||||||
|
"""), {'id': invoice_id}).mappings().first()
|
||||||
|
if not inv:
|
||||||
|
return jsonify({'error': 'Factura no encontrada'}), 404
|
||||||
|
|
||||||
|
items = session.execute(text("""
|
||||||
|
SELECT ii.*, p.oem_part_number, ap.part_number AS aftermarket_number
|
||||||
|
FROM invoice_items ii
|
||||||
|
LEFT JOIN parts p ON ii.part_id = p.id_part
|
||||||
|
LEFT JOIN aftermarket_parts ap ON ii.aftermarket_id = ap.id_aftermarket_parts
|
||||||
|
WHERE ii.invoice_id = :id
|
||||||
|
ORDER BY ii.id_invoice_item
|
||||||
|
"""), {'id': invoice_id}).mappings().all()
|
||||||
|
|
||||||
|
return jsonify({'invoice': dict(inv), 'items': [dict(it) for it in items]})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices', methods=['POST'])
|
||||||
|
def api_pos_create_invoice():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
customer_id = data['customer_id']
|
||||||
|
items = data['items'] # [{part_id, aftermarket_id, description, quantity, unit_cost, margin_pct, unit_price}]
|
||||||
|
tax_rate = data.get('tax_rate', 0.16)
|
||||||
|
notes = data.get('notes', '')
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return jsonify({'error': 'La factura debe tener al menos una línea'}), 400
|
||||||
|
|
||||||
|
# Generate folio
|
||||||
|
folio_num = session.execute(text("SELECT nextval('invoice_folio_seq')")).scalar()
|
||||||
|
folio = f"NX-{folio_num:06d}"
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
subtotal = sum(it['quantity'] * it['unit_price'] for it in items)
|
||||||
|
tax_amount = round(subtotal * tax_rate, 2)
|
||||||
|
total = round(subtotal + tax_amount, 2)
|
||||||
|
|
||||||
|
# Create invoice
|
||||||
|
result = session.execute(text("""
|
||||||
|
INSERT INTO invoices (customer_id, folio, subtotal, tax_rate, tax_amount, total, notes)
|
||||||
|
VALUES (:customer_id, :folio, :subtotal, :tax_rate, :tax_amount, :total, :notes)
|
||||||
|
RETURNING id_invoice
|
||||||
|
"""), {
|
||||||
|
'customer_id': customer_id, 'folio': folio,
|
||||||
|
'subtotal': subtotal, 'tax_rate': tax_rate,
|
||||||
|
'tax_amount': tax_amount, 'total': total, 'notes': notes
|
||||||
|
})
|
||||||
|
invoice_id = result.scalar()
|
||||||
|
|
||||||
|
# Create items
|
||||||
|
for it in items:
|
||||||
|
line_total = it['quantity'] * it['unit_price']
|
||||||
|
session.execute(text("""
|
||||||
|
INSERT INTO invoice_items (invoice_id, part_id, aftermarket_id, description,
|
||||||
|
quantity, unit_cost, margin_pct, unit_price, line_total)
|
||||||
|
VALUES (:inv_id, :part_id, :af_id, :desc, :qty, :cost, :margin, :price, :total)
|
||||||
|
"""), {
|
||||||
|
'inv_id': invoice_id,
|
||||||
|
'part_id': it.get('part_id'),
|
||||||
|
'af_id': it.get('aftermarket_id'),
|
||||||
|
'desc': it['description'],
|
||||||
|
'qty': it['quantity'],
|
||||||
|
'cost': it.get('unit_cost', 0),
|
||||||
|
'margin': it.get('margin_pct', 30),
|
||||||
|
'price': it['unit_price'],
|
||||||
|
'total': line_total
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update customer balance
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE customers SET balance = balance + :total WHERE id_customer = :id"
|
||||||
|
), {'total': total, 'id': customer_id})
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'id': invoice_id, 'folio': folio, 'total': total, 'message': 'Factura creada'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices/<int:invoice_id>/cancel', methods=['PUT'])
|
||||||
|
def api_pos_cancel_invoice(invoice_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
inv = session.execute(text(
|
||||||
|
"SELECT total, customer_id, status FROM invoices WHERE id_invoice = :id"
|
||||||
|
), {'id': invoice_id}).mappings().first()
|
||||||
|
if not inv:
|
||||||
|
return jsonify({'error': 'Factura no encontrada'}), 404
|
||||||
|
if inv['status'] == 'cancelled':
|
||||||
|
return jsonify({'error': 'La factura ya está cancelada'}), 400
|
||||||
|
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE invoices SET status = 'cancelled' WHERE id_invoice = :id"
|
||||||
|
), {'id': invoice_id})
|
||||||
|
|
||||||
|
# Reverse the balance
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE customers SET balance = balance - :total WHERE id_customer = :cid"
|
||||||
|
), {'total': inv['total'], 'cid': inv['customer_id']})
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'message': 'Factura cancelada'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/server.py && git commit -m "feat(pos): add invoice endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: API endpoints — Payments and statements
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py`
|
||||||
|
|
||||||
|
**Step 1: Add payment endpoints** (insert after invoice endpoints)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- Payments ----
|
||||||
|
|
||||||
|
@app.route('/api/pos/payments', methods=['POST'])
|
||||||
|
def api_pos_create_payment():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
customer_id = data['customer_id']
|
||||||
|
amount = float(data['amount'])
|
||||||
|
payment_method = data.get('payment_method', 'efectivo')
|
||||||
|
reference = data.get('reference')
|
||||||
|
invoice_id = data.get('invoice_id')
|
||||||
|
notes = data.get('notes')
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
return jsonify({'error': 'El monto debe ser mayor a 0'}), 400
|
||||||
|
|
||||||
|
result = session.execute(text("""
|
||||||
|
INSERT INTO payments (customer_id, invoice_id, amount, payment_method, reference, notes)
|
||||||
|
VALUES (:cid, :inv_id, :amount, :method, :ref, :notes)
|
||||||
|
RETURNING id_payment
|
||||||
|
"""), {
|
||||||
|
'cid': customer_id, 'inv_id': invoice_id,
|
||||||
|
'amount': amount, 'method': payment_method,
|
||||||
|
'ref': reference, 'notes': notes
|
||||||
|
})
|
||||||
|
payment_id = result.scalar()
|
||||||
|
|
||||||
|
# Update customer balance
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE customers SET balance = balance - :amount WHERE id_customer = :id"
|
||||||
|
), {'amount': amount, 'id': customer_id})
|
||||||
|
|
||||||
|
# If applied to specific invoice, update its amount_paid and status
|
||||||
|
if invoice_id:
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE invoices SET amount_paid = amount_paid + :amount WHERE id_invoice = :id"
|
||||||
|
), {'amount': amount, 'id': invoice_id})
|
||||||
|
# Update invoice status
|
||||||
|
session.execute(text("""
|
||||||
|
UPDATE invoices SET status = CASE
|
||||||
|
WHEN amount_paid >= total THEN 'paid'
|
||||||
|
WHEN amount_paid > 0 THEN 'partial'
|
||||||
|
ELSE 'pending'
|
||||||
|
END WHERE id_invoice = :id
|
||||||
|
"""), {'id': invoice_id})
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'id': payment_id, 'message': 'Pago registrado'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers/<int:customer_id>/statement')
|
||||||
|
def api_pos_customer_statement(customer_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
customer = session.execute(text(
|
||||||
|
"SELECT * FROM customers WHERE id_customer = :id"
|
||||||
|
), {'id': customer_id}).mappings().first()
|
||||||
|
if not customer:
|
||||||
|
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||||
|
|
||||||
|
invoices = session.execute(text("""
|
||||||
|
SELECT id_invoice, folio, date_issued, total, amount_paid, status
|
||||||
|
FROM invoices WHERE customer_id = :id AND status != 'cancelled'
|
||||||
|
ORDER BY date_issued DESC LIMIT 100
|
||||||
|
"""), {'id': customer_id}).mappings().all()
|
||||||
|
|
||||||
|
payments = session.execute(text("""
|
||||||
|
SELECT p.id_payment, p.amount, p.payment_method, p.reference,
|
||||||
|
p.date_payment, p.notes, i.folio AS invoice_folio
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN invoices i ON p.invoice_id = i.id_invoice
|
||||||
|
WHERE p.customer_id = :id
|
||||||
|
ORDER BY p.date_payment DESC LIMIT 100
|
||||||
|
"""), {'id': customer_id}).mappings().all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'customer': dict(customer),
|
||||||
|
'invoices': [dict(i) for i in invoices],
|
||||||
|
'payments': [dict(p) for p in payments]
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/search-parts')
|
||||||
|
def api_pos_search_parts():
|
||||||
|
"""Search parts for the POS cart — returns OEM and aftermarket with prices."""
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
q = request.args.get('q', '')
|
||||||
|
if len(q) < 2:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Search OEM parts
|
||||||
|
oem = session.execute(text("""
|
||||||
|
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||||
|
p.cost_usd, pg.name_part_group AS group_name,
|
||||||
|
'oem' AS part_type
|
||||||
|
FROM parts p
|
||||||
|
JOIN part_groups pg ON p.group_id = pg.id_part_group
|
||||||
|
WHERE p.oem_part_number ILIKE :q OR p.name_part ILIKE :q
|
||||||
|
ORDER BY p.oem_part_number LIMIT 20
|
||||||
|
"""), {'q': f'%{q}%'}).mappings().all()
|
||||||
|
results.extend([dict(r) for r in oem])
|
||||||
|
|
||||||
|
# Search aftermarket parts
|
||||||
|
af = session.execute(text("""
|
||||||
|
SELECT ap.id_aftermarket_parts AS id_part, ap.part_number AS oem_part_number,
|
||||||
|
ap.name_aftermarket_parts AS name_part, ap.name_es,
|
||||||
|
COALESCE(ap.cost_usd, ap.price_usd) AS cost_usd,
|
||||||
|
m.name_manufacture AS group_name,
|
||||||
|
'aftermarket' AS part_type
|
||||||
|
FROM aftermarket_parts ap
|
||||||
|
JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture
|
||||||
|
WHERE ap.part_number ILIKE :q OR ap.name_aftermarket_parts ILIKE :q
|
||||||
|
ORDER BY ap.part_number LIMIT 20
|
||||||
|
"""), {'q': f'%{q}%'}).mappings().all()
|
||||||
|
results.extend([dict(r) for r in af])
|
||||||
|
|
||||||
|
return jsonify(results)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/server.py && git commit -m "feat(pos): add payment and search endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Frontend — POS page (pos.html + pos.css + pos.js)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/Autopartes/dashboard/pos.html`
|
||||||
|
- Create: `/home/Autopartes/dashboard/pos.css`
|
||||||
|
- Create: `/home/Autopartes/dashboard/pos.js`
|
||||||
|
|
||||||
|
**Step 1: Create pos.html**
|
||||||
|
|
||||||
|
HTML page with:
|
||||||
|
- Customer selector (search + create new)
|
||||||
|
- Part search bar (searches OEM + aftermarket)
|
||||||
|
- Cart table (description, qty, cost, margin%, price, total)
|
||||||
|
- Totals section (subtotal, IVA 16%, total)
|
||||||
|
- "Facturar" button
|
||||||
|
|
||||||
|
**Step 2: Create pos.css**
|
||||||
|
|
||||||
|
Styles for the POS layout: 2-column (left=search+cart, right=customer info + totals).
|
||||||
|
|
||||||
|
**Step 3: Create pos.js**
|
||||||
|
|
||||||
|
JavaScript logic:
|
||||||
|
- Customer search and selection
|
||||||
|
- Part search → add to cart
|
||||||
|
- Editable margin per line
|
||||||
|
- Auto-calculate prices: `unit_price = cost * (1 + margin/100)`
|
||||||
|
- Totals: subtotal, IVA, total
|
||||||
|
- Facturar → POST /api/pos/invoices
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/pos.html dashboard/pos.css dashboard/pos.js
|
||||||
|
git commit -m "feat(pos): add point of sale frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Frontend — Cuentas page (cuentas.html + cuentas.css + cuentas.js)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/Autopartes/dashboard/cuentas.html`
|
||||||
|
- Create: `/home/Autopartes/dashboard/cuentas.css`
|
||||||
|
- Create: `/home/Autopartes/dashboard/cuentas.js`
|
||||||
|
|
||||||
|
**Step 1: Create cuentas.html**
|
||||||
|
|
||||||
|
HTML page with:
|
||||||
|
- Customer list with balances
|
||||||
|
- Customer detail: info card, pending invoices, payment history
|
||||||
|
- Payment form: amount, method, reference, apply to invoice
|
||||||
|
- Create/edit customer modal
|
||||||
|
|
||||||
|
**Step 2: Create cuentas.js**
|
||||||
|
|
||||||
|
JavaScript logic:
|
||||||
|
- Load customers with balances
|
||||||
|
- Customer detail view with statement
|
||||||
|
- Register payment → POST /api/pos/payments
|
||||||
|
- Create/edit customer form
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/cuentas.html dashboard/cuentas.css dashboard/cuentas.js
|
||||||
|
git commit -m "feat(pos): add accounts receivable frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Navigation + final integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/nav.js`
|
||||||
|
|
||||||
|
**Step 1: Add POS and Cuentas links to nav**
|
||||||
|
|
||||||
|
Add to the `navLinks` array and `isActive` function:
|
||||||
|
```javascript
|
||||||
|
// isActive:
|
||||||
|
if ((h === '/pos') && (p === '/pos')) return true;
|
||||||
|
if ((h === '/cuentas') && (p === '/cuentas')) return true;
|
||||||
|
|
||||||
|
// navLinks:
|
||||||
|
{ label: 'POS', href: '/pos' },
|
||||||
|
{ label: 'Cuentas', href: '/cuentas' },
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Test full flow**
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
nohup python3 /home/Autopartes/dashboard/server.py > /tmp/nexus-server.log 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Test customer creation
|
||||||
|
curl -s -X POST http://localhost:5000/api/pos/customers \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Taller Prueba","rfc":"TAL123456XX0","credit_limit":50000,"payment_terms":30}'
|
||||||
|
|
||||||
|
# Test page loads
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/pos
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/cuentas
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Final commit**
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "feat(pos): complete POS and accounts system"
|
||||||
|
```
|
||||||
242
docs/plans/2026-03-15-saas-aftermarket-design.md
Normal file
242
docs/plans/2026-03-15-saas-aftermarket-design.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Nexus Autoparts — SaaS + Aftermarket Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two features for Nexus Autoparts:
|
||||||
|
1. **SaaS user system** — auth, roles (ADMIN, OWNER, TALLER, BODEGA), warehouse inventory uploads with flexible column mapping, availability/pricing visible to authenticated talleres.
|
||||||
|
2. **Aftermarket parts cleanup** — migrate 357K AFT- prefixed parts from `parts` table into `aftermarket_parts`, link to OEM parts, fix import pipeline.
|
||||||
|
|
||||||
|
Architecture: **Monolith approach** — everything in the existing PostgreSQL DB and Flask backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Authentication & Users
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
**`roles`** (existing, update names):
|
||||||
|
|
||||||
|
| id_rol | name_rol |
|
||||||
|
|--------|----------|
|
||||||
|
| 1 | ADMIN |
|
||||||
|
| 2 | OWNER |
|
||||||
|
| 3 | TALLER |
|
||||||
|
| 4 | BODEGA |
|
||||||
|
|
||||||
|
**`users`** (existing, extend):
|
||||||
|
```
|
||||||
|
id_user (PK)
|
||||||
|
name_user (VARCHAR 200)
|
||||||
|
email (VARCHAR 200, UNIQUE)
|
||||||
|
pass (VARCHAR 200, bcrypt hash)
|
||||||
|
id_rol (FK → roles)
|
||||||
|
business_name (VARCHAR 200)
|
||||||
|
phone (VARCHAR 50)
|
||||||
|
address (TEXT)
|
||||||
|
is_active (BOOLEAN DEFAULT false)
|
||||||
|
created_at (TIMESTAMP DEFAULT now())
|
||||||
|
last_login (TIMESTAMP)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`sessions`** (new):
|
||||||
|
```
|
||||||
|
id_session (PK)
|
||||||
|
user_id (FK → users)
|
||||||
|
refresh_token (VARCHAR 500, UNIQUE)
|
||||||
|
expires_at (TIMESTAMP)
|
||||||
|
created_at (TIMESTAMP DEFAULT now())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Flow
|
||||||
|
- Login: `POST /api/auth/login` → JWT access token (15 min) + refresh token (30 days)
|
||||||
|
- Refresh: `POST /api/auth/refresh` → new access token
|
||||||
|
- JWT payload: `user_id`, `role`, `business_name`
|
||||||
|
- Public endpoints: catalog, landing, search, aftermarket list
|
||||||
|
- Protected endpoints: pricing, inventory, admin, POS
|
||||||
|
- Flask middleware validates JWT on protected routes
|
||||||
|
|
||||||
|
### Registration
|
||||||
|
- Bodegas and talleres register via form
|
||||||
|
- ADMIN approves accounts (is_active = false by default)
|
||||||
|
- ADMIN can create accounts directly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Warehouse Inventory
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
**`warehouse_inventory`** (new):
|
||||||
|
```
|
||||||
|
id_inventory (PK, BIGINT)
|
||||||
|
user_id (FK → users)
|
||||||
|
part_id (FK → parts)
|
||||||
|
price (NUMERIC 12,2)
|
||||||
|
stock_quantity (INTEGER)
|
||||||
|
min_order_quantity (INTEGER DEFAULT 1)
|
||||||
|
warehouse_location (VARCHAR 100)
|
||||||
|
updated_at (TIMESTAMP DEFAULT now())
|
||||||
|
UNIQUE (user_id, part_id, warehouse_location)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`inventory_uploads`** (new):
|
||||||
|
```
|
||||||
|
id_upload (PK)
|
||||||
|
user_id (FK → users)
|
||||||
|
filename (VARCHAR 200)
|
||||||
|
status (VARCHAR 20: pending, processing, completed, failed)
|
||||||
|
rows_total (INTEGER)
|
||||||
|
rows_imported (INTEGER)
|
||||||
|
rows_errors (INTEGER)
|
||||||
|
error_log (TEXT)
|
||||||
|
created_at (TIMESTAMP DEFAULT now())
|
||||||
|
completed_at (TIMESTAMP)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`inventory_column_mappings`** (new):
|
||||||
|
```
|
||||||
|
id_mapping (PK)
|
||||||
|
user_id (FK → users, UNIQUE)
|
||||||
|
mapping (JSONB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example JSONB mapping:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"part_number": "COLUMNA_A",
|
||||||
|
"price": "PRECIO_VENTA",
|
||||||
|
"stock": "EXISTENCIAS",
|
||||||
|
"location": "SUCURSAL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload Flow
|
||||||
|
1. Bodega uploads CSV/Excel → `POST /api/inventory/upload`
|
||||||
|
2. Backend reads file, applies JSONB mapping for that bodega
|
||||||
|
3. Matches part numbers against `parts.oem_part_number`
|
||||||
|
4. UPSERT into `warehouse_inventory`
|
||||||
|
5. Records result in `inventory_uploads`
|
||||||
|
|
||||||
|
### Catalog Display (authenticated TALLER)
|
||||||
|
```
|
||||||
|
Disponibilidad en bodegas:
|
||||||
|
BODEGA CENTRAL MX | $450.00 | 12 en stock | Guadalajara
|
||||||
|
REFACCIONES DEL NORTE | $485.00 | 3 en stock | Monterrey
|
||||||
|
```
|
||||||
|
- Only authenticated talleres see prices and stock
|
||||||
|
- Public users see catalog without prices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Aftermarket Parts Migration
|
||||||
|
|
||||||
|
### Current Problem
|
||||||
|
- 357,360 parts with `AFT-` prefix in `parts` table treated as OEM
|
||||||
|
- Format: `AFT-{articleNo}-{supplierName}` (e.g., `AFT-AC191-PARTQUIP`)
|
||||||
|
- Description: `"Aftermarket PARTQUIP"`
|
||||||
|
- Have vehicle_parts linked
|
||||||
|
- `aftermarket_parts` table exists but has only 1 record
|
||||||
|
|
||||||
|
### Migration Steps
|
||||||
|
|
||||||
|
**Step 1: Parse AFT- prefix**
|
||||||
|
```
|
||||||
|
AFT-AC191-PARTQUIP → part_number: AC191, manufacturer: PARTQUIP
|
||||||
|
AFT-10-0058-Airstal → part_number: 10-0058, manufacturer: Airstal
|
||||||
|
```
|
||||||
|
Logic: last segment after last `-` that matches a `manufacturers.name_manufacture` is the manufacturer. The rest (without `AFT-`) is the part number.
|
||||||
|
|
||||||
|
**Step 2: Find corresponding OEM part**
|
||||||
|
- Search `part_cross_references` where `cross_reference_number` = articleNo and `source_ref` = supplierName
|
||||||
|
- That gives us the `part_id` of the real OEM part
|
||||||
|
- If no cross-reference, search via `vehicle_parts` — OEM parts linked to same vehicles in same category
|
||||||
|
|
||||||
|
**Step 3: Populate `aftermarket_parts`**
|
||||||
|
```
|
||||||
|
oem_part_id → the OEM part found
|
||||||
|
manufacturer_id → FK to manufacturer (PARTQUIP, Airstal, etc.)
|
||||||
|
part_number → AC191 (clean, no prefix)
|
||||||
|
name_aftermarket_parts → original name_part
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Migrate vehicle_parts**
|
||||||
|
- vehicle_parts pointing to AFT- part get re-linked to the real OEM part
|
||||||
|
- Or deleted if OEM already has that link (ON CONFLICT DO NOTHING)
|
||||||
|
|
||||||
|
**Step 5: Delete AFT- parts from `parts`**
|
||||||
|
- Once migrated to `aftermarket_parts` and re-linked, remove from `parts`
|
||||||
|
|
||||||
|
### Import Pipeline Changes
|
||||||
|
- `import_live.py` and `import_tecdoc_parts.py` stop creating `AFT-` parts in `parts`
|
||||||
|
- Instead insert directly into `aftermarket_parts` with clean manufacturer and part number
|
||||||
|
- `vehicle_parts` only link to real OEM parts
|
||||||
|
|
||||||
|
### Catalog Display
|
||||||
|
```
|
||||||
|
Alternativas aftermarket:
|
||||||
|
PARTQUIP AC191 | Ver disponibilidad →
|
||||||
|
BOSCH 0 986 AB2 854 | Ver disponibilidad →
|
||||||
|
KAWE 6497 10 | Ver disponibilidad →
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: API Endpoints & Pages
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
|
||||||
|
**Auth:**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| POST | `/api/auth/register` | Public | Register taller/bodega |
|
||||||
|
| POST | `/api/auth/login` | Public | Login → JWT + refresh |
|
||||||
|
| POST | `/api/auth/refresh` | Authenticated | Renew access token |
|
||||||
|
| GET | `/api/auth/me` | Authenticated | User profile |
|
||||||
|
|
||||||
|
**Inventory (BODEGA):**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| POST | `/api/inventory/upload` | BODEGA | Upload CSV/Excel |
|
||||||
|
| GET | `/api/inventory/uploads` | BODEGA | Upload history |
|
||||||
|
| GET | `/api/inventory/mapping` | BODEGA | View column mapping |
|
||||||
|
| PUT | `/api/inventory/mapping` | BODEGA | Configure mapping |
|
||||||
|
| GET | `/api/inventory/items` | BODEGA | View own inventory |
|
||||||
|
| DELETE | `/api/inventory/items` | BODEGA | Clear inventory |
|
||||||
|
|
||||||
|
**Availability (TALLER):**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| GET | `/api/parts/{id}/availability` | TALLER | Prices/stock from all bodegas |
|
||||||
|
| GET | `/api/parts/{id}/aftermarket` | Public | Aftermarket alternatives list |
|
||||||
|
|
||||||
|
**Admin:**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| GET | `/api/admin/users` | ADMIN | List users |
|
||||||
|
| PUT | `/api/admin/users/{id}/activate` | ADMIN | Approve/deactivate account |
|
||||||
|
|
||||||
|
### New Pages
|
||||||
|
|
||||||
|
**`login.html`** — Login/registration form. Redirects by role after login.
|
||||||
|
|
||||||
|
**`bodega.html`** — Warehouse panel:
|
||||||
|
- Configure column mapping
|
||||||
|
- Upload CSV/Excel
|
||||||
|
- View upload history with status
|
||||||
|
- View current inventory with search
|
||||||
|
|
||||||
|
### Modified Pages
|
||||||
|
- **`index.html` / `demo.html`** — Add "Disponibilidad en bodegas" section in part detail (TALLER only). Add "Alternativas aftermarket" section (public).
|
||||||
|
- **`admin.html`** — Add "Usuarios" tab for account management.
|
||||||
|
- **`nav.js`** — Add login/logout button, show username.
|
||||||
|
|
||||||
|
### Auth Middleware
|
||||||
|
```
|
||||||
|
Public: catalog, search, landing, login, aftermarket list
|
||||||
|
TALLER: prices, availability, history
|
||||||
|
BODEGA: upload, mapping, own inventory
|
||||||
|
ADMIN/OWNER: all above + user management + admin panel
|
||||||
|
```
|
||||||
1306
docs/plans/2026-03-15-saas-aftermarket-plan.md
Normal file
1306
docs/plans/2026-03-15-saas-aftermarket-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user