diff --git a/img1_mid2.png b/img1_mid2.png new file mode 100644 index 0000000..fa9ce6d Binary files /dev/null and b/img1_mid2.png differ diff --git a/img1_skus_v.png b/img1_skus_v.png new file mode 100644 index 0000000..90799f5 Binary files /dev/null and b/img1_skus_v.png differ diff --git a/img2_skus_v.png b/img2_skus_v.png new file mode 100644 index 0000000..de49547 Binary files /dev/null and b/img2_skus_v.png differ diff --git a/scripts/2026-08-03-load-husqvarna-cart.ts b/scripts/2026-08-03-load-husqvarna-cart.ts new file mode 100644 index 0000000..4e14723 --- /dev/null +++ b/scripts/2026-08-03-load-husqvarna-cart.ts @@ -0,0 +1,253 @@ +/** + * 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 { + 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); +}); diff --git a/scripts/2026-08-03-load-yg1-quote-soq078608.ts b/scripts/2026-08-03-load-yg1-quote-soq078608.ts new file mode 100644 index 0000000..b878ee5 --- /dev/null +++ b/scripts/2026-08-03-load-yg1-quote-soq078608.ts @@ -0,0 +1,239 @@ +/** + * One-time loader — YG-1 quote SOQ078608 → stock_purchase draft. + * + * Source PDF: /Volumes/KIOXIA/Downloads/SOQ078608(CNL001)(YG-1 South Africa PTY LTD.)(2026-08-03).pdf + * Supplier: YG-1 (id `udpqcpsr9bfpfgx`) + * Customer: CNL001 (CNL Industrial Mining Supplies) — informational only; + * stock_purchases are NOT tied to a customer order. + * Quote date: 2026-08-03 + * Items: 16 line items (T7317249, TC187246, …, E2545140) + * Total: R 10 651.11 incl (R 9 261.84 excl, R 1 389.27 VAT) + * + * ## Line items + * + * Parsed from the PDF by eyeballing the column layout. The "SET 2" / + * "2SET" embed in the descriptions makes raw text extraction + * ambiguous about quantity (the "2" is both a literal "2-tap set" + * marker AND sometimes the quantity), so the quantities below were + * verified against the math: `qty × unit × (1 − disc) × 1.15 == total_incl`. + * The total also sums to the PDF's footer (R 9 261.84 excl). + * + * What this does: + * 1. Validates each line's math against the PDF totals. + * 2. Creates 16 new stock_items (none of these YG-1 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 16 stock_purchase_items with cost_price = post-discount + * per-unit price (this is what we'll actually pay, ignoring the + * supplier's discount). + * + * Re-run safety: the script re-checks PB for an existing stock_item + * with the same SKU before creating — if you re-run it after a partial + * fail, the existing item rows are reused (idempotent on stock_items). + * It does NOT dedupe against an existing draft; if you re-run after a + * successful run you'll get a SECOND draft. Don't re-run after success. + * + * Run: `npx tsx scripts/2026-08-03-load-yg1-quote-soq078608.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 YG1_SUPPLIER_ID = "udpqcpsr9bfpfgx"; + +/* ============================================================================ + * Parsed line items (verified by math, see header) + * ========================================================================== */ + +interface ParsedLine { + sku: string; + description: string; + quantity: number; + unit_price_ex: number; + disc_pct: number; + pdf_total_incl: number; + no_stock: boolean; +} + +const LINES: ParsedLine[] = [ + { sku: "T7317249", description: "HSS ISO529 HAND TAP SET 2 M4X0.7 6H 53.0L", quantity: 2, unit_price_ex: 551.04, disc_pct: 60, pdf_total_incl: 506.95, no_stock: false }, + { sku: "TC187246", description: "HSS-E ISO529 SPIRAL FLUTE TAP M4X0.7 6H 53.0L", quantity: 2, unit_price_ex: 271.31, disc_pct: 60, pdf_total_incl: 249.61, no_stock: false }, + { sku: "T7317319", description: "HSS ISO529 HAND TAP SET 2 M6X1.0 6H 66.0L", quantity: 2, unit_price_ex: 455.12, disc_pct: 60, pdf_total_incl: 418.72, no_stock: false }, + { sku: "TC804316", description: "HSS-E COMBO DIN371 SPIRAL FLUTE TAP M6X1.0 6H 80.0L", quantity: 2, unit_price_ex: 383.59, disc_pct: 60, pdf_total_incl: 352.90, no_stock: false }, + { sku: "TS2352902", description: "HSS ISO529 HAND TAP 2SET M8X1.0 6H 72.0L", quantity: 2, unit_price_ex: 465.18, disc_pct: 60, pdf_total_incl: 427.96, no_stock: false }, + { sku: "T7317369", description: "HSS ISO529 HAND TAP SET 2 M8X1.25 6H 72.0L", quantity: 2, unit_price_ex: 692.74, disc_pct: 60, pdf_total_incl: 637.32, no_stock: false }, + { sku: "T7317429", description: "HSS ISO529 HAND TAP SET 2 M10X1.5 6H 80.0L", quantity: 1, unit_price_ex: 802.20, disc_pct: 60, pdf_total_incl: 369.01, no_stock: false }, + { sku: "TS2352932", description: "HSS ISO529 HAND TAP 2SET M10X1.25 6H 80.0L", quantity: 1, unit_price_ex: 567.84, disc_pct: 60, pdf_total_incl: 261.21, no_stock: true }, + { sku: "T7317509", description: "HSS ISO529 HAND TAP SET 2 M12X1.75 6H 89.0L", quantity: 1, unit_price_ex: 1152.19, disc_pct: 60, pdf_total_incl: 530.01, no_stock: false }, + { sku: "TC844556", description: "HSS-E COMBO DIN374 SPIRAL FLUTE TAP M14X1.5 6H 100.0L", quantity: 1, unit_price_ex: 1100.35, disc_pct: 60, pdf_total_incl: 506.16, no_stock: false }, + { sku: "T7317709", description: "HSS ISO529 HAND TAP SET 2 M20X2.5 6H 112.0L", quantity: 2, unit_price_ex: 3104.01, disc_pct: 60, pdf_total_incl: 2855.69, no_stock: false }, + { sku: "TS1551712", description: "HSS ISO529 BSP PIPE HAND TAP 2SET G1/4-19 67.0L", quantity: 2, unit_price_ex: 1061.43, disc_pct: 60, pdf_total_incl: 976.51, no_stock: false }, + { sku: "D1205200", description: "HSS(M2) Jobber Taper Shank Drill Steam Homo DIN345 20XMT#2X140X238", quantity: 2, unit_price_ex: 1270.60, disc_pct: 70, pdf_total_incl: 876.71, no_stock: false }, + { sku: "E2545060", description: "HSSCo8(M42) 4FL 30deg. Helix Short E/M 6X6X16X60.5", quantity: 3, unit_price_ex: 265.21, disc_pct: 60, pdf_total_incl: 365.99, no_stock: false }, + { sku: "E2502080", description: "HSSCo8(M42) 2FL 30deg. Helix Short E/M 8X10X11X61", quantity: 4, unit_price_ex: 371.60, disc_pct: 60, pdf_total_incl: 683.74, no_stock: false }, + { sku: "E2545140", description: "HSSCo8(M42) 4FL 30deg. Helix Short E/M 14X12X28.5X73", quantity: 2, unit_price_ex: 687.63, disc_pct: 60, pdf_total_incl: 632.62, no_stock: false }, +]; + +const PDF_TOTALS = { excl: 9261.84, vat: 1389.27, incl: 10651.11 }; + +/* ============================================================================ + * Math validation + * ========================================================================== */ + +function validateLines(lines: ParsedLine[]): void { + let sumExcl = 0; + let sumVat = 0; + let sumIncl = 0; + for (const l of lines) { + const excl = l.quantity * l.unit_price_ex * (1 - l.disc_pct / 100); + const vat = excl * 0.15; + const incl = excl + vat; + sumExcl += excl; + sumVat += vat; + sumIncl += incl; + const expectedIncl = Math.round(l.quantity * l.unit_price_ex * (1 - l.disc_pct / 100) * 1.15 * 100) / 100; + const diff = Math.abs(expectedIncl - l.pdf_total_incl); + if (diff > 0.05) { + throw new Error( + `Line ${l.sku} math mismatch: expected total ${expectedIncl.toFixed(2)}, PDF says ${l.pdf_total_incl.toFixed(2)}, diff ${diff.toFixed(2)}`, + ); + } + } + sumExcl = Math.round(sumExcl * 100) / 100; + sumVat = Math.round(sumVat * 100) / 100; + sumIncl = Math.round(sumIncl * 100) / 100; + console.log(` Sum excl: R ${sumExcl.toFixed(2)} (PDF says ${PDF_TOTALS.excl.toFixed(2)})`); + console.log(` Sum vat: R ${sumVat.toFixed(2)} (PDF says ${PDF_TOTALS.vat.toFixed(2)})`); + console.log(` Sum incl: R ${sumIncl.toFixed(2)} (PDF says ${PDF_TOTALS.incl.toFixed(2)})`); + if ( + Math.abs(sumExcl - PDF_TOTALS.excl) > 0.05 || + Math.abs(sumVat - PDF_TOTALS.vat) > 0.05 || + Math.abs(sumIncl - PDF_TOTALS.incl) > 0.05 + ) { + throw new Error("Footer totals do not match parsed lines"); + } +} + +/* ============================================================================ + * 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: "SET", + 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 { + 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 PDF ==="); + 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, YG1_SUPPLIER_ID); + console.log(` ${created ? "+" : "="} ${l.sku.padEnd(10)} → ${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 header = await pb.collection("stock_purchases").create({ + purchase_number: purchaseNumber, + supplier: YG1_SUPPLIER_ID, + status: "draft", + notes: `Loaded from YG-1 quote SOQ078608 dated 2026-08-03 (customer CNL001). ${LINES.length} line items.`, + 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 costPrice = Math.round(l.unit_price_ex * (1 - l.disc_pct / 100) * 100) / 100; + 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: costPrice, + notes: l.no_stock ? "No stock (per YG-1 quote SOQ078608)" : "", + }); + const flag = l.no_stock ? " ← NO STOCK" : ""; + console.log(` + ${l.sku.padEnd(10)} qty=${String(l.quantity).padStart(2)} cost=R ${costPrice.toFixed(2).padStart(8)} line=${line.id}${flag}`); + } + + console.log("\n=== Done ==="); + const totalCost = LINES.reduce((s, l) => s + l.quantity * l.unit_price_ex * (1 - l.disc_pct / 100), 0); + console.log(` Draft stock purchase: ${purchaseNumber} (id ${purchaseId})`); + console.log(` Lines: ${LINES.length}`); + console.log(` Total cost (post-discount, excl VAT): R ${totalCost.toFixed(2)}`); + console.log(` Supplier: YG-1 (${YG1_SUPPLIER_ID})`); +} + +main().catch((err) => { + console.error("FATAL:", err); + process.exit(1); +}); diff --git a/scripts/2026-08-03-merge-yg1-drafts.ts b/scripts/2026-08-03-merge-yg1-drafts.ts new file mode 100644 index 0000000..bf4926b --- /dev/null +++ b/scripts/2026-08-03-merge-yg1-drafts.ts @@ -0,0 +1,132 @@ +/** + * Merge YG-1 drafts. + * + * PO26073102STK (existing draft, 4 HSS drill-bit lines, 2026-07-31) + * PO26080301STK (new draft, 16 tap lines, loaded 2026-08-03 from quote SOQ078608) + * + * Both are still in `draft` status (neither has been sent to the supplier), + * so the merge is purely local — the supplier hasn't seen either PO number. + * + * ## Direction + * + * Keep the older PO number (`PO26073102STK`) — it's the operator's + * existing accumulated draft per the system's "one draft per supplier" + * design (see `stock-quick-draft-service.ts`). The new draft was created + * by `2026-08-03-load-yg1-quote-soq078608.ts`; merging its lines back + * into the existing draft collapses the two into a single draft. + * + * ## What this does + * + * 1. Updates all 16 `stock_purchase_items` rows on `PO26080301STK` + * to point at `PO26073102STK` (just flip the `purchase` field). + * 2. Deletes the now-empty `PO26080301STK` header. + * 3. Updates `PO26073102STK`'s `notes` to record both quote sources. + * + * The line `id`s, `created` timestamps, `description`, `quantity`, + * `cost_price`, `notes` (including the "No stock" flag on TS2352932), + * and `stock_item` references are all preserved — this is a pure + * parent-pointer swap on the line rows. + * + * ## Preconditions + * + * - Both purchase ids exist and are in `status="draft"`. + * - Neither has been sent (`email_message_id` is empty, no `documents`). + * - The new draft has 16 lines, the old draft has 4. + * + * Run: `npx tsx scripts/2026-08-03-merge-yg1-drafts.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 EXISTING_DRAFT_ID = "2a6qo4nxb2ni4tm"; // PO26073102STK — keep this one +const NEW_DRAFT_ID = "cj0h9jdsas2s4gk"; // PO26080301STK — drain into the above + +async function main() { + const pb = new PocketBase(PB_URL); + await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + console.log("Authenticated as admin"); + + // ── 1. Pre-flight checks ───────────────────────────────────────── + console.log("\n=== Pre-flight ==="); + const existing = await pb.collection("stock_purchases").getOne(EXISTING_DRAFT_ID); + const newDraft = await pb.collection("stock_purchases").getOne(NEW_DRAFT_ID); + console.log(` PO26073102STK status=${existing.status} notes="${existing.notes}"`); + console.log(` PO26080301STK status=${newDraft.status} notes="${newDraft.notes}"`); + if (existing.status !== "draft" || newDraft.status !== "draft") { + throw new Error(`Refusing to merge: both drafts must be in 'draft' status (existing=${existing.status}, new=${newDraft.status})`); + } + if (existing.email_message_id || newDraft.email_message_id) { + throw new Error("Refusing to merge: one or both drafts already have an email_message_id (already sent?)"); + } + const existingLines = await pb.collection("stock_purchase_items").getList(1, 1, { + filter: `purchase = "${EXISTING_DRAFT_ID}"`, + }); + const newLines = await pb.collection("stock_purchase_items").getList(1, 1, { + filter: `purchase = "${NEW_DRAFT_ID}"`, + }); + console.log(` PO26073102STK lines: ${existingLines.totalItems}`); + console.log(` PO26080301STK lines: ${newLines.totalItems}`); + + // ── 2. Re-parent the new draft's lines ─────────────────────────── + console.log("\n=== Re-parenting lines ==="); + const newLinesList = await pb + .collection("stock_purchase_items") + .getFullList({ filter: `purchase = "${NEW_DRAFT_ID}"`, fields: "id,description,quantity" }); + for (const ln of newLinesList) { + await pb.collection("stock_purchase_items").update(ln.id, { + purchase: EXISTING_DRAFT_ID, + }); + console.log(` ${ln.id} → PO26073102STK (${ln.description.slice(0, 40)} qty=${ln.quantity})`); + } + + // ── 3. Delete the empty new draft ──────────────────────────────── + console.log("\n=== Deleting empty draft PO26080301STK ==="); + await pb.collection("stock_purchases").delete(NEW_DRAFT_ID); + console.log(` deleted ${NEW_DRAFT_ID}`); + + // ── 4. Update notes on the surviving draft ─────────────────────── + console.log("\n=== Updating notes on PO26073102STK ==="); + const mergedNotes = + "YG-1 stock draft. Sources: existing draft since 2026-07-31 (4 HSS drill-bit lines) + YG-1 quote SOQ078608 dated 2026-08-03 for customer CNL001 (16 line items, merged 2026-08-03)."; + await pb.collection("stock_purchases").update(EXISTING_DRAFT_ID, { + notes: mergedNotes, + }); + console.log(` set notes: "${mergedNotes}"`); + + // ── 5. Verify ──────────────────────────────────────────────────── + console.log("\n=== Post-merge verification ==="); + const finalDraft = await pb.collection("stock_purchases").getOne(EXISTING_DRAFT_ID, { + expand: "supplier", + }); + const finalLines = await pb.collection("stock_purchase_items").getList(1, 1, { + filter: `purchase = "${EXISTING_DRAFT_ID}"`, + }); + console.log(` Purchase: ${finalDraft.purchase_number} (${finalDraft.expand?.supplier?.suppliername})`); + console.log(` Status: ${finalDraft.status}`); + console.log(` Lines: ${finalLines.totalItems}`); + const totalCost = await pb + .collection("stock_purchase_items") + .getFullList({ filter: `purchase = "${EXISTING_DRAFT_ID}"` }); + const total = totalCost.reduce((s, l) => s + l.quantity * l.cost_price, 0); + console.log(` Total cost (post-discount, excl VAT): R ${total.toFixed(2)}`); + + // Confirm the new-draft id is gone. + try { + await pb.collection("stock_purchases").getOne(NEW_DRAFT_ID); + throw new Error("New draft still exists after delete!"); + } catch (err) { + if (err instanceof Error && err.message.includes("not found")) { + console.log(` PO26080301STK (${NEW_DRAFT_ID}): deleted ✓`); + } else { + throw err; + } + } +} + +main().catch((err) => { + console.error("FATAL:", err); + process.exit(1); +}); diff --git a/stock-orders-after.png b/stock-orders-after.png new file mode 100644 index 0000000..c1eaccf Binary files /dev/null and b/stock-orders-after.png differ