RECORDGOD/app/sales_routes.py

156 lines
7.0 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: str | None = None
class PayIn(BaseModel):
amount: float
method: str = "cash"
@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()
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, NULL, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade,
:due, CAST(:split AS jsonb), now(), now())
RETURNING id
"""), {"sn": sale_number, "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}