- 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>
253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|