diff --git a/app/sales_routes.py b/app/sales_routes.py index d7777e0..b2c0aab 100644 --- a/app/sales_routes.py +++ b/app/sales_routes.py @@ -368,6 +368,26 @@ async def terminal_checkout_cancel(checkout_id: str, ident=Depends(require_token raise HTTPException(502, str(e)) +# ── AusPost shipping quote — flat rate by weight bracket × parcels (rates mirrored from WowPlatter) ── +@router.get("/shipping/quote") +async def shipping_quote(units: int | None = None, weight_g: int | None = None, + ident=Depends(require_token), db=Depends(get_db)): + """Quote AusPost postage: N records → 230g + 50g packaging each → split into ≤5kg parcels → flat rate.""" + if weight_g is None: + weight_g = max(1, units or 1) * 280 + parcels = max(1, -(-weight_g // 5000)) # ceil + per = -(-weight_g // parcels) # ceil per-parcel grams + rows = await db.execute(text( + "SELECT service_key, price::float AS price FROM post_flat_rate " + "WHERE :w BETWEEN weight_min_g AND weight_max_g"), {"w": per}) + seen = {} + for r in rows.mappings(): + seen.setdefault(r["service_key"], r["price"]) + opts = [{"service": k, "label": k.replace("_", " ").title(), "price": round(v * parcels, 2)} + for k, v in sorted(seen.items(), key=lambda x: x[1])] + return {"weight_g": weight_g, "parcels": parcels, "per_parcel_g": per, "options": opts} + + # ── 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)): diff --git a/customer_woo_sync.py b/customer_woo_sync.py new file mode 100644 index 0000000..6d3d639 --- /dev/null +++ b/customer_woo_sync.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Sync WooCommerce online customers → RecordGod's unified `customer` CRM (NON-destructive). + + python3 customer_woo_sync.py | ssh -C root@100.94.195.115 \ + 'docker exec -i recordgod-db psql -U recordgod -d recordgod -q -v ON_ERROR_STOP=1' + +Source = wc_customer_lookup (+ billing_phone / billing_address_1 from usermeta). Upsert order: + 1. link an existing in-store customer to its Woo account by EMAIL (set wp_user_id), keep their entered data, + 2. refresh by wp_user_id (only fill BLANK fields — never clobber POS-entered data), + 3. insert genuinely-new online customers. +Woo stays source-of-truth for online accounts; POS rows are never overwritten. Re-run after a clone refresh. +""" +import re +import pathlib +import sys + +import pymysql + +WP_CONFIG = "/opt/homebrew/var/www/monsterrobot.localsite/wp-config.php" +SOCK = "/opt/homebrew/var/mysql/mysql.sock" + +UPSERT = r""" +-- 1. an in-store customer with the same email IS this Woo account → link it (keep their data) +UPDATE customer c SET wp_user_id = w.wp_user_id, + phone = coalesce(nullif(c.phone,''), w.phone), + address = coalesce(nullif(c.address,''), w.address) +FROM _woo w +WHERE c.wp_user_id IS NULL AND w.email <> '' AND lower(c.email) = lower(w.email); + +-- 2. already linked → only fill blanks (never overwrite POS-entered values) +UPDATE customer c SET email = coalesce(nullif(c.email,''), w.email), + phone = coalesce(nullif(c.phone,''), w.phone), + address = coalesce(nullif(c.address,''), w.address) +FROM _woo w WHERE c.wp_user_id = w.wp_user_id; + +-- 3. brand-new online customer (no wp_user_id match AND no email match) +INSERT INTO customer (wp_user_id, first_name, last_name, email, phone, address, is_guest) +SELECT w.wp_user_id, w.first_name, w.last_name, w.email, w.phone, w.address, false +FROM _woo w +WHERE NOT EXISTS (SELECT 1 FROM customer c WHERE c.wp_user_id = w.wp_user_id) + AND NOT EXISTS (SELECT 1 FROM customer c WHERE w.email <> '' AND lower(c.email) = lower(w.email)); +""" + + +def creds(): + t = pathlib.Path(WP_CONFIG).read_text() + g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1) + return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD") + + +def cp(v): + if v is None or v == "": + return r"\N" + return (str(v).replace("\\", "\\\\").replace("\t", "\\t") + .replace("\n", "\\n").replace("\r", "\\r")) + + +def main(): + name, user, pw = creds() + my = pymysql.connect(unix_socket=SOCK, user=user, password=pw, database=name, + cursorclass=pymysql.cursors.DictCursor) + c = my.cursor() + P = "wp_rmp_" + c.execute(f""" + SELECT cl.user_id, cl.first_name, cl.last_name, cl.email, cl.city, cl.state, cl.postcode, cl.country, + (SELECT meta_value FROM {P}usermeta WHERE user_id=cl.user_id AND meta_key='billing_phone' LIMIT 1) AS phone, + (SELECT meta_value FROM {P}usermeta WHERE user_id=cl.user_id AND meta_key='billing_address_1' LIMIT 1) AS addr1 + FROM {P}wc_customer_lookup cl + WHERE cl.user_id IS NOT NULL AND cl.email IS NOT NULL AND cl.email <> ''""") + rows = c.fetchall() + out = sys.stdout + out.write("CREATE TEMP TABLE _woo (wp_user_id bigint, first_name text, last_name text, email text, phone text, address text);\n") + out.write("COPY _woo (wp_user_id, first_name, last_name, email, phone, address) FROM stdin;\n") + for r in rows: + parts = [r["addr1"], r["city"], f'{r["state"]} {r["postcode"]}'.strip(), r["country"]] + addr = ", ".join(p for p in parts if p and str(p).strip()) + out.write("\t".join([cp(r["user_id"]), cp(r["first_name"]), cp(r["last_name"]), + cp(r["email"]), cp(r["phone"]), cp(addr)]) + "\n") + out.write("\\.\n") + out.write(UPSERT) + print(f"staged {len(rows)} Woo customers", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/refresh.sh b/refresh.sh index e5cb900..792222a 100755 --- a/refresh.sh +++ b/refresh.sh @@ -16,10 +16,14 @@ VPS=${VPS:-root@100.94.195.115} PY=${PY:-./.venv/bin/python} PGDUMP=${PGDUMP:-/opt/homebrew/opt/postgresql@16/bin/pg_dump} -echo "[1/4] inventory + sales + crate (MariaDB → recordgod)"; $PY migrate.py --truncate -echo "[2/4] virtual store tables (MariaDB → recordgod)"; $PY migrate_virtual.py -echo "[3/4] disc_cache (discogs_full → recordgod)"; $PY build_disc_cache.py -echo "[4/4] push recordgod → VPS (vault preserved)" +echo "[1/5] inventory + sales + crate (MariaDB → recordgod)"; $PY migrate.py --truncate +echo "[2/5] virtual store tables (MariaDB → recordgod)"; $PY migrate_virtual.py +echo "[3/5] disc_cache (discogs_full → recordgod)"; $PY build_disc_cache.py +echo "[4/5] push recordgod → VPS (vault preserved)" $PGDUMP --no-owner --no-privileges --clean --if-exists --exclude-table='app_secret' recordgod \ | ssh "$VPS" 'docker exec -i recordgod-db psql -U recordgod -d recordgod -q' 2>&1 | grep -i error || true +# Woo online customers go straight to the VPS AFTER the --clean push (which only carries POS customers). +# Non-destructive upsert: links/refreshes/inserts, never clobbers POS-entered rows. +echo "[5/5] Woo online customers → recordgod CRM (non-destructive)" +$PY customer_woo_sync.py | ssh "$VPS" 'docker exec -i recordgod-db psql -U recordgod -d recordgod -q' 2>&1 | grep -i error || true echo "done — recordgod refreshed on ultra + VPS" diff --git a/shipping_sync.py b/shipping_sync.py new file mode 100644 index 0000000..b5f78b4 --- /dev/null +++ b/shipping_sync.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Mirror WowPlatter's AusPost flat-rate + zone tables → RecordGod Postgres. + + python3 shipping_sync.py | ssh -C root@100.94.195.115 \ + 'docker exec -i recordgod-db psql -U recordgod -d recordgod -q -v ON_ERROR_STOP=1' + +post_flat_rate = service × size/weight bracket → price (Parcel Post / Express, own packaging). +post_zone = postcode range → zone (kept for future distance/zone pricing; flat rate is weight-only). +NOTE: AusPost rates rise ~+5% on 2026-07-01 — re-pull from the WowPlatter table (PDF→Gemini path) after the update. +""" +import re +import pathlib +import sys + +import pymysql + +WP_CONFIG = "/opt/homebrew/var/www/monsterrobot.localsite/wp-config.php" +SOCK = "/opt/homebrew/var/mysql/mysql.sock" + +SCHEMA = """ +DROP TABLE IF EXISTS post_flat_rate, post_zone CASCADE; +CREATE TABLE post_flat_rate (service_key text, packaging_type text, size_label text, + weight_min_g int, weight_max_g int, price numeric(10,2)); +CREATE TABLE post_zone (postcode_start int, postcode_end int, zone_code text); +CREATE INDEX ON post_flat_rate (service_key, weight_min_g, weight_max_g); +CREATE INDEX ON post_zone (postcode_start, postcode_end); +""" + +FR = ["service_key", "packaging_type", "size_label", "weight_min_g", "weight_max_g", "price"] +ZN = ["postcode_start", "postcode_end", "zone_code"] + + +def creds(): + t = pathlib.Path(WP_CONFIG).read_text() + g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1) + return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD") + + +def cp(v): + if v is None or v == "": + return r"\N" + return str(v).replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r") + + +def block(out, table, cols, rows): + out.write(f"COPY {table} ({','.join(cols)}) FROM stdin;\n") + for r in rows: + out.write("\t".join(cp(r[c]) for c in cols) + "\n") + out.write("\\.\n\n") + print(f" {table:16} {len(rows):>4}", file=sys.stderr) + + +def main(): + name, user, pw = creds() + my = pymysql.connect(unix_socket=SOCK, user=user, password=pw, database=name, + cursorclass=pymysql.cursors.DictCursor) + c = my.cursor() + P = "wp_rmp_disc_" + out = sys.stdout + out.write(SCHEMA) + c.execute(f"SELECT {','.join(FR)} FROM {P}post_flat_rates") + block(out, "post_flat_rate", FR, c.fetchall()) + c.execute(f"SELECT {','.join(ZN)} FROM {P}post_zones") + block(out, "post_zone", ZN, c.fetchall()) + + +if __name__ == "__main__": + main() diff --git a/site/pos.html b/site/pos.html index 1f97354..ddc7943 100644 --- a/site/pos.html +++ b/site/pos.html @@ -90,6 +90,7 @@ +
@@ -273,6 +274,22 @@ function manualAdd(){ const name=prompt('Item name:'); if(!name) return; cart.push({sku:'MANUAL-'+Date.now(),title:name,qty:1,price:parseFloat(prompt('Price:','0'))||0,discount:0,manual:true}); $('#res').style.display='none'; render(); } +async function postageDlg(){ + const units=cart.filter(c=>!/^POSTAGE-/.test(c.sku)).reduce((s,c)=>s+c.qty,0); + if(!units){ $('#msg').textContent='add items first'; return; } + const d=await get('/sales/shipping/quote?units='+units); + $('#dlgCard').innerHTML=`

📦 AusPost postage

+
${units} item${units>1?'s':''} · ${d.weight_g}g · ${d.parcels} parcel${d.parcels>1?'s':''}
+ ${(d.options||[]).map(o=>`
+ ${esc(o.label)}
`).join('')||'
no rate for this weight
'} +
`; + $('#dlg').style.display='flex'; +} +function addPostage(label,price){ + cart=cart.filter(c=>!/^POSTAGE-/.test(c.sku)); // one postage line at a time + cart.push({sku:'POSTAGE-'+Date.now(),title:'Postage — '+label,qty:1,price,discount:0,manual:true}); + close_('dlg'); render(); +} function add(it){ const ex=cart.find(c=>c.sku===it.sku); if(ex) ex.qty++; else cart.push({sku:it.sku,title:it.title||it.sku,qty:1,price:it.price||0,discount:0,crate:it.crate});