156 lines
7.0 KiB
Python
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}
|