diff --git a/app/main.py b/app/main.py index 828e74d..c6d706e 100644 --- a/app/main.py +++ b/app/main.py @@ -37,6 +37,15 @@ app.include_router(sales_router) _STARTUP_DDL = [ "CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())", + # POS reference tables (populated by migrate.py; created here so prod has them on deploy) + "CREATE TABLE IF NOT EXISTS customer (id bigint PRIMARY KEY, wp_user_id bigint, first_name text NOT NULL DEFAULT '', last_name text DEFAULT '', email text, phone text, address text, is_guest boolean NOT NULL DEFAULT false, created_at timestamptz DEFAULT now())", + "CREATE SEQUENCE IF NOT EXISTS customer_id_seq", + "ALTER TABLE customer ALTER COLUMN id SET DEFAULT nextval('customer_id_seq')", + "SELECT setval('customer_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM customer)))", + "CREATE TABLE IF NOT EXISTS sales_discount (id bigint PRIMARY KEY, name text NOT NULL, percentage numeric(5,2) NOT NULL DEFAULT 0, is_active boolean NOT NULL DEFAULT true, manual_active boolean NOT NULL DEFAULT false, discount_type text DEFAULT 'manual', description text, bubble_text text, bubble_color text, start_at timestamptz, end_at timestamptz, created_at timestamptz DEFAULT now())", + "CREATE TABLE IF NOT EXISTS sales_setting (setting_key text PRIMARY KEY, setting_value text, is_active boolean NOT NULL DEFAULT true, updated_at timestamptz DEFAULT now())", + # a Guest customer always available at the counter + "INSERT INTO customer (id, first_name, last_name, email, is_guest) VALUES (0, 'Guest', 'Sale', NULL, true) ON CONFLICT (id) DO NOTHING", # POS fields on the migrated sales tables "ALTER TABLE sales ADD COLUMN IF NOT EXISTS subtotal numeric(10,2)", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0", diff --git a/app/sales_routes.py b/app/sales_routes.py index 72815c0..e80c1c4 100644 --- a/app/sales_routes.py +++ b/app/sales_routes.py @@ -30,7 +30,7 @@ class SaleIn(BaseModel): split: list[dict] | None = None hold: dict | None = None trade_in: float = 0 - customer: str | None = None + customer_id: int | None = None # 0 = guest sale; None when unset class PayIn(BaseModel): @@ -38,6 +38,14 @@ class PayIn(BaseModel): 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.""" @@ -74,14 +82,15 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get 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, NULL, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade, + VALUES (:sn, :cust, :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, + """), {"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] @@ -153,3 +162,101 @@ async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token), """), {"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()]} diff --git a/migrate.py b/migrate.py index 021fa3a..9765775 100644 --- a/migrate.py +++ b/migrate.py @@ -69,7 +69,7 @@ def main(): pg.execute(open(HERE / "schema.sql").read()) pg.commit() if truncate: - pg.execute("TRUNCATE inventory, sale_items, sales, crate") + pg.execute("TRUNCATE inventory, sale_items, sales, crate, customer, sales_discount, sales_setting") c = my.cursor() @@ -136,6 +136,30 @@ def main(): n_orphan = sum(1 for i in items if i["sale_id"] not in sale_ids) items = [i for i in items if i["sale_id"] in sale_ids] + # --- customers (the POS customer picker) --------------------------------------------------- + c.execute("SELECT * FROM wp_rmp_disc_sales_customers") + customers = [dict( + id=r["id"], wp_user_id=r["wp_user_id"], first_name=r["first_name"] or "", + last_name=r["last_name"] or "", email=r["email"], phone=r["phone"], + address=r.get("address") or r.get("address_line1"), is_guest=bool(r["is_guest"]), + created_at=r["created_at"] or now, + ) for r in c.fetchall()] + + # --- named discounts / promotions ---------------------------------------------------------- + c.execute("SELECT * FROM wp_rmp_disc_sales_discounts") + discounts = [dict( + id=r["id"], name=r["name"], percentage=r["percentage"], is_active=bool(r["is_active"]), + manual_active=bool(r["manual_active"]), discount_type=r["discount_type"], + description=r["description"], bubble_text=r["bubble_text"], bubble_color=r["bubble_color"], + start_at=r["start_at"], end_at=r["end_at"], created_at=r["created_at"] or now, + ) for r in c.fetchall()] + + # --- sales settings (currency / tax / default discount) ------------------------------------ + c.execute("SELECT * FROM wp_rmp_disc_sales_settings") + settings = [dict(setting_key=r["setting_key"], setting_value=r["setting_value"], + is_active=bool(r["is_active"]), updated_at=r["updated_at"] or now) + for r in c.fetchall()] + # order matters: crate + sales before things that could reference them n = {} n["crate"] = insert_many(pg, "crate", ["id", "name", "label_text", "purpose", "notes"], crates) @@ -147,6 +171,15 @@ def main(): n["sale_items"] = insert_many(pg, "sale_items", ["id", "sale_id", "sku", "release_id", "item_name", "qty", "unit_price", "line_total"], items) + n["customer"] = insert_many(pg, "customer", + ["id", "wp_user_id", "first_name", "last_name", "email", "phone", + "address", "is_guest", "created_at"], customers) + n["sales_discount"] = insert_many(pg, "sales_discount", + ["id", "name", "percentage", "is_active", "manual_active", + "discount_type", "description", "bubble_text", "bubble_color", + "start_at", "end_at", "created_at"], discounts) + n["sales_setting"] = insert_many(pg, "sales_setting", + ["setting_key", "setting_value", "is_active", "updated_at"], settings) pg.commit() for k, v in n.items(): print(f" {k:22} {v:>6}") diff --git a/schema.sql b/schema.sql index dd1fae1..f7ff65a 100644 --- a/schema.sql +++ b/schema.sql @@ -105,5 +105,44 @@ CREATE TABLE IF NOT EXISTS app_secret ( updated_at timestamptz NOT NULL DEFAULT now() ); +-- POS customers (from wp_rmp_disc_sales_customers) — the counter's customer dropdown + add-new. +CREATE TABLE IF NOT EXISTS customer ( + id bigint PRIMARY KEY, + wp_user_id bigint, + first_name text NOT NULL DEFAULT '', + last_name text DEFAULT '', + email text, + phone text, + address text, + is_guest boolean NOT NULL DEFAULT false, + created_at timestamptz DEFAULT now() +); +CREATE INDEX IF NOT EXISTS customer_name_idx ON customer (lower(first_name), lower(last_name)); +CREATE INDEX IF NOT EXISTS customer_email_idx ON customer (lower(email)); + +-- Named discounts / promotions (from wp_rmp_disc_sales_discounts) — the Promotions tab + POS promo picker. +CREATE TABLE IF NOT EXISTS sales_discount ( + id bigint PRIMARY KEY, + name text NOT NULL, + percentage numeric(5,2) NOT NULL DEFAULT 0, + is_active boolean NOT NULL DEFAULT true, + manual_active boolean NOT NULL DEFAULT false, -- currently switched on at the counter + discount_type text DEFAULT 'manual', -- manual|promotion|tax + description text, + bubble_text text, + bubble_color text, + start_at timestamptz, + end_at timestamptz, + created_at timestamptz DEFAULT now() +); + +-- Sales settings (from wp_rmp_disc_sales_settings) — currency / tax / default discount, key-value. +CREATE TABLE IF NOT EXISTS sales_setting ( + setting_key text PRIMARY KEY, + setting_value text, + is_active boolean NOT NULL DEFAULT true, + updated_at timestamptz DEFAULT now() +); + -- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search -- measurably falls short, not before. diff --git a/site/pos.html b/site/pos.html index 574dfa7..c652aac 100644 --- a/site/pos.html +++ b/site/pos.html @@ -6,35 +6,50 @@ RecordGod — sales @@ -42,83 +57,230 @@
-
-
-

🔍 Ring up — search title / artist / SKU / scan

-
-
-

🛒 Cart

- -
ItemQtyPriceDiscLine
-
cart is empty — search above to add items
+