221 lines
8.6 KiB
TypeScript
221 lines
8.6 KiB
TypeScript
/**
|
|
* Receipts for spreadsheets sent to the catalogue ingest service.
|
|
*
|
|
* This is OUR record, in Fiesta — not the ingest service's. The two are read
|
|
* together and neither is redundant:
|
|
*
|
|
* - **Fiesta** knows the batch id, which shop the sheet was for, who sent it,
|
|
* and whether the products reached that shop's shelf. None of which the
|
|
* ingest service has any concept of — it writes the shared global
|
|
* catalogue and has no tenant and no branch.
|
|
* - **The ingest service** knows what became of the drop, and is the only
|
|
* authority on that.
|
|
*
|
|
* Fiesta's status columns are a CACHE of the second, written by whichever
|
|
* browser last polled. They exist so a list of twenty receipts renders without
|
|
* twenty network calls to a host that spends minutes per batch; the live read
|
|
* is what any single receipt is judged by.
|
|
*
|
|
* ── Why the receipt has to exist at all ──────────────────────────────────────
|
|
*
|
|
* Three facts from the ingest service's own documentation, and any one of them
|
|
* would be enough:
|
|
*
|
|
* 1. **The batch id is the credential.** `GET /api/uploads/catalog/{id}` is
|
|
* anonymous by design — holding the id is the proof of having sent the
|
|
* drop. Handed out once, to one browser. Lose it and the result is
|
|
* unreadable by anyone, including whoever uploaded the file.
|
|
* 2. **An unreviewed drop is deleted after seven days.** Nothing runs on
|
|
* arrival; a drop waits for an admin to press Start. If nobody does, the
|
|
* evidence expires.
|
|
* 3. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
|
|
* the credential that sent them, production has no API keys configured at
|
|
* all, and the only account that could read it is a superuser over their
|
|
* entire application.
|
|
*/
|
|
|
|
import { api, WEB } from './client';
|
|
|
|
/**
|
|
* One upload, as Fiesta stores it.
|
|
*
|
|
* The two count groups are deliberately not merged. `inserted` and its
|
|
* neighbours are the ingest service's — products in the GLOBAL catalogue, which
|
|
* every merchant shares and which therefore carries no price and no stock.
|
|
* `shelvedcount` is ours: priced, on a branch's shelf, with opening stock
|
|
* recorded. A product can be in the first and not the second, and reporting it
|
|
* as "added" would tell a shopkeeper they can sell something nobody can buy.
|
|
*/
|
|
export interface UploadReceipt {
|
|
uploadid: number;
|
|
tenantid: number;
|
|
locationid: number;
|
|
categoryid: number;
|
|
|
|
/** The drop id. The only field here that cannot be reconstructed. */
|
|
batchid: string;
|
|
/** The run an admin released the drop into, once they have. */
|
|
runid: string;
|
|
|
|
filename: string;
|
|
/** The label their review inbox shows. */
|
|
sender: string;
|
|
|
|
uploadedby: number;
|
|
uploadedname: string;
|
|
/** Rows we parsed before sending — independent of anything the service says. */
|
|
rowcount: number;
|
|
/**
|
|
* The parsed sheet as JSON, or empty when it was never stored.
|
|
*
|
|
* Empty is the interesting case: it means the prices and opening stock are
|
|
* gone, and the only way to shelve this upload is for somebody to hand the
|
|
* file over again. Receipts written before this column existed are all in
|
|
* that state.
|
|
*/
|
|
sheetrows?: string;
|
|
|
|
/* ── Cached from the ingest service ──────────────────────────────────── */
|
|
laststatus: string;
|
|
inserted: number;
|
|
backfilled: number;
|
|
skipped: number;
|
|
rejected: number;
|
|
|
|
/* ── Ours ────────────────────────────────────────────────────────────── */
|
|
shelvedcount: number;
|
|
skippedcount: number;
|
|
shelvedat: string | null;
|
|
|
|
created: string;
|
|
updated: string;
|
|
|
|
/** Joined for display; a receipt outlives the page that made it. */
|
|
tenantname?: string;
|
|
locationname?: string;
|
|
}
|
|
|
|
export interface RecordUploadBody {
|
|
/**
|
|
* The parsed sheet as JSON — SKU, price and opening stock per row.
|
|
*
|
|
* Stored so the shelving step can run later, from the Uploads page, without
|
|
* the original file or the tab that sent it. The prices and opening stock
|
|
* exist nowhere else: the ingest service's catalogue is shared by every
|
|
* merchant and carries neither.
|
|
*/
|
|
sheetrows?: string;
|
|
tenantid: number;
|
|
locationid: number;
|
|
categoryid: number;
|
|
batchid: string;
|
|
filename: string;
|
|
sender: string;
|
|
uploadedby: number;
|
|
uploadedname: string;
|
|
rowcount: number;
|
|
laststatus?: string;
|
|
}
|
|
|
|
export interface UploadQuery {
|
|
/** 0 or omitted means every tenant — how a Nearle Admin sees the platform. */
|
|
tenantid?: number;
|
|
locationid?: number;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}
|
|
|
|
/**
|
|
* How long the ingest service keeps a drop nobody has acted on.
|
|
*
|
|
* `BATCH_RETENTION_DAYS` on their side. Worth showing rather than discovering:
|
|
* a drop that expires unreviewed leaves no trace at either end, and the only
|
|
* remedy — asking an admin to release it — has to happen before the deadline.
|
|
*/
|
|
export const DROP_RETENTION_DAYS = 7;
|
|
|
|
/** Days left before an unreviewed drop is deleted; null once it has run. */
|
|
export function daysUntilExpiry(receipt: UploadReceipt): number | null {
|
|
// Only a drop still sitting in the review inbox expires. Once released, the
|
|
// run is the record and retention no longer applies to it.
|
|
if (receipt.runid || receipt.laststatus !== 'pending') return null;
|
|
const created = Date.parse(receipt.created);
|
|
if (Number.isNaN(created)) return null;
|
|
const elapsedDays = (Date.now() - created) / 86_400_000;
|
|
return Math.max(0, Math.ceil(DROP_RETENTION_DAYS - elapsedDays));
|
|
}
|
|
|
|
/**
|
|
* The label the ingest service's admin sees in their review inbox.
|
|
*
|
|
* Their field is free text capped at 60 characters, and until now every upload
|
|
* from this console arrived as the same constant — so an admin deciding what to
|
|
* approve could not tell one merchant's sheet from another's.
|
|
*
|
|
* Safe to make specific precisely because we never send a credential. Their
|
|
* ownership filter matches `sender` EXACTLY, and a run an admin assembles from
|
|
* several drops carries a joined list ("alice, bob") — so a credentialed caller
|
|
* gets a 404 on a run containing their own file. We read anonymously, holding
|
|
* the id, which is what their documentation tells integrators to do.
|
|
*
|
|
* The tenant name is truncated rather than the whole label, so the branch and
|
|
* the person survive: "Ninhao The New Age Chinese Restaurant" is 36 characters
|
|
* on its own and would otherwise push everything identifying off the end.
|
|
*/
|
|
export function buildSender(parts: {
|
|
tenantname?: string;
|
|
locationname?: string;
|
|
username?: string;
|
|
}): string {
|
|
const tenant = (parts.tenantname ?? '').trim().slice(0, 24);
|
|
const label = [tenant, (parts.locationname ?? '').trim(), (parts.username ?? '').trim()]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
return (label || 'nearle-console').slice(0, 60);
|
|
}
|
|
|
|
export const uploadsApi = {
|
|
/**
|
|
* Store the receipt. Called the instant the drop is accepted, before polling.
|
|
*
|
|
* That timing is the whole point: it is the one moment the batch id is
|
|
* guaranteed to exist and guaranteed not to have been lost to a closed tab.
|
|
* Idempotent on `batchid` server-side, so a retry or a second tab is safe.
|
|
*/
|
|
record: (body: RecordUploadBody) => api.post<UploadReceipt>(`${WEB}/uploads/record`, body),
|
|
|
|
list: (query: UploadQuery = {}) =>
|
|
api.list<UploadReceipt>(`${WEB}/uploads/list`, {
|
|
tenantid: query.tenantid ?? 0,
|
|
locationid: query.locationid ?? 0,
|
|
pageno: query.pageno ?? 1,
|
|
pagesize: query.pagesize ?? 50,
|
|
}),
|
|
|
|
/** Cache what the ingest service last reported, so the next reader need not wait. */
|
|
updateStatus: (body: {
|
|
batchid: string;
|
|
laststatus: string;
|
|
runid?: string;
|
|
inserted?: number;
|
|
backfilled?: number;
|
|
skipped?: number;
|
|
rejected?: number;
|
|
}) => api.put<unknown>(`${WEB}/uploads/update`, body),
|
|
|
|
/**
|
|
* Supply the prices and opening stock for a receipt that has none.
|
|
*
|
|
* The rescue path. A receipt filed before the sheet was stored cannot be
|
|
* shelved from anywhere, because the ingest service holds a catalogue every
|
|
* merchant shares and it carries neither figure — so the file has to come
|
|
* back. Sent as JSON so the shelving can then run without it again.
|
|
*/
|
|
attachSheet: (body: { batchid: string; sheetrows: string }) =>
|
|
api.put<unknown>(`/uploads/sheet`, body),
|
|
|
|
/** Record the other half: priced, shelved and stocked at a branch. */
|
|
markShelved: (body: { batchid: string; shelved: number; skipped: number }) =>
|
|
api.put<unknown>(`${WEB}/uploads/shelved`, body),
|
|
};
|