fix(pos): enrich quotation/layaway items from inventory and allow final payment on layaway complete

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>
This commit is contained in:
2026-03-31 03:51:21 +00:00
parent b2484af0fb
commit 09980c1cdb

View File

@@ -19,6 +19,57 @@ from services.audit import log_action
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api') 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 ─────────────────────────────────────── # ─── Sales ───────────────────────────────────────
@pos_bp.route('/sales', methods=['POST']) @pos_bp.route('/sales', methods=['POST'])
@@ -362,8 +413,15 @@ def create_quotation():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() 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 # Calculate totals
totals = calculate_totals(items) totals = calculate_totals(enriched)
valid_days = int(data.get('valid_days', 7)) valid_days = int(data.get('valid_days', 7))
valid_until = (date.today() + timedelta(days=valid_days)).isoformat() valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
@@ -371,24 +429,21 @@ def create_quotation():
try: try:
cur.execute(""" cur.execute("""
INSERT INTO quotations INSERT INTO quotations
(branch_id, customer_id, employee_id, subtotal, discount_total, (branch_id, customer_id, employee_id, subtotal,
tax_total, total, status, valid_until, notes) tax_total, total, status, valid_until, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,'active',%s,%s) VALUES (%s,%s,%s,%s,%s,%s,'active',%s,%s)
RETURNING id, created_at RETURNING id, created_at
""", ( """, (
g.branch_id, data.get('customer_id'), g.employee_id, g.branch_id, data.get('customer_id'), g.employee_id,
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['subtotal'], totals['tax_total'],
totals['total'], valid_until, data.get('notes') totals['total'], valid_until, data.get('notes')
)) ))
quot_id, created_at = cur.fetchone() quot_id, created_at = cur.fetchone()
# Insert quotation items # Insert quotation items
for item in totals['items']: for item in totals['items']:
# Look up part_number and name from inventory part_number = item.get('part_number', '')
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (item['inventory_id'],)) name = item.get('name', '')
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( line_subtotal = round(
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2 item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
@@ -670,8 +725,15 @@ def create_layaway():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() 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 # Calculate totals
totals = calculate_totals(items) totals = calculate_totals(enriched)
if initial_payment > totals['total']: if initial_payment > totals['total']:
cur.close(); conn.close() cur.close(); conn.close()
@@ -697,11 +759,9 @@ def create_layaway():
# Insert layaway items and reserve stock (table created by migration v1.1_pos_tables.sql) # Insert layaway items and reserve stock (table created by migration v1.1_pos_tables.sql)
from services.inventory_engine import record_operation from services.inventory_engine import record_operation
for item in totals['items']: for item in totals['items']:
cur.execute("SELECT part_number, name, branch_id FROM inventory WHERE id = %s", (item['inventory_id'],)) part_number = item.get('part_number', '')
inv = cur.fetchone() name = item.get('name', '')
part_number = inv[0] if inv else item.get('part_number', '') item_branch_id = item.get('branch_id', g.branch_id)
name = inv[1] if inv else item.get('name', '')
item_branch_id = inv[2] if inv else g.branch_id
line_subtotal = round( line_subtotal = round(
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2 item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
@@ -997,11 +1057,29 @@ def complete_layaway(layaway_id):
if status != 'active': if status != 'active':
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'error': f'Layaway is {status}'}), 400 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
remaining = round(total - paid, 2)
try: 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 # Get layaway items
cur.execute(""" cur.execute("""
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate