194 lines
7.5 KiB
JavaScript
194 lines
7.5 KiB
JavaScript
/**
|
|
* 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)'}`,
|
|
);
|