WFDASH-WEBAPP/scripts/2026-08-03-load-husqvarna-cart.ts
SJCoetz e795e8584d chore(scripts): one-time stock-order loaders + source screenshots
- scripts/2026-08-03-load-husqvarna-cart.ts: parses two cart
  screenshots into a stock_purchase draft.
- scripts/2026-08-03-load-yg1-quote-soq078608.ts: extracts YG-1
  quote SOQ078608 into a stock_purchase draft.
- scripts/2026-08-03-merge-yg1-drafts.ts: consolidates the new
  YG-1 draft with the existing PO26073102STK into a single
  PO26080301STK.

Source images (img1_*.png, img2_*.png) are kept alongside the
husqvarna loader so the parse step is reproducible. The
stock-orders-after.png screenshot documents the page state after
the recent wrap + PDF-download fixes.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 17:33:28 +02:00

253 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* One-time loader — Husqvarna "AUGUST STOCK" cart (2 screenshots) →
* stock_purchase draft.
*
* Source images:
* /Volumes/KIOXIA/Downloads/WhatsApp Image 2026-08-03 at 15.14.37.jpeg
* /Volumes/KIOXIA/Downloads/WhatsApp Image 2026-08-03 at 15.14.38.jpeg
*
* Supplier: HUSQVARNA,.. (id `x7n42attp5qkvhx`)
* Cart: "AUGUST STOCK" (Husqvarna South Africa web shop cart)
* Screenshot date: 2026-08-03 15:14
* Items: 14 rows across the two images
*
* ## Columns on the cart screens
*
* Article | Net price | RRP | Discount | Quantity | Total net price | Availability
* ---------- | --------- | ------ | -------- | -------- | --------------- | -----------
* 575 84 22-58 | R 237.39 | R 339.13 | 30% | 2 | R 474.78 | In stock
*
* `Net price` is the per-unit price AFTER the supplier's discount has
* been applied (RRP × (1 − discount)). `Total net price` = qty × net price.
* e.g. 339.13 × (1 − 0.30) = 237.391 ≈ 237.39 ✓
* 2 × 237.39 = 474.78 ✓
*
* ## ⚠ Cart total discrepancy
*
* The cart footer shows **Total R 25,369.86** but the sum of the 14
* visible line totals is **R 19,115.94** (a R 6,253.92 gap). Possible
* causes:
* 1. The two screenshots don't capture the whole cart — the page
* header says "Multiple carts" and the footer total may be
* aggregating across carts.
* 2. The supplier's site has a display bug.
* 3. There's a fee (delivery, handling) added to the footer that's
* not in the per-line totals.
*
* The script records the supplier's reported total in the headers
* `notes` field so the discrepancy is visible to the operator. The
* 14 per-line totals are loaded exactly as shown.
*
* ## What this does
*
* 1. Validates each line's math: qty × net_price = total.
* 2. Creates 14 new stock_items (none of these Husqvarna SKUs exist
* yet — verified against the running PB before the script started).
* 3. Creates one stock_purchase with status="draft",
* purchase_number = `PO{yy}{mm}{dd}{seq}STK` (today's first in the day).
* 4. Creates 14 stock_purchase_items with cost_price = net price
* (already post-discount).
*
* Re-run safety: idempotent on stock_items (re-checks SKU before
* creating). Does NOT dedupe against an existing draft — don't re-run
* after success.
*
* Run: `npx tsx scripts/2026-08-03-load-husqvarna-cart.ts`
*/
import PocketBase from "pocketbase";
const PB_URL = "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io";
const ADMIN_EMAIL = "david.els1@outlook.com";
const ADMIN_PASSWORD = "1234567890";
const HUSQVARNA_SUPPLIER_ID = "x7n42attp5qkvhx";
/* ============================================================================
* Parsed line items (from eyeballing the two screenshots)
* ========================================================================== */
interface ParsedLine {
article: string; // e.g. "575 84 22-58"
sku: string; // e.g. "575269102"
description: string; // full product description
net_price: number; // per-unit price AFTER supplier's discount
rrp: number; // pre-discount RRP
disc_pct: number; // supplier discount shown on the row
quantity: number;
pdf_total: number; // "Total net price" column
}
// Image 1 — 6 rows
const LINES: ParsedLine[] = [
{ article: "575 84 22-58", sku: "575269102", description: "Bars 1/4\" 1.3 A318 Pole saw 10\" / 58DL / 1/4\" A318 1.3mm for 327PT5S", net_price: 237.39, rrp: 339.13, disc_pct: 30, quantity: 2, pdf_total: 474.78 },
{ article: "575 84 22-64", sku: "575842264", description: "Bars 1/4\" 1.3 A318 Pole saw 12\" / 64DL / 1/4\" A318 1.3mm for 327PT5S", net_price: 286.09, rrp: 408.70, disc_pct: 30, quantity: 2, pdf_total: 572.18 },
{ article: "501 84 40-64", sku: "501844064", description: "Saw chain H00 1/4\" 1.3 mm 64DL / 12\"", net_price: 213.04, rrp: 304.35, disc_pct: 30, quantity: 3, pdf_total: 639.13 },
{ article: "581 62 66-58", sku: "581626668", description: "Saw Chain X-CUT C85 Chisel 3/8\" 1.5mm 58DL / 18\"", net_price: 213.75, rrp: 305.36, disc_pct: 30, quantity: 6, pdf_total: 1282.51 },
{ article: "581 62 66-60", sku: "581626660", description: "Saw Chain X-CUT C85 Chisel 3/8\" 1.5mm 60DL / 16\"", net_price: 188.60, rrp: 269.43, disc_pct: 30, quantity: 2, pdf_total: 377.20 },
{ article: "967 63 90-02", sku: "967639002", description: "HUSQVARNA W50P INLET/OUTLET SIZE 50MM, ENGINE 196CC, DISCHARGE CAPACITY 28m³/h", net_price: 3213.91, rrp: 4017.39, disc_pct: 20, quantity: 2, pdf_total: 6427.82 },
// Image 2 — 8 rows
{ article: "970 48 83-01", sku: "970488301", description: "Husqvarna LC 151 LAWN MOWER", net_price: 5551.30, rrp: 6939.13, disc_pct: 20, quantity: 1, pdf_total: 5551.30 },
{ article: "587 80 85-30", sku: "587808530", description: "Two stroke oil, HP 20 litre", net_price: 1940.32, rrp: 2771.89, disc_pct: 30, quantity: 1, pdf_total: 1940.32 },
{ article: "531 14 78-13", sku: "531147813", description: "LANCE LANCE", net_price: 50.87, rrp: 78.26, disc_pct: 35, quantity: 1, pdf_total: 50.87 },
{ article: "599 34 85-70", sku: "599348570", description: "BLADE BLADE", net_price: 197.83, rrp: 304.35, disc_pct: 35, quantity: 4, pdf_total: 791.31 },
{ article: "525 26 91-02", sku: "00057-52.691.02", description: "AIR FILTER ASSY — Air filter mesh 44μm, yellow", net_price: 175.22, rrp: 269.57, disc_pct: 35, quantity: 1, pdf_total: 175.22 },
{ article: "503 23 51-08", sku: "62550-32.351.08", description: "SPARK PLUG — Spark Plug RCJ7Y", net_price: 33.91, rrp: 52.17, disc_pct: 35, quantity: 6, pdf_total: 203.46 },
{ article: "503 44 32-01", sku: "62550-34.432.01", description: "FUEL FILTER — Fuel filter Koaxial 35-50μm", net_price: 16.96, rrp: 26.09, disc_pct: 35, quantity: 6, pdf_total: 101.75 },
{ article: "581 62 66-84", sku: "581626684", description: "Saw Chain X-CUT C85 Chisel 3/8\" 1.5mm 84DL / 24\"", net_price: 264.05, rrp: 377.21, disc_pct: 30, quantity: 2, pdf_total: 528.09 },
];
const CART_DISPLAYED_TOTAL = 25369.86;
/* ============================================================================
* Math validation
* ========================================================================== */
function validateLines(lines: ParsedLine[]): { sum: number } {
let sum = 0;
for (const l of lines) {
// Verify the published net price = RRP × (1 − disc).
const expectedNet = Math.round(l.rrp * (1 - l.disc_pct / 100) * 100) / 100;
if (Math.abs(expectedNet - l.net_price) > 0.05) {
throw new Error(
`Line ${l.sku} net price mismatch: RRP ${l.rrp} × (1 − ${l.disc_pct}%) = ${expectedNet.toFixed(2)}, screenshot says ${l.net_price.toFixed(2)}`,
);
}
// Verify line total = qty × net price.
const expectedTotal = Math.round(l.quantity * l.net_price * 100) / 100;
if (Math.abs(expectedTotal - l.pdf_total) > 0.05) {
throw new Error(
`Line ${l.sku} total mismatch: ${l.quantity} × ${l.net_price} = ${expectedTotal.toFixed(2)}, screenshot says ${l.pdf_total.toFixed(2)}`,
);
}
sum += l.pdf_total;
}
sum = Math.round(sum * 100) / 100;
console.log(` Sum of 14 line totals: R ${sum.toFixed(2)}`);
console.log(` Supplier displays: R ${CART_DISPLAYED_TOTAL.toFixed(2)}`);
console.log(` Discrepancy: R ${(CART_DISPLAYED_TOTAL - sum).toFixed(2)}`);
return { sum };
}
/* ============================================================================
* PB utilities
* ========================================================================== */
async function ensureStockItem(
pb: PocketBase,
sku: string,
description: string,
supplierId: string,
): Promise<{ id: string; created: boolean }> {
const existing = await pb.collection("stock_items").getList(1, 1, {
filter: `sku = "${sku}"`,
fields: "id",
});
if (existing.items.length > 0) {
return { id: String(existing.items[0].id), created: false };
}
const created = await pb.collection("stock_items").create({
sku,
name: description,
description,
unit: "EA",
current_qty: 0,
min_qty: 0,
target_qty: 0,
is_active: true,
supplier: supplierId,
location: "",
notes: "",
});
return { id: String(created.id), created: true };
}
async function generateDraftNumber(pb: PocketBase): Promise<string> {
const now = new Date();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, "0");
const dd = String(now.getDate()).padStart(2, "0");
const prefix = `PO${yy}${mm}${dd}`;
const rows = await pb.collection("stock_purchases").getFullList({
filter: `purchase_number ~ "${prefix}"`,
fields: "purchase_number",
});
let max = 0;
for (const r of rows) {
const m = String(r.purchase_number ?? "").match(/^PO\d{6}(\d{2})STK$/);
if (m) {
const n = parseInt(m[1], 10);
if (Number.isFinite(n) && n > max) max = n;
}
}
const seq = String(max + 1).padStart(2, "0");
return `${prefix}${seq}STK`;
}
/* ============================================================================
* Main
* ========================================================================== */
async function main() {
console.log("=== 1. Validating math against cart screenshots ===");
const { sum } = validateLines(LINES);
console.log(`\n=== 2. Connecting to PocketBase (${PB_URL}) ===`);
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
console.log(" Authenticated as admin");
console.log("\n=== 3. Creating stock_items ===");
const itemIds: string[] = [];
for (const l of LINES) {
const { id, created } = await ensureStockItem(pb, l.sku, l.description, HUSQVARNA_SUPPLIER_ID);
console.log(` ${created ? "+" : "="} ${l.sku.padEnd(18)} → ${id} ${created ? "" : "(already existed)"}`);
itemIds.push(id);
}
console.log("\n=== 4. Creating stock_purchase draft ===");
const purchaseNumber = await generateDraftNumber(pb);
console.log(` purchase_number: ${purchaseNumber}`);
const notes =
`Loaded from Husqvarna SA "AUGUST STOCK" cart screenshots (2026-08-03 15:14). ` +
`14 line items. ` +
`Sum of per-line totals: R ${sum.toFixed(2)}; supplier cart footer shows R ${CART_DISPLAYED_TOTAL.toFixed(2)} ` +
`(discrepancy R ${(CART_DISPLAYED_TOTAL - sum).toFixed(2)} — likely other carts/items not shown in the two screenshots; needs operator reconciliation).`;
const header = await pb.collection("stock_purchases").create({
purchase_number: purchaseNumber,
supplier: HUSQVARNA_SUPPLIER_ID,
status: "draft",
notes,
email_error: "",
email_message_id: "",
email_payload: null,
});
const purchaseId = String(header.id);
console.log(` draft id: ${purchaseId}`);
console.log("\n=== 5. Creating stock_purchase_items ===");
for (let i = 0; i < LINES.length; i++) {
const l = LINES[i];
const line = await pb.collection("stock_purchase_items").create({
purchase: purchaseId,
stock_item: itemIds[i],
description: l.description,
quantity: l.quantity,
quantity_received: 0,
cost_price: l.net_price,
notes: `Husqvarna article ${l.article}, RRP ${l.rrp}, disc ${l.disc_pct}%`,
});
console.log(` + ${l.sku.padEnd(18)} qty=${String(l.quantity).padStart(2)} cost=R ${l.net_price.toFixed(2).padStart(8)} line=${line.id}`);
}
console.log("\n=== Done ===");
console.log(` Draft stock purchase: ${purchaseNumber} (id ${purchaseId})`);
console.log(` Lines: ${LINES.length}`);
console.log(` Total cost (post-discount, excl VAT): R ${sum.toFixed(2)}`);
console.log(` Supplier cart total (per screenshot): R ${CART_DISPLAYED_TOTAL.toFixed(2)} (diff R ${(CART_DISPLAYED_TOTAL - sum).toFixed(2)})`);
console.log(` Supplier: HUSQVARNA,.. (${HUSQVARNA_SUPPLIER_ID})`);
}
main().catch((err) => {
console.error("FATAL:", err);
process.exit(1);
});