feat: Woo customer sync (non-destructive, in refresh.sh) + AusPost rates mirror & quote calc

customer_woo_sync.py: WooCommerce online customers (wc_customer_lookup+billing) → RecordGod
customer CRM, links-by-email/refresh-by-wp_user_id/insert-new, never clobbers POS rows; added
as refresh.sh step 5 (after the --clean push). shipping_sync.py mirrors disc_post_flat_rates
(+zones) → post_flat_rate/post_zone. /sales/shipping/quote (units|weight_g → 280g/record →
≤5kg parcels → flat rate × parcels, Parcel Post + Express). POS Register '📦 post' quotes the
cart + adds a postage line. AusPost +5% lands 2026-07-01 — re-pull rates then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-24 00:55:56 +10:00
parent 4c152954b5
commit ef4769784f
5 changed files with 198 additions and 4 deletions

View File

@ -368,6 +368,26 @@ async def terminal_checkout_cancel(checkout_id: str, ident=Depends(require_token
raise HTTPException(502, str(e)) 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) ──────────────────────────── # ── Past sales (the migrated history — receipts / lookup / refund) ────────────────────────────
@router.get("/past") @router.get("/past")
async def past_sales(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)): async def past_sales(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):

85
customer_woo_sync.py Normal file
View File

@ -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()

View File

@ -16,10 +16,14 @@ VPS=${VPS:-root@100.94.195.115}
PY=${PY:-./.venv/bin/python} PY=${PY:-./.venv/bin/python}
PGDUMP=${PGDUMP:-/opt/homebrew/opt/postgresql@16/bin/pg_dump} PGDUMP=${PGDUMP:-/opt/homebrew/opt/postgresql@16/bin/pg_dump}
echo "[1/4] inventory + sales + crate (MariaDB → recordgod)"; $PY migrate.py --truncate echo "[1/5] inventory + sales + crate (MariaDB → recordgod)"; $PY migrate.py --truncate
echo "[2/4] virtual store tables (MariaDB → recordgod)"; $PY migrate_virtual.py echo "[2/5] virtual store tables (MariaDB → recordgod)"; $PY migrate_virtual.py
echo "[3/4] disc_cache (discogs_full → recordgod)"; $PY build_disc_cache.py echo "[3/5] disc_cache (discogs_full → recordgod)"; $PY build_disc_cache.py
echo "[4/4] push recordgod → VPS (vault preserved)" echo "[4/5] push recordgod → VPS (vault preserved)"
$PGDUMP --no-owner --no-privileges --clean --if-exists --exclude-table='app_secret' recordgod \ $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 | 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" echo "done — recordgod refreshed on ultra + VPS"

68
shipping_sync.py Normal file
View File

@ -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 (PDFGemini 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()

View File

@ -90,6 +90,7 @@
<div id="res" class="drop" style="display:none"></div></div> <div id="res" class="drop" style="display:none"></div></div>
<button class="ghost" onclick="bulkDlg()">bulk</button> <button class="ghost" onclick="bulkDlg()">bulk</button>
<button class="ghost" onclick="manualAdd()">manual</button> <button class="ghost" onclick="manualAdd()">manual</button>
<button class="ghost" onclick="postageDlg()">📦 post</button>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
@ -273,6 +274,22 @@ function manualAdd(){
const name=prompt('Item name:'); if(!name) return; 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(); 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=`<h3 style="margin:0 0 6px">📦 AusPost postage</h3>
<div class="muted" style="margin-bottom:8px">${units} item${units>1?'s':''} · ${d.weight_g}g · ${d.parcels} parcel${d.parcels>1?'s':''}</div>
${(d.options||[]).map(o=>`<div class="row" style="justify-content:space-between;padding:8px 0;border-bottom:1px solid #ededf2">
<span>${esc(o.label)}</span><button class="btn" onclick='addPostage(${JSON.stringify(o.label)},${o.price})'>${money(o.price)}</button></div>`).join('')||'<div class="muted">no rate for this weight</div>'}
<div class="row" style="margin-top:12px"><button class="ghost" onclick="close_('dlg')">Cancel</button></div>`;
$('#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){ function add(it){
const ex=cart.find(c=>c.sku===it.sku); 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}); 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});