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:
parent
4c152954b5
commit
ef4769784f
@ -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
85
customer_woo_sync.py
Normal 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()
|
||||||
12
refresh.sh
12
refresh.sh
@ -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
68
shipping_sync.py
Normal 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 (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()
|
||||||
@ -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});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user