/** * Putting an ingested batch on a branch's shelf. * * The half the catalogue ingest service cannot do, extracted so it has exactly * one implementation. Their pipeline writes the GLOBAL catalogue, which every * merchant shares and which therefore holds no price and no stock; the sheet * carries both. Joining them is what turns "the products exist" into "this shop * can sell them", and until it runs the products are invisible to shoppers. * * It lived inside the import panel, in a closure over that page's state. That * was wrong in the ordinary case rather than a rare one: a drop waits for the * ingest service's admin to release it and the run then takes minutes, so by the * time there is anything to shelve the tab that uploaded the file is usually * closed — and the step had nowhere else to run from. Now the Uploads page can * call it days later, from a receipt. */ import { catalogueApi } from '@/api/catalogue'; import { productsApi, type SheetProductRow } from '@/api/products'; import { productsOf, type IngestBatch } from '@/api/ingest'; import { buildImportRequests, planOpeningStock, type StockPlan } from './openingStock'; export interface ShelveTarget { tenantid: number; locationid: number; /** Applied to every row; no sheet column carries one any more. */ categoryid: number; } export interface ShelveResult { /** Products priced, shelved and given their opening stock. */ shelved: number; /** Products the run produced that the sheet could not price. */ skipped: number; /** Kept so the caller can say which ones, and why. */ plan: StockPlan; /** * Brands the catalogue could not be read for, if any. * * Named rather than counted, because the answer is always "ask about this * brand" and a number does not say which. */ failedBrands: string[]; } /** * Joins a finished run's manifest to the sheet, and writes the result. * * `filename` is not optional in spirit. A run an admin assembles from several * drops lists every file in it, so its manifest can carry other senders' * products — and the sheet's price and opening stock are applied to whatever the * manifest is matched against. Without narrowing, another merchant's product * sharing a name with one of our rows would be priced and stocked into THIS * merchant's branch. */ export async function shelveBatch( batch: IngestBatch, rows: readonly SheetProductRow[], target: ShelveTarget, filename?: string, ): Promise { const products = productsOf(batch, filename ? [filename] : undefined); const plan = planOpeningStock(products, rows); // One catalogue read per BRAND rather than per product. A 500-row sheet would // otherwise open 500 requests from a shop's connection. // // A brand that cannot be read does NOT fail the batch. It used to: a sheet of // twenty products naming one brand the catalogue could not resolve threw on // the first lookup and shelved nothing, so nineteen products the shop was // entitled to sell stayed unpriced because of the twentieth. Its products now // fall through to `unresolved`, which is already reported product by product, // and the brand is named so the cause is not left to guesswork. const brands = [...new Set(plan.matched.map((entry) => entry.product.brand))]; const catalogueIds = new Map(); const failedBrands: string[] = []; for (const brand of brands) { try { for (const [imageId, id] of await catalogueApi.idsByImageId(brand)) { catalogueIds.set(imageId, id); } } catch { failedBrands.push(brand); } } const { requests, unresolved, unpriced } = buildImportRequests(plan, { tenantid: target.tenantid, locationid: target.locationid, fallbackCategoryId: target.categoryid, catalogueIds, }); // One `importcatalogueproduct` call for the batch, not one per product: it // takes an array, and each element writes the product row, the outlet row and // the stock ledger entry together. if (requests.length > 0) await productsApi.importFromCatalogue(requests); return { shelved: requests.length, skipped: unresolved.length + unpriced.length, plan, failedBrands, }; }