Files
Autoparts-DB/docs/plans/2026-03-27-pos-plan-3-pos-cashregister.md
consultoria-as 7036a18601 docs: add POS + Cash Register implementation plan (3 of 5)
9-task plan covering:
- POS engine (sale processing, Decimal totals, margin info)
- Customers blueprint (CRUD, credit, vehicle history, statements, payments)
- POS blueprint (sales, quotations, layaways with stock reservation)
- Cash register (open/close, X/Z cuts, daily consolidated summary)
- POS frontend (F1-F6 shortcuts, margin display, payment modal)
- Customers frontend (search, credit status, account statements)
- Full integration test (20+ tests covering entire sale lifecycle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:32:00 +00:00

5211 lines
207 KiB
Markdown

# POS + Cash Register Implementation Plan (3 of 5)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the complete Point of Sale module: sale processing engine, customer management, quotations, layaways, cash register operations, and the POS frontend with F-key shortcuts for rapid counter operation.
**Architecture:** Three Flask blueprints (`pos_bp.py`, `customers_bp.py`, `cashregister_bp.py`) with one core service (`pos_engine.py`). The POS engine calls `record_sale()` from `inventory_engine` for stock deduction -- it does NOT create its own inventory operations. The frontend is a split-panel layout (search/items left, current sale right) with keyboard-driven operation.
**Tech Stack:** Python 3, Flask blueprints, psycopg2, HTML/JS/CSS (vanilla)
**Spec:** `/home/Autopartes/docs/plans/2026-03-27-pos-inventario-design.md` (section 2: POS module)
**Depends on:** Plan 1 Foundation (complete) -- tenant_db, middleware, audit service. Plan 2 Inventory + Catalog (complete) -- inventory_engine, catalog with cart.
**Sub-plans:**
1. Foundation (complete)
2. Inventory + Catalog (complete)
3. **POS + Cash Register** (this plan)
4. CFDI + Accounting
5. PWA + Sync
---
## File Structure
```
/home/Autopartes/pos/
├── app.py # MODIFY: register pos_bp, customers_bp, cashregister_bp, add page routes
├── blueprints/
│ ├── pos_bp.py # CREATE: sales, quotations, layaways endpoints
│ ├── customers_bp.py # CREATE: customer CRUD, credit, vehicles, statements
│ └── cashregister_bp.py # CREATE: register open/close, movements, cuts X/Z
├── services/
│ └── pos_engine.py # CREATE: sale processing, totals, pricing, cancellation
├── templates/
│ ├── pos.html # CREATE: POS page (split layout, cart, payment modal)
│ └── customers.html # CREATE: customer management page
└── static/
└── js/
├── pos.js # CREATE: POS UI, F-keys, payment flow, ticket print
└── customers.js # CREATE: customer CRUD UI, credit, vehicles
```
---
### Task 1: POS engine service (`pos/services/pos_engine.py`)
**Files:**
- Create: `/home/Autopartes/pos/services/pos_engine.py`
Core sale processing logic. All monetary calculations happen here. This service calls `record_sale()` from `inventory_engine` for stock deduction -- it never creates inventory operations directly.
- [ ] **Step 1: Create pos_engine.py**
```python
# /home/Autopartes/pos/services/pos_engine.py
"""POS engine: sale processing, totals calculation, pricing, cancellation.
All sale operations go through this service. Stock deductions are delegated
to inventory_engine.record_sale() — this service NEVER creates inventory
operations directly.
Monetary amounts: NUMERIC(12,2) in DB, float in Python.
Tax: 16% IVA per item (from item.tax_rate field).
"""
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from flask import g
from services.audit import log_action
from services.inventory_engine import (
record_sale as inventory_record_sale,
record_operation,
get_stock,
)
def _to_dec(val):
"""Convert a value to Decimal for precise arithmetic."""
return Decimal(str(val))
def calculate_totals(items):
"""Compute subtotal, discount amounts, tax, and total for a list of items.
Uses Python Decimal for all intermediate calculations to avoid
floating-point accumulation errors. Each line item is rounded
individually before summing. Converts back to float only at
the end for JSON serialization.
Each item dict must have: unit_price, quantity, discount_pct, tax_rate.
Returns dict with computed values and enriched items list.
Args:
items: list of dicts with keys:
- unit_price (float): price per unit
- quantity (int): number of units
- discount_pct (float): discount percentage (0-100)
- tax_rate (float): tax rate as decimal (e.g., 0.16 for 16%)
Returns:
dict: {subtotal, discount_total, tax_total, total, items: [...enriched...]}
"""
subtotal = Decimal('0')
discount_total = Decimal('0')
tax_total = Decimal('0')
enriched_items = []
TWO = Decimal('0.01')
for item in items:
qty = int(item['quantity'])
price = _to_dec(item['unit_price'])
discount_pct = _to_dec(item.get('discount_pct', 0))
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
line_gross = (price * qty).quantize(TWO, rounding=ROUND_HALF_UP)
line_discount = (line_gross * discount_pct / Decimal('100')).quantize(TWO, rounding=ROUND_HALF_UP)
line_after_discount = (line_gross - line_discount).quantize(TWO, rounding=ROUND_HALF_UP)
line_tax = (line_after_discount * tax_rate).quantize(TWO, rounding=ROUND_HALF_UP)
line_subtotal = (line_after_discount + line_tax).quantize(TWO, rounding=ROUND_HALF_UP)
subtotal += line_after_discount
discount_total += line_discount
tax_total += line_tax
enriched_items.append({
**item,
'line_gross': float(line_gross),
'discount_amount': float(line_discount),
'tax_amount': float(line_tax),
'subtotal': float(line_subtotal),
})
return {
'subtotal': float(subtotal.quantize(TWO, rounding=ROUND_HALF_UP)),
'discount_total': float(discount_total.quantize(TWO, rounding=ROUND_HALF_UP)),
'tax_total': float(tax_total.quantize(TWO, rounding=ROUND_HALF_UP)),
'total': float((subtotal + tax_total).quantize(TWO, rounding=ROUND_HALF_UP)),
'items': enriched_items,
}
def get_price_for_customer(inventory_item, customer):
"""Return the correct price based on the customer's price tier.
Args:
inventory_item: dict with price_1, price_2, price_3
customer: dict with price_tier (1, 2, or 3), or None for publico general
Returns:
float: the applicable price
"""
if customer is None:
return float(inventory_item.get('price_1', 0))
tier = customer.get('price_tier', 1)
if tier == 3:
price = inventory_item.get('price_3', 0)
elif tier == 2:
price = inventory_item.get('price_2', 0)
else:
price = inventory_item.get('price_1', 0)
return float(price) if price else float(inventory_item.get('price_1', 0))
def get_margin_info(inventory_item, selling_price=None, discount_pct=0):
"""Return cost, price, margin %, and max discount without losing margin.
Only meaningful for employees with pos.view_cost permission (checked by caller).
Args:
inventory_item: dict with cost, price_1 (or selling_price override)
selling_price: override price (if None, uses price_1)
discount_pct: current discount percentage
Returns:
dict: {cost, price, margin_pct, max_discount_pct}
"""
cost = float(inventory_item.get('cost', 0))
price = float(selling_price) if selling_price else float(inventory_item.get('price_1', 0))
if price <= 0:
return {'cost': cost, 'price': price, 'margin_pct': 0.0, 'max_discount_pct': 0.0}
effective_price = price * (1 - discount_pct / 100)
margin_pct = ((effective_price - cost) / effective_price * 100) if effective_price > 0 else 0.0
# Max discount before margin hits zero: price * (1 - d/100) = cost => d = (1 - cost/price) * 100
max_discount_pct = ((1 - cost / price) * 100) if price > cost else 0.0
return {
'cost': round(cost, 2),
'price': round(price, 2),
'margin_pct': round(margin_pct, 2),
'max_discount_pct': round(max_discount_pct, 2),
}
def process_sale(conn, sale_data):
"""Process a complete sale: validate, create records, deduct inventory, record payment.
This is the main entry point for creating a sale. It handles the full transaction:
1. Validate all items exist and have sufficient stock
2. Calculate totals
3. Create sale + sale_items records
4. Call inventory_engine.record_sale() for each item (stock deduction)
5. Record payment on cash register
6. Update customer credit balance (if credit sale)
7. Create audit log entry
Args:
conn: psycopg2 connection to tenant DB (caller controls commit)
sale_data: dict with keys:
- items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}]
- customer_id: int or None (publico general)
- payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto'
- sale_type: 'cash' | 'credit' | 'mixed'
- register_id: int (cash register session ID)
- amount_paid: float
- payment_details: [{method, amount, reference}] (for mixed payments)
- notes: str (optional)
Returns:
dict: complete sale object with id, items, totals, change
Raises:
ValueError: on validation errors (insufficient stock, invalid items, etc.)
"""
cur = conn.cursor()
items = sale_data.get('items', [])
customer_id = sale_data.get('customer_id')
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', 0))
payment_details = sale_data.get('payment_details', [])
notes = sale_data.get('notes')
branch_id = getattr(g, 'branch_id', None)
employee_id = getattr(g, 'employee_id', None)
if not items:
raise ValueError("No items in sale")
# Validate register is open
if register_id:
cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
reg = cur.fetchone()
if not reg or reg[0] != 'open':
raise ValueError("Cash register is not open")
# Validate and enrich items from inventory
enriched_items = []
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if qty <= 0:
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id
FROM inventory WHERE id = %s AND is_active = true
""", (inv_id,))
inv = cur.fetchone()
if not inv:
raise ValueError(f"Inventory item {inv_id} not found or inactive")
# Check stock (allow negative stock for offline tolerance, but warn)
current_stock = get_stock(conn, inv_id, inv[8]) # inv[8] = branch_id
# Use provided price or fetch from inventory
unit_price = float(item.get('unit_price', inv[4])) # default to price_1
discount_pct = float(item.get('discount_pct', 0))
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
unit_cost = float(inv[3]) if inv[3] else 0
# Validate discount against employee max
max_discount = float(getattr(g, 'max_discount_pct', 100) or 100)
if g.employee_role not in ('owner', 'admin') and discount_pct > max_discount:
raise ValueError(
f"Discount {discount_pct}% exceeds your maximum allowed {max_discount}% "
f"for item {inv[2]}"
)
enriched_items.append({
'inventory_id': inv_id,
'part_number': inv[1],
'name': inv[2],
'quantity': qty,
'unit_price': unit_price,
'unit_cost': unit_cost,
'discount_pct': discount_pct,
'tax_rate': tax_rate,
'branch_id': inv[8],
'stock_before': current_stock,
})
# Calculate totals
totals = calculate_totals(enriched_items)
# Validate credit sale
if sale_type == 'credit' and customer_id:
cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
cust = cur.fetchone()
if cust:
credit_limit = float(cust[0] or 0)
credit_balance = float(cust[1] or 0)
credit_available = credit_limit - credit_balance
if totals['total'] > credit_available and credit_limit > 0:
raise ValueError(
f"Insufficient credit. Available: ${credit_available:.2f}, "
f"Required: ${totals['total']:.2f}"
)
# Calculate change
change_given = 0.0
if sale_type == 'cash' and payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
# SAT payment method codes
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')
# Create sale record
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, device_id, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, totals['subtotal'], totals['discount_total'],
totals['tax_total'], totals['total'], amount_paid, change_given,
metodo_pago_sat, forma_pago_sat,
getattr(g, 'device_id', None), notes
))
sale_id, created_at = cur.fetchone()
# Create sale items and deduct inventory
sale_items = []
for idx, item in enumerate(totals['items']):
cur.execute("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, discount_pct, discount_amount,
tax_rate, tax_amount, subtotal)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
sale_id, item['inventory_id'], item['part_number'], item['name'],
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal']
))
sale_item_id = cur.fetchone()[0]
# Deduct inventory via inventory_engine (NEVER create operations directly)
inventory_record_sale(
conn,
item['inventory_id'],
item.get('branch_id', branch_id),
item['quantity'],
sale_id=sale_id,
cost_at_time=item.get('unit_cost')
)
sale_items.append({
'id': sale_item_id,
'inventory_id': item['inventory_id'],
'part_number': item['part_number'],
'name': item['name'],
'quantity': item['quantity'],
'unit_price': item['unit_price'],
'unit_cost': item.get('unit_cost', 0),
'discount_pct': item['discount_pct'],
'discount_amount': item['discount_amount'],
'tax_rate': item['tax_rate'],
'tax_amount': item['tax_amount'],
'subtotal': item['subtotal'],
})
# Record payment on cash register (cash movements for efectivo)
if register_id and payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref))
elif register_id:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s,%s,%s,%s,%s)
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', '')))
# Update customer credit balance if credit sale
if sale_type == 'credit' and customer_id:
cur.execute("""
UPDATE customers
SET credit_balance = credit_balance + %s
WHERE id = %s
""", (totals['total'], customer_id))
# Audit log
log_action(conn, 'SALE', 'sale', sale_id,
new_value={
'total': totals['total'],
'items_count': len(sale_items),
'payment_method': payment_method,
'sale_type': sale_type,
'customer_id': customer_id,
})
cur.close()
return {
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': sale_type,
'payment_method': payment_method,
'subtotal': totals['subtotal'],
'discount_total': totals['discount_total'],
'tax_total': totals['tax_total'],
'total': totals['total'],
'amount_paid': amount_paid,
'change_given': change_given,
'metodo_pago_sat': metodo_pago_sat,
'forma_pago_sat': forma_pago_sat,
'status': 'completed',
'items': sale_items,
'created_at': str(created_at),
}
def cancel_sale(conn, sale_id, reason):
"""Cancel a sale: validate permissions, reverse inventory, update credit.
Business rules:
- Cashiers can only cancel their own sales within 30 minutes
- Admins and owners can cancel any sale
- Cancelled sales are not deleted, just marked as 'cancelled'
- Inventory is restored via RETURN operations
- Customer credit balance is adjusted back
Args:
conn: psycopg2 connection
sale_id: int
reason: str (mandatory, min 3 chars)
Returns:
dict: cancellation result
Raises:
ValueError: on validation errors
"""
if not reason or len(reason.strip()) < 3:
raise ValueError("Cancellation reason is mandatory (min 3 characters)")
cur = conn.cursor()
# Get sale details
cur.execute("""
SELECT id, employee_id, customer_id, sale_type, total, status, created_at,
branch_id, register_id
FROM sales WHERE id = %s
""", (sale_id,))
sale = cur.fetchone()
if not sale:
raise ValueError("Sale not found")
s_id, s_emp_id, s_cust_id, s_type, s_total, s_status, s_created, s_branch, s_register = sale
if s_status == 'cancelled':
raise ValueError("Sale is already cancelled")
# Permission check: cashiers can only cancel own sales within 30 min
role = getattr(g, 'employee_role', 'cashier')
emp_id = getattr(g, 'employee_id', None)
if role == 'cashier':
if s_emp_id != emp_id:
raise ValueError("Cashiers can only cancel their own sales")
if datetime.utcnow() - s_created > timedelta(minutes=30):
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
# Get sale items for inventory reversal
cur.execute("""
SELECT inventory_id, quantity, unit_cost
FROM sale_items WHERE sale_id = %s
""", (sale_id,))
sale_items = cur.fetchall()
# Reverse inventory: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion venta #{sale_id}: {reason}"
)
# Update sale status
cur.execute("""
UPDATE sales SET status = 'cancelled', notes = COALESCE(notes || ' | ', '') || %s
WHERE id = %s
""", (f"CANCELADA: {reason}", sale_id))
# Reverse customer credit balance if credit sale
if s_type == 'credit' and s_cust_id:
cur.execute("""
UPDATE customers
SET credit_balance = credit_balance - %s
WHERE id = %s
""", (float(s_total), s_cust_id))
# Audit log
log_action(conn, 'CANCEL', 'sale', sale_id,
old_value={'status': 'completed', 'total': float(s_total)},
new_value={'status': 'cancelled', 'reason': reason})
cur.close()
return {
'sale_id': sale_id,
'status': 'cancelled',
'reason': reason,
'items_reversed': len(sale_items),
'total_reversed': float(s_total),
}
```
---
### Task 2: Customers blueprint (`pos/blueprints/customers_bp.py`)
**Files:**
- Create: `/home/Autopartes/pos/blueprints/customers_bp.py`
Customer CRUD, credit management, vehicle history, account statements. The `customers` table already exists in the tenant schema from Plan 1.
- [ ] **Step 1: Create customers_bp.py**
```python
# /home/Autopartes/pos/blueprints/customers_bp.py
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""
import json
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.audit import log_action
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
# ─── Customer CRUD ─────────────────────────────
@customers_bp.route('', methods=['GET'])
@require_auth('customers.view')
def list_customers():
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
Query params:
q: search string (matches name, RFC, phone via ILIKE)
page: page number (default 1)
per_page: items per page (default 50, max 200)
branch_id: filter by branch (default: current user's branch)
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
search = request.args.get('q', '').strip()
branch_id = request.args.get('branch_id')
where_clauses = ["c.is_active = true"]
params = []
if branch_id:
where_clauses.append("c.branch_id = %s")
params.append(int(branch_id))
if search:
where_clauses.append(
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
)
params.extend([f'%{search}%'] * 4)
where = " AND ".join(where_clauses)
# Count
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
total = cur.fetchone()[0]
# Fetch
cur.execute(f"""
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
c.branch_id
FROM customers c
WHERE {where}
ORDER BY c.name
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
customers = []
for r in cur.fetchall():
customers.append({
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
'phone': r[4], 'email': r[5], 'price_tier': r[6],
'credit_limit': float(r[7]) if r[7] else 0,
'credit_balance': float(r[8]) if r[8] else 0,
'vehicle_info': r[9],
'branch_id': r[10],
})
cur.close()
conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': customers,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
@customers_bp.route('/<int:customer_id>', methods=['GET'])
@require_auth('customers.view')
def get_customer(customer_id):
"""Get customer details with credit info, vehicle history, and recent purchases."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, credit_balance,
is_active, vehicle_info, created_at
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
cols = [desc[0] for desc in cur.description]
customer = dict(zip(cols, row))
# Convert Decimal to float
for k in ('credit_limit', 'credit_balance'):
if customer.get(k) is not None:
customer[k] = float(customer[k])
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
# Recent purchases (last 20)
cur.execute("""
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
e.name as employee_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
WHERE s.customer_id = %s AND s.status != 'cancelled'
ORDER BY s.created_at DESC
LIMIT 20
""", (customer_id,))
customer['recent_purchases'] = []
for r in cur.fetchall():
customer['recent_purchases'].append({
'id': r[0], 'total': float(r[1]) if r[1] else 0,
'payment_method': r[2], 'sale_type': r[3],
'status': r[4], 'created_at': str(r[5]),
'employee_name': r[6],
})
# Credit summary
customer['credit_available'] = round(
float(customer['credit_limit']) - float(customer['credit_balance']), 2
)
cur.close()
conn.close()
return jsonify(customer)
@customers_bp.route('', methods=['POST'])
@require_auth('customers.create')
def create_customer():
"""Create a new customer.
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
phone, address, price_tier, credit_limit, vehicle_info}
"""
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'name is required'}), 400
branch_id = data.get('branch_id', g.branch_id)
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO customers
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, vehicle_info)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
branch_id, data['name'], data.get('rfc'), data.get('razon_social'),
data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
data.get('cp'), data.get('email'), data.get('phone'),
data.get('address'), data.get('price_tier', 1),
data.get('credit_limit', 0),
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
))
customer_id = cur.fetchone()[0]
log_action(conn, 'CUSTOMER_CREATE', 'customer', customer_id,
new_value={'name': data['name'], 'rfc': data.get('rfc')})
conn.commit()
cur.close(); conn.close()
return jsonify({'id': customer_id, 'message': 'Customer created'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@customers_bp.route('/<int:customer_id>', methods=['PUT'])
@require_auth('customers.edit')
def update_customer(customer_id):
"""Update customer fields. Credit limit changes require customers.edit_credit permission."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Verify customer exists
cur.execute("SELECT id, credit_limit FROM customers WHERE id = %s", (customer_id,))
existing = cur.fetchone()
if not existing:
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
# Credit limit change requires special permission
if 'credit_limit' in data and float(data['credit_limit']) != float(existing[1] or 0):
if not has_permission('customers.edit_credit'):
cur.close(); conn.close()
return jsonify({'error': 'Permission customers.edit_credit required to change credit limit'}), 403
log_action(conn, 'CREDIT_CHANGE', 'customer', customer_id,
old_value={'credit_limit': float(existing[1] or 0)},
new_value={'credit_limit': float(data['credit_limit'])})
# Build dynamic update
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
'vehicle_info', 'is_active', 'branch_id']
sets = []
vals = []
for field in allowed:
if field in data:
val = data[field]
if field == 'vehicle_info' and isinstance(val, (dict, list)):
val = json.dumps(val)
sets.append(f"{field} = %s")
vals.append(val)
if not sets:
cur.close(); conn.close()
return jsonify({'error': 'No fields to update'}), 400
vals.append(customer_id)
cur.execute(f"UPDATE customers SET {', '.join(sets)} WHERE id = %s", vals)
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Customer updated'})
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
@require_auth('customers.view')
def customer_statement(customer_id):
"""Account statement: sales (invoices), payments, running balance.
Query params:
from_date: start date (YYYY-MM-DD), default 30 days ago
to_date: end date (YYYY-MM-DD), default today
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
from_date = request.args.get('from_date')
to_date = request.args.get('to_date')
# Verify customer exists
cur.execute("SELECT name, credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
where_date = ""
params = [customer_id]
if from_date:
where_date += " AND s.created_at >= %s"
params.append(from_date)
if to_date:
where_date += " AND s.created_at < %s::date + interval '1 day'"
params.append(to_date)
# Get credit sales (charges / cargos)
cur.execute(f"""
SELECT s.id, 'charge' as type, s.total as amount, s.created_at,
'Venta #' || s.id as description, s.status
FROM sales s
WHERE s.customer_id = %s AND s.sale_type = 'credit' {where_date}
ORDER BY s.created_at
""", params)
entries = []
for r in cur.fetchall():
entries.append({
'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
'date': str(r[3]), 'description': r[4], 'status': r[5]
})
# Get customer payments (abonos) from the customer_payments table
pay_params = [customer_id]
pay_where = ""
if from_date:
pay_where += " AND cp.created_at >= %s"
pay_params.append(from_date)
if to_date:
pay_where += " AND cp.created_at < %s::date + interval '1 day'"
pay_params.append(to_date)
cur.execute(f"""
SELECT cp.id, 'payment' as type, cp.amount, cp.created_at,
'Abono - ' || cp.payment_method as description, 'completed' as status
FROM customer_payments cp
WHERE cp.customer_id = %s {pay_where}
ORDER BY cp.created_at
""", pay_params)
for r in cur.fetchall():
entries.append({
'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
'date': str(r[3]), 'description': r[4], 'status': r[5]
})
# Sort all entries by date for correct running balance
entries.sort(key=lambda e: e['date'])
# Compute running balance
balance = 0.0
for entry in entries:
if entry['status'] == 'cancelled':
continue
if entry['type'] == 'charge':
balance += entry['amount']
elif entry['type'] == 'payment':
balance -= entry['amount']
entry['running_balance'] = round(balance, 2)
cur.close()
conn.close()
return jsonify({
'customer': {
'id': customer_id,
'name': cust[0],
'credit_limit': float(cust[1]) if cust[1] else 0,
'credit_balance': float(cust[2]) if cust[2] else 0,
},
'entries': entries,
'balance': round(balance, 2),
})
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
@require_auth('customers.view')
def customer_vehicles(customer_id):
"""Get customer's vehicle list with last purchases per vehicle.
Vehicle info is stored as JSONB in customers.vehicle_info:
[{make, model, year, vin, plates}, ...]
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT vehicle_info FROM customers WHERE id = %s", (customer_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
vehicles = row[0] or []
# Get recent purchases for this customer to match with vehicles
cur.execute("""
SELECT s.id, s.total, s.created_at, s.notes,
array_agg(si.name ORDER BY si.id) as items
FROM sales s
JOIN sale_items si ON si.sale_id = s.id
WHERE s.customer_id = %s AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at, s.notes
ORDER BY s.created_at DESC
LIMIT 50
""", (customer_id,))
recent_sales = []
for r in cur.fetchall():
recent_sales.append({
'sale_id': r[0], 'total': float(r[1]) if r[1] else 0,
'date': str(r[2]), 'notes': r[3], 'items': r[4]
})
cur.close()
conn.close()
return jsonify({
'vehicles': vehicles,
'recent_sales': recent_sales,
})
@customers_bp.route('/<int:customer_id>/payment', methods=['POST'])
@require_auth('customers.edit')
def record_customer_payment(customer_id):
"""Record a payment against a customer's credit balance (abono).
Body: {amount: float, payment_method: str, reference: str, register_id: int}
"""
data = request.get_json() or {}
amount = float(data.get('amount', 0))
payment_method = data.get('payment_method', 'efectivo')
reference = data.get('reference', '')
register_id = data.get('register_id')
if amount <= 0:
return jsonify({'error': 'Amount must be greater than 0'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Verify customer exists and has a balance
cur.execute("SELECT name, credit_balance FROM customers WHERE id = %s", (customer_id,))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
credit_balance = float(cust[1] or 0)
if amount > credit_balance:
cur.close(); conn.close()
return jsonify({
'error': f'Payment ${amount:.2f} exceeds current balance ${credit_balance:.2f}'
}), 400
try:
# Record the payment
cur.execute("""
INSERT INTO customer_payments
(customer_id, amount, payment_method, reference, employee_id, register_id)
VALUES (%s,%s,%s,%s,%s,%s)
RETURNING id, created_at
""", (
customer_id, amount, payment_method, reference,
getattr(g, 'employee_id', None), register_id
))
payment_id, created_at = cur.fetchone()
# Reduce customer credit balance
new_balance = round(credit_balance - amount, 2)
cur.execute("""
UPDATE customers SET credit_balance = %s WHERE id = %s
""", (new_balance, customer_id))
# Record cash movement on register if cash payment
if register_id and payment_method == 'efectivo':
cur.execute("""
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
VALUES (%s, 'in', %s, %s, %s)
""", (register_id, amount, f'Abono cliente #{customer_id} - {cust[0]}',
getattr(g, 'employee_id', None)))
log_action(conn, 'CUSTOMER_PAYMENT', 'customer', customer_id,
old_value={'credit_balance': credit_balance},
new_value={'credit_balance': new_balance, 'payment': amount})
conn.commit()
cur.close(); conn.close()
return jsonify({
'payment_id': payment_id,
'amount': amount,
'previous_balance': credit_balance,
'new_balance': new_balance,
'created_at': str(created_at),
'message': 'Payment recorded'
}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
```
---
### Task 3: POS blueprint (`pos/blueprints/pos_bp.py`)
**Files:**
- Create: `/home/Autopartes/pos/blueprints/pos_bp.py`
Main POS endpoints: sales, quotations, and layaways. This blueprint is the HTTP layer -- all business logic lives in `pos_engine.py`.
- [ ] **Step 1: Create pos_bp.py**
```python
# /home/Autopartes/pos/blueprints/pos_bp.py
"""POS blueprint: sales, quotations, layaways.
All sale business logic is in services.pos_engine. This blueprint is the HTTP layer
that validates input, calls the engine, and returns JSON responses.
"""
import json
from datetime import datetime, date, timedelta
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.pos_engine import (
process_sale, cancel_sale, calculate_totals,
get_price_for_customer, get_margin_info
)
from services.audit import log_action
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
# ─── Sales ───────────────────────────────────────
@pos_bp.route('/sales', methods=['POST'])
@require_auth('pos.sell')
def create_sale():
"""Create a new sale.
Body: {
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int | null,
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
sale_type: 'cash' | 'credit' | 'mixed',
register_id: int,
amount_paid: float,
payment_details: [{method, amount, reference}], (for mixed payments)
notes: str
}
"""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
sale = process_sale(conn, data)
conn.commit()
conn.close()
return jsonify(sale), 201
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales', methods=['GET'])
@require_auth('pos.view')
def list_sales():
"""List sales with filters.
Query params:
date_from: YYYY-MM-DD
date_to: YYYY-MM-DD
employee_id: int
customer_id: int
status: completed | cancelled | returned
register_id: int
page: int (default 1)
per_page: int (default 50, max 200)
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
employee_id = request.args.get('employee_id')
customer_id = request.args.get('customer_id')
status = request.args.get('status')
register_id = request.args.get('register_id')
if date_from:
where_clauses.append("s.created_at >= %s")
params.append(date_from)
if date_to:
where_clauses.append("s.created_at < %s::date + interval '1 day'")
params.append(date_to)
if employee_id:
where_clauses.append("s.employee_id = %s")
params.append(int(employee_id))
if customer_id:
where_clauses.append("s.customer_id = %s")
params.append(int(customer_id))
if status:
where_clauses.append("s.status = %s")
params.append(status)
if register_id:
where_clauses.append("s.register_id = %s")
params.append(int(register_id))
# Default to current branch
if g.branch_id:
where_clauses.append("s.branch_id = %s")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM sales s WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
s.sale_type, s.payment_method, s.subtotal, s.discount_total,
s.tax_total, s.total, s.amount_paid, s.change_given,
s.status, s.created_at,
e.name as employee_name,
c.name as customer_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY s.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
sales = []
for r in cur.fetchall():
sales.append({
'id': r[0], 'branch_id': r[1], 'customer_id': r[2],
'employee_id': r[3], 'register_id': r[4],
'sale_type': r[5], 'payment_method': r[6],
'subtotal': float(r[7]) if r[7] else 0,
'discount_total': float(r[8]) if r[8] else 0,
'tax_total': float(r[9]) if r[9] else 0,
'total': float(r[10]) if r[10] else 0,
'amount_paid': float(r[11]) if r[11] else 0,
'change_given': float(r[12]) if r[12] else 0,
'status': r[13], 'created_at': str(r[14]),
'employee_name': r[15], 'customer_name': r[16],
})
cur.close()
conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': sales,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
@require_auth('pos.view')
def get_sale(sale_id):
"""Get sale detail with items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.*, e.name as employee_name, c.name as customer_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.id = %s
""", (sale_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404
cols = [desc[0] for desc in cur.description]
sale = dict(zip(cols, row))
# Convert Decimal fields
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
if sale.get(k) is not None:
sale[k] = float(sale[k])
if sale.get('created_at'):
sale['created_at'] = str(sale['created_at'])
# Get items
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal,
clave_prod_serv, clave_unidad
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'quantity': r[4], 'unit_price': float(r[5]) if r[5] else 0,
'unit_cost': float(r[6]) if r[6] else 0,
'discount_pct': float(r[7]) if r[7] else 0,
'discount_amount': float(r[8]) if r[8] else 0,
'tax_rate': float(r[9]) if r[9] else 0,
'tax_amount': float(r[10]) if r[10] else 0,
'subtotal': float(r[11]) if r[11] else 0,
'clave_prod_serv': r[12], 'clave_unidad': r[13],
})
sale['items'] = items
# Get payments
cur.execute("""
SELECT id, method, amount, reference, created_at
FROM sale_payments WHERE sale_id = %s ORDER BY id
""", (sale_id,))
payments = []
for r in cur.fetchall():
payments.append({
'id': r[0], 'method': r[1], 'amount': float(r[2]) if r[2] else 0,
'reference': r[3], 'created_at': str(r[4]) if r[4] else None,
})
sale['payments'] = payments
cur.close()
conn.close()
return jsonify(sale)
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
def api_cancel_sale(sale_id):
"""Cancel a sale. Requires mandatory reason.
Body: {reason: str}
"""
data = request.get_json() or {}
reason = data.get('reason', '').strip()
conn = get_tenant_conn(g.tenant_id)
try:
result = cancel_sale(conn, sale_id, reason)
conn.commit()
conn.close()
return jsonify(result)
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
# ─── Quotations ──────────────────────────────────
@pos_bp.route('/quotations', methods=['POST'])
@require_auth('pos.sell')
def create_quotation():
"""Save a quotation from current cart.
Body: {
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int | null,
valid_days: int (default 7),
notes: str
}
"""
data = request.get_json() or {}
items = data.get('items', [])
if not items:
return jsonify({'error': 'No items in quotation'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Calculate totals
totals = calculate_totals(items)
valid_days = int(data.get('valid_days', 7))
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
try:
cur.execute("""
INSERT INTO quotations
(branch_id, customer_id, employee_id, subtotal, discount_total,
tax_total, total, status, valid_until, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,'active',%s,%s)
RETURNING id, created_at
""", (
g.branch_id, data.get('customer_id'), g.employee_id,
totals['subtotal'], totals['discount_total'], totals['tax_total'],
totals['total'], valid_until, data.get('notes')
))
quot_id, created_at = cur.fetchone()
# Insert quotation items
for item in totals['items']:
# Look up part_number and name from inventory
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (item['inventory_id'],))
inv = cur.fetchone()
part_number = inv[0] if inv else item.get('part_number', '')
name = inv[1] if inv else item.get('name', '')
line_subtotal = round(
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
)
cur.execute("""
INSERT INTO quotation_items
(quotation_id, inventory_id, part_number, name, quantity,
unit_price, discount_pct, tax_rate, subtotal)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
quot_id, item['inventory_id'], part_number, name,
item['quantity'], item['unit_price'], item['discount_pct'],
item['tax_rate'], line_subtotal
))
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
new_value={'total': totals['total'], 'items_count': len(items)})
conn.commit()
cur.close(); conn.close()
return jsonify({
'id': quot_id,
'total': totals['total'],
'valid_until': valid_until,
'created_at': str(created_at),
'message': 'Quotation created'
}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/quotations', methods=['GET'])
@require_auth('pos.view')
def list_quotations():
"""List quotations with filters.
Query params: customer_id, status (active|converted|expired|cancelled), page, per_page
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
customer_id = request.args.get('customer_id')
status = request.args.get('status')
if customer_id:
where_clauses.append("q.customer_id = %s")
params.append(int(customer_id))
if status:
where_clauses.append("q.status = %s")
params.append(status)
if g.branch_id:
where_clauses.append("q.branch_id = %s")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM quotations q WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total,
q.total, q.status, q.valid_until, q.created_at,
c.name as customer_name, e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
LEFT JOIN employees e ON q.employee_id = e.id
WHERE {where}
ORDER BY q.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
quotations = []
for r in cur.fetchall():
quotations.append({
'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
'subtotal': float(r[3]) if r[3] else 0,
'tax_total': float(r[4]) if r[4] else 0,
'total': float(r[5]) if r[5] else 0,
'status': r[6], 'valid_until': str(r[7]) if r[7] else None,
'created_at': str(r[8]),
'customer_name': r[9], 'employee_name': r[10],
})
cur.close(); conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': quotations,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
@pos_bp.route('/quotations/<int:quot_id>', methods=['GET'])
@require_auth('pos.view')
def get_quotation(quot_id):
"""Get quotation detail with items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT q.*, c.name as customer_name, e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
LEFT JOIN employees e ON q.employee_id = e.id
WHERE q.id = %s
""", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
cols = [desc[0] for desc in cur.description]
quot = dict(zip(cols, row))
for k in ('subtotal', 'tax_total', 'total'):
if quot.get(k) is not None:
quot[k] = float(quot[k])
if quot.get('created_at'):
quot['created_at'] = str(quot['created_at'])
if quot.get('valid_until'):
quot['valid_until'] = str(quot['valid_until'])
# Get items
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_price,
discount_pct, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quot_id,))
quot['items'] = []
for r in cur.fetchall():
quot['items'].append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'quantity': r[4], 'unit_price': float(r[5]) if r[5] else 0,
'discount_pct': float(r[6]) if r[6] else 0,
'tax_rate': float(r[7]) if r[7] else 0,
'subtotal': float(r[8]) if r[8] else 0,
})
cur.close(); conn.close()
return jsonify(quot)
@pos_bp.route('/quotations/<int:quot_id>/convert', methods=['POST'])
@require_auth('pos.sell')
def convert_quotation(quot_id):
"""Convert a quotation to a sale. Uses current stock and prices from the quotation.
Body: {
register_id: int,
payment_method: str,
sale_type: str,
amount_paid: float,
payment_details: [{method, amount, reference}]
}
"""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Get quotation
cur.execute("SELECT id, customer_id, status FROM quotations WHERE id = %s", (quot_id,))
quot = cur.fetchone()
if not quot:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if quot[2] != 'active':
cur.close(); conn.close()
return jsonify({'error': f'Quotation is {quot[2]}, cannot convert'}), 400
# Get quotation items
cur.execute("""
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
FROM quotation_items WHERE quotation_id = %s
""", (quot_id,))
items = []
for r in cur.fetchall():
items.append({
'inventory_id': r[0], 'quantity': r[1], 'unit_price': float(r[2]),
'discount_pct': float(r[3]) if r[3] else 0,
'tax_rate': float(r[4]) if r[4] else 0.16,
})
# Build sale_data
sale_data = {
'items': items,
'customer_id': quot[1],
'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', 0),
'payment_details': data.get('payment_details', []),
'notes': f'Convertida de cotizacion #{quot_id}',
}
try:
sale = process_sale(conn, sale_data)
# Mark quotation as converted
cur.execute("""
UPDATE quotations SET status = 'converted', converted_sale_id = %s
WHERE id = %s
""", (sale['id'], quot_id))
conn.commit()
cur.close(); conn.close()
return jsonify(sale), 201
except ValueError as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/quotations/<int:quot_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
def cancel_quotation(quot_id):
"""Cancel a quotation."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
quot = cur.fetchone()
if not quot:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if quot[1] != 'active':
cur.close(); conn.close()
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation cancelled'})
# ─── Layaways (Apartados) ────────────────────────
@pos_bp.route('/layaways', methods=['POST'])
@require_auth('pos.sell')
def create_layaway():
"""Create a layaway. Requires customer_id and partial payment.
Body: {
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int (required),
initial_payment: float (required, > 0),
payment_method: str,
reference: str,
register_id: int,
expires_days: int (default 30),
notes: str
}
"""
data = request.get_json() or {}
items = data.get('items', [])
customer_id = data.get('customer_id')
initial_payment = float(data.get('initial_payment', 0))
register_id = data.get('register_id')
if not items:
return jsonify({'error': 'No items in layaway'}), 400
if not customer_id:
return jsonify({'error': 'customer_id required for layaway'}), 400
if initial_payment <= 0:
return jsonify({'error': 'Initial payment must be greater than 0'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Calculate totals
totals = calculate_totals(items)
if initial_payment > totals['total']:
cur.close(); conn.close()
return jsonify({'error': 'Initial payment exceeds total'}), 400
expires_days = int(data.get('expires_days', 30))
expires_at = (date.today() + timedelta(days=expires_days)).isoformat()
try:
# Create layaway record
cur.execute("""
INSERT INTO layaways
(branch_id, customer_id, employee_id, total, amount_paid,
status, expires_at, notes)
VALUES (%s,%s,%s,%s,%s,'active',%s,%s)
RETURNING id, created_at
""", (
g.branch_id, customer_id, g.employee_id,
totals['total'], initial_payment, expires_at, data.get('notes')
))
layaway_id, created_at = cur.fetchone()
# Insert layaway items and reserve stock (table created by migration v1.1_pos_tables.sql)
from services.inventory_engine import record_operation
for item in totals['items']:
cur.execute("SELECT part_number, name, branch_id FROM inventory WHERE id = %s", (item['inventory_id'],))
inv = cur.fetchone()
part_number = inv[0] if inv else item.get('part_number', '')
name = inv[1] if inv else item.get('name', '')
item_branch_id = inv[2] if inv else g.branch_id
line_subtotal = round(
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
)
cur.execute("""
INSERT INTO layaway_items
(layaway_id, inventory_id, part_number, name, quantity,
unit_price, discount_pct, tax_rate, subtotal)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
layaway_id, item['inventory_id'], part_number, name,
item['quantity'], item['unit_price'], item['discount_pct'],
item['tax_rate'], line_subtotal
))
# Reserve stock immediately (negative quantity = stock deduction)
record_operation(
conn, item['inventory_id'], item_branch_id,
operation_type='LAYAWAY_RESERVE',
quantity=-item['quantity'],
notes=f'Apartado #{layaway_id} - reserva'
)
# Record initial payment
cur.execute("""
INSERT INTO layaway_payments
(layaway_id, amount, payment_method, reference, employee_id)
VALUES (%s,%s,%s,%s,%s)
""", (
layaway_id, initial_payment,
data.get('payment_method', 'efectivo'),
data.get('reference'), g.employee_id
))
# Record cash movement on register if cash payment
if register_id and data.get('payment_method', 'efectivo') == 'efectivo':
cur.execute("""
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
VALUES (%s, 'in', %s, %s, %s)
""", (register_id, initial_payment, f'Apartado #{layaway_id} - anticipo', g.employee_id))
log_action(conn, 'LAYAWAY_CREATE', 'layaway', layaway_id,
new_value={'total': totals['total'], 'initial_payment': initial_payment,
'customer_id': customer_id})
conn.commit()
cur.close(); conn.close()
return jsonify({
'id': layaway_id,
'total': totals['total'],
'amount_paid': initial_payment,
'remaining': round(totals['total'] - initial_payment, 2),
'expires_at': expires_at,
'created_at': str(created_at),
'message': 'Layaway created'
}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/layaways', methods=['GET'])
@require_auth('pos.view')
def list_layaways():
"""List layaways. Query params: customer_id, status, page, per_page."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
customer_id = request.args.get('customer_id')
status = request.args.get('status')
if customer_id:
where_clauses.append("l.customer_id = %s")
params.append(int(customer_id))
if status:
where_clauses.append("l.status = %s")
params.append(status)
if g.branch_id:
where_clauses.append("l.branch_id = %s")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM layaways l WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT l.id, l.customer_id, l.employee_id, l.total, l.amount_paid,
l.status, l.expires_at, l.created_at,
c.name as customer_name, e.name as employee_name
FROM layaways l
LEFT JOIN customers c ON l.customer_id = c.id
LEFT JOIN employees e ON l.employee_id = e.id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
layaways = []
for r in cur.fetchall():
layaways.append({
'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
'total': float(r[3]) if r[3] else 0,
'amount_paid': float(r[4]) if r[4] else 0,
'remaining': round(float(r[3] or 0) - float(r[4] or 0), 2),
'status': r[5], 'expires_at': str(r[6]) if r[6] else None,
'created_at': str(r[7]),
'customer_name': r[8], 'employee_name': r[9],
})
cur.close(); conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': layaways,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
@pos_bp.route('/layaways/<int:layaway_id>', methods=['GET'])
@require_auth('pos.view')
def get_layaway(layaway_id):
"""Get layaway detail with items and payments."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT l.*, c.name as customer_name, e.name as employee_name
FROM layaways l
LEFT JOIN customers c ON l.customer_id = c.id
LEFT JOIN employees e ON l.employee_id = e.id
WHERE l.id = %s
""", (layaway_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Layaway not found'}), 404
cols = [desc[0] for desc in cur.description]
layaway = dict(zip(cols, row))
for k in ('total', 'amount_paid'):
if layaway.get(k) is not None:
layaway[k] = float(layaway[k])
layaway['remaining'] = round(layaway['total'] - layaway['amount_paid'], 2)
if layaway.get('created_at'):
layaway['created_at'] = str(layaway['created_at'])
if layaway.get('expires_at'):
layaway['expires_at'] = str(layaway['expires_at'])
# Get items
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_price,
discount_pct, tax_rate, subtotal
FROM layaway_items WHERE layaway_id = %s ORDER BY id
""", (layaway_id,))
layaway['items'] = []
for r in cur.fetchall():
layaway['items'].append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'quantity': r[4], 'unit_price': float(r[5]) if r[5] else 0,
'discount_pct': float(r[6]) if r[6] else 0,
'tax_rate': float(r[7]) if r[7] else 0,
'subtotal': float(r[8]) if r[8] else 0,
})
# Get payments
cur.execute("""
SELECT id, amount, payment_method, reference, employee_id, created_at
FROM layaway_payments WHERE layaway_id = %s ORDER BY created_at
""", (layaway_id,))
layaway['payments'] = []
for r in cur.fetchall():
layaway['payments'].append({
'id': r[0], 'amount': float(r[1]) if r[1] else 0,
'payment_method': r[2], 'reference': r[3],
'employee_id': r[4], 'created_at': str(r[5]) if r[5] else None,
})
cur.close(); conn.close()
return jsonify(layaway)
@pos_bp.route('/layaways/<int:layaway_id>/payment', methods=['POST'])
@require_auth('pos.sell')
def layaway_payment(layaway_id):
"""Add a payment to a layaway.
Body: {amount, payment_method, reference, register_id}
"""
data = request.get_json() or {}
amount = float(data.get('amount', 0))
if amount <= 0:
return jsonify({'error': 'Amount must be greater than 0'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT total, amount_paid, status FROM layaways WHERE id = %s", (layaway_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Layaway not found'}), 404
total, paid, status = float(row[0]), float(row[1]), row[2]
if status != 'active':
cur.close(); conn.close()
return jsonify({'error': f'Layaway is {status}'}), 400
remaining = round(total - paid, 2)
if amount > remaining:
cur.close(); conn.close()
return jsonify({'error': f'Payment ${amount:.2f} exceeds remaining ${remaining:.2f}'}), 400
try:
# Record payment
cur.execute("""
INSERT INTO layaway_payments
(layaway_id, amount, payment_method, reference, employee_id)
VALUES (%s,%s,%s,%s,%s)
RETURNING id
""", (
layaway_id, amount,
data.get('payment_method', 'efectivo'),
data.get('reference'), g.employee_id
))
payment_id = cur.fetchone()[0]
# Update amount_paid
new_paid = round(paid + amount, 2)
cur.execute("UPDATE layaways SET amount_paid = %s WHERE id = %s", (new_paid, layaway_id))
# Record cash movement if applicable
register_id = data.get('register_id')
if register_id and data.get('payment_method', 'efectivo') == 'efectivo':
cur.execute("""
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
VALUES (%s, 'in', %s, %s, %s)
""", (register_id, amount, f'Apartado #{layaway_id} - abono', g.employee_id))
conn.commit()
cur.close(); conn.close()
new_remaining = round(total - new_paid, 2)
return jsonify({
'payment_id': payment_id,
'amount': amount,
'total_paid': new_paid,
'remaining': new_remaining,
'fully_paid': new_remaining <= 0,
'message': 'Payment recorded'
})
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/layaways/<int:layaway_id>/complete', methods=['POST'])
@require_auth('pos.sell')
def complete_layaway(layaway_id):
"""Convert a fully paid layaway to a sale.
Body: {register_id: int}
The layaway must be fully paid (amount_paid >= total).
"""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT id, customer_id, total, amount_paid, status, branch_id
FROM layaways WHERE id = %s
""", (layaway_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Layaway not found'}), 404
l_id, cust_id, total, paid, status, branch_id = row
total, paid = float(total), float(paid)
if status != 'active':
cur.close(); conn.close()
return jsonify({'error': f'Layaway is {status}'}), 400
if paid < total:
cur.close(); conn.close()
return jsonify({'error': f'Layaway not fully paid. Remaining: ${total - paid:.2f}'}), 400
try:
# Get layaway items
cur.execute("""
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
FROM layaway_items WHERE layaway_id = %s
""", (layaway_id,))
items = []
for r in cur.fetchall():
items.append({
'inventory_id': r[0], 'quantity': r[1],
'unit_price': float(r[2]) if r[2] else 0,
'discount_pct': float(r[3]) if r[3] else 0,
'tax_rate': float(r[4]) if r[4] else 0.16,
})
# Create sale record directly instead of calling process_sale(),
# because stock was already reserved at layaway creation time via
# LAYAWAY_RESERVE operations. Calling process_sale() would deduct
# inventory again (double deduction).
from services.pos_engine import calculate_totals
totals_calc = calculate_totals(items)
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,'cash','efectivo',%s,%s,%s,%s,%s,0,'PUE','01','completed',%s)
RETURNING id, created_at
""", (
branch_id, cust_id, g.employee_id, data.get('register_id'),
totals_calc['subtotal'], totals_calc['discount_total'],
totals_calc['tax_total'], totals_calc['total'], total,
f'Completado de apartado #{layaway_id}',
))
sale_id, sale_created = cur.fetchone()
# Create sale_items (no inventory deduction — already reserved)
sale_items = []
for item in totals_calc['items']:
cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
(item['inventory_id'],))
inv = cur.fetchone()
cur.execute("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, discount_pct, discount_amount,
tax_rate, tax_amount, subtotal)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
sale_id, item['inventory_id'],
inv[0] if inv else '', inv[1] if inv else '',
item['quantity'], item['unit_price'],
float(inv[2]) if inv and inv[2] else 0,
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal']
))
# Record payment on register
register_id = data.get('register_id')
if register_id:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s,%s,'efectivo',%s,%s)
""", (sale_id, register_id, total, f'Apartado #{layaway_id} completado'))
sale = {
'id': sale_id, 'status': 'completed', 'total': totals_calc['total'],
'created_at': str(sale_created),
}
# Mark layaway as completed
cur.execute("""
UPDATE layaways SET status = 'completed', converted_sale_id = %s
WHERE id = %s
""", (sale['id'], layaway_id))
log_action(conn, 'LAYAWAY_COMPLETE', 'layaway', layaway_id,
new_value={'sale_id': sale['id'], 'total': total})
conn.commit()
cur.close(); conn.close()
return jsonify(sale), 201
except ValueError as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/layaways/<int:layaway_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
def cancel_layaway(layaway_id):
"""Cancel a layaway. Refunds must be handled separately.
Body: {reason: str}
"""
data = request.get_json() or {}
reason = data.get('reason', '').strip()
if not reason or len(reason) < 3:
return jsonify({'error': 'Reason is required (min 3 characters)'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, status, amount_paid, total, branch_id FROM layaways WHERE id = %s", (layaway_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Layaway not found'}), 404
if row[1] != 'active':
cur.close(); conn.close()
return jsonify({'error': f'Layaway is already {row[1]}'}), 400
layaway_branch = row[4] or g.branch_id
# Reverse stock reservations (return reserved items to available stock)
from services.inventory_engine import record_operation
cur.execute("""
SELECT inventory_id, quantity FROM layaway_items WHERE layaway_id = %s
""", (layaway_id,))
layaway_items = cur.fetchall()
for inv_id, qty in layaway_items:
# Positive quantity = return stock
record_operation(
conn, inv_id, layaway_branch,
operation_type='LAYAWAY_CANCEL',
quantity=qty,
notes=f'Cancelacion apartado #{layaway_id}: {reason}'
)
cur.execute("""
UPDATE layaways SET status = 'cancelled',
notes = COALESCE(notes || ' | ', '') || %s
WHERE id = %s
""", (f"CANCELADO: {reason}", layaway_id))
log_action(conn, 'LAYAWAY_CANCEL', 'layaway', layaway_id,
old_value={'status': 'active', 'amount_paid': float(row[2])},
new_value={'status': 'cancelled', 'reason': reason,
'items_unreserved': len(layaway_items)})
conn.commit()
cur.close(); conn.close()
return jsonify({
'message': 'Layaway cancelled',
'amount_paid': float(row[2]),
'items_unreserved': len(layaway_items),
'note': 'Stock reservations reversed. Refund of paid amount must be processed separately.'
})
```
---
### Task 4: Cash register blueprint (`pos/blueprints/cashregister_bp.py`)
**Files:**
- Create: `/home/Autopartes/pos/blueprints/cashregister_bp.py`
Cash register operations: open, close, movements, X-cut (read-only), Z-cut (closing).
- [ ] **Step 1: Create cashregister_bp.py**
```python
# /home/Autopartes/pos/blueprints/cashregister_bp.py
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.audit import log_action
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
@cashregister_bp.route('/open', methods=['POST'])
@require_auth('pos.sell')
def open_register():
"""Open a cash register session.
Body: {register_number: int, opening_amount: float}
Business rules:
- An employee can only have one open register at a time
- Register number identifies the physical register (1, 2, 3...)
"""
data = request.get_json() or {}
register_number = data.get('register_number')
opening_amount = float(data.get('opening_amount', 0))
if not register_number:
return jsonify({'error': 'register_number required'}), 400
if opening_amount < 0:
return jsonify({'error': 'opening_amount cannot be negative'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Check if employee already has an open register
cur.execute("""
SELECT id, register_number FROM cash_registers
WHERE employee_id = %s AND status = 'open'
""", (g.employee_id,))
existing = cur.fetchone()
if existing:
cur.close(); conn.close()
return jsonify({
'error': f'You already have register #{existing[1]} open (id={existing[0]}). Close it first.'
}), 409
# Check if this register number is already open at this branch
cur.execute("""
SELECT id, employee_id FROM cash_registers
WHERE branch_id = %s AND register_number = %s AND status = 'open'
""", (g.branch_id, register_number))
in_use = cur.fetchone()
if in_use:
cur.close(); conn.close()
return jsonify({'error': f'Register #{register_number} is already open by another employee'}), 409
try:
cur.execute("""
INSERT INTO cash_registers
(branch_id, employee_id, register_number, opening_amount, status)
VALUES (%s,%s,%s,%s,'open')
RETURNING id, opened_at
""", (g.branch_id, g.employee_id, register_number, opening_amount))
reg_id, opened_at = cur.fetchone()
log_action(conn, 'REGISTER_OPEN', 'cash_register', reg_id,
new_value={'register_number': register_number, 'opening_amount': opening_amount})
conn.commit()
cur.close(); conn.close()
return jsonify({
'id': reg_id,
'register_number': register_number,
'opening_amount': opening_amount,
'opened_at': str(opened_at),
'message': f'Register #{register_number} opened'
}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@cashregister_bp.route('/current', methods=['GET'])
@require_auth('pos.sell')
def current_register():
"""Get the current open register for this employee."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT cr.id, cr.register_number, cr.opening_amount, cr.opened_at,
cr.branch_id, b.name as branch_name
FROM cash_registers cr
LEFT JOIN branches b ON cr.branch_id = b.id
WHERE cr.employee_id = %s AND cr.status = 'open'
""", (g.employee_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'register': None, 'message': 'No open register'})
register = {
'id': row[0], 'register_number': row[1],
'opening_amount': float(row[2]) if row[2] else 0,
'opened_at': str(row[3]),
'branch_id': row[4], 'branch_name': row[5],
}
cur.close(); conn.close()
return jsonify({'register': register})
@cashregister_bp.route('/movement', methods=['POST'])
@require_auth('pos.sell')
def cash_movement():
"""Record a cash in/out movement with mandatory reason.
Body: {type: 'in'|'out', amount: float, reason: str}
"""
data = request.get_json() or {}
mov_type = data.get('type')
amount = float(data.get('amount', 0))
reason = data.get('reason', '').strip()
if mov_type not in ('in', 'out'):
return jsonify({'error': "type must be 'in' or 'out'"}), 400
if amount <= 0:
return jsonify({'error': 'amount must be positive'}), 400
if not reason or len(reason) < 3:
return jsonify({'error': 'reason required (min 3 characters)'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Get employee's open register
cur.execute("""
SELECT id FROM cash_registers
WHERE employee_id = %s AND status = 'open'
""", (g.employee_id,))
reg = cur.fetchone()
if not reg:
cur.close(); conn.close()
return jsonify({'error': 'No open register. Open a register first.'}), 400
register_id = reg[0]
try:
cur.execute("""
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
VALUES (%s,%s,%s,%s,%s)
RETURNING id, created_at
""", (register_id, mov_type, amount, reason, g.employee_id))
mov_id, created_at = cur.fetchone()
log_action(conn, f'CASH_{mov_type.upper()}', 'cash_register', register_id,
new_value={'movement_id': mov_id, 'type': mov_type,
'amount': amount, 'reason': reason})
conn.commit()
cur.close(); conn.close()
return jsonify({
'id': mov_id,
'type': mov_type,
'amount': amount,
'reason': reason,
'created_at': str(created_at),
'message': 'Movement recorded'
}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
def _compute_register_summary(conn, register_id):
"""Compute the register summary for X-cut or Z-cut.
Returns a dict with totals by payment method, cash movements, and expected cash amount.
"""
cur = conn.cursor()
# Get register info
cur.execute("""
SELECT opening_amount, opened_at, employee_id
FROM cash_registers WHERE id = %s
""", (register_id,))
reg = cur.fetchone()
opening_amount = float(reg[0]) if reg[0] else 0
# Sales totals by payment method
cur.execute("""
SELECT payment_method, COUNT(*), COALESCE(SUM(total), 0)
FROM sales
WHERE register_id = %s AND status = 'completed'
GROUP BY payment_method
""", (register_id,))
sales_by_method = {}
total_sales = 0.0
total_sales_count = 0
for r in cur.fetchall():
method, count, amount = r[0], r[1], float(r[2])
sales_by_method[method] = {'count': count, 'amount': amount}
total_sales += amount
total_sales_count += count
# Cash sales specifically (for expected cash calculation)
cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
# Change given (cash out)
cur.execute("""
SELECT COALESCE(SUM(change_given), 0) FROM sales
WHERE register_id = %s AND status = 'completed' AND payment_method = 'efectivo'
""", (register_id,))
change_given = float(cur.fetchone()[0])
# Cash movements
cur.execute("""
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
WHERE register_id = %s GROUP BY type
""", (register_id,))
movements = {'in': 0.0, 'out': 0.0}
for r in cur.fetchall():
movements[r[0]] = float(r[1])
# Cancelled sales
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
WHERE register_id = %s AND status = 'cancelled'
""", (register_id,))
cancelled = cur.fetchone()
cancelled_count = cancelled[0]
cancelled_amount = float(cancelled[1])
# Expected cash = opening + cash sales - change + cash_in - cash_out
expected_cash = round(
opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
2
)
# Detail of cash movements
cur.execute("""
SELECT id, type, amount, reason, created_at
FROM cash_movements WHERE register_id = %s ORDER BY created_at
""", (register_id,))
movement_detail = []
for r in cur.fetchall():
movement_detail.append({
'id': r[0], 'type': r[1], 'amount': float(r[2]),
'reason': r[3], 'created_at': str(r[4])
})
cur.close()
return {
'opening_amount': opening_amount,
'sales_by_method': sales_by_method,
'total_sales': round(total_sales, 2),
'total_sales_count': total_sales_count,
'cash_from_sales': round(cash_from_sales, 2),
'change_given': round(change_given, 2),
'cash_movements_in': round(movements['in'], 2),
'cash_movements_out': round(movements['out'], 2),
'movement_detail': movement_detail,
'cancelled_count': cancelled_count,
'cancelled_amount': round(cancelled_amount, 2),
'expected_cash': expected_cash,
}
@cashregister_bp.route('/cut-x', methods=['GET'])
@require_auth('pos.sell')
def cut_x():
"""Partial cut (corte X): read-only summary without closing the register."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT id FROM cash_registers
WHERE employee_id = %s AND status = 'open'
""", (g.employee_id,))
reg = cur.fetchone()
if not reg:
cur.close(); conn.close()
return jsonify({'error': 'No open register'}), 400
register_id = reg[0]
summary = _compute_register_summary(conn, register_id)
cur.close(); conn.close()
return jsonify({
'type': 'X',
'register_id': register_id,
'status': 'open',
**summary,
})
@cashregister_bp.route('/cut-z', methods=['POST'])
@require_auth('pos.sell')
def cut_z():
"""Final cut (corte Z): close the register.
Body: {closing_amount: float} (the amount physically counted in the register)
"""
data = request.get_json() or {}
closing_amount = float(data.get('closing_amount', 0))
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT id FROM cash_registers
WHERE employee_id = %s AND status = 'open'
""", (g.employee_id,))
reg = cur.fetchone()
if not reg:
cur.close(); conn.close()
return jsonify({'error': 'No open register'}), 400
register_id = reg[0]
summary = _compute_register_summary(conn, register_id)
expected = summary['expected_cash']
difference = round(closing_amount - expected, 2)
try:
cur.execute("""
UPDATE cash_registers
SET closing_amount = %s, expected_amount = %s, difference = %s,
status = 'closed', closed_at = NOW()
WHERE id = %s
""", (closing_amount, expected, difference, register_id))
log_action(conn, 'REGISTER_CLOSE', 'cash_register', register_id,
new_value={
'closing_amount': closing_amount,
'expected_amount': expected,
'difference': difference,
'total_sales': summary['total_sales'],
})
conn.commit()
cur.close(); conn.close()
return jsonify({
'type': 'Z',
'register_id': register_id,
'status': 'closed',
'closing_amount': closing_amount,
'expected_amount': expected,
'difference': difference,
**summary,
})
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@cashregister_bp.route('/history', methods=['GET'])
@require_auth('pos.view')
def register_history():
"""List closed registers with summary.
Query params: date_from, date_to, employee_id, page, per_page
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["cr.status = 'closed'"]
params = []
if g.branch_id:
where_clauses.append("cr.branch_id = %s")
params.append(g.branch_id)
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
employee_id = request.args.get('employee_id')
if date_from:
where_clauses.append("cr.closed_at >= %s")
params.append(date_from)
if date_to:
where_clauses.append("cr.closed_at < %s::date + interval '1 day'")
params.append(date_to)
if employee_id:
where_clauses.append("cr.employee_id = %s")
params.append(int(employee_id))
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM cash_registers cr WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT cr.id, cr.register_number, cr.opening_amount, cr.closing_amount,
cr.expected_amount, cr.difference, cr.opened_at, cr.closed_at,
cr.employee_id, e.name as employee_name
FROM cash_registers cr
LEFT JOIN employees e ON cr.employee_id = e.id
WHERE {where}
ORDER BY cr.closed_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
registers = []
for r in cur.fetchall():
registers.append({
'id': r[0], 'register_number': r[1],
'opening_amount': float(r[2]) if r[2] else 0,
'closing_amount': float(r[3]) if r[3] else 0,
'expected_amount': float(r[4]) if r[4] else 0,
'difference': float(r[5]) if r[5] else 0,
'opened_at': str(r[6]), 'closed_at': str(r[7]),
'employee_id': r[8], 'employee_name': r[9],
})
cur.close(); conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': registers,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
@cashregister_bp.route('/daily-summary', methods=['GET'])
@require_auth('pos.view')
def daily_summary():
"""Consolidated daily summary across all registers for a given date.
Query params:
date: YYYY-MM-DD (default: today)
Returns aggregated totals: total sales, total by payment method,
total movements, and per-register breakdown.
"""
from datetime import date as date_type
target_date = request.args.get('date', date_type.today().isoformat())
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_clause = ""
params = [target_date, target_date]
if g.branch_id:
branch_clause = "AND s.branch_id = %s"
params.append(g.branch_id)
# Total sales by payment method for the date
cur.execute(f"""
SELECT s.payment_method, COUNT(*), COALESCE(SUM(s.total), 0)
FROM sales s
WHERE s.created_at >= %s::date
AND s.created_at < %s::date + interval '1 day'
AND s.status = 'completed'
{branch_clause}
GROUP BY s.payment_method
""", params)
sales_by_method = {}
total_sales = 0.0
total_sales_count = 0
for method, count, amount in cur.fetchall():
sales_by_method[method] = {'count': count, 'amount': float(amount)}
total_sales += float(amount)
total_sales_count += count
# Cancelled sales
cancel_params = [target_date, target_date]
if g.branch_id:
cancel_params.append(g.branch_id)
cur.execute(f"""
SELECT COUNT(*), COALESCE(SUM(s.total), 0)
FROM sales s
WHERE s.created_at >= %s::date
AND s.created_at < %s::date + interval '1 day'
AND s.status = 'cancelled'
{branch_clause}
""", cancel_params)
cancelled = cur.fetchone()
cancelled_count = cancelled[0]
cancelled_amount = float(cancelled[1])
# Cash movements for the date
mov_params = [target_date, target_date]
mov_branch_clause = ""
if g.branch_id:
mov_branch_clause = "AND cr.branch_id = %s"
mov_params.append(g.branch_id)
cur.execute(f"""
SELECT cm.type, COUNT(*), COALESCE(SUM(cm.amount), 0)
FROM cash_movements cm
JOIN cash_registers cr ON cm.register_id = cr.id
WHERE cm.created_at >= %s::date
AND cm.created_at < %s::date + interval '1 day'
{mov_branch_clause}
GROUP BY cm.type
""", mov_params)
movements = {'in': {'count': 0, 'amount': 0.0}, 'out': {'count': 0, 'amount': 0.0}}
for mov_type, count, amount in cur.fetchall():
movements[mov_type] = {'count': count, 'amount': float(amount)}
# Per-register breakdown
reg_params = [target_date, target_date]
reg_branch_clause = ""
if g.branch_id:
reg_branch_clause = "AND cr.branch_id = %s"
reg_params.append(g.branch_id)
cur.execute(f"""
SELECT cr.id, cr.register_number, cr.status,
cr.opening_amount, cr.closing_amount, cr.expected_amount,
cr.difference, cr.opened_at, cr.closed_at,
e.name as employee_name,
(SELECT COUNT(*) FROM sales s WHERE s.register_id = cr.id AND s.status = 'completed') as sale_count,
(SELECT COALESCE(SUM(s.total), 0) FROM sales s WHERE s.register_id = cr.id AND s.status = 'completed') as sale_total
FROM cash_registers cr
LEFT JOIN employees e ON cr.employee_id = e.id
WHERE cr.opened_at >= %s::date
AND cr.opened_at < %s::date + interval '1 day'
{reg_branch_clause}
ORDER BY cr.opened_at
""", reg_params)
registers = []
for r in cur.fetchall():
registers.append({
'id': r[0], 'register_number': r[1], 'status': r[2],
'opening_amount': float(r[3]) if r[3] else 0,
'closing_amount': float(r[4]) if r[4] else 0,
'expected_amount': float(r[5]) if r[5] else 0,
'difference': float(r[6]) if r[6] else 0,
'opened_at': str(r[7]) if r[7] else None,
'closed_at': str(r[8]) if r[8] else None,
'employee_name': r[9],
'sale_count': r[10], 'sale_total': float(r[11]),
})
cur.close(); conn.close()
return jsonify({
'date': target_date,
'total_sales': round(total_sales, 2),
'total_sales_count': total_sales_count,
'sales_by_method': sales_by_method,
'cancelled_count': cancelled_count,
'cancelled_amount': round(cancelled_amount, 2),
'movements_in': movements['in'],
'movements_out': movements['out'],
'registers': registers,
})
```
---
### Task 5: Register blueprints in app.py
**Files:**
- Modify: `/home/Autopartes/pos/app.py`
Add the three new blueprints and page routes.
- [ ] **Step 1: Update app.py**
```python
# /home/Autopartes/pos/app.py
from flask import Flask
def create_app():
app = Flask(__name__)
# Register blueprints
from blueprints.auth_bp import auth_bp
app.register_blueprint(auth_bp)
from blueprints.config_bp import config_bp
app.register_blueprint(config_bp)
from blueprints.inventory_bp import inventory_bp
app.register_blueprint(inventory_bp)
from blueprints.catalog_bp import catalog_bp
app.register_blueprint(catalog_bp)
from blueprints.pos_bp import pos_bp
app.register_blueprint(pos_bp)
from blueprints.customers_bp import customers_bp
app.register_blueprint(customers_bp)
from blueprints.cashregister_bp import cashregister_bp
app.register_blueprint(cashregister_bp)
# Health check
@app.route('/pos/health')
def health():
return {'status': 'ok'}
from flask import render_template, send_from_directory
@app.route('/pos/login')
def pos_login():
return render_template('login.html')
@app.route('/pos/catalog')
def pos_catalog():
return render_template('catalog.html')
@app.route('/pos/inventory')
def pos_inventory():
return render_template('inventory.html')
@app.route('/pos/sale')
def pos_sale():
return render_template('pos.html')
@app.route('/pos/customers')
def pos_customers():
return render_template('customers.html')
@app.route('/pos/static/<path:filename>')
def pos_static(filename):
return send_from_directory('static', filename)
return app
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5001, debug=True)
```
---
### Task 6: POS frontend (`pos/templates/pos.html` + `pos/static/js/pos.js`)
**Files:**
- Create: `/home/Autopartes/pos/templates/pos.html`
- Create: `/home/Autopartes/pos/static/js/pos.js`
Complete POS page with split layout, F-key shortcuts, payment modal, and ticket printing.
- [ ] **Step 1: Create pos.html**
```html
<!-- /home/Autopartes/pos/templates/pos.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Punto de Venta - Nexus POS</title>
<link rel="stylesheet" href="/pos/static/css/common.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* Top bar */
.pos-topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 8px 16px; display: flex; align-items: center; justify-content: space-between; font-size: 14px; }
.pos-topbar .employee-info { display: flex; align-items: center; gap: 12px; }
.pos-topbar .register-info { display: flex; align-items: center; gap: 8px; opacity: 0.8; }
.pos-topbar .register-info.no-register { color: #ff6b6b; opacity: 1; }
.pos-topbar .shortcuts-hint { font-size: 12px; opacity: 0.6; }
/* Main layout */
.pos-main { display: flex; flex: 1; overflow: hidden; }
/* Left panel: search + items */
.pos-left { width: 55%; display: flex; flex-direction: column; border-right: 2px solid var(--color-border, #ddd); background: #fff; }
/* Customer bar */
.customer-bar { padding: 8px 12px; background: var(--color-surface, #f8f9fa); border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; align-items: center; }
.customer-bar input { flex: 1; padding: 8px 12px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
.customer-bar .customer-selected { background: #e8f5e9; padding: 6px 12px; border-radius: var(--radius, 6px); font-size: 13px; display: flex; align-items: center; gap: 8px; }
.customer-bar .customer-selected .tier-badge { font-size: 11px; padding: 2px 6px; border-radius: 3px; background: #1976d2; color: #fff; }
.customer-bar .customer-selected .credit-info { font-size: 11px; color: #666; }
.customer-bar .btn-new-customer { padding: 8px 12px; border: 1px dashed var(--color-border, #ddd); border-radius: var(--radius, 6px); background: none; cursor: pointer; font-size: 13px; white-space: nowrap; }
.customer-autocomplete { position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid var(--color-border, #ddd); border-radius: 0 0 var(--radius, 6px) var(--radius, 6px); box-shadow: var(--shadow, 0 4px 12px rgba(0,0,0,0.1)); z-index: 100; max-height: 200px; overflow-y: auto; }
.customer-autocomplete .ac-item { padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
.customer-autocomplete .ac-item:hover { background: #f0f2f5; }
.customer-autocomplete .ac-item .ac-meta { font-size: 11px; color: #999; }
/* Vehicle info banner */
.vehicle-banner { display: none; padding: 6px 12px; background: #fff3e0; border-bottom: 1px solid #ffe0b2; font-size: 12px; }
.vehicle-banner.visible { display: flex; align-items: center; gap: 8px; }
/* Search bar */
.search-bar { padding: 8px 12px; border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; }
.search-bar input { flex: 1; padding: 10px 14px; border: 2px solid var(--color-primary, #1a1a2e); border-radius: var(--radius, 6px); font-size: 15px; outline: none; }
.search-bar input:focus { border-color: var(--color-accent, #4361ee); box-shadow: 0 0 0 3px rgba(67,97,238,0.15); }
/* Cart items */
.cart-items { flex: 1; overflow-y: auto; padding: 0; }
.cart-items table { width: 100%; border-collapse: collapse; }
.cart-items thead th { position: sticky; top: 0; background: var(--color-surface, #f8f9fa); padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
.cart-items tbody tr { border-bottom: 1px solid #f0f0f0; cursor: pointer; }
.cart-items tbody tr:hover { background: #f8f9fa; }
.cart-items tbody tr.selected { background: #e3f2fd; }
.cart-items td { padding: 8px 10px; font-size: 13px; }
.cart-items td.num { text-align: right; font-variant-numeric: tabular-nums; }
.cart-items td .part-name { font-weight: 500; }
.cart-items td .part-number { font-size: 11px; color: #999; }
.cart-items td .margin-info { font-size: 11px; color: #888; }
.cart-items td .margin-warning { color: #e53935; font-weight: 600; }
.cart-items .qty-input { width: 50px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
.cart-items .discount-input { width: 55px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
.cart-items .btn-remove { background: none; border: none; color: #e53935; cursor: pointer; font-size: 16px; padding: 2px 6px; }
.cart-empty { display: flex; align-items: center; justify-content: center; flex: 1; color: #999; font-size: 15px; }
/* Right panel: totals + actions */
.pos-right { width: 45%; display: flex; flex-direction: column; background: var(--color-surface, #f8f9fa); }
/* Search results (right side when searching) */
.search-results { flex: 1; overflow-y: auto; padding: 8px; display: none; }
.search-results.active { display: block; }
.search-result-item { padding: 10px 12px; background: #fff; border: 1px solid #eee; border-radius: var(--radius, 6px); margin-bottom: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.search-result-item:hover { border-color: var(--color-primary, #1a1a2e); background: #fafafa; }
.search-result-item .sr-name { font-weight: 500; font-size: 14px; }
.search-result-item .sr-pn { font-size: 12px; color: #666; }
.search-result-item .sr-stock { font-size: 12px; }
.search-result-item .sr-stock.zero { color: #e53935; }
.search-result-item .sr-price { font-weight: 600; font-size: 15px; }
/* Totals panel */
.totals-panel { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; padding: 16px; }
.totals-panel.hidden { display: none; }
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
.totals-row.discount { color: #e53935; }
.totals-row.total { font-size: 28px; font-weight: 700; padding: 12px 0; border-top: 2px solid var(--color-border, #ddd); margin-top: 8px; }
/* Global discount */
.global-discount { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid #eee; margin-top: 8px; }
.global-discount label { font-size: 13px; color: #666; }
.global-discount input { width: 60px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
/* Action buttons */
.action-buttons { padding: 12px 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; border-top: 2px solid var(--color-border, #ddd); }
.action-buttons .btn { padding: 14px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px; transition: opacity 0.15s; }
.action-buttons .btn:hover { opacity: 0.85; }
.action-buttons .btn:active { transform: scale(0.98); }
.btn-cobrar { background: #2e7d32; color: #fff; grid-column: 1 / -1; font-size: 18px; padding: 18px; }
.btn-cotizacion { background: #1565c0; color: #fff; }
.btn-apartado { background: #e65100; color: #fff; }
.btn-credito { background: #6a1b9a; color: #fff; }
.btn-last-sale { background: #455a64; color: #fff; }
.btn-shortcut { font-size: 11px; opacity: 0.7; margin-left: 4px; }
/* Payment modal */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: #fff; border-radius: 12px; padding: 24px; width: 500px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
.modal h2 { margin-bottom: 16px; font-size: 20px; }
.modal .modal-total { font-size: 32px; font-weight: 700; text-align: center; padding: 12px; background: #f5f5f5; border-radius: 8px; margin-bottom: 16px; }
.modal .payment-methods { display: flex; gap: 8px; margin-bottom: 16px; }
.modal .payment-methods .pm-btn { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; background: #fff; cursor: pointer; text-align: center; font-size: 13px; font-weight: 500; }
.modal .payment-methods .pm-btn.active { border-color: var(--color-primary, #1a1a2e); background: #f0f4ff; }
.modal .payment-field { margin-bottom: 12px; }
.modal .payment-field label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; }
.modal .payment-field input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
.modal .payment-field input.amount-input { font-size: 24px; text-align: right; font-weight: 600; }
.modal .change-display { text-align: center; padding: 12px; font-size: 20px; font-weight: 600; border-radius: 8px; margin: 12px 0; }
.modal .change-display.positive { background: #e8f5e9; color: #2e7d32; }
.modal .change-display.negative { background: #ffebee; color: #c62828; }
/* Mixed payment rows */
.mixed-payments { margin-bottom: 12px; }
.mixed-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
.mixed-row select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.mixed-row input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
.mixed-row .btn-remove-row { background: none; border: none; color: #e53935; cursor: pointer; font-size: 18px; }
.btn-add-mixed { background: none; border: 1px dashed #999; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #666; }
.modal .modal-actions { display: flex; gap: 8px; margin-top: 16px; }
.modal .modal-actions .btn { flex: 1; padding: 14px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
.modal .btn-confirm-payment { background: #2e7d32; color: #fff; }
.modal .btn-cancel-modal { background: #eee; color: #333; }
/* New customer modal */
.modal .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.modal .form-grid .full-width { grid-column: 1 / -1; }
.modal .form-field { display: flex; flex-direction: column; gap: 4px; }
.modal .form-field label { font-size: 12px; color: #666; font-weight: 500; }
.modal .form-field input, .modal .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
/* Ticket preview */
.ticket-preview { font-family: var(--font-mono, 'Courier New', monospace); font-size: 12px; white-space: pre-wrap; background: #fff; padding: 16px; border: 1px dashed #999; max-width: 300px; margin: 0 auto; line-height: 1.4; }
/* Keyboard hint bar */
.keyboard-bar { background: #263238; color: #b0bec5; padding: 6px 16px; display: flex; gap: 16px; font-size: 11px; }
.keyboard-bar .kb-key { background: #37474f; padding: 2px 6px; border-radius: 3px; color: #e0e0e0; font-weight: 600; margin-right: 4px; }
@media print {
body * { visibility: hidden; }
.ticket-preview, .ticket-preview * { visibility: visible; }
.ticket-preview { position: absolute; left: 0; top: 0; }
}
</style>
</head>
<body>
<!-- Top Bar -->
<div class="pos-topbar">
<div class="employee-info">
<span id="employeeName">Cargando...</span>
<span id="branchName" style="opacity: 0.7; font-size: 12px;"></span>
</div>
<div class="register-info" id="registerInfo">
<span>Caja: --</span>
</div>
<div class="shortcuts-hint">F1=Buscar F2=Cliente F3=Cobrar F4=Cotizacion F5=Ult.Venta</div>
</div>
<!-- Main Layout -->
<div class="pos-main">
<!-- Left: Search + Cart -->
<div class="pos-left">
<!-- Customer Bar -->
<div class="customer-bar" style="position: relative;">
<span style="font-size: 13px; color: #666;">Cliente:</span>
<div id="customerSearchWrap" style="flex: 1; position: relative;">
<input type="text" id="customerSearch" placeholder="Buscar cliente por nombre, RFC, telefono... (F2)" autocomplete="off">
<div class="customer-autocomplete" id="customerAutocomplete" style="display:none;"></div>
</div>
<div id="customerSelected" class="customer-selected" style="display:none;">
<span id="customerName"></span>
<span class="tier-badge" id="customerTier"></span>
<span class="credit-info" id="customerCredit"></span>
<button onclick="POS.clearCustomer()" style="background:none;border:none;cursor:pointer;color:#999;font-size:16px;" title="Quitar cliente">&times;</button>
</div>
<button class="btn-new-customer" onclick="POS.showNewCustomerModal()">+ Nuevo</button>
</div>
<!-- Vehicle Banner -->
<div class="vehicle-banner" id="vehicleBanner">
<span style="font-weight: 600;">Vehiculo:</span>
<span id="vehicleInfo"></span>
<span id="lastPurchaseInfo" style="margin-left: auto; font-size: 11px; color: #e65100;"></span>
</div>
<!-- Search Bar -->
<div class="search-bar">
<input type="text" id="itemSearch" placeholder="Buscar por # parte, nombre o codigo de barras... (F1)" autocomplete="off">
</div>
<!-- Cart Table -->
<div class="cart-items" id="cartContainer">
<div class="cart-empty" id="cartEmpty">
<div>Carrito vacio<br><span style="font-size: 13px;">Busca productos o presiona F1</span></div>
</div>
<table id="cartTable" style="display:none;">
<thead>
<tr>
<th style="width: 30px;">#</th>
<th>Producto</th>
<th style="width: 60px;">Cant</th>
<th style="width: 90px;">Precio</th>
<th style="width: 65px;">Desc%</th>
<th style="width: 90px;" class="num">Subtotal</th>
<th id="thCost" style="width: 70px; display:none;" class="num">Costo</th>
<th id="thMargin" style="width: 65px; display:none;" class="num">Margen</th>
<th style="width: 30px;"></th>
</tr>
</thead>
<tbody id="cartBody"></tbody>
</table>
</div>
</div>
<!-- Right: Search Results / Totals + Actions -->
<div class="pos-right">
<!-- Search results (shown when searching) -->
<div class="search-results" id="searchResults"></div>
<!-- Totals panel -->
<div class="totals-panel" id="totalsPanel">
<div>
<div class="totals-row"><span>Subtotal:</span><span id="dispSubtotal">$0.00</span></div>
<div class="totals-row discount" id="discountRow" style="display:none;">
<span>Descuento:</span><span id="dispDiscount">-$0.00</span>
</div>
<div class="totals-row"><span>IVA (16%):</span><span id="dispTax">$0.00</span></div>
<div class="totals-row total"><span>TOTAL:</span><span id="dispTotal">$0.00</span></div>
</div>
<div class="global-discount">
<label>Descuento global:</label>
<input type="number" id="globalDiscount" value="0" min="0" max="100" step="0.5">
<span>%</span>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button class="btn btn-cobrar" onclick="POS.checkout()">
Cobrar<span class="btn-shortcut">F3</span>
</button>
<button class="btn btn-cotizacion" onclick="POS.saveQuotation()">
Cotizacion<span class="btn-shortcut">F4</span>
</button>
<button class="btn btn-apartado" onclick="POS.createLayaway()">
Apartado
</button>
<button class="btn btn-credito" onclick="POS.creditSale()">
Credito
</button>
<button class="btn btn-last-sale" onclick="POS.showLastSale()">
Ult. Venta<span class="btn-shortcut">F5</span>
</button>
<button class="btn" style="background:#78909c;color:#fff;" onclick="POS.openDrawer()">
Cajon<span class="btn-shortcut">F6</span>
</button>
</div>
</div>
</div>
<!-- Keyboard hints -->
<div class="keyboard-bar">
<span><span class="kb-key">F1</span>Buscar</span>
<span><span class="kb-key">F2</span>Cliente</span>
<span><span class="kb-key">F3</span>Cobrar</span>
<span><span class="kb-key">F4</span>Cotizacion</span>
<span><span class="kb-key">F5</span>Ult.Venta</span>
<span><span class="kb-key">F6</span>Cajon</span>
<span><span class="kb-key">+/-</span>Cantidad</span>
<span><span class="kb-key">*</span>Descuento</span>
<span><span class="kb-key">Enter</span>Agregar</span>
<span><span class="kb-key">Del</span>Eliminar</span>
</div>
<!-- Payment Modal -->
<div class="modal-overlay" id="paymentModal">
<div class="modal">
<h2>Cobrar Venta</h2>
<div class="modal-total" id="modalTotal">$0.00</div>
<div class="payment-methods">
<button class="pm-btn active" data-method="efectivo" onclick="POS.selectPaymentMethod('efectivo', this)">Efectivo</button>
<button class="pm-btn" data-method="transferencia" onclick="POS.selectPaymentMethod('transferencia', this)">Transferencia</button>
<button class="pm-btn" data-method="tarjeta" onclick="POS.selectPaymentMethod('tarjeta', this)">Tarjeta</button>
<button class="pm-btn" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">Mixto</button>
</div>
<!-- Cash payment -->
<div id="cashPayment">
<div class="payment-field">
<label>Monto recibido:</label>
<input type="number" id="cashReceived" class="amount-input" step="0.01" min="0" oninput="POS.updateChange()">
</div>
<div class="change-display positive" id="changeDisplay">Cambio: $0.00</div>
</div>
<!-- Transfer/Card payment -->
<div id="refPayment" style="display:none;">
<div class="payment-field">
<label>Referencia:</label>
<input type="text" id="paymentRef" placeholder="Numero de referencia o autorizacion">
</div>
</div>
<!-- Mixed payment -->
<div id="mixedPayment" style="display:none;">
<div class="mixed-payments" id="mixedRows">
<div class="mixed-row">
<select><option value="efectivo">Efectivo</option><option value="transferencia">Transferencia</option><option value="tarjeta">Tarjeta</option></select>
<input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
<input type="text" placeholder="Ref." style="width: 100px;">
</div>
<div class="mixed-row">
<select><option value="transferencia">Transferencia</option><option value="efectivo">Efectivo</option><option value="tarjeta">Tarjeta</option></select>
<input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
<input type="text" placeholder="Ref." style="width: 100px;">
</div>
</div>
<div id="mixedRemaining" style="text-align:center; font-size: 14px; color: #666; padding: 8px 0;">Faltante: $0.00</div>
</div>
<div class="modal-actions">
<button class="btn btn-cancel-modal" onclick="POS.closePaymentModal()">Cancelar (Esc)</button>
<button class="btn btn-confirm-payment" id="btnConfirmPayment" onclick="POS.confirmPayment()">Confirmar Pago</button>
</div>
</div>
</div>
<!-- New Customer Modal -->
<div class="modal-overlay" id="newCustomerModal">
<div class="modal">
<h2>Nuevo Cliente</h2>
<div class="form-grid">
<div class="form-field full-width"><label>Nombre *</label><input type="text" id="ncName"></div>
<div class="form-field"><label>RFC</label><input type="text" id="ncRfc" maxlength="13" placeholder="XAXX010101000"></div>
<div class="form-field"><label>Razon Social</label><input type="text" id="ncRazonSocial"></div>
<div class="form-field"><label>Regimen Fiscal</label>
<select id="ncRegimenFiscal">
<option value="">Seleccionar...</option>
<option value="601">601 - General de Ley PM</option>
<option value="603">603 - Personas Morales Fines No Lucrativos</option>
<option value="605">605 - Sueldos y Salarios</option>
<option value="606">606 - Arrendamiento</option>
<option value="612">612 - Personas Fisicas Actividad Empresarial</option>
<option value="616">616 - Sin Obligaciones Fiscales</option>
<option value="621">621 - Incorporacion Fiscal</option>
<option value="625">625 - RESICO</option>
</select>
</div>
<div class="form-field"><label>Uso CFDI</label>
<select id="ncUsoCfdi">
<option value="G03">G03 - Gastos en general</option>
<option value="G01">G01 - Adquisicion de mercancias</option>
<option value="P01">P01 - Por definir</option>
</select>
</div>
<div class="form-field"><label>Telefono</label><input type="tel" id="ncPhone"></div>
<div class="form-field"><label>Email</label><input type="email" id="ncEmail"></div>
<div class="form-field"><label>Lista de precio</label>
<select id="ncPriceTier">
<option value="1">Mostrador (Precio 1)</option>
<option value="2">Taller (Precio 2)</option>
<option value="3">Mayoreo (Precio 3)</option>
</select>
</div>
<div class="form-field"><label>Limite de credito</label><input type="number" id="ncCreditLimit" value="0" min="0" step="100"></div>
<div class="form-field full-width"><label>Vehiculo (opcional)</label></div>
<div class="form-field"><label>Marca</label><input type="text" id="ncVehMake" placeholder="Nissan, Toyota..."></div>
<div class="form-field"><label>Modelo</label><input type="text" id="ncVehModel" placeholder="Tsuru, Corolla..."></div>
<div class="form-field"><label>Ano</label><input type="number" id="ncVehYear" placeholder="2020"></div>
<div class="form-field"><label>Placas</label><input type="text" id="ncVehPlates"></div>
</div>
<div class="modal-actions" style="margin-top: 16px;">
<button class="btn btn-cancel-modal" onclick="POS.closeNewCustomerModal()">Cancelar</button>
<button class="btn btn-confirm-payment" onclick="POS.saveNewCustomer()">Guardar Cliente</button>
</div>
</div>
</div>
<!-- Ticket Modal -->
<div class="modal-overlay" id="ticketModal">
<div class="modal" style="width: 380px;">
<h2>Venta Completada</h2>
<div class="ticket-preview" id="ticketPreview"></div>
<div class="modal-actions" style="margin-top: 16px;">
<button class="btn btn-cancel-modal" onclick="POS.closeTicketModal()">Cerrar</button>
<button class="btn btn-confirm-payment" onclick="POS.printTicket()">Imprimir</button>
</div>
</div>
</div>
<script src="/pos/static/js/pos.js"></script>
</body>
</html>
```
- [ ] **Step 2: Create pos.js**
```javascript
// /home/Autopartes/pos/static/js/pos.js
/**
* POS Frontend: sale processing, F-key shortcuts, payment modal, ticket printing.
*
* Communicates with:
* - /pos/api/sales (pos_bp)
* - /pos/api/quotations (pos_bp)
* - /pos/api/layaways (pos_bp)
* - /pos/api/customers (customers_bp)
* - /pos/api/register (cashregister_bp)
* - /pos/api/inventory/items (inventory_bp) — for item search
* - /pos/api/catalog/search (catalog_bp) — for catalog search
*/
const POS = (() => {
// ─── State ───────────────────────────
let token = localStorage.getItem('pos_token') || '';
let cart = [];
let selectedRow = -1;
let currentCustomer = null;
let currentRegister = null;
let paymentMethod = 'efectivo';
let canViewCost = false;
let employeeMaxDiscount = 100;
let lastSaleId = null;
let searchTimeout = null;
let customerSearchTimeout = null;
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
async function api(url, options = {}) {
options.headers = headers();
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
// ─── Init ────────────────────────────
async function init() {
// Parse JWT to get employee info
try {
const payload = JSON.parse(atob(token.split('.')[1]));
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
document.getElementById('branchName').textContent = payload.branch_name || '';
canViewCost = (payload.permissions || []).includes('pos.view_cost');
employeeMaxDiscount = payload.max_discount_pct || 100;
// Show cost/margin columns if permission
if (canViewCost) {
document.getElementById('thCost').style.display = '';
document.getElementById('thMargin').style.display = '';
}
} catch (e) {
console.warn('Could not parse token:', e);
}
// Load cart from localStorage (from catalog)
const catalogCart = localStorage.getItem('pos_cart');
if (catalogCart) {
try {
const items = JSON.parse(catalogCart);
for (const item of items) {
addToCart(item);
}
localStorage.removeItem('pos_cart');
} catch (e) { console.warn('Could not load catalog cart:', e); }
}
// Load current register
await loadRegister();
// Setup event listeners
setupKeyboard();
setupSearch();
setupCustomerSearch();
}
// ─── Register ────────────────────────
async function loadRegister() {
try {
const data = await api('/pos/api/register/current');
if (data.register) {
currentRegister = data.register;
document.getElementById('registerInfo').innerHTML =
`<span>Caja #${data.register.register_number}</span>`;
document.getElementById('registerInfo').classList.remove('no-register');
} else {
document.getElementById('registerInfo').innerHTML =
'<span>Sin caja abierta</span>';
document.getElementById('registerInfo').classList.add('no-register');
}
} catch (e) {
console.warn('Register check failed:', e);
}
}
// ─── Cart ────────────────────────────
function addToCart(item) {
// Check if item already in cart
const existing = cart.find(c => c.inventory_id === item.inventory_id);
if (existing) {
existing.quantity += (item.quantity || 1);
renderCart();
return;
}
cart.push({
inventory_id: item.inventory_id || item.id,
part_number: item.part_number || '',
name: item.name || '',
quantity: item.quantity || 1,
unit_price: parseFloat(item.unit_price || item.price_1 || 0),
unit_cost: parseFloat(item.cost || 0),
discount_pct: parseFloat(item.discount_pct || 0),
tax_rate: parseFloat(item.tax_rate || 0.16),
stock: item.stock || 0,
});
renderCart();
}
function removeFromCart(index) {
cart.splice(index, 1);
if (selectedRow >= cart.length) selectedRow = cart.length - 1;
renderCart();
}
function renderCart() {
const tbody = document.getElementById('cartBody');
const table = document.getElementById('cartTable');
const empty = document.getElementById('cartEmpty');
if (cart.length === 0) {
table.style.display = 'none';
empty.style.display = 'flex';
updateTotals();
return;
}
table.style.display = '';
empty.style.display = 'none';
let html = '';
cart.forEach((item, i) => {
const lineGross = item.unit_price * item.quantity;
const lineDiscount = lineGross * item.discount_pct / 100;
const lineSubtotal = lineGross - lineDiscount;
const costHtml = canViewCost ? `<td class="num">${fmt(item.unit_cost)}</td>` : '';
let marginHtml = '';
if (canViewCost) {
const effectivePrice = item.unit_price * (1 - item.discount_pct / 100);
const marginPct = effectivePrice > 0
? ((effectivePrice - item.unit_cost) / effectivePrice * 100).toFixed(1)
: '0.0';
const cls = parseFloat(marginPct) < 5 ? 'margin-warning' : 'margin-info';
marginHtml = `<td class="num"><span class="${cls}">${marginPct}%</span></td>`;
}
html += `<tr class="${i === selectedRow ? 'selected' : ''}" onclick="POS.selectRow(${i})">
<td>${i + 1}</td>
<td>
<div class="part-name">${item.name}</div>
<div class="part-number">${item.part_number} | Stock: ${item.stock}</div>
</td>
<td><input type="number" class="qty-input" value="${item.quantity}" min="1"
onchange="POS.updateQty(${i}, this.value)" onclick="event.stopPropagation()"></td>
<td class="num">${fmt(item.unit_price)}</td>
<td><input type="number" class="discount-input" value="${item.discount_pct}" min="0" max="100" step="0.5"
onchange="POS.updateDiscount(${i}, this.value)" onclick="event.stopPropagation()">%</td>
<td class="num">${fmt(lineSubtotal)}</td>
${costHtml}
${marginHtml}
<td><button class="btn-remove" onclick="event.stopPropagation(); POS.removeFromCart(${i})">&times;</button></td>
</tr>`;
});
tbody.innerHTML = html;
updateTotals();
}
function updateQty(index, val) {
const qty = Math.max(1, parseInt(val) || 1);
cart[index].quantity = qty;
renderCart();
}
function updateDiscount(index, val) {
let disc = Math.max(0, Math.min(100, parseFloat(val) || 0));
if (disc > employeeMaxDiscount) {
alert(`Descuento maximo permitido: ${employeeMaxDiscount}%`);
disc = employeeMaxDiscount;
}
cart[index].discount_pct = disc;
renderCart();
}
function selectRow(index) {
selectedRow = index;
renderCart();
}
function updateTotals() {
let subtotal = 0, discountTotal = 0, taxTotal = 0;
cart.forEach(item => {
const lineGross = item.unit_price * item.quantity;
const lineDiscount = lineGross * item.discount_pct / 100;
const lineAfterDiscount = lineGross - lineDiscount;
const lineTax = lineAfterDiscount * item.tax_rate;
subtotal += lineAfterDiscount;
discountTotal += lineDiscount;
taxTotal += lineTax;
});
const total = subtotal + taxTotal;
document.getElementById('dispSubtotal').textContent = fmt(subtotal);
document.getElementById('dispTax').textContent = fmt(taxTotal);
document.getElementById('dispTotal').textContent = fmt(total);
if (discountTotal > 0) {
document.getElementById('discountRow').style.display = '';
document.getElementById('dispDiscount').textContent = '-' + fmt(discountTotal);
} else {
document.getElementById('discountRow').style.display = 'none';
}
}
function getTotal() {
let subtotal = 0, taxTotal = 0;
cart.forEach(item => {
const lineGross = item.unit_price * item.quantity;
const lineDiscount = lineGross * item.discount_pct / 100;
const lineAfterDiscount = lineGross - lineDiscount;
const lineTax = lineAfterDiscount * item.tax_rate;
subtotal += lineAfterDiscount;
taxTotal += lineTax;
});
return Math.round((subtotal + taxTotal) * 100) / 100;
}
// ─── Search ──────────────────────────
function setupSearch() {
const input = document.getElementById('itemSearch');
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchItems(input.value.trim());
}
if (e.key === 'Escape') {
input.value = '';
hideSearchResults();
}
});
}
async function searchItems(q) {
if (!q || q.length < 2) { hideSearchResults(); return; }
try {
const data = await api(`/pos/api/inventory/items?q=${encodeURIComponent(q)}&per_page=20`);
const container = document.getElementById('searchResults');
const totals = document.getElementById('totalsPanel');
if (data.data.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:#999;">Sin resultados</div>';
} else {
let html = '';
data.data.forEach(item => {
// Determine price for current customer
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
const stockClass = item.stock <= 0 ? 'zero' : '';
html += `<div class="search-result-item" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div>
<div class="sr-name">${item.name}</div>
<div class="sr-pn">${item.part_number} | ${item.brand || ''}</div>
<div class="sr-stock ${stockClass}">Stock: ${item.stock}</div>
</div>
<div class="sr-price">${fmt(price)}</div>
</div>`;
});
container.innerHTML = html;
}
container.classList.add('active');
totals.classList.add('hidden');
} catch (e) {
console.error('Search error:', e);
}
}
function addFromSearch(item, price) {
addToCart({
inventory_id: item.id,
part_number: item.part_number,
name: item.name,
unit_price: price,
cost: item.cost,
tax_rate: item.tax_rate,
stock: item.stock,
});
hideSearchResults();
document.getElementById('itemSearch').value = '';
document.getElementById('itemSearch').focus();
}
function hideSearchResults() {
document.getElementById('searchResults').classList.remove('active');
document.getElementById('totalsPanel').classList.remove('hidden');
}
// ─── Customer Search ─────────────────
function setupCustomerSearch() {
const input = document.getElementById('customerSearch');
input.addEventListener('input', () => {
clearTimeout(customerSearchTimeout);
customerSearchTimeout = setTimeout(() => searchCustomers(input.value.trim()), 300);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
input.value = '';
document.getElementById('customerAutocomplete').style.display = 'none';
}
});
}
async function searchCustomers(q) {
if (!q || q.length < 2) {
document.getElementById('customerAutocomplete').style.display = 'none';
return;
}
try {
const data = await api(`/pos/api/customers?q=${encodeURIComponent(q)}&per_page=10`);
const ac = document.getElementById('customerAutocomplete');
if (data.data.length === 0) {
ac.innerHTML = '<div class="ac-item" style="color:#999;">Sin resultados</div>';
} else {
let html = '';
data.data.forEach(c => {
const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' };
html += `<div class="ac-item" onclick='POS.selectCustomer(${JSON.stringify(c).replace(/'/g, "&#39;")})'>
<div>${c.name}</div>
<div class="ac-meta">${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}</div>
</div>`;
});
ac.innerHTML = html;
}
ac.style.display = 'block';
} catch (e) {
console.error('Customer search error:', e);
}
}
async function selectCustomer(customer) {
currentCustomer = customer;
document.getElementById('customerAutocomplete').style.display = 'none';
document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none';
const tiers = { 1: 'P1 Mostrador', 2: 'P2 Taller', 3: 'P3 Mayoreo' };
document.getElementById('customerName').textContent = customer.name;
document.getElementById('customerTier').textContent = tiers[customer.price_tier] || 'P1';
document.getElementById('customerCredit').textContent =
`Credito: ${fmt(customer.credit_balance)} / ${fmt(customer.credit_limit)}`;
document.getElementById('customerSelected').style.display = '';
// Show vehicle info
if (customer.vehicle_info && customer.vehicle_info.length > 0) {
const v = customer.vehicle_info[0];
document.getElementById('vehicleInfo').textContent =
`${v.make || ''} ${v.model || ''} ${v.year || ''} ${v.plates ? '(' + v.plates + ')' : ''}`;
document.getElementById('vehicleBanner').classList.add('visible');
}
// Fetch full customer detail to get recent purchase info
try {
const detail = await api(`/pos/api/customers/${customer.id}`);
if (detail.recent_purchases && detail.recent_purchases.length > 0) {
const last = detail.recent_purchases[0];
const daysAgo = Math.floor((Date.now() - new Date(last.created_at).getTime()) / 86400000);
const daysText = daysAgo === 0 ? 'hoy' : daysAgo === 1 ? 'hace 1 dia' : `hace ${daysAgo} dias`;
document.getElementById('lastPurchaseInfo').textContent =
`Ultima compra: ${fmt(last.total)} ${daysText}`;
document.getElementById('vehicleBanner').classList.add('visible');
}
} catch (e) {
console.warn('Could not fetch customer detail:', e);
}
// Re-apply prices based on customer tier
cart.forEach(item => {
// Fetch updated price for this customer tier (would need to re-query)
// For now, prices stay as-is (they were set at add time)
});
renderCart();
}
function clearCustomer() {
currentCustomer = null;
document.getElementById('customerSelected').style.display = 'none';
document.getElementById('customerSearchWrap').querySelector('input').style.display = '';
document.getElementById('customerSearchWrap').querySelector('input').value = '';
document.getElementById('vehicleBanner').classList.remove('visible');
renderCart();
}
// ─── New Customer Modal ──────────────
function showNewCustomerModal() {
document.getElementById('newCustomerModal').classList.add('active');
document.getElementById('ncName').focus();
}
function closeNewCustomerModal() {
document.getElementById('newCustomerModal').classList.remove('active');
}
async function saveNewCustomer() {
const name = document.getElementById('ncName').value.trim();
if (!name) { alert('Nombre es requerido'); return; }
const vehicle_info = [];
const make = document.getElementById('ncVehMake').value.trim();
if (make) {
vehicle_info.push({
make: make,
model: document.getElementById('ncVehModel').value.trim(),
year: document.getElementById('ncVehYear').value.trim(),
plates: document.getElementById('ncVehPlates').value.trim(),
});
}
const body = {
name: name,
rfc: document.getElementById('ncRfc').value.trim() || null,
razon_social: document.getElementById('ncRazonSocial').value.trim() || null,
regimen_fiscal: document.getElementById('ncRegimenFiscal').value || null,
uso_cfdi: document.getElementById('ncUsoCfdi').value || 'G03',
phone: document.getElementById('ncPhone').value.trim() || null,
email: document.getElementById('ncEmail').value.trim() || null,
price_tier: parseInt(document.getElementById('ncPriceTier').value) || 1,
credit_limit: parseFloat(document.getElementById('ncCreditLimit').value) || 0,
vehicle_info: vehicle_info.length > 0 ? vehicle_info : null,
};
try {
const result = await api('/pos/api/customers', {
method: 'POST',
body: JSON.stringify(body),
});
// Select the new customer
selectCustomer({
id: result.id,
name: body.name,
rfc: body.rfc,
phone: body.phone,
price_tier: body.price_tier,
credit_limit: body.credit_limit,
credit_balance: 0,
vehicle_info: body.vehicle_info,
});
closeNewCustomerModal();
} catch (e) {
alert('Error al crear cliente: ' + e.message);
}
}
// ─── Payment ─────────────────────────
function checkout() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!currentRegister) { alert('No hay caja abierta. Abra una caja primero.'); return; }
paymentMethod = 'efectivo';
const total = getTotal();
document.getElementById('modalTotal').textContent = fmt(total);
document.getElementById('cashReceived').value = '';
document.getElementById('changeDisplay').textContent = 'Cambio: $0.00';
document.getElementById('changeDisplay').className = 'change-display positive';
document.getElementById('paymentRef').value = '';
// Reset payment method buttons
document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.pm-btn[data-method="efectivo"]').classList.add('active');
document.getElementById('cashPayment').style.display = '';
document.getElementById('refPayment').style.display = 'none';
document.getElementById('mixedPayment').style.display = 'none';
document.getElementById('paymentModal').classList.add('active');
setTimeout(() => document.getElementById('cashReceived').focus(), 100);
}
function selectPaymentMethod(method, btn) {
paymentMethod = method;
document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('cashPayment').style.display = method === 'efectivo' ? '' : 'none';
document.getElementById('refPayment').style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
document.getElementById('mixedPayment').style.display = method === 'mixto' ? '' : 'none';
if (method === 'efectivo') document.getElementById('cashReceived').focus();
if (method === 'transferencia' || method === 'tarjeta') document.getElementById('paymentRef').focus();
}
function updateChange() {
const total = getTotal();
const received = parseFloat(document.getElementById('cashReceived').value) || 0;
const change = received - total;
const el = document.getElementById('changeDisplay');
el.textContent = `Cambio: ${fmt(Math.abs(change))}`;
el.className = 'change-display ' + (change >= 0 ? 'positive' : 'negative');
}
function updateMixedTotal() {
const total = getTotal();
let sum = 0;
document.querySelectorAll('.mixed-amount').forEach(input => {
sum += parseFloat(input.value) || 0;
});
const remaining = total - sum;
document.getElementById('mixedRemaining').textContent =
remaining > 0 ? `Faltante: ${fmt(remaining)}` : `Cubierto (${fmt(sum)})`;
document.getElementById('mixedRemaining').style.color = remaining > 0 ? '#c62828' : '#2e7d32';
}
function closePaymentModal() {
document.getElementById('paymentModal').classList.remove('active');
}
async function confirmPayment() {
const total = getTotal();
let amountPaid = 0;
let paymentDetails = [];
let reference = '';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
if (amountPaid < total) { alert(`Monto insuficiente. Total: ${fmt(total)}`); return; }
} else if (paymentMethod === 'transferencia' || paymentMethod === 'tarjeta') {
amountPaid = total;
reference = document.getElementById('paymentRef').value.trim();
} else if (paymentMethod === 'mixto') {
const rows = document.querySelectorAll('.mixed-row');
rows.forEach(row => {
const method = row.querySelector('select').value;
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
const ref = row.querySelectorAll('input')[1]?.value || '';
if (amount > 0) {
paymentDetails.push({ method, amount, reference: ref });
amountPaid += amount;
}
});
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
}
const saleData = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer ? currentCustomer.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
reference: reference,
};
document.getElementById('btnConfirmPayment').disabled = true;
document.getElementById('btnConfirmPayment').textContent = 'Procesando...';
try {
const sale = await api('/pos/api/sales', {
method: 'POST',
body: JSON.stringify(saleData),
});
lastSaleId = sale.id;
closePaymentModal();
showTicket(sale);
// Clear cart
cart = [];
selectedRow = -1;
clearCustomer();
renderCart();
} catch (e) {
alert('Error al procesar venta: ' + e.message);
} finally {
document.getElementById('btnConfirmPayment').disabled = false;
document.getElementById('btnConfirmPayment').textContent = 'Confirmar Pago';
}
}
// ─── Credit Sale ─────────────────────
async function creditSale() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
if (!currentRegister) { alert('No hay caja abierta.'); return; }
const total = getTotal();
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (currentCustomer.credit_limit > 0 && total > available) {
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar de todas formas?`)) {
return;
}
}
const saleData = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer.id,
payment_method: 'credito',
sale_type: 'credit',
register_id: currentRegister ? currentRegister.id : null,
amount_paid: 0,
};
try {
const sale = await api('/pos/api/sales', {
method: 'POST',
body: JSON.stringify(saleData),
});
lastSaleId = sale.id;
showTicket(sale);
cart = [];
selectedRow = -1;
clearCustomer();
renderCart();
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Quotation ───────────────────────
async function saveQuotation() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
const body = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer ? currentCustomer.id : null,
};
try {
const result = await api('/pos/api/quotations', {
method: 'POST',
body: JSON.stringify(body),
});
alert(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}\nValida hasta: ${result.valid_until}`);
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Layaway ─────────────────────────
async function createLayaway() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
const total = getTotal();
const initialPayment = prompt(`Total: ${fmt(total)}\nIngrese monto del anticipo:`);
if (!initialPayment) return;
const amount = parseFloat(initialPayment);
if (isNaN(amount) || amount <= 0) { alert('Monto invalido'); return; }
if (amount > total) { alert('El anticipo no puede exceder el total'); return; }
const body = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer.id,
initial_payment: amount,
payment_method: 'efectivo',
register_id: currentRegister ? currentRegister.id : null,
};
try {
const result = await api('/pos/api/layaways', {
method: 'POST',
body: JSON.stringify(body),
});
alert(
`Apartado #${result.id} creado.\n` +
`Total: ${fmt(result.total)}\n` +
`Anticipo: ${fmt(result.amount_paid)}\n` +
`Restante: ${fmt(result.remaining)}\n` +
`Vence: ${result.expires_at}`
);
cart = [];
selectedRow = -1;
clearCustomer();
renderCart();
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Ticket ──────────────────────────
function showTicket(sale) {
const lines = [];
lines.push('========================================');
lines.push(' NEXUS POS');
lines.push('========================================');
lines.push(`Venta #${sale.id}`);
lines.push(`Fecha: ${new Date(sale.created_at).toLocaleString('es-MX')}`);
if (currentCustomer) {
lines.push(`Cliente: ${currentCustomer.name}`);
if (currentCustomer.rfc) lines.push(`RFC: ${currentCustomer.rfc}`);
} else {
lines.push('Cliente: Publico General');
}
lines.push('----------------------------------------');
(sale.items || []).forEach(item => {
lines.push(`${item.name}`);
let line = ` ${item.quantity} x ${fmt(item.unit_price)}`;
if (item.discount_pct > 0) line += ` (-${item.discount_pct}%)`;
line += ` ${fmt(item.subtotal)}`;
lines.push(line);
});
lines.push('----------------------------------------');
lines.push(`Subtotal: ${fmt(sale.subtotal).padStart(12)}`);
if (sale.discount_total > 0) {
lines.push(`Descuento: -${fmt(sale.discount_total).padStart(12)}`);
}
lines.push(`IVA: ${fmt(sale.tax_total).padStart(12)}`);
lines.push('========================================');
lines.push(`TOTAL: ${fmt(sale.total).padStart(12)}`);
lines.push('========================================');
if (sale.payment_method === 'efectivo') {
lines.push(`Efectivo: ${fmt(sale.amount_paid).padStart(12)}`);
lines.push(`Cambio: ${fmt(sale.change_given).padStart(12)}`);
} else {
lines.push(`Pago: ${sale.payment_method}`);
}
lines.push('');
lines.push(' Gracias por su compra!');
lines.push('');
document.getElementById('ticketPreview').textContent = lines.join('\n');
document.getElementById('ticketModal').classList.add('active');
}
function closeTicketModal() {
document.getElementById('ticketModal').classList.remove('active');
}
function printTicket() {
window.print();
}
// ─── Last Sale ───────────────────────
async function showLastSale() {
if (!lastSaleId) { alert('No hay venta reciente'); return; }
try {
const sale = await api(`/pos/api/sales/${lastSaleId}`);
showTicket(sale);
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Drawer ──────────────────────────
function openDrawer() {
// Cash drawer open command (ESC/POS compatible)
// In a real implementation, this would send the command to the printer
alert('Comando enviado al cajon de efectivo.');
}
// ─── Keyboard Shortcuts ──────────────
function setupKeyboard() {
document.addEventListener('keydown', (e) => {
// Don't intercept when typing in inputs
const tag = e.target.tagName;
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
switch (e.key) {
case 'F1':
e.preventDefault();
document.getElementById('itemSearch').focus();
break;
case 'F2':
e.preventDefault();
document.getElementById('customerSearch').focus();
document.getElementById('customerSearch').style.display = '';
document.getElementById('customerSelected').style.display = 'none';
break;
case 'F3':
e.preventDefault();
checkout();
break;
case 'F4':
e.preventDefault();
saveQuotation();
break;
case 'F5':
e.preventDefault();
showLastSale();
break;
case 'F6':
e.preventDefault();
openDrawer();
break;
case 'Escape':
e.preventDefault();
if (document.getElementById('paymentModal').classList.contains('active')) {
closePaymentModal();
} else if (document.getElementById('newCustomerModal').classList.contains('active')) {
closeNewCustomerModal();
} else if (document.getElementById('ticketModal').classList.contains('active')) {
closeTicketModal();
} else {
hideSearchResults();
}
break;
case 'Delete':
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
e.preventDefault();
removeFromCart(selectedRow);
}
break;
case '+':
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
e.preventDefault();
cart[selectedRow].quantity++;
renderCart();
}
break;
case '-':
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
e.preventDefault();
if (cart[selectedRow].quantity > 1) {
cart[selectedRow].quantity--;
renderCart();
}
}
break;
case '*':
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
e.preventDefault();
const disc = prompt('Descuento %:', cart[selectedRow].discount_pct);
if (disc !== null) {
updateDiscount(selectedRow, disc);
}
}
break;
case 'ArrowUp':
if (!inInput && cart.length > 0) {
e.preventDefault();
selectedRow = Math.max(0, selectedRow - 1);
renderCart();
}
break;
case 'ArrowDown':
if (!inInput && cart.length > 0) {
e.preventDefault();
selectedRow = Math.min(cart.length - 1, selectedRow + 1);
renderCart();
}
break;
case 'Enter':
if (e.target.id === 'cashReceived') {
e.preventDefault();
confirmPayment();
}
break;
}
});
}
// ─── Public API ──────────────────────
init();
return {
addToCart, removeFromCart, selectRow,
updateQty, updateDiscount,
addFromSearch, hideSearchResults,
selectCustomer, clearCustomer,
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
selectPaymentMethod, updateChange, updateMixedTotal,
creditSale, saveQuotation, createLayaway,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,
};
})();
```
---
### Task 7: Customers frontend (`pos/templates/customers.html` + `pos/static/js/customers.js`)
**Files:**
- Create: `/home/Autopartes/pos/templates/customers.html`
- Create: `/home/Autopartes/pos/static/js/customers.js`
Customer management page with search, CRUD, credit status, vehicles, and account statement.
- [ ] **Step 1: Create customers.html**
```html
<!-- /home/Autopartes/pos/templates/customers.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clientes - Nexus POS</title>
<link rel="stylesheet" href="/pos/static/css/common.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); }
.topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
.topbar h1 { font-size: 18px; font-weight: 600; }
.topbar .nav-links a { color: #b0bec5; text-decoration: none; margin-left: 16px; font-size: 14px; }
.topbar .nav-links a:hover { color: #fff; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
.toolbar input { flex: 1; padding: 10px 14px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
.toolbar .btn { padding: 10px 20px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 500; }
.btn-primary { background: var(--color-primary, #1a1a2e); color: #fff; }
.btn-secondary { background: #e0e0e0; color: #333; }
.customers-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius, 6px); overflow: hidden; box-shadow: var(--shadow, 0 1px 3px rgba(0,0,0,0.1)); }
.customers-table th { background: var(--color-surface, #f8f9fa); padding: 10px 12px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
.customers-table td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
.customers-table tr { cursor: pointer; }
.customers-table tr:hover { background: #f8f9fa; }
.tier-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.tier-1 { background: #e3f2fd; color: #1565c0; }
.tier-2 { background: #e8f5e9; color: #2e7d32; }
.tier-3 { background: #fff3e0; color: #e65100; }
.credit-bar { width: 80px; height: 6px; background: #e0e0e0; border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 6px; }
.credit-fill { height: 100%; border-radius: 3px; background: #4caf50; }
.credit-fill.warning { background: #ff9800; }
.credit-fill.danger { background: #f44336; }
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
.pagination .btn { padding: 6px 12px; font-size: 13px; }
.pagination .btn.active { background: var(--color-primary, #1a1a2e); color: #fff; }
/* Detail panel */
.detail-panel { display: none; position: fixed; top: 0; right: 0; width: 480px; height: 100vh; background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.15); z-index: 100; overflow-y: auto; }
.detail-panel.active { display: block; }
.detail-header { padding: 16px 20px; background: var(--color-primary, #1a1a2e); color: #fff; display: flex; justify-content: space-between; align-items: center; }
.detail-header h2 { font-size: 16px; }
.detail-header .btn-close { background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; }
.detail-body { padding: 20px; }
.detail-section { margin-bottom: 20px; }
.detail-section h3 { font-size: 14px; font-weight: 600; color: #666; margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 4px; }
.detail-field { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.detail-field .label { color: #999; }
.credit-summary { background: #f5f5f5; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
.credit-summary .big-number { font-size: 24px; font-weight: 700; }
.purchases-list { max-height: 300px; overflow-y: auto; }
.purchase-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; display: flex; justify-content: space-between; }
.vehicle-card { background: #f5f5f5; padding: 10px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 13px; }
/* Modal */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: #fff; border-radius: 12px; padding: 24px; width: 550px; max-width: 95vw; max-height: 90vh; overflow-y: auto; }
.modal h2 { margin-bottom: 16px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-grid .full-width { grid-column: 1 / -1; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-field label { font-size: 12px; color: #666; font-weight: 500; }
.form-field input, .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
.modal-actions .btn { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
</style>
</head>
<body>
<div class="topbar">
<h1>Clientes</h1>
<div class="nav-links">
<a href="/pos/sale">POS</a>
<a href="/pos/catalog">Catalogo</a>
<a href="/pos/inventory">Inventario</a>
<a href="/pos/customers">Clientes</a>
</div>
</div>
<div class="container">
<div class="toolbar">
<input type="text" id="searchInput" placeholder="Buscar por nombre, RFC, telefono..." oninput="Customers.search()">
<button class="btn btn-primary" onclick="Customers.showCreateModal()">+ Nuevo Cliente</button>
</div>
<table class="customers-table">
<thead>
<tr>
<th>Nombre</th>
<th>RFC</th>
<th>Telefono</th>
<th>Lista</th>
<th>Credito</th>
<th>Saldo</th>
</tr>
</thead>
<tbody id="customersBody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
</div>
<!-- Detail Panel (slides in from right) -->
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<h2 id="detailName">Cliente</h2>
<button class="btn-close" onclick="Customers.closeDetail()">&times;</button>
</div>
<div class="detail-body">
<div class="detail-section">
<div class="credit-summary">
<div>Credito disponible</div>
<div class="big-number" id="detailCreditAvailable">$0.00</div>
<div style="font-size: 12px; color: #666;">
Limite: <span id="detailCreditLimit">$0.00</span> |
Saldo: <span id="detailCreditBalance">$0.00</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Datos Fiscales</h3>
<div id="detailFiscal"></div>
</div>
<div class="detail-section">
<h3>Contacto</h3>
<div id="detailContact"></div>
</div>
<div class="detail-section">
<h3>Vehiculos</h3>
<div id="detailVehicles"></div>
</div>
<div class="detail-section">
<h3>Compras Recientes</h3>
<div class="purchases-list" id="detailPurchases"></div>
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button class="btn btn-primary" onclick="Customers.editCurrent()">Editar</button>
<button class="btn btn-secondary" onclick="Customers.showStatement()">Estado de Cuenta</button>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal-overlay" id="customerModal">
<div class="modal">
<h2 id="modalTitle">Nuevo Cliente</h2>
<input type="hidden" id="editId">
<div class="form-grid">
<div class="form-field full-width"><label>Nombre *</label><input type="text" id="fName"></div>
<div class="form-field"><label>RFC</label><input type="text" id="fRfc" maxlength="13"></div>
<div class="form-field"><label>Razon Social</label><input type="text" id="fRazonSocial"></div>
<div class="form-field"><label>Regimen Fiscal</label>
<select id="fRegimenFiscal">
<option value="">Seleccionar...</option>
<option value="601">601 - General de Ley PM</option>
<option value="603">603 - PM Fines No Lucrativos</option>
<option value="605">605 - Sueldos y Salarios</option>
<option value="606">606 - Arrendamiento</option>
<option value="612">612 - PF Actividad Empresarial</option>
<option value="616">616 - Sin Obligaciones Fiscales</option>
<option value="621">621 - Incorporacion Fiscal</option>
<option value="625">625 - RESICO</option>
</select>
</div>
<div class="form-field"><label>Uso CFDI</label>
<select id="fUsoCfdi">
<option value="G03">G03 - Gastos en general</option>
<option value="G01">G01 - Adquisicion de mercancias</option>
<option value="P01">P01 - Por definir</option>
</select>
</div>
<div class="form-field"><label>Codigo Postal</label><input type="text" id="fCp" maxlength="5"></div>
<div class="form-field"><label>Telefono</label><input type="tel" id="fPhone"></div>
<div class="form-field"><label>Email</label><input type="email" id="fEmail"></div>
<div class="form-field full-width"><label>Direccion</label><input type="text" id="fAddress"></div>
<div class="form-field"><label>Lista de precio</label>
<select id="fPriceTier">
<option value="1">1 - Mostrador</option>
<option value="2">2 - Taller</option>
<option value="3">3 - Mayoreo</option>
</select>
</div>
<div class="form-field"><label>Limite de credito</label><input type="number" id="fCreditLimit" value="0" min="0" step="100"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="Customers.closeModal()">Cancelar</button>
<button class="btn btn-primary" onclick="Customers.save()">Guardar</button>
</div>
</div>
</div>
<!-- Statement Modal -->
<div class="modal-overlay" id="statementModal">
<div class="modal" style="width: 650px;">
<h2>Estado de Cuenta: <span id="statementName"></span></h2>
<div id="statementContent" style="max-height: 500px; overflow-y: auto;"></div>
<div class="modal-actions" style="margin-top: 16px;">
<button class="btn btn-secondary" onclick="document.getElementById('statementModal').classList.remove('active')">Cerrar</button>
</div>
</div>
</div>
<script src="/pos/static/js/customers.js"></script>
</body>
</html>
```
- [ ] **Step 2: Create customers.js**
```javascript
// /home/Autopartes/pos/static/js/customers.js
/**
* Customers management frontend.
* Communicates with /pos/api/customers (customers_bp).
*/
const Customers = (() => {
let token = localStorage.getItem('pos_token') || '';
let currentPage = 1;
let currentCustomer = null;
let searchTimeout = null;
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
async function api(url, options = {}) {
options.headers = headers();
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
// ─── List ────────────────────────────
async function loadCustomers(page, q) {
page = page || currentPage;
q = q !== undefined ? q : (document.getElementById('searchInput').value || '');
try {
const params = new URLSearchParams({ page, per_page: 50 });
if (q) params.append('q', q);
const data = await api(`/pos/api/customers?${params}`);
renderTable(data.data);
renderPagination(data.pagination);
} catch (e) {
console.error('Load customers failed:', e);
}
}
function renderTable(customers) {
const tbody = document.getElementById('customersBody');
const tiers = { 1: ['Mostrador', 'tier-1'], 2: ['Taller', 'tier-2'], 3: ['Mayoreo', 'tier-3'] };
let html = '';
customers.forEach(c => {
const [tierName, tierClass] = tiers[c.price_tier] || ['P1', 'tier-1'];
const limit = c.credit_limit || 0;
const balance = c.credit_balance || 0;
const usagePct = limit > 0 ? Math.min(100, (balance / limit) * 100) : 0;
const fillClass = usagePct > 90 ? 'danger' : usagePct > 70 ? 'warning' : '';
html += `<tr onclick="Customers.showDetail(${c.id})">
<td><strong>${c.name}</strong></td>
<td>${c.rfc || '-'}</td>
<td>${c.phone || '-'}</td>
<td><span class="tier-badge ${tierClass}">${tierName}</span></td>
<td>${fmt(limit)}
${limit > 0 ? `<div class="credit-bar"><div class="credit-fill ${fillClass}" style="width:${usagePct}%"></div></div>` : ''}
</td>
<td>${balance > 0 ? fmt(balance) : '-'}</td>
</tr>`;
});
if (customers.length === 0) {
html = '<tr><td colspan="6" style="text-align:center;color:#999;padding:20px;">Sin resultados</td></tr>';
}
tbody.innerHTML = html;
}
function renderPagination(pag) {
const container = document.getElementById('pagination');
if (pag.total_pages <= 1) { container.innerHTML = ''; return; }
let html = '';
if (pag.page > 1) {
html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page - 1})">Anterior</button>`;
}
for (let i = 1; i <= Math.min(pag.total_pages, 10); i++) {
html += `<button class="btn ${i === pag.page ? 'active' : 'btn-secondary'}" onclick="Customers.goToPage(${i})">${i}</button>`;
}
if (pag.page < pag.total_pages) {
html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page + 1})">Siguiente</button>`;
}
container.innerHTML = html;
}
function goToPage(page) {
currentPage = page;
loadCustomers(page);
}
function search() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage = 1;
loadCustomers(1);
}, 300);
}
// ─── Detail ──────────────────────────
async function showDetail(id) {
try {
const c = await api(`/pos/api/customers/${id}`);
currentCustomer = c;
document.getElementById('detailName').textContent = c.name;
// Credit
const available = (c.credit_limit || 0) - (c.credit_balance || 0);
document.getElementById('detailCreditAvailable').textContent = fmt(available);
document.getElementById('detailCreditLimit').textContent = fmt(c.credit_limit);
document.getElementById('detailCreditBalance').textContent = fmt(c.credit_balance);
// Fiscal
let fiscalHtml = '';
fiscalHtml += `<div class="detail-field"><span class="label">RFC</span><span>${c.rfc || '-'}</span></div>`;
fiscalHtml += `<div class="detail-field"><span class="label">Razon Social</span><span>${c.razon_social || '-'}</span></div>`;
fiscalHtml += `<div class="detail-field"><span class="label">Regimen</span><span>${c.regimen_fiscal || '-'}</span></div>`;
fiscalHtml += `<div class="detail-field"><span class="label">Uso CFDI</span><span>${c.uso_cfdi || '-'}</span></div>`;
fiscalHtml += `<div class="detail-field"><span class="label">CP</span><span>${c.cp || '-'}</span></div>`;
document.getElementById('detailFiscal').innerHTML = fiscalHtml;
// Contact
let contactHtml = '';
contactHtml += `<div class="detail-field"><span class="label">Telefono</span><span>${c.phone || '-'}</span></div>`;
contactHtml += `<div class="detail-field"><span class="label">Email</span><span>${c.email || '-'}</span></div>`;
contactHtml += `<div class="detail-field"><span class="label">Direccion</span><span>${c.address || '-'}</span></div>`;
document.getElementById('detailContact').innerHTML = contactHtml;
// Vehicles
const vehicles = c.vehicle_info || [];
if (vehicles.length === 0) {
document.getElementById('detailVehicles').innerHTML = '<div style="color:#999;font-size:13px;">Sin vehiculos registrados</div>';
} else {
document.getElementById('detailVehicles').innerHTML = vehicles.map(v =>
`<div class="vehicle-card">
<strong>${v.make || ''} ${v.model || ''} ${v.year || ''}</strong>
${v.plates ? `<span style="margin-left:8px;color:#666;">Placas: ${v.plates}</span>` : ''}
${v.vin ? `<div style="font-size:11px;color:#999;">VIN: ${v.vin}</div>` : ''}
</div>`
).join('');
}
// Recent purchases
const purchases = c.recent_purchases || [];
if (purchases.length === 0) {
document.getElementById('detailPurchases').innerHTML = '<div style="color:#999;font-size:13px;">Sin compras recientes</div>';
} else {
document.getElementById('detailPurchases').innerHTML = purchases.map(p =>
`<div class="purchase-item">
<span>Venta #${p.id} - ${new Date(p.created_at).toLocaleDateString('es-MX')}</span>
<span>${fmt(p.total)}</span>
</div>`
).join('');
}
document.getElementById('detailPanel').classList.add('active');
} catch (e) {
alert('Error: ' + e.message);
}
}
function closeDetail() {
document.getElementById('detailPanel').classList.remove('active');
currentCustomer = null;
}
// ─── Create/Edit Modal ───────────────
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Nuevo Cliente';
document.getElementById('editId').value = '';
document.getElementById('fName').value = '';
document.getElementById('fRfc').value = '';
document.getElementById('fRazonSocial').value = '';
document.getElementById('fRegimenFiscal').value = '';
document.getElementById('fUsoCfdi').value = 'G03';
document.getElementById('fCp').value = '';
document.getElementById('fPhone').value = '';
document.getElementById('fEmail').value = '';
document.getElementById('fAddress').value = '';
document.getElementById('fPriceTier').value = '1';
document.getElementById('fCreditLimit').value = '0';
document.getElementById('customerModal').classList.add('active');
document.getElementById('fName').focus();
}
function editCurrent() {
if (!currentCustomer) return;
const c = currentCustomer;
document.getElementById('modalTitle').textContent = 'Editar Cliente';
document.getElementById('editId').value = c.id;
document.getElementById('fName').value = c.name || '';
document.getElementById('fRfc').value = c.rfc || '';
document.getElementById('fRazonSocial').value = c.razon_social || '';
document.getElementById('fRegimenFiscal').value = c.regimen_fiscal || '';
document.getElementById('fUsoCfdi').value = c.uso_cfdi || 'G03';
document.getElementById('fCp').value = c.cp || '';
document.getElementById('fPhone').value = c.phone || '';
document.getElementById('fEmail').value = c.email || '';
document.getElementById('fAddress').value = c.address || '';
document.getElementById('fPriceTier').value = c.price_tier || '1';
document.getElementById('fCreditLimit').value = c.credit_limit || 0;
document.getElementById('customerModal').classList.add('active');
}
function closeModal() {
document.getElementById('customerModal').classList.remove('active');
}
async function save() {
const name = document.getElementById('fName').value.trim();
if (!name) { alert('Nombre es requerido'); return; }
const body = {
name: name,
rfc: document.getElementById('fRfc').value.trim() || null,
razon_social: document.getElementById('fRazonSocial').value.trim() || null,
regimen_fiscal: document.getElementById('fRegimenFiscal').value || null,
uso_cfdi: document.getElementById('fUsoCfdi').value || 'G03',
cp: document.getElementById('fCp').value.trim() || null,
phone: document.getElementById('fPhone').value.trim() || null,
email: document.getElementById('fEmail').value.trim() || null,
address: document.getElementById('fAddress').value.trim() || null,
price_tier: parseInt(document.getElementById('fPriceTier').value) || 1,
credit_limit: parseFloat(document.getElementById('fCreditLimit').value) || 0,
};
const editId = document.getElementById('editId').value;
try {
if (editId) {
await api(`/pos/api/customers/${editId}`, {
method: 'PUT',
body: JSON.stringify(body),
});
} else {
await api('/pos/api/customers', {
method: 'POST',
body: JSON.stringify(body),
});
}
closeModal();
loadCustomers();
if (editId && currentCustomer) {
showDetail(editId);
}
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Statement ───────────────────────
async function showStatement() {
if (!currentCustomer) return;
document.getElementById('statementName').textContent = currentCustomer.name;
try {
const data = await api(`/pos/api/customers/${currentCustomer.id}/statement`);
let html = `<div style="margin-bottom:12px;font-size:14px;">
<strong>Saldo actual: ${fmt(data.balance)}</strong> |
Limite: ${fmt(data.customer.credit_limit)}
</div>`;
if (data.entries.length === 0) {
html += '<div style="color:#999;padding:20px;text-align:center;">Sin movimientos</div>';
} else {
html += '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
html += '<tr style="background:#f5f5f5;"><th style="padding:8px;text-align:left;">Fecha</th><th style="text-align:left;">Concepto</th><th style="text-align:right;padding:8px;">Cargo</th><th style="text-align:right;padding:8px;">Abono</th><th style="text-align:right;padding:8px;">Saldo</th></tr>';
data.entries.forEach(e => {
const dateStr = new Date(e.date).toLocaleDateString('es-MX');
html += `<tr style="border-bottom:1px solid #eee;">
<td style="padding:6px 8px;">${dateStr}</td>
<td>${e.description}</td>
<td style="text-align:right;padding:6px 8px;">${e.type === 'charge' ? fmt(e.amount) : ''}</td>
<td style="text-align:right;padding:6px 8px;">${e.type === 'payment' ? fmt(e.amount) : ''}</td>
<td style="text-align:right;padding:6px 8px;">${fmt(e.running_balance)}</td>
</tr>`;
});
html += '</table>';
}
document.getElementById('statementContent').innerHTML = html;
document.getElementById('statementModal').classList.add('active');
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Init ────────────────────────────
loadCustomers();
return {
search, goToPage, loadCustomers,
showDetail, closeDetail,
showCreateModal, editCurrent, closeModal, save,
showStatement,
};
})();
```
---
### Task 8: Database migration for sale_payments and layaway_items
**Files:**
- Create: `/home/Autopartes/pos/migrations/v1.1_pos_tables.sql`
The original schema (v1.0) includes sales, sale_items, quotations, quotation_items, layaways, layaway_payments, cash_registers, and cash_movements. This migration adds the `sale_payments` and `layaway_items` tables that were not in the original schema but are needed by the POS engine.
- [ ] **Step 1: Create migration file**
```sql
-- /home/Autopartes/pos/migrations/v1.1_pos_tables.sql
-- POS Plan 3: Additional tables for sale payments and layaway items.
-- Run against each tenant DB.
-- Sale payments: tracks individual payment methods for a sale (especially mixed payments)
CREATE TABLE IF NOT EXISTS sale_payments (
id SERIAL PRIMARY KEY,
sale_id INTEGER REFERENCES sales(id),
register_id INTEGER REFERENCES cash_registers(id),
method VARCHAR(20) NOT NULL, -- efectivo, transferencia, tarjeta
amount NUMERIC(12,2) NOT NULL,
reference VARCHAR(100), -- transaction ref for non-cash
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sale_payments_sale ON sale_payments(sale_id);
-- Customer payments: tracks credit payments (abonos) against customer balance
CREATE TABLE IF NOT EXISTS customer_payments (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
amount NUMERIC(12,2) NOT NULL,
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
reference VARCHAR(100),
employee_id INTEGER REFERENCES employees(id),
register_id INTEGER REFERENCES cash_registers(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_customer_payments_customer ON customer_payments(customer_id);
-- Layaway items: line items for a layaway (mirrors quotation_items structure)
CREATE TABLE IF NOT EXISTS layaway_items (
id SERIAL PRIMARY KEY,
layaway_id INTEGER REFERENCES layaways(id),
inventory_id INTEGER REFERENCES inventory(id),
part_number VARCHAR(100),
name VARCHAR(300),
quantity INTEGER NOT NULL,
unit_price NUMERIC(12,2) NOT NULL,
discount_pct NUMERIC(5,2) DEFAULT 0,
tax_rate NUMERIC(5,4) DEFAULT 0.16,
subtotal NUMERIC(12,2) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_layaway_items_layaway ON layaway_items(layaway_id);
-- Additional indexes for POS query performance
CREATE INDEX IF NOT EXISTS idx_sales_register ON sales(register_id) WHERE status = 'completed';
CREATE INDEX IF NOT EXISTS idx_sales_customer ON sales(customer_id);
CREATE INDEX IF NOT EXISTS idx_sales_created ON sales(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sales_branch_date ON sales(branch_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_quotations_customer ON quotations(customer_id);
CREATE INDEX IF NOT EXISTS idx_layaways_customer ON layaways(customer_id);
CREATE INDEX IF NOT EXISTS idx_cash_registers_employee ON cash_registers(employee_id) WHERE status = 'open';
```
- [ ] **Step 2: Apply migration**
Run the migration against each tenant database. Example for the test tenant:
```bash
cd /home/Autopartes/pos
python3 -c "
from tenant_db import get_tenant_conn_by_dbname
conn = get_tenant_conn_by_dbname('tenant_inv_test')
cur = conn.cursor()
cur.execute(open('migrations/v1.1_pos_tables.sql').read())
conn.commit()
print('Migration v1.1 applied successfully')
"
```
For all tenants, iterate using the tenant registry:
```bash
cd /home/Autopartes/pos
python3 -c "
from tenant_db import get_all_tenant_dbnames, get_tenant_conn_by_dbname
sql = open('migrations/v1.1_pos_tables.sql').read()
for dbname in get_all_tenant_dbnames():
conn = get_tenant_conn_by_dbname(dbname)
cur = conn.cursor()
cur.execute(sql)
conn.commit()
conn.close()
print(f'Migration applied to {dbname}')
print('All tenants migrated')
"
```
> **Note:** The `layaway_items` table must exist before creating any layaways. The migration must be applied before deploying POS Plan 3 code.
---
### Task 9: Integration test
**Files:**
- Create: `/home/Autopartes/pos/tests/test_pos_integration.py`
Full integration test covering: register open, customer create, sale processing, quotation conversion, layaway workflow, cancellation, and register closing.
- [ ] **Step 1: Create test file**
```python
# /home/Autopartes/pos/tests/test_pos_integration.py
"""Integration tests for POS Plan 3: Sales, Customers, Cash Register.
Covers the full lifecycle:
1. Open register
2. Create customer
3. Process sale (verify inventory deduction, customer credit)
4. Create quotation -> convert to sale
5. Create layaway -> add payments -> complete
6. Cancel a sale (verify inventory reversal, credit reversal)
7. Cut-X (verify read-only summary)
8. Cut-Z (verify register closes with correct amounts)
Prerequisites:
- Flask app running or test client configured
- A test tenant DB with at least one branch, one employee, and inventory items
"""
import json
import pytest
import sys
import os
# Add pos directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from app import create_app
from tenant_db import get_tenant_conn
@pytest.fixture
def app():
app = create_app()
app.config['TESTING'] = True
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def auth_token(client):
"""Login and get a valid JWT token. Adjust credentials for your test tenant."""
res = client.post('/pos/api/auth/login', json={
'pin': '1234',
'device_id': 'test-device-001'
})
assert res.status_code == 200, f"Login failed: {res.get_json()}"
return res.get_json()['token']
def headers(token):
return {
'Content-Type': 'application/json',
'Authorization': f'Bearer {token}',
'X-Device-Id': 'test-device-001'
}
class TestPOSIntegration:
"""Full POS lifecycle integration test."""
register_id = None
customer_id = None
sale_id = None
quotation_id = None
layaway_id = None
inventory_id = None
initial_stock = None
def test_01_open_register(self, client, auth_token):
"""Open a cash register with an opening amount."""
res = client.post('/pos/api/register/open', headers=headers(auth_token), json={
'register_number': 99,
'opening_amount': 1000.00
})
data = res.get_json()
assert res.status_code == 201, f"Open register failed: {data}"
assert data['register_number'] == 99
assert data['opening_amount'] == 1000.00
TestPOSIntegration.register_id = data['id']
def test_02_verify_current_register(self, client, auth_token):
"""Verify the current register is returned."""
res = client.get('/pos/api/register/current', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
assert data['register'] is not None
assert data['register']['id'] == TestPOSIntegration.register_id
def test_03_create_customer(self, client, auth_token):
"""Create a test customer with credit limit."""
res = client.post('/pos/api/customers', headers=headers(auth_token), json={
'name': 'Test Customer POS',
'rfc': 'XAXX010101000',
'phone': '5551234567',
'price_tier': 1,
'credit_limit': 50000.00,
'vehicle_info': [{'make': 'Nissan', 'model': 'Tsuru', 'year': '2017', 'plates': 'ABC-123'}]
})
data = res.get_json()
assert res.status_code == 201, f"Create customer failed: {data}"
TestPOSIntegration.customer_id = data['id']
def test_04_search_customer(self, client, auth_token):
"""Search for the created customer."""
res = client.get('/pos/api/customers?q=Test Customer POS', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
assert len(data['data']) >= 1
assert any(c['name'] == 'Test Customer POS' for c in data['data'])
def test_05_get_inventory_item(self, client, auth_token):
"""Get an inventory item to use for sales. Uses the first active item."""
res = client.get('/pos/api/inventory/items?per_page=1', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
assert len(data['data']) >= 1, "Need at least one inventory item for testing"
item = data['data'][0]
TestPOSIntegration.inventory_id = item['id']
TestPOSIntegration.initial_stock = item['stock']
def test_06_process_sale(self, client, auth_token):
"""Process a cash sale and verify inventory deduction."""
inv_id = TestPOSIntegration.inventory_id
res = client.post('/pos/api/sales', headers=headers(auth_token), json={
'items': [{
'inventory_id': inv_id,
'quantity': 2,
'unit_price': 150.00,
'discount_pct': 0,
'tax_rate': 0.16
}],
'customer_id': TestPOSIntegration.customer_id,
'payment_method': 'efectivo',
'sale_type': 'cash',
'register_id': TestPOSIntegration.register_id,
'amount_paid': 400.00,
})
data = res.get_json()
assert res.status_code == 201, f"Process sale failed: {data}"
assert data['total'] == 348.00 # 150*2 * 1.16 = 348.00
assert data['change_given'] == 52.00 # 400 - 348 = 52
assert data['status'] == 'completed'
assert len(data['items']) == 2 or len(data['items']) >= 1
TestPOSIntegration.sale_id = data['id']
def test_07_verify_stock_deducted(self, client, auth_token):
"""Verify inventory stock was deducted after the sale."""
inv_id = TestPOSIntegration.inventory_id
res = client.get(f'/pos/api/inventory/items/{inv_id}', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
expected_stock = TestPOSIntegration.initial_stock - 2
assert data['stock'] == expected_stock, f"Stock should be {expected_stock}, got {data['stock']}"
def test_08_get_sale_detail_and_payments(self, client, auth_token):
"""Verify sale detail retrieval including sale_payments records."""
sale_id = TestPOSIntegration.sale_id
res = client.get(f'/pos/api/sales/{sale_id}', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
assert data['id'] == sale_id
assert 'items' in data
assert 'payments' in data
# Verify sale_payments were created
assert len(data['payments']) >= 1, "sale_payments should have at least one record"
assert data['payments'][0]['method'] == 'efectivo'
assert data['payments'][0]['amount'] > 0
def test_08b_mixed_payment_sale(self, client, auth_token):
"""Process a sale with mixed payment (cash + transfer)."""
inv_id = TestPOSIntegration.inventory_id
res = client.post('/pos/api/sales', headers=headers(auth_token), json={
'items': [{
'inventory_id': inv_id,
'quantity': 1,
'unit_price': 200.00,
'discount_pct': 0,
'tax_rate': 0.16
}],
'customer_id': None,
'payment_method': 'mixto',
'sale_type': 'cash',
'register_id': TestPOSIntegration.register_id,
'amount_paid': 232.00,
'payment_details': [
{'method': 'efectivo', 'amount': 132.00, 'reference': ''},
{'method': 'transferencia', 'amount': 100.00, 'reference': 'TRF-001'}
],
})
data = res.get_json()
assert res.status_code == 201, f"Mixed payment sale failed: {data}"
assert data['total'] == 232.00 # 200 * 1.16
assert data['payment_method'] == 'mixto'
# Verify sale_payments has two records for mixed payment
sale_detail = client.get(f'/pos/api/sales/{data["id"]}', headers=headers(auth_token))
detail = sale_detail.get_json()
assert len(detail['payments']) == 2, f"Expected 2 payment records, got {len(detail['payments'])}"
def test_08c_credit_sale(self, client, auth_token):
"""Process a credit sale and verify customer balance updated."""
inv_id = TestPOSIntegration.inventory_id
cust_id = TestPOSIntegration.customer_id
res = client.post('/pos/api/sales', headers=headers(auth_token), json={
'items': [{
'inventory_id': inv_id,
'quantity': 1,
'unit_price': 100.00,
'discount_pct': 0,
'tax_rate': 0.16
}],
'customer_id': cust_id,
'payment_method': 'credito',
'sale_type': 'credit',
'register_id': TestPOSIntegration.register_id,
'amount_paid': 0,
})
data = res.get_json()
assert res.status_code == 201, f"Credit sale failed: {data}"
assert data['sale_type'] == 'credit'
assert data['total'] == 116.00 # 100 * 1.16
# Verify customer credit balance increased
cust_res = client.get(f'/pos/api/customers/{cust_id}', headers=headers(auth_token))
cust_data = cust_res.get_json()
assert cust_data['credit_balance'] >= 116.00, \
f"Credit balance should include {116.00}, got {cust_data['credit_balance']}"
def test_09_create_quotation(self, client, auth_token):
"""Create a quotation."""
inv_id = TestPOSIntegration.inventory_id
res = client.post('/pos/api/quotations', headers=headers(auth_token), json={
'items': [{
'inventory_id': inv_id,
'quantity': 1,
'unit_price': 200.00,
'discount_pct': 5,
'tax_rate': 0.16
}],
'customer_id': TestPOSIntegration.customer_id,
})
data = res.get_json()
assert res.status_code == 201, f"Create quotation failed: {data}"
TestPOSIntegration.quotation_id = data['id']
def test_10_convert_quotation(self, client, auth_token):
"""Convert quotation to sale."""
quot_id = TestPOSIntegration.quotation_id
res = client.post(f'/pos/api/quotations/{quot_id}/convert', headers=headers(auth_token), json={
'register_id': TestPOSIntegration.register_id,
'payment_method': 'transferencia',
'sale_type': 'cash',
'amount_paid': 220.40, # 200 * 0.95 * 1.16 = 220.40
})
data = res.get_json()
assert res.status_code == 201, f"Convert quotation failed: {data}"
assert data['status'] == 'completed'
def test_11_create_layaway(self, client, auth_token):
"""Create a layaway with partial payment."""
inv_id = TestPOSIntegration.inventory_id
res = client.post('/pos/api/layaways', headers=headers(auth_token), json={
'items': [{
'inventory_id': inv_id,
'quantity': 3,
'unit_price': 100.00,
'discount_pct': 0,
'tax_rate': 0.16
}],
'customer_id': TestPOSIntegration.customer_id,
'initial_payment': 100.00,
'payment_method': 'efectivo',
'register_id': TestPOSIntegration.register_id,
})
data = res.get_json()
assert res.status_code == 201, f"Create layaway failed: {data}"
assert data['total'] == 348.00 # 100*3*1.16
assert data['amount_paid'] == 100.00
assert data['remaining'] == 248.00
TestPOSIntegration.layaway_id = data['id']
def test_12_layaway_add_payment(self, client, auth_token):
"""Add a payment to the layaway."""
layaway_id = TestPOSIntegration.layaway_id
res = client.post(f'/pos/api/layaways/{layaway_id}/payment', headers=headers(auth_token), json={
'amount': 248.00,
'payment_method': 'efectivo',
'register_id': TestPOSIntegration.register_id,
})
data = res.get_json()
assert res.status_code == 200, f"Layaway payment failed: {data}"
assert data['fully_paid'] is True
assert data['remaining'] == 0
def test_13_layaway_complete(self, client, auth_token):
"""Complete layaway (convert to sale)."""
layaway_id = TestPOSIntegration.layaway_id
res = client.post(f'/pos/api/layaways/{layaway_id}/complete', headers=headers(auth_token), json={
'register_id': TestPOSIntegration.register_id,
})
data = res.get_json()
assert res.status_code == 201, f"Layaway complete failed: {data}"
assert data['status'] == 'completed'
def test_14_cancel_sale(self, client, auth_token):
"""Cancel the first sale and verify inventory reversal."""
sale_id = TestPOSIntegration.sale_id
res = client.put(f'/pos/api/sales/{sale_id}/cancel', headers=headers(auth_token), json={
'reason': 'Test cancellation — integration test'
})
data = res.get_json()
assert res.status_code == 200, f"Cancel sale failed: {data}"
assert data['status'] == 'cancelled'
assert data['items_reversed'] >= 1
def test_15_verify_stock_restored(self, client, auth_token):
"""Verify inventory stock was restored after cancellation."""
inv_id = TestPOSIntegration.inventory_id
res = client.get(f'/pos/api/inventory/items/{inv_id}', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
# Stock should be initial - quotation_qty(1) - layaway_qty(3) since the first sale (2) was cancelled
expected = TestPOSIntegration.initial_stock - 1 - 3
assert data['stock'] == expected, f"Stock should be {expected}, got {data['stock']}"
def test_16_cash_movement(self, client, auth_token):
"""Record a manual cash withdrawal."""
res = client.post('/pos/api/register/movement', headers=headers(auth_token), json={
'type': 'out',
'amount': 200.00,
'reason': 'Retiro para cambio'
})
data = res.get_json()
assert res.status_code == 201, f"Cash movement failed: {data}"
def test_17_cut_x(self, client, auth_token):
"""X-cut: read-only summary without closing."""
res = client.get('/pos/api/register/cut-x', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
assert data['type'] == 'X'
assert data['status'] == 'open'
assert 'expected_cash' in data
assert 'sales_by_method' in data
assert data['opening_amount'] == 1000.00
def test_18_cut_z(self, client, auth_token):
"""Z-cut: close register with counted amount."""
# First get expected from X-cut
res_x = client.get('/pos/api/register/cut-x', headers=headers(auth_token))
expected = res_x.get_json()['expected_cash']
# Close with the expected amount (perfect close)
res = client.post('/pos/api/register/cut-z', headers=headers(auth_token), json={
'closing_amount': expected
})
data = res.get_json()
assert res.status_code == 200
assert data['type'] == 'Z'
assert data['status'] == 'closed'
assert data['difference'] == 0.0, f"Difference should be 0, got {data['difference']}"
def test_19_register_history(self, client, auth_token):
"""Verify closed register appears in history."""
res = client.get('/pos/api/register/history', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
assert len(data['data']) >= 1
# Find our register
our_reg = [r for r in data['data'] if r['id'] == TestPOSIntegration.register_id]
assert len(our_reg) == 1
assert our_reg[0]['register_number'] == 99
def test_20_customer_statement(self, client, auth_token):
"""Verify customer account statement."""
cust_id = TestPOSIntegration.customer_id
res = client.get(f'/pos/api/customers/{cust_id}/statement', headers=headers(auth_token))
data = res.get_json()
assert res.status_code == 200
assert 'entries' in data
assert 'balance' in data
```
---
## Summary of Endpoints
| Method | Endpoint | Blueprint | Permission | Description |
|--------|----------|-----------|------------|-------------|
| POST | /pos/api/sales | pos_bp | pos.sell | Create sale |
| GET | /pos/api/sales | pos_bp | pos.view | List sales |
| GET | /pos/api/sales/:id | pos_bp | pos.view | Sale detail |
| PUT | /pos/api/sales/:id/cancel | pos_bp | pos.sell | Cancel sale |
| POST | /pos/api/quotations | pos_bp | pos.sell | Create quotation |
| GET | /pos/api/quotations | pos_bp | pos.view | List quotations |
| GET | /pos/api/quotations/:id | pos_bp | pos.view | Quotation detail |
| POST | /pos/api/quotations/:id/convert | pos_bp | pos.sell | Convert to sale |
| PUT | /pos/api/quotations/:id/cancel | pos_bp | pos.sell | Cancel quotation |
| POST | /pos/api/layaways | pos_bp | pos.sell | Create layaway |
| GET | /pos/api/layaways | pos_bp | pos.view | List layaways |
| GET | /pos/api/layaways/:id | pos_bp | pos.view | Layaway detail |
| POST | /pos/api/layaways/:id/payment | pos_bp | pos.sell | Add payment |
| POST | /pos/api/layaways/:id/complete | pos_bp | pos.sell | Complete layaway |
| PUT | /pos/api/layaways/:id/cancel | pos_bp | pos.sell | Cancel layaway |
| GET | /pos/api/customers | customers_bp | customers.view | Search/list customers |
| GET | /pos/api/customers/:id | customers_bp | customers.view | Customer detail |
| POST | /pos/api/customers | customers_bp | customers.create | Create customer |
| PUT | /pos/api/customers/:id | customers_bp | customers.edit | Update customer |
| GET | /pos/api/customers/:id/statement | customers_bp | customers.view | Account statement |
| GET | /pos/api/customers/:id/vehicles | customers_bp | customers.view | Vehicle history |
| POST | /pos/api/customers/:id/payment | customers_bp | customers.edit | Record credit payment |
| POST | /pos/api/register/open | cashregister_bp | pos.sell | Open register |
| GET | /pos/api/register/current | cashregister_bp | pos.sell | Current register |
| POST | /pos/api/register/movement | cashregister_bp | pos.sell | Cash in/out |
| GET | /pos/api/register/cut-x | cashregister_bp | pos.sell | Partial cut (X) |
| POST | /pos/api/register/cut-z | cashregister_bp | pos.sell | Final cut (Z) |
| GET | /pos/api/register/history | cashregister_bp | pos.view | Closed registers |
| GET | /pos/api/register/daily-summary | cashregister_bp | pos.view | Consolidated daily summary |
## Page Routes
| Route | Template | Description |
|-------|----------|-------------|
| /pos/sale | pos.html | POS sale page |
| /pos/customers | customers.html | Customer management |