changes with catalogue

This commit is contained in:
2026-08-28 11:57:41 +05:30
parent dd6a4771ed
commit 590e31e6a9
3 changed files with 446 additions and 1 deletions

View File

@@ -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",

197
scripts/appfix.mjs Normal file
View File

@@ -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);
}

246
scripts/appsweep.mjs Normal file
View File

@@ -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();
}
}