From 590e31e6a97e78189d4ffbb2c961fc20bcaed428 Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 28 Aug 2026 11:57:41 +0530 Subject: [PATCH] changes with catalogue --- package.json | 4 +- scripts/appfix.mjs | 197 ++++++++++++++++++++++++++++++++++ scripts/appsweep.mjs | 246 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 scripts/appfix.mjs create mode 100644 scripts/appsweep.mjs diff --git a/package.json b/package.json index 776c290..321d94f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "contract": "node scripts/contract.mjs", "db": "node scripts/db.mjs", "appgap": "node scripts/appgap.mjs", - "verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"" + "verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"", + "appsweep": "node scripts/appsweep.mjs", + "appfix": "node scripts/appfix.mjs" }, "dependencies": { "@astryxdesign/core": "^0.4.5", diff --git a/scripts/appfix.mjs b/scripts/appfix.mjs new file mode 100644 index 0000000..a530f0b --- /dev/null +++ b/scripts/appfix.mjs @@ -0,0 +1,197 @@ +/** + * Repairs the products the customer app cannot show. + * + * node scripts/appfix.mjs # dry run — prints, writes nothing + * node scripts/appfix.mjs --apply # performs the repair + * node scripts/appfix.mjs --apply --tenant 1135 + * + * `appsweep.mjs` finds them; this puts them right. Same detection, so the two + * cannot disagree about what is broken. + * + * ── What it does, and why it is shaped like this ───────────────────────────── + * + * The repair is "give the product a category", and for a long time there was no + * way to do it. `products/update` writes only `productlocations.status` despite + * its name, and `importcatalogueproduct` took an existing product down a branch + * that corrected the PRICE and left the category alone — so re-importing, the + * obvious fix, appeared to work and changed nothing. + * + * That branch now also calls `UpdateProductCategory` + * (`services/productService.go`), which makes re-import the repair path. This + * script drives it: for each orphan it re-sends the original import with a real + * `categoryid`. + * + * REQUIRES THE FIXED BACKEND. Against the currently deployed one every call + * returns 200 and nothing changes, which is exactly the failure that makes this + * bug expensive — so the script verifies each product afterwards and reports + * what actually moved rather than what it asked for. + * + * ── What it deliberately does not do ───────────────────────────────────────── + * + * It sends `quantity: 0` and `stocktype: "in"`, so no stock ledger entry is + * written — `CreateProductLocation` only records stock when quantity > 0. The + * products already have their stock and this must not add to it. + * + * It re-sends each product's EXISTING price, cost and tax, because the same + * call updates pricing. Sending zeros would wipe the prices while fixing the + * category. + */ + +const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app'; +const WEB = `${BASE}/live/api/v1/web`; + +const apply = process.argv.includes('--apply'); +const tenantArg = process.argv.indexOf('--tenant'); +const onlyTenant = tenantArg > -1 ? Number(process.argv[tenantArg + 1]) : null; + +async function get(path, params = {}) { + const query = new URLSearchParams( + Object.entries(params).filter(([, v]) => v !== undefined && v !== ''), + ); + const response = await fetch(`${WEB}${path}?${query}`, { headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`); + const payload = await response.json().catch(() => null); + return payload?.details ?? payload?.data ?? null; +} + +async function post(path, body) { + const response = await fetch(`${WEB}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => null); + return { ok: response.ok && payload?.status !== false, status: response.status, payload }; +} + +async function tenantsWithOrphans() { + const seen = new Map(); + for (let page = 1; page <= 20; page++) { + const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? []; + const list = Array.isArray(rows) ? rows : []; + for (const t of list) if (t?.tenantid) seen.set(t.tenantid, t); + if (list.length < 200) break; + } + const out = []; + for (const tenant of seen.values()) { + if (onlyTenant && tenant.tenantid !== onlyTenant) continue; + const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; + const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []); + const orphans = products.filter((p) => !p.categoryid); + if (orphans.length > 0) out.push({ tenant, orphans }); + } + return out; +} + +/** + * The category to file a product under. + * + * `gettenantcategories` is synthesised from the categories the tenant's own + * products already use, so it is the tenant's real answer rather than the + * master table's — which is unscoped and, for the tenants seen here, offered a + * category (1001) that the app does not browse. + * + * Refusing rather than guessing when the tenant has none: a wrong category is + * findable and fixable, but writing one at random across a live catalogue is + * not something a repair script should decide. + */ +async function categoryFor(tenantid) { + const rows = (await get('/products/gettenantcategories', { tenantid })) ?? []; + const usable = (Array.isArray(rows) ? rows : []).filter((r) => r?.categoryid > 0); + return usable[0]?.categoryid ?? null; +} + +const work = await tenantsWithOrphans(); + +if (work.length === 0) { + console.log('Nothing to repair — no product is missing a category.'); + process.exit(0); +} + +console.log(apply ? 'APPLYING repairs\n' : 'DRY RUN — nothing will be written. Pass --apply.\n'); + +let repaired = 0; +let unchanged = 0; +let refused = 0; + +for (const { tenant, orphans } of work) { + const categoryid = await categoryFor(tenant.tenantid); + console.log(`${tenant.tenantid} ${tenant.tenantname} — ${orphans.length} to repair`); + + if (!categoryid) { + console.log(' SKIPPED: this tenant has no category of its own to file into.\n'); + refused += orphans.length; + continue; + } + + for (const p of orphans) { + const label = `${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`; + + if (!p.productbrand || !p.catalogueid) { + // Not imported from the global catalogue, so brand+catalogueid cannot + // address it and re-import is not available. Says so rather than + // reporting a success it did not achieve. + console.log(` ${label} — SKIPPED: no catalogue reference to re-import from`); + refused++; + continue; + } + + if (!apply) { + console.log(` ${label} → categoryid ${categoryid}`); + continue; + } + + const result = await post('/products/importcatalogueproduct', [ + { + tenantid: tenant.tenantid, + // Any outlet the product already sits at; the upsert leaves the row + // alone and the category is written on `products`, which is tenant-wide. + locationid: p.locationid ?? p.applocationid ?? 0, + brand: p.productbrand, + catalogueid: p.catalogueid, + categoryid, + subcategoryid: p.subcategoryid ?? 0, + quantity: 0, + stocktype: 'in', + status: p.productstatus || 'Active', + retailprice: p.retailprice ?? 0, + productcost: p.productcost ?? 0, + taxpercent: p.taxpercent ?? 0, + }, + ]); + + if (!result.ok) { + console.log(` ${label} — FAILED: HTTP ${result.status} ${result.payload?.message ?? ''}`); + refused++; + continue; + } + + // Verified, not assumed. The whole reason this bug survived is that the + // call that was supposed to fix it returned success and did nothing. + const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; + const after = (Array.isArray(groups) ? groups : []) + .flatMap((g) => g?.products ?? []) + .find((x) => x.productid === p.productid); + + if (after?.categoryid > 0) { + console.log(` ${label} → categoryid ${after.categoryid} ✓`); + repaired++; + } else { + console.log( + ` ${label} — NO CHANGE: the call succeeded but the category is still 0.` + + ' The backend fix is not deployed.', + ); + unchanged++; + } + } + console.log(); +} + +console.log(`repaired ${repaired} · unchanged ${unchanged} · skipped ${refused}`); +if (unchanged > 0) { + console.log( + '\nProducts reported NO CHANGE need the backend fix deployed' + + ' (services/productService.go — re-import must call UpdateProductCategory).', + ); + process.exit(1); +} diff --git a/scripts/appsweep.mjs b/scripts/appsweep.mjs new file mode 100644 index 0000000..35b6ed5 --- /dev/null +++ b/scripts/appsweep.mjs @@ -0,0 +1,246 @@ +/** + * Every product on the platform the customer app cannot show, and why. + * + * node scripts/appsweep.mjs + * node scripts/appsweep.mjs --json > sweep.json + * + * `appgap.mjs` answers this for ONE outlet and is the tool to reach for when + * somebody reports a specific shop. This is the platform-wide version: it walks + * every tenant, in every approval state, and reports the products that are + * unreachable no matter what the app asks for. + * + * ── The rule it applies ────────────────────────────────────────────────────── + * + * Read off the backend rather than inferred from responses + * (`controllers/productController.go:431`, `repositories/productRepository.go:858`): + * + * 1. `GetProductsBySubcategory` REJECTS `categoryid = 0` with a 400 — + * "Valid categoryid is required". It is the first thing the controller + * does. + * 2. The query then filters `WHERE a.categoryid = ?` unconditionally. + * + * Together those mean a product stored with `categoryid = 0` is returned for NO + * request the app can make. Not "usually hidden" — unreachable. It is still + * listed by `getlocationproducts`, which does not filter on category, so the + * console shows it and the shop believes it is on sale. + * + * Nothing else in that query gates visibility: there is no filter on + * `approved`, `publishedat`, `productstatus`, or stock level. A product with + * zero stock still appears. So `categoryid = 0` is the whole defect, and this + * script looks for exactly it. + */ + +const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app'; +const WEB = `${BASE}/live/api/v1/web`; + +const asJson = process.argv.includes('--json'); + +/** Fiesta answers under `details` in most places and `data` in a few. */ +async function get(path, params = {}) { + const query = new URLSearchParams( + Object.entries(params).filter(([, v]) => v !== undefined && v !== ''), + ); + const response = await fetch(`${WEB}${path}?${query}`, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`); + const payload = await response.json().catch(() => null); + return payload?.details ?? payload?.data ?? null; +} + +/** Requests that never succeeded. A non-empty list invalidates the report. */ +const failures = []; + +/** + * Bounded concurrency, with retries, and failures that are never swallowed. + * + * The first version of this caught every error and substituted `null`. Run + * across 262 tenants at a concurrency of 8, enough requests were refused that + * it reported ONE affected tenant out of three known ones — and reported it as + * a clean result, with no indication anything had gone wrong. A sweep that + * fails silently is worse than no sweep: it is used to close the investigation. + * + * So: three attempts with a widening delay, and anything still failing is + * recorded and printed at the end as an explicit gap in coverage. + */ +async function mapLimit(items, limit, fn, label = 'request') { + const out = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + let lastError; + for (let attempt = 0; attempt < 3; attempt++) { + try { + out[i] = await fn(items[i], i); + lastError = null; + break; + } catch (cause) { + lastError = cause; + await new Promise((r) => setTimeout(r, 250 * (attempt + 1))); + } + } + if (lastError) { + out[i] = null; + failures.push(`${label}[${i}]: ${lastError?.message ?? lastError}`); + } + } + }), + ); + return out; +} + +/** + * Every tenant, from two sources because neither is complete on its own. + * + * `getalltenants` paginates and its `pageno` is 1-BASED — page 0 returns an + * empty list rather than the first page, the same off-by-one that + * `getlocationproducts` has. It carries 262 tenants where the status lists + * carry 142. + * + * `/tenants/search` is still needed alongside it: it branches on the word + * "pending" and queries `approved = 0` instead of a status + * (`tenantRepository.go:45-77`), which is the only way to learn that a tenant + * is unapproved. Suriya Store is one. + * + * Building from the status lists ALONE was the first version's other bug: R + * mart (1147) appears in none of the three, and a sweep that cannot see a + * tenant reports it as having nothing wrong. + */ +async function allTenants() { + const seen = new Map(); + + for (let page = 1; page <= 20; page++) { + const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? []; + const list = Array.isArray(rows) ? rows : []; + for (const t of list) if (t?.tenantid && !seen.has(t.tenantid)) seen.set(t.tenantid, { ...t }); + if (list.length < 200) break; + } + + for (const status of ['Active', 'pending', 'InActive']) { + const rows = (await get('/tenants/search', { status })) ?? []; + for (const t of Array.isArray(rows) ? rows : []) { + if (!t?.tenantid) continue; + const existing = seen.get(t.tenantid) ?? { ...t }; + seen.set(t.tenantid, { ...existing, approvalState: status }); + } + } + + return [...seen.values()]; +} + +const tenants = await allTenants(); +if (!asJson) console.error(`Scanning ${tenants.length} tenants…`); + +/* Pass one — the whole platform, one request per tenant. + `getallproducts` returns {tenant, products} groups, not a flat list. */ +const scanned = await mapLimit(tenants, 8, async (tenant) => { + const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; + const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []); + return { tenant, products, orphans: products.filter((p) => !p.categoryid) }; +}, "tenant-products"); + +const affected = scanned.filter((row) => row && row.orphans.length > 0); + +/* Pass two — only the tenants that failed, so the outlet detail costs nothing + on a clean platform. */ +const detailed = await mapLimit(affected, 6, async (row) => { + const locations = (await get('/tenants/gettenantlocations', { tenantid: row.tenant.tenantid })) ?? []; + const branches = await mapLimit(Array.isArray(locations) ? locations : [], 4, async (loc) => { + const shelved = (await get('/products/getlocationproducts', { + tenantid: row.tenant.tenantid, + locationid: loc.locationid, + pageno: 1, + pagesize: 500, + })) ?? []; + const list = Array.isArray(shelved) ? shelved : []; + const hidden = list.filter((p) => !p.categoryid); + return { + locationid: loc.locationid, + locationname: loc.locationname, + shelved: list.length, + hidden: hidden.length, + // The number that matters to a shopper: an outlet whose entire range is + // invisible looks like a closed shop, not like a partial catalogue. + visible: list.length - hidden.length, + }; + }); + return { ...row, branches: branches.filter(Boolean) }; +}, "tenant-branches"); + +/* Coverage is part of the result, not a footnote. A tenant whose products + never loaded is UNKNOWN, not clean, and the difference decides whether this + report can be used to say the platform is fixed. */ +const unreached = scanned.filter((row) => !row).length; + +if (asJson) { + console.log( + JSON.stringify( + { + scannedTenants: tenants.length, + affectedTenants: detailed.length, + orphanProducts: detailed.reduce((n, r) => n + r.orphans.length, 0), + tenants: detailed.map((r) => ({ + tenantid: r.tenant.tenantid, + tenantname: r.tenant.tenantname, + approvalState: r.tenant.approvalState, + totalProducts: r.products.length, + orphans: r.orphans.map((p) => ({ + productid: p.productid, + productname: p.productname, + productbrand: p.productbrand, + catalogueid: p.catalogueid, + retailprice: p.retailprice, + })), + branches: r.branches, + })), + }, + null, + 2, + ), + ); +} else { + const orphanCount = detailed.reduce((n, r) => n + r.orphans.length, 0); + const blindOutlets = detailed.flatMap((r) => + r.branches.filter((b) => b.shelved > 0 && b.visible === 0), + ); + + console.log(`\n${tenants.length} tenants scanned`); + console.log(`${detailed.length} affected`); + console.log(`${orphanCount} products with categoryid 0 — invisible in the app`); + console.log(`${blindOutlets.length} outlets stocked but showing NOTHING to shoppers\n`); + + if (unreached > 0 || failures.length > 0) { + console.log( + `!! ${unreached} tenants could not be read after 3 attempts — this report is INCOMPLETE +`, + ); + for (const f of failures.slice(0, 10)) console.log(` ${f}`); + if (failures.length > 10) console.log(` … ${failures.length - 10} more +`); + console.log(); + } + + for (const row of detailed.sort((a, b) => b.orphans.length - a.orphans.length)) { + const { tenant } = row; + console.log( + `${tenant.tenantid} ${tenant.tenantname}` + + ` — ${row.orphans.length}/${row.products.length} products hidden` + + (tenant.approvalState === 'pending' ? ' [unapproved]' : ''), + ); + for (const p of row.orphans) { + console.log(` ${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`); + } + for (const b of row.branches) { + if (b.shelved === 0) continue; + const flag = b.visible === 0 ? ' ← app shows an EMPTY shop' : ''; + console.log( + ` · ${String(b.locationname ?? b.locationid).padEnd(28)}` + + ` ${b.visible}/${b.shelved} visible${flag}`, + ); + } + console.log(); + } +}