feat: cashier/counter reports, service-order & remission flows, Rached migration utils
- Add "Mis cortes de caja" report for cashiers/counters with sales detail. - Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint. - Remove dashboard from cashier menu; add Reports to cashier/counter. - Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission. - Remission notes module (UI, CSS, courier, counter remissions). - Customer hard-delete and vehicle/customer linkage in workshop. - POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio. - Inventory: CSV template with sku_secondary, alias import. - Rached migration scripts and DB migrations. - Version-bump cached JS/CSS query strings. Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
This commit is contained in:
@@ -230,6 +230,54 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
||||
return op_id
|
||||
|
||||
|
||||
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
|
||||
cost_at_time=None, remaining_stock=None):
|
||||
"""Reserve stock for a counter remission note (negative quantity).
|
||||
|
||||
The reserved quantity is deducted from available stock until the note is
|
||||
paid or cancelled.
|
||||
"""
|
||||
op_id = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
|
||||
notes='Reserva por nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
|
||||
try:
|
||||
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
|
||||
if remaining <= 0:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
||||
inv_row = cur.fetchone()
|
||||
cur.close()
|
||||
if inv_row:
|
||||
from services.push_service import notify_owner
|
||||
notify_owner(
|
||||
conn,
|
||||
'Stock en Cero',
|
||||
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
|
||||
'/pos'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return op_id
|
||||
|
||||
|
||||
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Release a previous reservation (positive quantity)."""
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale',
|
||||
notes=notes or 'Liberacion de reserva de nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Record a customer return (positive quantity)."""
|
||||
result = record_operation(
|
||||
|
||||
@@ -16,6 +16,8 @@ from flask import g
|
||||
from services.audit import log_action
|
||||
from services.inventory_engine import (
|
||||
record_sale as inventory_record_sale,
|
||||
record_reservation as inventory_record_reservation,
|
||||
release_reservation as inventory_release_reservation,
|
||||
record_operation,
|
||||
get_stock,
|
||||
get_stock_bulk,
|
||||
@@ -313,24 +315,34 @@ def process_sale(conn, sale_data):
|
||||
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:
|
||||
if totals['total'] > credit_available:
|
||||
raise ValueError(
|
||||
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
||||
f"Required: ${totals['total']:.2f}"
|
||||
)
|
||||
|
||||
# Pending payment sale (e.g. "Pendiente")
|
||||
is_pending = payment_method == 'pendiente'
|
||||
if is_pending:
|
||||
amount_paid = 0.0
|
||||
sale_type = 'cash'
|
||||
|
||||
# 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'
|
||||
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
|
||||
forma_pago_map = {
|
||||
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
||||
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
||||
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
# Determine sale status
|
||||
status = 'pending_payment' if is_pending else 'completed'
|
||||
|
||||
# Create sale record (with currency)
|
||||
cur.execute("""
|
||||
INSERT INTO sales
|
||||
@@ -338,14 +350,14 @@ def process_sale(conn, sale_data):
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, device_id, notes, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%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,
|
||||
_safe_g('device_id'), notes,
|
||||
status, _safe_g('device_id'), notes,
|
||||
currency, exchange_rate
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
@@ -404,23 +416,26 @@ def process_sale(conn, sale_data):
|
||||
'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', '')
|
||||
# Record payment on cash register (skip pending sales and zero-amount rows)
|
||||
if not is_pending:
|
||||
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', '')
|
||||
if amt <= 0:
|
||||
continue
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
elif register_id and amount_paid > 0:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
elif register_id:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||
|
||||
# Update customer credit balance if credit sale
|
||||
if sale_type == 'credit' and customer_id:
|
||||
@@ -430,6 +445,16 @@ def process_sale(conn, sale_data):
|
||||
WHERE id = %s
|
||||
""", (totals['total'], customer_id))
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
# Audit log
|
||||
log_action(conn, 'SALE', 'sale', sale_id,
|
||||
new_value={
|
||||
@@ -506,6 +531,8 @@ def process_sale(conn, sale_data):
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
'employee_id': employee_id,
|
||||
'register_id': register_id,
|
||||
'sale_type': sale_type,
|
||||
@@ -526,6 +553,342 @@ def process_sale(conn, sale_data):
|
||||
}
|
||||
|
||||
|
||||
def create_remission_note(conn, data):
|
||||
"""Create a counter remission note: reserve stock, no payment, pending status.
|
||||
|
||||
data: {
|
||||
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
|
||||
customer_id: int | null,
|
||||
notes: str,
|
||||
branch_id: int,
|
||||
register_id: int | null (optional)
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
items = data.get('items', [])
|
||||
customer_id = data.get('customer_id')
|
||||
notes = data.get('notes')
|
||||
branch_id = data.get('branch_id') or _safe_g('branch_id')
|
||||
register_id = data.get('register_id')
|
||||
employee_id = _safe_g('employee_id')
|
||||
currency = data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
raise ValueError("Unsupported currency")
|
||||
exchange_rate = float(data.get('exchange_rate') or 1.0)
|
||||
if currency != 'MXN' and not exchange_rate:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
|
||||
if not branch_id:
|
||||
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
branch_id = row[0] if row else None
|
||||
if not branch_id:
|
||||
raise ValueError("No hay sucursal activa disponible")
|
||||
|
||||
if not items:
|
||||
raise ValueError("No items in remission note")
|
||||
|
||||
inv_ids = [item.get('inventory_id') for item in items]
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id, retail_price
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
""", (inv_ids,))
|
||||
inv_rows = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock
|
||||
WHERE branch_id = %s AND inventory_id = ANY(%s)
|
||||
FOR UPDATE
|
||||
""", (branch_id, inv_ids))
|
||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
|
||||
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}")
|
||||
inv = inv_rows.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||
current_stock = stock_map.get(inv_id, 0)
|
||||
unit_price = float(item.get('unit_price', inv[4]))
|
||||
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
|
||||
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,
|
||||
})
|
||||
|
||||
totals = calculate_totals(enriched_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, status, device_id, notes, currency, exchange_rate,
|
||||
courier_id)
|
||||
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
|
||||
%s, %s, %s, %s, 0, 0, 'pending_payment',
|
||||
%s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, customer_id, employee_id, register_id,
|
||||
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
|
||||
_safe_g('device_id'), notes, currency, exchange_rate,
|
||||
data.get('courier_id')
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
|
||||
sale_items_data = []
|
||||
for item in totals['items']:
|
||||
inv = inv_rows.get(item['inventory_id'])
|
||||
retail_price = inv[9] if inv else None
|
||||
sale_items_data.append((
|
||||
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'],
|
||||
retail_price, currency, exchange_rate
|
||||
))
|
||||
|
||||
cur.executemany("""
|
||||
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, retail_price, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", sale_items_data)
|
||||
|
||||
sale_items = []
|
||||
for item in totals['items']:
|
||||
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
|
||||
remaining_after = stock_before - item['quantity']
|
||||
inventory_record_reservation(
|
||||
conn,
|
||||
item['inventory_id'],
|
||||
item.get('branch_id', branch_id),
|
||||
item['quantity'],
|
||||
sale_id=sale_id,
|
||||
cost_at_time=item.get('unit_cost'),
|
||||
remaining_stock=remaining_after
|
||||
)
|
||||
sale_items.append({
|
||||
'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'],
|
||||
})
|
||||
|
||||
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
|
||||
new_value={
|
||||
'total': totals['total'],
|
||||
'items_count': len(sale_items),
|
||||
'customer_id': customer_id,
|
||||
})
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
'employee_id': employee_id,
|
||||
'register_id': register_id,
|
||||
'sale_type': 'counter_remission',
|
||||
'payment_method': 'remission',
|
||||
'subtotal': totals['subtotal'],
|
||||
'discount_total': totals['discount_total'],
|
||||
'tax_total': totals['tax_total'],
|
||||
'total': totals['total'],
|
||||
'amount_paid': 0.0,
|
||||
'change_given': 0.0,
|
||||
'status': 'pending_payment',
|
||||
'courier_id': data.get('courier_id'),
|
||||
'items': sale_items,
|
||||
'created_at': str(created_at),
|
||||
'currency': currency,
|
||||
'exchange_rate': exchange_rate,
|
||||
}
|
||||
|
||||
|
||||
def pay_pending_sale(conn, sale_id, data):
|
||||
"""Pay a pending counter remission note and convert it into a completed sale.
|
||||
|
||||
data: {
|
||||
payment_method: str,
|
||||
amount_paid: float,
|
||||
payment_details: [{method, amount, reference}],
|
||||
register_id: int,
|
||||
reference: str
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||
currency, exchange_rate
|
||||
FROM sales WHERE id = %s
|
||||
FOR UPDATE
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Sale not found")
|
||||
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||
currency, exchange_rate) = row
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
if status != 'pending_payment':
|
||||
raise ValueError("La nota no esta pendiente de pago")
|
||||
|
||||
payment_method = data.get('payment_method', 'efectivo')
|
||||
sale_type = 'cash'
|
||||
amount_paid = float(data.get('amount_paid', 0))
|
||||
payment_details = data.get('payment_details', [])
|
||||
register_id = data.get('register_id')
|
||||
reference = data.get('reference', '')
|
||||
|
||||
if register_id:
|
||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
|
||||
reg = cur.fetchone()
|
||||
if not reg or reg[0] != 'open':
|
||||
raise ValueError("Cash register is not open")
|
||||
|
||||
totals = {'total': float(total)}
|
||||
change_given = 0.0
|
||||
if payment_method == 'efectivo':
|
||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||
|
||||
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
cur.execute("""
|
||||
UPDATE sales
|
||||
SET status = 'completed',
|
||||
sale_type = %s,
|
||||
payment_method = %s,
|
||||
amount_paid = %s,
|
||||
change_given = %s,
|
||||
register_id = COALESCE(%s, register_id),
|
||||
metodo_pago_sat = 'PUE',
|
||||
forma_pago_sat = %s
|
||||
WHERE id = %s
|
||||
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
|
||||
|
||||
if 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, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
|
||||
|
||||
# Release reservation and record actual sale movement
|
||||
cur.execute("""
|
||||
SELECT inventory_id, quantity, unit_cost
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
for inv_id, qty, cost in cur.fetchall():
|
||||
inventory_release_reservation(
|
||||
conn, inv_id, branch_id, qty, sale_id=sale_id,
|
||||
notes='Pago de nota de remision'
|
||||
)
|
||||
inventory_record_sale(
|
||||
conn, inv_id, branch_id, qty,
|
||||
sale_id=sale_id, cost_at_time=float(cost) if cost else None
|
||||
)
|
||||
|
||||
# Accounting (non-blocking)
|
||||
try:
|
||||
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
|
||||
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
|
||||
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(unit_cost * quantity), 0)
|
||||
FROM sale_items WHERE sale_id = %s
|
||||
""", (sale_id,))
|
||||
cost_total = float(cur.fetchone()[0] or 0)
|
||||
record_sale_entry(conn, {
|
||||
'id': sale_id,
|
||||
'sale_type': sale_type,
|
||||
'total': total_mxn,
|
||||
'tax_total': tax_mxn,
|
||||
'subtotal': sub_mxn,
|
||||
'cost_total': cost_total,
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
|
||||
old_value={'status': 'pending_payment', 'total': totals['total']},
|
||||
new_value={'status': 'completed', 'payment_method': payment_method})
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'id': sale_id,
|
||||
'status': 'completed',
|
||||
'payment_method': payment_method,
|
||||
'amount_paid': amount_paid,
|
||||
'change_given': change_given,
|
||||
'total': totals['total'],
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
}
|
||||
|
||||
|
||||
def cancel_sale(conn, sale_id, reason):
|
||||
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
||||
|
||||
@@ -567,15 +930,15 @@ def cancel_sale(conn, sale_id, reason):
|
||||
if s_status == 'cancelled':
|
||||
raise ValueError("Sale is already cancelled")
|
||||
|
||||
# Permission check: cashiers can only cancel own sales within 30 min
|
||||
# Permission check: non-admin employees can only cancel their own docs within 30 min
|
||||
role = _safe_g('employee_role', 'cashier')
|
||||
emp_id = _safe_g('employee_id')
|
||||
|
||||
if role == 'cashier':
|
||||
if role not in ('owner', 'admin'):
|
||||
if s_emp_id != emp_id:
|
||||
raise ValueError("Cashiers can only cancel their own sales")
|
||||
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
|
||||
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
||||
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
|
||||
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
|
||||
|
||||
# Get sale items for inventory reversal
|
||||
cur.execute("""
|
||||
@@ -584,14 +947,23 @@ def cancel_sale(conn, sale_id, reason):
|
||||
""", (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}"
|
||||
)
|
||||
if s_status == 'pending_payment':
|
||||
# Pending remission: release reservation
|
||||
for inv_id, qty, cost in sale_items:
|
||||
inventory_release_reservation(
|
||||
conn, inv_id, s_branch, qty,
|
||||
sale_id=sale_id,
|
||||
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
|
||||
)
|
||||
else:
|
||||
# Completed sale: 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("""
|
||||
@@ -627,7 +999,7 @@ def cancel_sale(conn, sale_id, reason):
|
||||
|
||||
# Audit log
|
||||
log_action(conn, 'CANCEL', 'sale', sale_id,
|
||||
old_value={'status': 'completed', 'total': float(s_total)},
|
||||
old_value={'status': s_status, 'total': float(s_total)},
|
||||
new_value={'status': 'cancelled', 'reason': reason})
|
||||
|
||||
# Push notification to owner/admin (best-effort, non-blocking)
|
||||
|
||||
@@ -8,15 +8,43 @@ from datetime import datetime
|
||||
|
||||
from services import inventory_engine
|
||||
|
||||
# Rached workshop statuses (applies to all tenants).
|
||||
ORDER_STATUSES = [
|
||||
'por_revisar',
|
||||
'en_revision',
|
||||
'revisada',
|
||||
'cotizada',
|
||||
'por_autorizar',
|
||||
'autorizada',
|
||||
'autorizacion_parcial',
|
||||
'en_reparacion',
|
||||
'reparada',
|
||||
'por_entregar',
|
||||
'entregado',
|
||||
'por_enviar',
|
||||
'enviado',
|
||||
'por_facturar',
|
||||
'facturada',
|
||||
'por_recolectar',
|
||||
'cancelada',
|
||||
]
|
||||
TERMINAL_STATUSES = {'entregado', 'facturada', 'cancelada'}
|
||||
|
||||
# Allow moving from any non-terminal status to any other non-terminal status,
|
||||
# plus cancellation. Terminal statuses cannot change.
|
||||
VALID_TRANSITIONS = {
|
||||
'received': ['diagnosis', 'cancelled'],
|
||||
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
||||
'waiting_parts': ['repair', 'cancelled'],
|
||||
'repair': ['ready', 'cancelled'],
|
||||
'ready': ['delivered', 'cancelled'],
|
||||
'delivered': [],
|
||||
'cancelled': [],
|
||||
status: [s for s in ORDER_STATUSES if s != status]
|
||||
for status in ORDER_STATUSES
|
||||
}
|
||||
for terminal in TERMINAL_STATUSES:
|
||||
VALID_TRANSITIONS[terminal] = []
|
||||
|
||||
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
|
||||
_LEGACY_STATUSES = {
|
||||
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
|
||||
}
|
||||
for legacy in _LEGACY_STATUSES:
|
||||
VALID_TRANSITIONS.setdefault(legacy, [])
|
||||
|
||||
|
||||
def _generate_order_number(conn):
|
||||
@@ -45,6 +73,18 @@ def _generate_order_number(conn):
|
||||
return f"{prefix}{new_num}"
|
||||
|
||||
|
||||
_VALID_DELIVERY_METHODS = {'pickup', 'delivery', 'courier'}
|
||||
|
||||
|
||||
def _normalize_delivery(data):
|
||||
"""Restrict delivery_method to allowed values and clear courier when not applicable."""
|
||||
dm = data.get('delivery_method')
|
||||
if dm not in _VALID_DELIVERY_METHODS:
|
||||
data['delivery_method'] = None
|
||||
if data.get('delivery_method') not in ('delivery', 'courier'):
|
||||
data['courier_id'] = None
|
||||
|
||||
|
||||
def create_service_order(conn, data):
|
||||
"""Create a new service order.
|
||||
|
||||
@@ -52,9 +92,10 @@ def create_service_order(conn, data):
|
||||
customer_id, vehicle_id, branch_id, priority,
|
||||
reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct
|
||||
delivery_method, courier_id, is_direct, requires_invoice
|
||||
}
|
||||
"""
|
||||
_normalize_delivery(data)
|
||||
cur = conn.cursor()
|
||||
order_number = _generate_order_number(conn)
|
||||
|
||||
@@ -62,19 +103,23 @@ def create_service_order(conn, data):
|
||||
INSERT INTO service_orders
|
||||
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
||||
priority, reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct)
|
||||
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s)
|
||||
employee_id, mechanic_name, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct, requires_invoice,
|
||||
workshop_name, customer_address, customer_phone, vehicle_description)
|
||||
VALUES (%s, %s, %s, %s, %s, 'por_revisar', %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
||||
data.get('vehicle_id'), order_number,
|
||||
data.get('priority', 'normal'), data.get('reception_notes'),
|
||||
data.get('estimated_cost'), data.get('estimated_completion'),
|
||||
data.get('employee_id'), data.get('mileage_in'),
|
||||
data.get('fuel_level'), data.get('created_by'),
|
||||
data.get('delivery_method'), data.get('courier_id'), data.get('is_direct', False),
|
||||
data.get('employee_id'), data.get('mechanic_name'),
|
||||
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'),
|
||||
data.get('delivery_method'), data.get('courier_id'),
|
||||
data.get('is_direct', False), data.get('requires_invoice', False),
|
||||
data.get('workshop_name'), data.get('customer_address'),
|
||||
data.get('customer_phone'), data.get('vehicle_description'),
|
||||
))
|
||||
so_id = cur.fetchone()[0]
|
||||
|
||||
@@ -82,7 +127,7 @@ def create_service_order(conn, data):
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_status_history
|
||||
(service_order_id, new_status, changed_by, notes)
|
||||
VALUES (%s, 'received', %s, 'Orden creada')
|
||||
VALUES (%s, 'por_revisar', %s, 'Orden creada')
|
||||
""", (so_id, data.get('created_by')))
|
||||
|
||||
conn.commit()
|
||||
@@ -95,7 +140,7 @@ def get_service_order(conn, so_id):
|
||||
cur.execute("""
|
||||
SELECT so.id, so.order_number, so.status, so.priority,
|
||||
so.customer_id, c.name as customer_name, c.phone as customer_phone,
|
||||
c.address as customer_address,
|
||||
c.address as customer_address, c.price_tier as customer_price_tier,
|
||||
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
||||
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone,
|
||||
so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
||||
@@ -103,10 +148,12 @@ def get_service_order(conn, so_id):
|
||||
so.estimated_completion, so.actual_completion, so.delivered_at,
|
||||
so.mileage_in, so.mileage_out, so.fuel_level,
|
||||
so.employee_id, e.name as employee_name,
|
||||
so.mechanic_name,
|
||||
so.created_by, creator.name as created_by_name,
|
||||
so.created_at, so.updated_at,
|
||||
so.delivery_method, so.courier_id, co.name as courier_name, so.is_direct,
|
||||
so.sale_id
|
||||
so.requires_invoice, so.sale_id,
|
||||
so.workshop_name, so.customer_address, so.customer_phone, so.vehicle_description
|
||||
FROM service_orders so
|
||||
LEFT JOIN customers c ON so.customer_id = c.id
|
||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||
@@ -124,28 +171,33 @@ def get_service_order(conn, so_id):
|
||||
so = {
|
||||
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
|
||||
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
|
||||
'customer_address': row[7],
|
||||
'vehicle_id': row[8], 'vehicle_plate': row[9], 'vehicle_make': row[10], 'vehicle_model': row[11],
|
||||
'branch_id': row[12], 'branch_name': row[13], 'branch_address': row[14], 'branch_phone': row[15],
|
||||
'reception_notes': row[16], 'diagnosis_notes': row[17],
|
||||
'repair_notes': row[18], 'delivery_notes': row[19],
|
||||
'estimated_cost': float(row[20]) if row[20] else None,
|
||||
'final_cost': float(row[21]) if row[21] else None,
|
||||
'estimated_completion': str(row[22]) if row[22] else None,
|
||||
'actual_completion': str(row[23]) if row[23] else None,
|
||||
'delivered_at': str(row[24]) if row[24] else None,
|
||||
'mileage_in': row[25], 'mileage_out': row[26], 'fuel_level': row[27],
|
||||
'employee_id': row[28], 'employee_name': row[29],
|
||||
'created_by': row[30], 'created_by_name': row[31],
|
||||
'created_at': str(row[32]), 'updated_at': str(row[33]),
|
||||
'delivery_method': row[34], 'courier_id': row[35], 'courier_name': row[36],
|
||||
'is_direct': bool(row[37]) if row[37] is not None else False,
|
||||
'sale_id': row[38],
|
||||
'customer_address': row[7], 'customer_price_tier': row[8],
|
||||
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12],
|
||||
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16],
|
||||
'reception_notes': row[17], 'diagnosis_notes': row[18],
|
||||
'repair_notes': row[19], 'delivery_notes': row[20],
|
||||
'estimated_cost': float(row[21]) if row[21] else None,
|
||||
'final_cost': float(row[22]) if row[22] else None,
|
||||
'estimated_completion': str(row[23]) if row[23] else None,
|
||||
'actual_completion': str(row[24]) if row[24] else None,
|
||||
'delivered_at': str(row[25]) if row[25] else None,
|
||||
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28],
|
||||
'employee_id': row[29], 'employee_name': row[30],
|
||||
'mechanic_name': row[31],
|
||||
'created_by': row[32], 'created_by_name': row[33],
|
||||
'created_at': str(row[34]), 'updated_at': str(row[35]),
|
||||
'delivery_method': row[36], 'courier_id': row[37], 'courier_name': row[38],
|
||||
'is_direct': bool(row[39]) if row[39] is not None else False,
|
||||
'requires_invoice': bool(row[40]) if row[40] is not None else False,
|
||||
'sale_id': row[41],
|
||||
'workshop_name': row[42], 'customer_address': row[43],
|
||||
'customer_phone': row[44], 'vehicle_description': row[45],
|
||||
}
|
||||
|
||||
# Items
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||
mechanic_id, observations
|
||||
FROM service_order_items
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY id
|
||||
@@ -161,6 +213,7 @@ def get_service_order(conn, so_id):
|
||||
'unit_cost': float(r[5]) if r[5] else None,
|
||||
'unit_price': price,
|
||||
'status': r[7], 'notes': r[8],
|
||||
'mechanic_id': r[9], 'observations': r[10],
|
||||
})
|
||||
total_parts += qty * price
|
||||
|
||||
@@ -238,8 +291,11 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
where_clauses.append("so.is_direct = %s")
|
||||
params.append(is_direct)
|
||||
if q:
|
||||
where_clauses.append("(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s)")
|
||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||
where_clauses.append(
|
||||
"(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s OR "
|
||||
"so.workshop_name ILIKE %s OR so.vehicle_description ILIKE %s)"
|
||||
)
|
||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||
|
||||
where_clauses.append("so.is_deleted = false")
|
||||
where = " AND ".join(where_clauses)
|
||||
@@ -259,14 +315,18 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
so.branch_id, b.name as branch_name,
|
||||
so.estimated_cost, so.final_cost,
|
||||
so.delivery_method, co.name as courier_name, so.is_direct,
|
||||
so.sale_id, so.created_at,
|
||||
creator.name as created_by_name
|
||||
so.requires_invoice, so.sale_id, so.created_at,
|
||||
creator.name as created_by_name,
|
||||
so.employee_id, mech.name as employee_name,
|
||||
so.mechanic_name,
|
||||
so.workshop_name, so.vehicle_description
|
||||
FROM service_orders so
|
||||
LEFT JOIN customers c ON so.customer_id = c.id
|
||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||
LEFT JOIN branches b ON so.branch_id = b.id
|
||||
LEFT JOIN couriers co ON so.courier_id = co.id
|
||||
LEFT JOIN employees creator ON so.created_by = creator.id
|
||||
LEFT JOIN employees mech ON so.employee_id = mech.id
|
||||
WHERE {where}
|
||||
ORDER BY
|
||||
CASE so.priority
|
||||
@@ -292,8 +352,12 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
'final_cost': final,
|
||||
'delivery_method': r[14], 'courier_name': r[15],
|
||||
'is_direct': bool(r[16]) if r[16] is not None else False,
|
||||
'sale_id': r[17], 'created_at': str(r[18]),
|
||||
'created_by_name': r[19],
|
||||
'requires_invoice': bool(r[17]) if r[17] is not None else False,
|
||||
'sale_id': r[18], 'created_at': str(r[19]),
|
||||
'created_by_name': r[20],
|
||||
'employee_id': r[21], 'employee_name': r[22],
|
||||
'mechanic_name': r[23],
|
||||
'workshop_name': r[24], 'vehicle_description': r[25],
|
||||
'total': round(final or estimated, 2),
|
||||
'paid': 0.0, # to be computed if needed
|
||||
})
|
||||
@@ -323,9 +387,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
||||
# Update status
|
||||
extra_sets = []
|
||||
extra_vals = []
|
||||
if new_status == 'ready':
|
||||
if new_status == 'reparada':
|
||||
extra_sets.append("actual_completion = NOW()")
|
||||
if new_status == 'delivered':
|
||||
if new_status == 'entregado':
|
||||
extra_sets.append("delivered_at = NOW()")
|
||||
extra_sets.append("delivered_by = %s")
|
||||
extra_vals.append(changed_by)
|
||||
@@ -354,14 +418,16 @@ def add_item(conn, so_id, item_data):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_items
|
||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||
mechanic_id, observations)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
||||
item_data.get('name'), item_data.get('quantity', 1),
|
||||
item_data.get('unit_cost'), item_data.get('unit_price'),
|
||||
item_data.get('status', 'pending'), item_data.get('notes'),
|
||||
item_data.get('mechanic_id'), item_data.get('observations'),
|
||||
))
|
||||
item_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
@@ -371,7 +437,7 @@ def add_item(conn, so_id, item_data):
|
||||
|
||||
def update_item(conn, item_id, data):
|
||||
cur = conn.cursor()
|
||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
|
||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
@@ -453,12 +519,14 @@ def remove_labor(conn, labor_id):
|
||||
|
||||
def update_service_order(conn, so_id, data):
|
||||
"""Update general service order fields."""
|
||||
_normalize_delivery(data)
|
||||
cur = conn.cursor()
|
||||
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
|
||||
'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
||||
'employee_id', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
||||
'delivery_method', 'courier_id', 'is_direct']
|
||||
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
||||
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
|
||||
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
@@ -491,15 +559,15 @@ def get_kanban_summary(conn, branch_id=None):
|
||||
GROUP BY status
|
||||
""", params)
|
||||
|
||||
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
|
||||
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
|
||||
for r in cur.fetchall():
|
||||
summary[r[0]] = r[1]
|
||||
|
||||
# Overdue orders (estimated_completion passed and not ready/delivered)
|
||||
# Overdue orders (estimated_completion passed and not delivered/invoiced)
|
||||
cur.execute(f"""
|
||||
SELECT count(*) FROM service_orders
|
||||
WHERE estimated_completion < NOW()
|
||||
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
||||
AND status NOT IN ('entregado', 'facturada', 'cancelada')
|
||||
AND is_deleted = false
|
||||
{branch_filter}
|
||||
""", params)
|
||||
@@ -812,6 +880,152 @@ def convert_to_sale(conn, so_id, sale_data, employee_id=None):
|
||||
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
||||
|
||||
|
||||
def convert_to_remission(conn, so_id, sale_data, employee_id=None):
|
||||
"""Convert a service order into a counter remission note (pending payment).
|
||||
|
||||
sale_data keys:
|
||||
register_id: int (optional)
|
||||
notes: str (optional)
|
||||
|
||||
Returns dict with sale_id, total, items_count.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
so = get_service_order(conn, so_id)
|
||||
if not so:
|
||||
cur.close()
|
||||
raise ValueError("Service order not found")
|
||||
if so["status"] == "cancelled":
|
||||
cur.close()
|
||||
raise ValueError("Cannot convert a cancelled service order")
|
||||
if so.get("sale_id"):
|
||||
cur.close()
|
||||
raise ValueError("Service order already converted")
|
||||
|
||||
branch_id = so["branch_id"]
|
||||
customer_id = so["customer_id"]
|
||||
|
||||
# Build sale items from SO parts and labor
|
||||
sale_items = []
|
||||
for item in so.get("items", []):
|
||||
if item.get("status") == "cancelled":
|
||||
continue
|
||||
qty = int(item.get("quantity", 1))
|
||||
unit_price = float(item.get("unit_price") or 0)
|
||||
unit_cost = float(item.get("unit_cost") or 0)
|
||||
sale_items.append({
|
||||
"inventory_id": item.get("inventory_id"),
|
||||
"part_number": item.get("part_number") or "PART",
|
||||
"name": item.get("name") or "Refaccion",
|
||||
"quantity": qty,
|
||||
"unit_price": unit_price,
|
||||
"unit_cost": unit_cost,
|
||||
"tax_rate": 0.16,
|
||||
})
|
||||
|
||||
for labor in so.get("labor", []):
|
||||
if labor.get("status") == "cancelled":
|
||||
continue
|
||||
sale_items.append({
|
||||
"inventory_id": None,
|
||||
"part_number": "SERV",
|
||||
"name": labor.get("description") or "Mano de obra",
|
||||
"quantity": 1,
|
||||
"unit_price": float(labor.get("total_cost") or 0),
|
||||
"unit_cost": 0,
|
||||
"tax_rate": 0.16,
|
||||
})
|
||||
|
||||
if not sale_items:
|
||||
cur.close()
|
||||
raise ValueError("No items or labor to invoice")
|
||||
|
||||
subtotal = 0.0
|
||||
tax_total = 0.0
|
||||
for item in sale_items:
|
||||
item_subtotal = item["quantity"] * item["unit_price"]
|
||||
item_tax = item_subtotal * item["tax_rate"]
|
||||
item["subtotal"] = item_subtotal
|
||||
item["tax_amount"] = item_tax
|
||||
subtotal += item_subtotal
|
||||
tax_total += item_tax
|
||||
|
||||
total = subtotal + tax_total
|
||||
register_id = sale_data.get("register_id")
|
||||
notes = sale_data.get("notes") or f"Nota de remision desde orden {so['order_number']}"
|
||||
|
||||
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, 'counter_remission', 'remission', %s, %s, %s, %s, %s, %s, 'PPD', '99', 'pending_payment', %s)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
branch_id,
|
||||
customer_id,
|
||||
employee_id,
|
||||
register_id,
|
||||
subtotal,
|
||||
0,
|
||||
tax_total,
|
||||
total,
|
||||
0,
|
||||
0,
|
||||
notes,
|
||||
),
|
||||
)
|
||||
sale_id, _created_at = cur.fetchone()
|
||||
|
||||
for item in sale_items:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
sale_id,
|
||||
item["inventory_id"],
|
||||
item["part_number"],
|
||||
item["name"],
|
||||
item["quantity"],
|
||||
item["unit_price"],
|
||||
item["unit_cost"],
|
||||
item["tax_rate"],
|
||||
item["tax_amount"],
|
||||
item["subtotal"],
|
||||
),
|
||||
)
|
||||
|
||||
# Reserve inventory for parts
|
||||
for item in so.get("items", []):
|
||||
if item.get("status") == "cancelled":
|
||||
continue
|
||||
inventory_id = item.get("inventory_id")
|
||||
qty = int(item.get("quantity", 0))
|
||||
if inventory_id and qty > 0:
|
||||
inventory_engine.record_reservation(
|
||||
conn,
|
||||
inventory_id,
|
||||
branch_id,
|
||||
qty,
|
||||
sale_id=sale_id,
|
||||
cost_at_time=float(item.get("unit_cost") or 0),
|
||||
notes=f"Reserva nota de remision orden {so['order_number']}"
|
||||
)
|
||||
|
||||
# Link order to sale (the remission note)
|
||||
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
||||
|
||||
|
||||
def assign_mechanic(conn, so_id, employee_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
cur = conn.cursor()
|
||||
|
||||
Reference in New Issue
Block a user