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>
5211 lines
207 KiB
Markdown
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">×</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})">×</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, "'")}, ${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, "'")})'>
|
|
<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()">×</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 |
|