initial commit
This commit is contained in:
193
scripts/appgap.mjs
Normal file
193
scripts/appgap.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Why the customer app shows fewer products than the console does.
|
||||
*
|
||||
* node scripts/appgap.mjs <tenantid> <locationid>
|
||||
* npm run appgap 1135 1166
|
||||
*
|
||||
* Straight at Fiesta — no Hasura, no admin secret, nothing to configure. The
|
||||
* first version of this went at the database through Hasura, which was the
|
||||
* wrong instrument twice over: it needed a secret, it 404'd on an admin API
|
||||
* that is often disabled, and it answered a question about the DATABASE when
|
||||
* the question is about what the API returns. This calls the very endpoint the
|
||||
* app calls and compares it with what the console can see.
|
||||
*
|
||||
* ── The three filters ────────────────────────────────────────────────────────
|
||||
*
|
||||
* `getproductsbysubcategory` drops a product for one of three reasons, and none
|
||||
* of them is an error, logged or visible from either end:
|
||||
*
|
||||
* A. `WHERE a.categoryid = 2` — not optional (`productRepository.go:865`).
|
||||
* A product in another category cannot appear, whatever else is true.
|
||||
*
|
||||
* B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. No row
|
||||
* for this outlet means the join yields NULL and the WHERE drops it. Being
|
||||
* in the catalogue is not the same as being on a shelf.
|
||||
*
|
||||
* C. The grouping in `GetProductsBySubcategory` collects products under each
|
||||
* real subcategory of category 2, then sweeps up `subcategoryid = 0` as
|
||||
* "Uncategorized". A subcategoryid that is non-zero and NOT a subcategory
|
||||
* of 2 matches neither and vanishes — present in the SQL, absent from the
|
||||
* JSON.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Through the console's own host, not Fiesta directly.
|
||||
*
|
||||
* `app.nearledaily.com/fiesta/...` is the nginx proxy the deployed console
|
||||
* already uses, so this script exercises exactly the path the browser takes —
|
||||
* if the proxy is misconfigured this finds out, where hitting fiesta.nearle.app
|
||||
* would quietly work and prove nothing about production.
|
||||
*
|
||||
* Override with FIESTA_URL to point at the backend directly or at a dev server.
|
||||
*/
|
||||
const BASE = process.env.FIESTA_URL ?? 'https://app.nearledaily.com/fiesta';
|
||||
const WEB = `${BASE}/live/api/v1/web`;
|
||||
const MOB = `${BASE}/live/api/v1/mob`;
|
||||
|
||||
const [, , tenantArg, locationArg] = process.argv;
|
||||
const tenantid = Number(tenantArg);
|
||||
const locationid = Number(locationArg);
|
||||
|
||||
if (!tenantid || !locationid) {
|
||||
console.error('Usage: node scripts/appgap.mjs <tenantid> <locationid>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Fiesta answers under `details` in most places and `data` in a few. */
|
||||
async function get(url, params) {
|
||||
const query = new URLSearchParams(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== ''),
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${url}?${query}`, { headers: { Accept: 'application/json' } });
|
||||
} catch (cause) {
|
||||
console.error(`Could not reach ${BASE} — ${cause.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!payload) {
|
||||
console.error(`Malformed response from ${url} (HTTP ${response.status})`);
|
||||
process.exit(1);
|
||||
}
|
||||
return payload.details ?? payload.data ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every product the tenant owns.
|
||||
*
|
||||
* `getallproducts` answers `[]models.Tenantproducts` — `{tenant, products}`
|
||||
* groups, not a flat list — and under `data` rather than `details`.
|
||||
*/
|
||||
async function allProducts() {
|
||||
const groups = await get(`${WEB}/products/getallproducts`, { tenantid });
|
||||
if (!Array.isArray(groups)) return [];
|
||||
return groups.flatMap((group) => group?.products ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is actually listed at this outlet.
|
||||
*
|
||||
* Paged, and the page size matters: the default is 50, so a shop with 200
|
||||
* products would look like one with 50 and every product past the first page
|
||||
* would be miscounted as "not listed". Walked until a short page comes back.
|
||||
*/
|
||||
async function locationProducts() {
|
||||
const rows = [];
|
||||
const pagesize = 200;
|
||||
for (let pageno = 1; pageno <= 50; pageno += 1) {
|
||||
const page = await get(`${WEB}/products/getlocationproducts`, {
|
||||
tenantid,
|
||||
locationid,
|
||||
pageno,
|
||||
pagesize,
|
||||
});
|
||||
const batch = Array.isArray(page) ? page : [];
|
||||
rows.push(...batch);
|
||||
if (batch.length < pagesize) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Exactly what the app asks for, so the comparison is against reality. */
|
||||
async function appView() {
|
||||
const payload = await get(`${MOB}/products/getproductsbysubcategory`, {
|
||||
categoryid: 2,
|
||||
tenantid,
|
||||
locationid,
|
||||
});
|
||||
const details = payload?.details ?? (Array.isArray(payload) ? payload : []);
|
||||
return Array.isArray(details) ? details : [];
|
||||
}
|
||||
|
||||
const [products, listedRows, groups, subcategories] = await Promise.all([
|
||||
allProducts(),
|
||||
locationProducts(),
|
||||
appView(),
|
||||
get(`${WEB}/products/getproductsubcategories`, { tenantid, categoryid: 2 }),
|
||||
]);
|
||||
|
||||
if (products.length === 0) {
|
||||
console.log(`Tenant ${tenantid} has no products at all — nothing for the app to show.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const listed = new Set(listedRows.map((row) => row.productid));
|
||||
/**
|
||||
* `subcatid`/`subcatname`, not `subcategoryid`/`subcategoryname`.
|
||||
*
|
||||
* This read the long names — the ones `getproductsubcategories` does NOT send —
|
||||
* so every entry was `undefined → undefined`, the map collapsed to a single
|
||||
* junk key, and check C below could never match. The footer duly announced
|
||||
* "(none returned)" for tenant 1147, whose category 2 has six subcategories.
|
||||
* A diagnostic that is confidently wrong is worse than one that is missing.
|
||||
*/
|
||||
const realSubs = new Map(
|
||||
(Array.isArray(subcategories) ? subcategories : []).map((row) => [row.subcatid, row.subcatname]),
|
||||
);
|
||||
|
||||
const inApp = new Set();
|
||||
for (const group of groups) {
|
||||
for (const product of group.products ?? []) inApp.add(product.productid);
|
||||
}
|
||||
|
||||
const buckets = new Map();
|
||||
const examples = new Map();
|
||||
|
||||
for (const product of products) {
|
||||
let reason;
|
||||
if (inApp.has(product.productid)) {
|
||||
reason = 'OK — the app shows this';
|
||||
} else if (product.categoryid !== 2) {
|
||||
reason = `A — categoryid is ${product.categoryid}, the app only asks for 2`;
|
||||
} else if (!listed.has(product.productid)) {
|
||||
reason = 'B — not listed at this outlet (no productlocations row)';
|
||||
} else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) {
|
||||
reason = `C — subcategoryid ${product.subcategoryid} is not a subcategory of 2, so it is dropped`;
|
||||
} else {
|
||||
// Everything checks out and it still is not there. Worth its own bucket
|
||||
// rather than being folded into one of the above: a wrong guess here would
|
||||
// send someone fixing data that is already correct.
|
||||
reason = '? — passes all three checks but the app still does not return it';
|
||||
}
|
||||
const key = reason.replace(/\d+/g, 'N');
|
||||
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||
if (!examples.has(key)) examples.set(key, { product, reason });
|
||||
}
|
||||
|
||||
console.log(`Tenant ${tenantid}, outlet ${locationid}`);
|
||||
console.log(` ${products.length} products in the catalogue`);
|
||||
console.log(` ${listed.size} listed at this outlet`);
|
||||
console.log(` ${inApp.size} returned by the app's endpoint\n`);
|
||||
|
||||
for (const [key, count] of [...buckets.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
const { product, reason } = examples.get(key);
|
||||
console.log(` ${String(count).padStart(5)} ${reason}`);
|
||||
console.log(
|
||||
` e.g. "${product.productname}" — id ${product.productid}, category ${product.categoryid}, subcategory ${product.subcategoryid}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none returned)'}`,
|
||||
);
|
||||
Reference in New Issue
Block a user