106 lines
4.1 KiB
TypeScript
106 lines
4.1 KiB
TypeScript
/**
|
|
* 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<ShelveResult> {
|
|
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<string, number>();
|
|
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,
|
|
};
|
|
}
|