RJBULK-APP/scripts/migrate_from_sqlite.py
SJCoetz 1efa979443 feat: initial setup of rj bulk supplies backoffice app
Adds complete initial codebase for the RJ Bulk Supplies fuel delivery backoffice:
- Authentication pages and protected route layouts
- Customer, supplier, fuel purchase, and delivery note management
- Custom reporting and customer statement sending tools
- Full UI component library and Tailwind styling
- Server actions, API routes, and PocketBase integration
- Development scripts and project configuration updates
- Updated README and package metadata
2026-07-19 20:38:16 +02:00

446 lines
17 KiB
Python

#!/usr/bin/env python3
"""
One-shot + incremental migration: SQLite dump → PocketBase.
Order:
1. Suppliers (no deps)
2. Customers (no deps)
3. Customer pricing (FK to customers — uses the map from step 2)
4. Fuel purchases (FK to suppliers — uses the map from step 1)
5. Delivery notes (FK to customers — uses the map from step 2)
IDEMPOTENT: All record IDs are deterministic — derived from the SQLite primary
key. Re-running on a populated PB is safe: the existence check will match the
already-imported records and skip them. Use this for incremental sync: after
the legacy app adds new rows to SQLite, just re-run this script and it picks
up only the new ones.
Usage:
python3 scripts/migrate_from_sqlite.py [path/to/db.sqlite3]
Optional env:
POCKETBASE_URL default: http://pocketbase-zinlbpsext2ro0lgb2558vfy.169.239.180.130.sslip.io
PB_ADMIN_EMAIL default: David.els1@outlook.com
PB_ADMIN_PASS default: 1234567890
"""
import json
import os
import sqlite3
import sys
import time
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor, as_completed
SQLITE_PATH = sys.argv[1] if len(sys.argv) > 1 else "/Volumes/KIOXIA/Downloads/db.sqlite3"
PB_URL = os.environ.get("POCKETBASE_URL", "http://pocketbase-zinlbpsext2ro0lgb2558vfy.169.239.180.130.sslip.io")
ADMIN_EMAIL = os.environ.get("PB_ADMIN_EMAIL", "David.els1@outlook.com")
ADMIN_PASS = os.environ.get("PB_ADMIN_PASS", "1234567890")
# Concurrent requests during bulk inserts. PB + the ss-lip tunnel can
# flake under heavy concurrency, so we keep this low.
BATCH_WORKERS = 5
RETRY_ON_TIMEOUT = 3
# Legacy status values that map to a non-empty value in the new app.
# The new app's status enum is: Pending | EFT | CASH | CARD. Everything else
# (Statement Viewed, Report Viewed, Invoiced) becomes "" (treated as "done")
# but the original value is preserved in `old_status` for reference.
LEGACY_STATUS_PASSTHROUGH = {"Pending", "EFT", "CASH", "CARD"}
def log(msg):
print(f"[migrate] {msg}", flush=True)
def deterministic_id(prefix, pk):
"""Deterministic 15-char base36 ID — same input always produces the same ID."""
body = f"{prefix}{pk}".encode("utf-8")
h = 0
for b in body:
h = (h * 31 + b) & 0xFFFFFFFFFFFFFFFF
chars = "abcdefghijklmnopqrstuvwxyz0123456789"
out = prefix
for _ in range(15 - len(prefix)):
h = (h * 1103515245 + 12345) & 0x7FFFFFFF
out += chars[h % 36]
return out
def clean(obj):
out = {}
for k, v in obj.items():
if v == "" or v is None:
continue
out[k] = v
return out
def pb_request(method, path, token=None, body=None):
url = f"{PB_URL}{path}"
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", token)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw.decode("utf-8")) if raw else None)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
return e.code, body
def get_auth_token():
code, data = pb_request("POST", "/api/collections/_superusers/auth-with-password", body={
"identity": ADMIN_EMAIL,
"password": ADMIN_PASS,
})
if code != 200:
raise SystemExit(f"Auth failed: {code} {data}")
return data["token"]
def get_existing_ids(token, collection, prefix=None):
"""Return set of IDs that exist. If prefix is set, filter to that prefix for speed."""
ids = set()
page = 1
while True:
path = f"/api/collections/{collection}/records?page={page}&perPage=500&fields=id"
code, data = pb_request("GET", path, token=token)
if code != 200 or not isinstance(data, dict) or not data.get("items"):
break
for r in data["items"]:
if prefix is None or r["id"].startswith(prefix):
ids.add(r["id"])
if page >= data.get("totalPages", 1):
break
page += 1
return ids
def list_existing_numbers(token, collection):
"""For delivery_notes: we need to know which `number` values are taken so we
avoid the unique-index collision when a legacy number is duplicated."""
nums = set()
page = 1
while True:
path = f"/api/collections/{collection}/records?page={page}&perPage=500&fields=number"
code, data = pb_request("GET", path, token=token)
if code != 200 or not isinstance(data, dict) or not data.get("items"):
break
for r in data["items"]:
if r.get("number"):
nums.add(str(r["number"]))
if page >= data.get("totalPages", 1):
break
page += 1
return nums
def create_record(token, collection, body, retries=RETRY_ON_TIMEOUT):
"""Create a record, retrying on connection/timeout errors."""
last_err = None
for attempt in range(retries):
try:
code, data = pb_request("POST", f"/api/collections/{collection}/records", token=token, body=body)
return code, data
except (urllib.error.URLError, TimeoutError) as e:
last_err = e
if attempt < retries - 1:
time.sleep(0.5 * (attempt + 1))
return 0, f"URLError after {retries} retries: {last_err}"
def fetch_rows(sql, table, where=None, order_by=None):
cur = sql.execute(f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "") + (f" ORDER BY {order_by}" if order_by else ""))
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def build_customer_index(name_to_id):
"""Return (lower_name, id) sorted by name length DESC so longest match wins."""
items = [(n.lower(), i) for n, i in name_to_id.items() if n and i]
items.sort(key=lambda x: (-len(x[0]), x[0]))
return items
def match_customer(legacy_name, index):
"""Best-effort match: exact (case-insensitive) → legacy is a prefix of a PB name."""
if not legacy_name:
return None
key = legacy_name.strip().lower()
for n, cid in index:
if n == key:
return cid
for n, cid in index:
if len(key) >= 3 and (n.startswith(key + " ") or n.startswith(key)) and len(n) > len(key):
return cid
return None
def migrate_suppliers(sql, token):
log("Suppliers…")
rows = fetch_rows(sql, "supplier")
existing = get_existing_ids(token, "suppliers")
name_to_id = {}
created, skipped = 0, 0
for row in rows:
name = (row.get("supplier_name") or "").strip()
new_id = deterministic_id("sup", row["id"])
if new_id in existing:
skipped += 1
else:
body = clean({
"id": new_id,
"name": name or "(unnamed)",
"contact_number": row.get("contact_number") or "",
"email": row.get("email") or "",
"address": row.get("address") or "",
"vat_number": row.get("vat_number") or "",
})
code, _ = create_record(token, "suppliers", body)
if code == 200:
created += 1
if name:
name_to_id[name] = new_id
# Augment with pre-existing suppliers in PB (so we can map legacy names that were already created outside this script)
page = 1
while True:
code, data = pb_request("GET", f"/api/collections/suppliers/records?page={page}&perPage=500&fields=id,name", token=token)
if code != 200 or not isinstance(data, dict) or not data.get("items"):
break
for r in data["items"]:
n = (r.get("name") or "").strip()
if n and n not in name_to_id:
name_to_id[n] = r["id"]
if page >= data.get("totalPages", 1):
break
page += 1
log(f" suppliers: created={created} skipped={skipped} (of {len(rows)} in sqlite)")
return created, skipped, name_to_id
def migrate_customers(sql, token):
log("Customers…")
rows = fetch_rows(sql, "customer", where="customer_name IS NOT NULL AND customer_name != ''")
existing = get_existing_ids(token, "customers")
created, skipped, failed = 0, 0, 0
old_id_to_new = {}
name_to_id = {}
for row in rows:
name = (row.get("customer_name") or "").strip()
if not name:
continue
new_id = deterministic_id("cu", row["id"])
if new_id in existing:
skipped += 1
else:
body = clean({
"id": new_id,
"name": name,
"contact_number": row.get("contact_number") or "",
"email": row.get("email") or "",
"address": row.get("address") or "",
"vat_number": row.get("vat_number") or "",
})
code, _ = create_record(token, "customers", body)
if code == 200:
created += 1
else:
failed += 1
old_id_to_new[str(row["id"])] = new_id
name_to_id[name] = new_id
page = 1
while True:
code, data = pb_request("GET", f"/api/collections/customers/records?page={page}&perPage=500&fields=id,name", token=token)
if code != 200 or not isinstance(data, dict) or not data.get("items"):
break
for r in data["items"]:
n = (r.get("name") or "").strip()
if n and n not in name_to_id:
name_to_id[n] = r["id"]
if page >= data.get("totalPages", 1):
break
page += 1
log(f" customers: created={created} skipped={skipped} failed={failed} (of {len(rows)} valid in sqlite)")
return created, skipped, old_id_to_new, name_to_id
def migrate_pricing(sql, token, old_id_to_new):
log("Customer pricing…")
rows = fetch_rows(sql, "customer_pricing")
existing = get_existing_ids(token, "customer_pricing")
created, skipped, missing = 0, 0, 0
for row in rows:
new_cust = old_id_to_new.get(str(row["customer_id"]))
if not new_cust:
missing += 1
continue
new_id = deterministic_id("pr", row["id"])
if new_id in existing:
skipped += 1
continue
body = clean({
"id": new_id,
"customer": new_cust,
"year": int(row["year"]),
"month": int(row["month"]),
"price_per_litre": float(row["price_per_litre"]),
})
if row.get("special_price") is not None:
body["special_price"] = float(row["special_price"])
code, _ = create_record(token, "customer_pricing", body)
if code == 200:
created += 1
log(f" customer_pricing: created={created} skipped={skipped} dropped_no_customer={missing} (of {len(rows)} in sqlite)")
return created, skipped, missing
def migrate_fuel_purchases(sql, token, supplier_name_to_id):
log("Fuel purchases…")
rows = fetch_rows(sql, "fuel_purchase")
existing = get_existing_ids(token, "fuel_purchases")
created, skipped, missing = 0, 0, 0
lc_lookup = {k.lower(): v for k, v in supplier_name_to_id.items() if v}
for row in rows:
legacy_name = (row.get("supplier_name") or "").strip()
match = lc_lookup.get(legacy_name.lower())
if not match:
missing += 1
log(f" !! no supplier named '{legacy_name}' — skipping fuel_purchase id={row.get('id')}")
continue
new_id = deterministic_id("fp", row["id"])
if new_id in existing:
skipped += 1
continue
body = clean({
"id": new_id,
"supplier": match,
"purchase_date": row.get("purchase_date") or "",
"purchase_qty": float(row.get("purchase_qty") or 0),
"purchase_price": float(row.get("purchase_price") or 0),
})
code, _ = create_record(token, "fuel_purchases", body)
if code == 200:
created += 1
log(f" fuel_purchases: created={created} skipped={skipped} dropped_no_supplier={missing} (of {len(rows)} in sqlite)")
return created, skipped, missing
def migrate_delivery_notes(sql, token, customer_name_to_id):
log("Delivery notes… (this is the big one — ~23k records, will take a few minutes)")
rows = fetch_rows(sql, "delivery_note", order_by="id")
existing = get_existing_ids(token, "delivery_notes", prefix="dn")
taken_numbers = list_existing_numbers(token, "delivery_notes")
cust_index = build_customer_index(customer_name_to_id)
log(f" preloaded: {len(existing)} already-imported notes, {len(taken_numbers)} taken numbers, {len(cust_index)} PB customers for matching")
created, skipped, missing_cust, dupes = 0, 0, 0, 0
to_create = []
for row in rows:
legacy_pk = row["id"]
new_id = deterministic_id("dn", legacy_pk)
if new_id in existing:
skipped += 1
continue
customer_id = match_customer(row.get("customer_name") or "", cust_index)
if not customer_id:
missing_cust += 1
continue
old_status = (row.get("status") or "").strip()
new_status = old_status if old_status in LEGACY_STATUS_PASSTHROUGH else ""
raw_number = row.get("delivery_note_number")
number = str(raw_number) if raw_number is not None else f"LEGACY-{legacy_pk}"
if number in taken_numbers:
number = f"{number}-{legacy_pk}"
dupes += 1
taken_numbers.add(number)
qty = row.get("qty_litres_supplied")
if qty is None or qty == 0:
qty = 1
date_str = (row.get("delivery_date") or "").split(" ")[0]
body = {
"id": new_id,
"number": number,
"date": date_str,
"customer": customer_id,
"vehicle_registration": row.get("vehicle_registration") or "",
"qty_litres_supplied": float(qty),
"plant_dispensing": row.get("plant_dispensing") or "",
"status": new_status,
"old_status": old_status,
}
if row.get("price_per_litre") is not None:
body["price_per_litre"] = float(row["price_per_litre"])
if row.get("total_value") is not None:
body["total_value"] = float(row["total_value"])
if row.get("pricing_source"):
body["pricing_source"] = row["pricing_source"]
to_create.append((new_id, body))
log(f" prepared: {len(to_create)} to create, {skipped} already imported, {missing_cust} skipped (no customer match), {dupes} duplicate numbers suffixed")
if not to_create:
return created, skipped, missing_cust, dupes
def _do_create(args):
new_id, body = args
code, _ = create_record(token, "delivery_notes", body)
return new_id, code
with ThreadPoolExecutor(max_workers=BATCH_WORKERS) as ex:
futures = [ex.submit(_do_create, item) for item in to_create]
done = 0
ok = 0
fail = 0
for f in as_completed(futures):
done += 1
new_id, code = f.result()
if code == 200:
ok += 1
else:
fail += 1
if fail <= 5:
log(f" !! create failed for {new_id}: {code}")
if done % 500 == 0:
log(f" … progress: {done}/{len(to_create)} processed (ok={ok} fail={fail})")
created = ok
log(f" delivery_notes: created={created} skipped={skipped} dropped_no_customer={missing_cust} duplicates_suffixed={dupes} (of {len(rows)} in sqlite)")
return created, skipped, missing_cust, dupes
def main():
if not os.path.exists(SQLITE_PATH):
raise SystemExit(f"SQLite file not found: {SQLITE_PATH}")
log(f"Source: {SQLITE_PATH}")
log(f"Target: {PB_URL}")
log("Authenticating as superuser…")
token = get_auth_token()
log("Authenticated.")
sql = sqlite3.connect(SQLITE_PATH)
sql.row_factory = None
sup = migrate_suppliers(sql, token)
cust = migrate_customers(sql, token)
pricing = migrate_pricing(sql, token, cust[2])
fuel = migrate_fuel_purchases(sql, token, sup[2])
dn = migrate_delivery_notes(sql, token, cust[3])
sql.close()
log("")
log("=== SUMMARY ===")
log(f" Suppliers: +{sup[0]} new, {sup[1]} skipped")
log(f" Customers: +{cust[0]} new, {cust[1]} skipped, {cust[2]} failed")
log(f" Customer pricing: +{pricing[0]} new, {pricing[1]} skipped, {pricing[2]} dropped (no matching customer)")
log(f" Fuel purchases: +{fuel[0]} new, {fuel[1]} skipped, {fuel[2]} dropped (no matching supplier)")
log(f" Delivery notes: +{dn[0]} new, {dn[1]} skipped, {dn[2]} dropped (no customer match), {dn[3]} numbers suffixed (dup collision)")
log("✅ Migration done.")
if __name__ == "__main__":
main()