RECORDGOD/app/sales_routes.py

263 lines
13 KiB
Python

import json
import secrets
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from .auth import require_token
from .db import get_db
# Point-of-sale — ring up sales, line/cart discounts, split tender, payment plans (holds),
# trade-in credit. Ported from WowPlatter's sales engine onto RecordGod's own tables.
router = APIRouter(prefix="/sales", tags=["sales"])
class LineIn(BaseModel):
sku: str
title: str | None = None
qty: int = 1
unit_price: float
discount: float = 0
class SaleIn(BaseModel):
items: list[LineIn]
cart_discount: float = 0
tax_rate: float = 0
payment_method: str = "cash"
split: list[dict] | None = None
hold: dict | None = None
trade_in: float = 0
customer_id: int | None = None # 0 = guest sale; None when unset
class PayIn(BaseModel):
amount: float
method: str = "cash"
class CustomerIn(BaseModel):
first_name: str
last_name: str | None = ""
email: str | None = None
phone: str | None = None
address: str | None = None
@router.get("/search")
async def search_items(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
"""Find in-stock items to ring up — title/artist/sku/barcode. The counter's search bar."""
if not q.strip():
return {"items": []}
rows = await db.execute(text("""
SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb,
i.price::float AS price, i.condition, i.crate_id, c.label_text AS crate
FROM inventory i
LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
LEFT JOIN crate c ON c.id = i.crate_id
WHERE i.store_id = 1 AND i.in_stock
AND (i.sku ILIKE :q OR i.identifier = :raw
OR coalesce(i.title, dc.title) ILIKE :q OR dc.artist ILIKE :q)
ORDER BY i.updated_at DESC NULLS LAST LIMIT 25
"""), {"q": f"%{q.strip()}%", "raw": q.strip()})
return {"items": [dict(r) for r in rows.mappings()]}
@router.post("")
async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get_db)):
if not body.items:
raise HTTPException(400, "no items")
gross = sum(li.unit_price * li.qty for li in body.items)
line_disc = sum(li.discount for li in body.items)
sub_after = gross - line_disc - body.cart_discount
tax = round(sub_after * body.tax_rate / 100, 2)
total = round(sub_after + tax - body.trade_in, 2)
is_hold = bool(body.hold)
deposit = float(body.hold.get("deposit", 0)) if is_hold else 0
amount_paid = deposit if is_hold else total
status, pay_status = ("held", "hold") if is_hold else ("completed", "paid")
hold_due = body.hold.get("due_date") if is_hold else None
sale_number = "S" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0)
sale_id = (await db.execute(text("""
INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total,
status, payment_method, payment_status, amount_paid, trade_in_credit,
hold_expires_at, split_payments, sale_date, created_at)
VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade,
:due, CAST(:split AS jsonb), now(), now())
RETURNING id
"""), {"sn": sale_number, "cust": cust, "sub": gross - line_disc, "disc": line_disc + body.cart_discount,
"tax": tax, "total": total, "st": status, "pm": body.payment_method, "ps": pay_status,
"paid": amount_paid, "trade": body.trade_in, "due": hold_due,
"split": json.dumps(body.split) if body.split else None})).first()[0]
for li in body.items:
await db.execute(text("""
INSERT INTO sale_items (sale_id, sku, item_name, qty, unit_price, line_total,
discount_amount, original_price)
VALUES (:sid, :sku, :name, :qty, :up, :lt, :disc, :op)
"""), {"sid": sale_id, "sku": li.sku, "name": li.title or li.sku, "qty": li.qty,
"up": li.unit_price, "lt": li.unit_price * li.qty - li.discount,
"disc": li.discount, "op": li.unit_price})
# take the items off the floor (sold, or reserved on a hold) so they can't be double-sold
await db.execute(text("""
UPDATE inventory SET in_stock = false, status = :ist,
sold_date = CASE WHEN :ist = 'sold' THEN now() ELSE NULL END
WHERE sku = ANY(:skus) AND store_id = 1
"""), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]})
await db.commit()
return {"ok": True, "sale_id": sale_id, "sale_number": sale_number, "total": total,
"amount_paid": amount_paid, "balance": round(total - amount_paid, 2), "status": status}
@router.get("")
async def list_sales(status: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if status == "holds":
where = "WHERE payment_status = 'hold'"
elif status:
where, params = "WHERE status = :st", {"st": status}
rows = await db.execute(text(f"""
SELECT id, sale_number, total::float AS total, coalesce(amount_paid,0)::float AS amount_paid,
(total - coalesce(amount_paid,0))::float AS balance, status, payment_status,
payment_method, hold_expires_at, sale_date
FROM sales {where} ORDER BY sale_date DESC NULLS LAST LIMIT 60
"""), params)
return {"sales": [dict(r) for r in rows.mappings()]}
@router.get("/{sale_id}")
async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
s = (await db.execute(text("SELECT * FROM sales WHERE id=:i"), {"i": sale_id})).mappings().first()
if not s:
raise HTTPException(404, "not found")
items = [dict(r) for r in (await db.execute(text(
"SELECT sku, item_name, qty, unit_price::float AS unit_price, line_total::float AS line_total, "
"discount_amount::float AS discount FROM sale_items WHERE sale_id=:i"), {"i": sale_id})).mappings()]
return {"sale": dict(s), "items": items}
@router.post("/{sale_id}/pay")
async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token), db=Depends(get_db)):
"""Payment toward a held / payment-plan sale. Fully paid → completes it + sells the items."""
s = (await db.execute(text("SELECT total::float AS total, coalesce(amount_paid,0)::float AS paid FROM sales WHERE id=:i"),
{"i": sale_id})).mappings().first()
if not s:
raise HTTPException(404, "not found")
new_paid = round(s["paid"] + body.amount, 2)
done = new_paid >= s["total"] - 0.005
await db.execute(text(
"UPDATE sales SET amount_paid=:p, payment_status=:ps, status=:st WHERE id=:i"),
{"p": new_paid, "ps": "paid" if done else "hold",
"st": "completed" if done else "held", "i": sale_id})
if done:
await db.execute(text("""
UPDATE inventory SET status='sold', sold_date=now()
WHERE sku IN (SELECT sku FROM sale_items WHERE sale_id=:i) AND store_id = 1
"""), {"i": sale_id})
await db.commit()
return {"ok": True, "amount_paid": new_paid, "balance": round(s["total"] - new_paid, 2), "completed": done}
# ── Customers ────────────────────────────────────────────────────────────────────────────────
@router.get("/customers")
async def list_customers(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if q.strip():
where = ("WHERE first_name ILIKE :q OR last_name ILIKE :q OR email ILIKE :q "
"OR (first_name || ' ' || coalesce(last_name,'')) ILIKE :q")
params = {"q": f"%{q.strip()}%"}
rows = await db.execute(text(f"""
SELECT id, trim(first_name || ' ' || coalesce(last_name,'')) AS name, email, phone, is_guest
FROM customer {where} ORDER BY is_guest DESC, first_name, last_name LIMIT 300
"""), params)
return {"customers": [dict(r) for r in rows.mappings()]}
@router.post("/customers")
async def add_customer(body: CustomerIn, ident=Depends(require_token), db=Depends(get_db)):
if not body.first_name.strip():
raise HTTPException(400, "first name required")
row = (await db.execute(text("""
INSERT INTO customer (first_name, last_name, email, phone, address, is_guest)
VALUES (:fn, :ln, :em, :ph, :ad, false) RETURNING id
"""), {"fn": body.first_name.strip(), "ln": (body.last_name or "").strip(),
"em": body.email, "ph": body.phone, "ad": body.address})).first()
await db.commit()
return {"ok": True, "id": row[0], "name": f"{body.first_name} {body.last_name or ''}".strip()}
# ── Discounts / promotions ───────────────────────────────────────────────────────────────────
@router.get("/discounts")
async def list_discounts(active_only: bool = Query(True), ident=Depends(require_token), db=Depends(get_db)):
where = "WHERE is_active" if active_only else ""
rows = await db.execute(text(f"""
SELECT id, name, percentage::float AS percentage, discount_type, description, manual_active,
bubble_text, bubble_color,
(is_active AND (start_at IS NULL OR start_at <= now())
AND (end_at IS NULL OR end_at >= now())) AS live
FROM sales_discount {where} ORDER BY manual_active DESC, percentage DESC
"""))
return {"discounts": [dict(r) for r in rows.mappings()]}
@router.post("/discounts/{discount_id}/toggle")
async def toggle_discount(discount_id: int, ident=Depends(require_token), db=Depends(get_db)):
row = (await db.execute(text(
"UPDATE sales_discount SET manual_active = NOT manual_active WHERE id=:i RETURNING manual_active"
), {"i": discount_id})).first()
if not row:
raise HTTPException(404, "not found")
await db.commit()
return {"ok": True, "manual_active": row[0]}
# ── Settings (currency / tax / default discount) ─────────────────────────────────────────────
@router.get("/settings")
async def get_settings(ident=Depends(require_token), db=Depends(get_db)):
rows = await db.execute(text("SELECT setting_key, setting_value FROM sales_setting WHERE is_active"))
s = {r["setting_key"]: r["setting_value"] for r in rows.mappings()}
return {"currency_symbol": s.get("currency_symbol", "$"),
"tax_rate": s.get("default_tax_rate", s.get("tax_rate", "0")),
"discount_rate": s.get("default_discount_rate", "0"),
"tax_type": s.get("tax_type", "exclusive")}
@router.post("/settings")
async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get_db)):
m = {"currency_symbol": body.get("currency_symbol"), "default_tax_rate": body.get("tax_rate"),
"default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type")}
for k, v in m.items():
if v is None:
continue
await db.execute(text("""
INSERT INTO sales_setting (setting_key, setting_value, updated_at) VALUES (:k, :v, now())
ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, updated_at = now()
"""), {"k": k, "v": str(v)})
await db.commit()
return {"ok": True}
# ── Past sales (the migrated history — receipts / lookup / refund) ────────────────────────────
@router.get("/past")
async def past_sales(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if q.strip():
where = ("WHERE s.sale_number ILIKE :q "
"OR trim(c.first_name || ' ' || coalesce(c.last_name,'')) ILIKE :q")
params = {"q": f"%{q.strip()}%"}
rows = await db.execute(text(f"""
SELECT s.id, s.sale_number, s.total::float AS total, s.status, s.payment_method,
s.payment_status, s.sale_date,
coalesce(nullif(trim(c.first_name || ' ' || coalesce(c.last_name,'')), ''), 'Guest') AS customer,
(SELECT count(*) FROM sale_items si WHERE si.sale_id = s.id) AS items
FROM sales s LEFT JOIN customer c ON c.id = s.customer_id
{where} ORDER BY s.sale_date DESC NULLS LAST LIMIT 100
"""), params)
return {"sales": [dict(r) for r in rows.mappings()]}