package models import "time" // CatalogueUpload is our own receipt for a spreadsheet sent to the ingest // service — the record that the upload happened, who sent it, and for which // shop. // // It exists because nothing else keeps one. The ingest service holds the drop, // but on its terms and not ours: // // 1. **A drop nobody acts on is deleted after seven days** // (`BATCH_RETENTION_DAYS`). If an admin never presses Start, the only proof // the upload ever happened disappears — including from the sender. // 2. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to // the credential that sent them, production has no API keys configured, and // the one admin account is a superuser over their whole application. So the // list is not available to us and should not be. // 3. **The batch id is the credential.** `GET /api/uploads/catalog/{batch_id}` // is anonymous by design — holding the id is the proof of having sent the // drop. Which means whoever holds the id can read the result, and nobody // else can. Losing the id loses the result permanently. // // So the id is the thing worth keeping, and this row is where we keep it. Every // status field beside it is a CACHE of what the ingest service last told us, // written by whichever browser was polling. It is never the authority — the // service is — and it exists so a receipt reads sensibly before anyone re-polls // it. // // The tenant and branch are ours alone. The ingest service writes the GLOBAL // catalogue and has no concept of either, so "which shop was this for" is a // question only this row can answer. type CatalogueUpload struct { Uploadid int `json:"uploadid" gorm:"primaryKey;autoIncrement;column:uploadid"` // Who the sheet was for. The ingest service knows neither. Tenantid int `json:"tenantid" gorm:"column:tenantid"` Locationid int `json:"locationid" gorm:"column:locationid"` // The category every row was filed under, chosen at upload time. Worth // keeping: a product filed outside the category the app browses is // invisible to shoppers, and this is the only record of what was chosen. Categoryid int `json:"categoryid" gorm:"column:categoryid"` // The drop id, and the only thing here that cannot be reconstructed. Batchid string `json:"batchid" gorm:"column:batchid"` // The run an admin released the drop into, once they have. Cached from // `released_to` so a receipt can be followed without re-walking the drop. Runid string `json:"runid" gorm:"column:runid"` Filename string `json:"filename" gorm:"column:filename"` // The label the ingest inbox shows their admin. Stored so we can tell, // afterwards, what they were looking at when they approved it. Sender string `json:"sender" gorm:"column:sender"` Uploadedby int `json:"uploadedby" gorm:"column:uploadedby"` Uploadedname string `json:"uploadedname" gorm:"column:uploadedname"` // Rows we parsed in the browser, before sending. Independent of anything // the service reports, so a drop that never runs still says how big it was. Rowcount int `json:"rowcount" gorm:"column:rowcount"` // The sheet itself, as the console parsed it: SKU, price and opening stock // per row, as JSON. // // Stored because the ingest service cannot hold it and nothing else can. // Their pipeline writes the GLOBAL catalogue, which every merchant shares // and which therefore carries no price and no stock; both live only in the // sheet. Shelving is the step that joins the two, and it used to be possible // only in the browser tab that did the upload, because that tab was the only // place the parsed rows existed. // // That failed in the ordinary case rather than a rare one. A drop waits for // their admin to release it and the run then takes minutes, so by the time // there is anything to shelve the tab is usually gone — and the products sit // in the global catalogue, unpriced and unstocked, with no way left to // finish. Keeping the rows here is what lets the Uploads page complete it // days later. Sheetrows string `json:"sheetrows" gorm:"column:sheetrows;type:jsonb"` // ── Cached from the ingest service, by whoever last polled ────────────── Laststatus string `json:"laststatus" gorm:"column:laststatus;default:pending"` // Their counts, so a settled receipt reads correctly with no network call. Inserted int `json:"inserted" gorm:"column:inserted"` Backfilled int `json:"backfilled" gorm:"column:backfilled"` Skipped int `json:"skipped" gorm:"column:skipped"` Rejected int `json:"rejected" gorm:"column:rejected"` // ── Ours: the half the ingest service cannot do ──────────────────────── // // Their pipeline writes the global catalogue, which every merchant shares // and which therefore holds no price and no stock. Shelving is what turns // "the product exists" into "this shop can sell it", and it is a separate // action that can be left undone — so it is recorded separately. Shelvedcount int `json:"shelvedcount" gorm:"column:shelvedcount"` Skippedcount int `json:"skippedcount" gorm:"column:skippedcount"` Shelvedat *time.Time `json:"shelvedat" gorm:"column:shelvedat"` Created time.Time `json:"created" gorm:"column:created;autoCreateTime"` Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"` // Joined for display, never stored. A receipt outlives the page that made // it, so it has to be able to name its own shop. Tenantname string `json:"tenantname" gorm:"->;column:tenantname"` Locationname string `json:"locationname" gorm:"->;column:locationname"` } func (CatalogueUpload) TableName() string { return "catalogueuploads" }