263 lines
11 KiB
JavaScript
263 lines
11 KiB
JavaScript
/**
|
|
* 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) continue;
|
|
|
|
/**
|
|
* Where each orphan already sits.
|
|
*
|
|
* `getallproducts` is tenant-wide and carries NO locationid — the first
|
|
* version of this read `p.locationid` off it, got undefined, sent 0, and
|
|
* every repair came back "missing required field(s): locationid". Nothing
|
|
* was written, which is the one good thing about that failure.
|
|
*
|
|
* The outlet matters beyond passing validation. Import writes a
|
|
* productlocations row, so naming an outlet the product is NOT on would put
|
|
* it on that shelf — silently extending the product's reach as a side
|
|
* effect of a repair. Only an outlet where it is already stocked is safe:
|
|
* there the upsert lands on the existing row.
|
|
*/
|
|
const locations = (await get('/tenants/gettenantlocations', { tenantid: tenant.tenantid })) ?? [];
|
|
const placement = new Map();
|
|
for (const loc of Array.isArray(locations) ? locations : []) {
|
|
const rows = (await get('/products/getlocationproducts', {
|
|
tenantid: tenant.tenantid,
|
|
locationid: loc.locationid,
|
|
pageno: 1,
|
|
pagesize: 500,
|
|
})) ?? [];
|
|
for (const row of Array.isArray(rows) ? rows : []) {
|
|
if (!placement.has(row.productid)) placement.set(row.productid, { loc, row });
|
|
}
|
|
}
|
|
|
|
out.push({ tenant, orphans, placement });
|
|
}
|
|
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, placement } 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;
|
|
}
|
|
|
|
const at = placement.get(p.productid);
|
|
if (!at) {
|
|
console.log(` ${label} — SKIPPED: not stocked at any outlet, so there is no safe row to repair through`);
|
|
refused++;
|
|
continue;
|
|
}
|
|
|
|
/**
|
|
* A product with no price must not be made visible.
|
|
*
|
|
* Repairing the category is what puts a product in front of shoppers, and
|
|
* the app has no price floor — `GetProducts` filters on category and outlet
|
|
* and nothing else. Fixing a product priced at 0 would not "restore" it; it
|
|
* would publish a free one. Idhayam Sesame Oil 500ml (7083) is in exactly
|
|
* this state, priced nowhere, and it wants a price before it wants a
|
|
* category.
|
|
*/
|
|
const price = Number(p.retailprice ?? 0);
|
|
if (!(price > 0)) {
|
|
console.log(` ${label} — SKIPPED: no price set. Repairing this would list it at ₹0. Price it first.`);
|
|
refused++;
|
|
continue;
|
|
}
|
|
|
|
if (!apply) {
|
|
console.log(
|
|
` ${label} → categoryid ${categoryid} (via ${at.loc.locationname ?? at.loc.locationid}, price ₹${price} unchanged)`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const result = await post('/products/importcatalogueproduct', [
|
|
{
|
|
tenantid: tenant.tenantid,
|
|
// An outlet the product ALREADY sits at, so the upsert lands on the
|
|
// existing row instead of putting it on a new shelf. The category
|
|
// itself is written to `products`, which is tenant-wide, so one call
|
|
// fixes the product everywhere.
|
|
locationid: at.loc.locationid,
|
|
brand: p.productbrand,
|
|
catalogueid: p.catalogueid,
|
|
categoryid,
|
|
subcategoryid: p.subcategoryid ?? 0,
|
|
// Zero, so CreateProductLocation writes no stock ledger entry — it only
|
|
// records stock when quantity > 0. These products already have their
|
|
// stock and a repair must not add to it.
|
|
quantity: 0,
|
|
stocktype: 'in',
|
|
status: at.row.productstatus || p.productstatus || 'Active',
|
|
// The product's OWN current values, re-sent unchanged. The same call
|
|
// updates pricing, and the location upsert sets productlocations.price
|
|
// from retailprice — verified equal for every product being repaired
|
|
// here, so this round-trips rather than overwriting an outlet price.
|
|
retailprice: price,
|
|
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);
|
|
}
|