Quotation and layaway endpoints were calling calculate_totals() on raw input items without looking up unit_price/tax_rate from inventory, causing KeyError. Added _enrich_items() helper (with customer price tier support). Also removed non-existent discount_total column from quotations INSERT, and made layaway complete accept a final payment for the remaining balance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1239 lines
43 KiB
Python
1239 lines
43 KiB
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')
|
|
|
|
|
|
def _enrich_items(cur, items, customer_id=None):
|
|
"""Look up inventory data for items that lack unit_price/tax_rate.
|
|
|
|
Returns list of dicts with all fields needed by calculate_totals.
|
|
"""
|
|
enriched = []
|
|
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")
|
|
|
|
# Determine price tier from customer if provided
|
|
price_tier = 1
|
|
if customer_id:
|
|
cur.execute("SELECT price_tier FROM customers WHERE id = %s", (customer_id,))
|
|
cust = cur.fetchone()
|
|
if cust and cust[0]:
|
|
price_tier = int(cust[0])
|
|
|
|
# price_1=inv[4], price_2=inv[5], price_3=inv[6]
|
|
tier_prices = {1: inv[4], 2: inv[5], 3: inv[6]}
|
|
default_price = float(tier_prices.get(price_tier, inv[4]) or inv[4])
|
|
|
|
unit_price = float(item.get('unit_price', default_price))
|
|
discount_pct = float(item.get('discount_pct', 0))
|
|
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
|
|
|
|
enriched.append({
|
|
'inventory_id': inv_id,
|
|
'part_number': inv[1],
|
|
'name': inv[2],
|
|
'quantity': qty,
|
|
'unit_price': unit_price,
|
|
'unit_cost': float(inv[3]) if inv[3] else 0,
|
|
'discount_pct': discount_pct,
|
|
'tax_rate': tax_rate,
|
|
'branch_id': inv[8],
|
|
})
|
|
return enriched
|
|
|
|
|
|
# ─── 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
|
|
|
|
|
|
@pos_bp.route('/sales/last', methods=['GET'])
|
|
@require_auth('pos.view')
|
|
def get_last_sale():
|
|
"""Get the last sale for the current employee."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
where_clauses = ["s.employee_id = %s"]
|
|
params = [g.employee_id]
|
|
|
|
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 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 1
|
|
""", params)
|
|
|
|
row = cur.fetchone()
|
|
if not row:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'No sales found'}), 404
|
|
|
|
sale = {
|
|
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
|
'employee_id': row[3], 'register_id': row[4],
|
|
'sale_type': row[5], 'payment_method': row[6],
|
|
'subtotal': float(row[7]) if row[7] else 0,
|
|
'discount_total': float(row[8]) if row[8] else 0,
|
|
'tax_total': float(row[9]) if row[9] else 0,
|
|
'total': float(row[10]) if row[10] else 0,
|
|
'amount_paid': float(row[11]) if row[11] else 0,
|
|
'change_given': float(row[12]) if row[12] else 0,
|
|
'status': row[13], 'created_at': str(row[14]),
|
|
'employee_name': row[15], 'customer_name': row[16],
|
|
}
|
|
|
|
# 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)
|
|
|
|
|
|
# ─── 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()
|
|
|
|
# Enrich items with inventory data (price, tax, etc.)
|
|
try:
|
|
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
|
except ValueError as e:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
# Calculate totals
|
|
totals = calculate_totals(enriched)
|
|
|
|
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,
|
|
tax_total, total, status, valid_until, notes)
|
|
VALUES (%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['tax_total'],
|
|
totals['total'], valid_until, data.get('notes')
|
|
))
|
|
quot_id, created_at = cur.fetchone()
|
|
|
|
# Insert quotation items
|
|
for item in totals['items']:
|
|
part_number = item.get('part_number', '')
|
|
name = 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()
|
|
|
|
# Enrich items with inventory data
|
|
try:
|
|
enriched = _enrich_items(cur, items, customer_id)
|
|
except ValueError as e:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
# Calculate totals
|
|
totals = calculate_totals(enriched)
|
|
|
|
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']:
|
|
part_number = item.get('part_number', '')
|
|
name = item.get('name', '')
|
|
item_branch_id = item.get('branch_id', 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
|
|
|
|
remaining = round(total - paid, 2)
|
|
try:
|
|
# If there's a remaining balance, accept a final payment with the complete call
|
|
if remaining > 0:
|
|
final_method = data.get('payment_method', 'efectivo')
|
|
cur.execute("""
|
|
INSERT INTO layaway_payments
|
|
(layaway_id, amount, payment_method, reference, employee_id)
|
|
VALUES (%s,%s,%s,%s,%s)
|
|
""", (layaway_id, remaining, final_method,
|
|
data.get('reference', 'Pago final al completar'), g.employee_id))
|
|
cur.execute("UPDATE layaways SET amount_paid = total WHERE id = %s", (layaway_id,))
|
|
paid = total
|
|
|
|
# Record cash movement for final payment
|
|
register_id = data.get('register_id')
|
|
if register_id and final_method == 'efectivo':
|
|
cur.execute("""
|
|
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
|
VALUES (%s, 'in', %s, %s, %s)
|
|
""", (register_id, remaining, f'Apartado #{layaway_id} - pago final', g.employee_id))
|
|
|
|
# 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.'
|
|
})
|