From ffc66462fc1fdd0cbcedd528a3e191dca25952cb Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 31 Aug 2026 12:33:10 +0530 Subject: [PATCH] changes on agent --- controllers/catalogueUploadController.go | 132 ++++++++++++++++++ facade/container.go | 60 ++++---- main.go | 55 ++++++++ models/catalogueupload.go | 93 +++++++++++++ repositories/catalogueUploadRepository.go | 150 ++++++++++++++++++++ routes/routes.go | 1 + routes/uploadroutes.go | 33 +++++ services/catalogueUploadService.go | 56 ++++++++ services/catalogueUpload_test.go | 160 ++++++++++++++++++++++ 9 files changed, 715 insertions(+), 25 deletions(-) create mode 100644 controllers/catalogueUploadController.go create mode 100644 models/catalogueupload.go create mode 100644 repositories/catalogueUploadRepository.go create mode 100644 routes/uploadroutes.go create mode 100644 services/catalogueUploadService.go create mode 100644 services/catalogueUpload_test.go diff --git a/controllers/catalogueUploadController.go b/controllers/catalogueUploadController.go new file mode 100644 index 0000000..7b0e522 --- /dev/null +++ b/controllers/catalogueUploadController.go @@ -0,0 +1,132 @@ +package controllers + +import ( + "net/http" + "strconv" + "strings" + + "nearle/models" + "nearle/services" + + "github.com/gofiber/fiber/v2" +) + +type CatalogueUploadController struct { + service services.CatalogueUploadService +} + +func NewCatalogueUploadController(service services.CatalogueUploadService) *CatalogueUploadController { + return &CatalogueUploadController{service: service} +} + +// RecordUpload stores the receipt for a spreadsheet just accepted by the ingest +// service. +// +// Called the moment the drop is accepted, before any polling — that is the one +// instant the batch id is guaranteed to exist and guaranteed not to have been +// lost. Everything after it (the review wait, the run, the shelving) can be +// recovered from the id; the id itself cannot be recovered from anything. +func (ctl *CatalogueUploadController) RecordUpload(c *fiber.Ctx) error { + var input models.CatalogueUpload + if err := c.BodyParser(&input); err != nil { + return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false}) + } + + if strings.TrimSpace(input.Batchid) == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "message": "batchid is required — it is the only credential for reading the result back", + "status": false, + }) + } + if input.Tenantid <= 0 { + // A receipt with no tenant belongs to nobody and would be visible to + // every store login that asks for "all". Refused rather than stored + // unscoped. + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "message": "tenantid is required — a receipt has to belong to a merchant", + "status": false, + }) + } + + if err := ctl.service.Record(&input); err != nil { + return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false}) + } + return c.JSON(fiber.Map{"code": 200, "message": "Upload recorded", "status": true, "details": input}) +} + +// GetUploads lists receipts for whoever is asking. +// +// `tenantid=0` means every tenant and is how a Nearle Admin sees the platform. +// The console decides which to send from the session — the same rule the import +// itself follows — because a store login reading another merchant's receipts +// would see what that merchant stocks. +func (ctl *CatalogueUploadController) GetUploads(c *fiber.Ctx) error { + tenantID, _ := strconv.Atoi(c.Query("tenantid", "0")) + locationID, _ := strconv.Atoi(c.Query("locationid", "0")) + pageNo, _ := strconv.Atoi(c.Query("pageno", "1")) + pageSize, _ := strconv.Atoi(c.Query("pagesize", "50")) + + data, err := ctl.service.List(tenantID, locationID, pageNo, pageSize) + if err != nil { + return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false}) + } + // Never null. An empty list is a legitimate answer — a shop that has never + // uploaded — and a JSON null here makes every caller guard a case that is + // really just "none yet". + if data == nil { + data = []models.CatalogueUpload{} + } + return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data}) +} + +// UpdateUpload caches what the ingest service last reported. +// +// Written by the browser that is polling, because that is where the reading +// happens: the batch id is the credential and the read is anonymous, so the +// person waiting is already holding the answer. This endpoint only writes it +// down so the next person does not have to wait for it again. +func (ctl *CatalogueUploadController) UpdateUpload(c *fiber.Ctx) error { + var input models.CatalogueUpload + if err := c.BodyParser(&input); err != nil { + return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false}) + } + if strings.TrimSpace(input.Batchid) == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, "message": "batchid is required", "status": false, + }) + } + if err := ctl.service.UpdateStatus(&input); err != nil { + return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false}) + } + return c.JSON(fiber.Map{"code": 200, "message": "Upload updated", "status": true}) +} + +// MarkShelved records that the products reached a branch's shelf with a price +// and their opening stock. +// +// Separate from UpdateUpload on purpose. The ingest service confirms the global +// catalogue, which every merchant shares and which therefore carries no price +// and no stock — so "added" and "on sale here" are two different claims, made by +// two different systems, and a receipt that ran them together would report +// products as sellable that no customer can buy. +func (ctl *CatalogueUploadController) MarkShelved(c *fiber.Ctx) error { + var input struct { + Batchid string `json:"batchid"` + Shelved int `json:"shelved"` + Skipped int `json:"skipped"` + } + if err := c.BodyParser(&input); err != nil { + return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false}) + } + if strings.TrimSpace(input.Batchid) == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, "message": "batchid is required", "status": false, + }) + } + if err := ctl.service.MarkShelved(input.Batchid, input.Shelved, input.Skipped); err != nil { + return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false}) + } + return c.JSON(fiber.Map{"code": 200, "message": "Shelving recorded", "status": true}) +} diff --git a/facade/container.go b/facade/container.go index 0ceba4f..fba786e 100644 --- a/facade/container.go +++ b/facade/container.go @@ -9,18 +9,19 @@ import ( ) type Facade struct { - UserController *controllers.UserController - ProductController *controllers.ProductController - OrderController *controllers.OrderController - DeliveriesController *controllers.DeliveriesController - UtilsController *controllers.UtilsController - TenantController *controllers.TenantController - PartnerController *controllers.PartnerController - CustomerController *controllers.CustomerController - StockRequestController *controllers.StockRequestController - CatalogueController *controllers.CatalogueController - PosController *controllers.PosController - LiveController *controllers.LiveController + UserController *controllers.UserController + ProductController *controllers.ProductController + OrderController *controllers.OrderController + DeliveriesController *controllers.DeliveriesController + UtilsController *controllers.UtilsController + TenantController *controllers.TenantController + PartnerController *controllers.PartnerController + CustomerController *controllers.CustomerController + StockRequestController *controllers.StockRequestController + CatalogueController *controllers.CatalogueController + PosController *controllers.PosController + LiveController *controllers.LiveController + CatalogueUploadController *controllers.CatalogueUploadController // Held so the NATS consumer can reach the ingest without going through // HTTP. Unexported: everything else should use the controller. @@ -100,20 +101,29 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade { // subscribers. liveController := controllers.NewLiveController(posService) + // Catalogue Upload Module — our own receipt for every spreadsheet sent to + // the ingest service. Their host deletes an unreviewed drop after seven + // days and the batch id is the only credential for reading the result + // back, so the id has to be kept somewhere that outlives a browser tab. + catalogueUploadRepo := repositories.NewCatalogueUploadRepository(db) + catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo) + catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService) + return &Facade{ - UserController: userController, - ProductController: productController, - OrderController: orderController, - DeliveriesController: deliveriesController, - UtilsController: utilsController, - TenantController: tenantController, - PartnerController: partnerController, - CustomerController: customerController, - StockRequestController: stockRequestController, - CatalogueController: catalogueController, - PosController: posController, - LiveController: liveController, - posService: posService, + UserController: userController, + ProductController: productController, + OrderController: orderController, + DeliveriesController: deliveriesController, + UtilsController: utilsController, + TenantController: tenantController, + PartnerController: partnerController, + CustomerController: customerController, + StockRequestController: stockRequestController, + CatalogueController: catalogueController, + PosController: posController, + LiveController: liveController, + CatalogueUploadController: catalogueUploadController, + posService: posService, } } diff --git a/main.go b/main.go index 5e98d47..207440c 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,61 @@ func main() { log.Fatal("could not backfill productlocations.publishedat:", err) } + // Receipts for spreadsheets sent to the catalogue ingest service. + // + // The ingest service holds a drop on its own terms: an unreviewed one is + // deleted after seven days, the list of a sender's drops needs a credential + // production does not issue, and the batch id — which is the ONLY + // credential for reading a result back — is handed out once, to a browser. + // Lose it and the upload becomes unfindable even to the person who sent it. + // This table is where we keep it. + // + // Raw SQL rather than AutoMigrate, matching the two ALTERs above: GORM + // reconciling a model against a live table has rewritten more than was + // asked for here before, and `IF NOT EXISTS` makes this a no-op after the + // first boot. + if err := db.DB.Exec(` + CREATE TABLE IF NOT EXISTS catalogueuploads ( + uploadid SERIAL PRIMARY KEY, + tenantid INT NOT NULL, + locationid INT NOT NULL DEFAULT 0, + categoryid INT NOT NULL DEFAULT 0, + batchid VARCHAR(64) NOT NULL, + runid VARCHAR(64), + filename TEXT, + sender TEXT, + uploadedby INT NOT NULL DEFAULT 0, + uploadedname TEXT, + rowcount INT NOT NULL DEFAULT 0, + laststatus VARCHAR(32) NOT NULL DEFAULT 'pending', + inserted INT NOT NULL DEFAULT 0, + backfilled INT NOT NULL DEFAULT 0, + skipped INT NOT NULL DEFAULT 0, + rejected INT NOT NULL DEFAULT 0, + shelvedcount INT NOT NULL DEFAULT 0, + skippedcount INT NOT NULL DEFAULT 0, + shelvedat TIMESTAMP, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )`).Error; err != nil { + log.Fatal("could not create catalogueuploads:", err) + } + // The upload is recorded the instant the drop is accepted and then polled, + // so a refresh, a retried request or a second tab would each insert the same + // receipt again. The constraint is what makes `Record` idempotent — without + // it one upload shows up three times and none of them is wrong. + if err := db.DB.Exec( + `CREATE UNIQUE INDEX IF NOT EXISTS catalogueuploads_batchid_key + ON catalogueuploads (batchid)`).Error; err != nil { + log.Fatal("could not add catalogueuploads.batchid unique index:", err) + } + // Every read is "this shop's uploads, newest first". + if err := db.DB.Exec( + `CREATE INDEX IF NOT EXISTS catalogueuploads_scope_idx + ON catalogueuploads (tenantid, locationid, created DESC)`).Error; err != nil { + log.Println("⚠️ could not add catalogueuploads scope index:", err) + } + f := facade.NewFacade(db.DB, db.CatalogueDB) routes.RegisterRoutes(app, f) diff --git a/models/catalogueupload.go b/models/catalogueupload.go new file mode 100644 index 0000000..b28349d --- /dev/null +++ b/models/catalogueupload.go @@ -0,0 +1,93 @@ +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"` + + // ── 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" +} diff --git a/repositories/catalogueUploadRepository.go b/repositories/catalogueUploadRepository.go new file mode 100644 index 0000000..da8b569 --- /dev/null +++ b/repositories/catalogueUploadRepository.go @@ -0,0 +1,150 @@ +package repositories + +import ( + "errors" + "strings" + + "nearle/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type CatalogueUploadRepository interface { + Record(upload *models.CatalogueUpload) error + List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) + UpdateStatus(upload *models.CatalogueUpload) error + MarkShelved(batchID string, shelved, skipped int) error +} + +type catalogueUploadRepository struct { + db *gorm.DB +} + +func NewCatalogueUploadRepository(db *gorm.DB) CatalogueUploadRepository { + return &catalogueUploadRepository{db: db} +} + +// Record stores the receipt, or returns the one already stored. +// +// Idempotent on `batchid`, which matters more than it looks. The console writes +// this the instant the drop is accepted and then polls; a refresh, a retry after +// a dropped response, or two tabs watching the same upload would otherwise each +// insert a row, and the operator would see one upload three times. +// +// `DoNothing` rather than an update, because everything a second caller could +// supply here is the same data it supplied the first time. Progress arrives +// through UpdateStatus, which is the call that is allowed to overwrite. +func (r *catalogueUploadRepository) Record(upload *models.CatalogueUpload) error { + if strings.TrimSpace(upload.Batchid) == "" { + // Without the id the row is worthless: the id is the ONLY credential + // for reading the result back, and nothing else in the receipt can + // stand in for it. + return errors.New("batchid is required — it is the only way to read the result back") + } + if err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "batchid"}}, + DoNothing: true, + }).Create(upload).Error; err != nil { + return err + } + // A conflict leaves Uploadid zero and the caller with nothing to show, so + // the existing row is read back and returned as if it had just been made. + if upload.Uploadid == 0 { + return r.db.Where("batchid = ?", upload.Batchid).First(upload).Error + } + return nil +} + +// List returns receipts newest first, scoped to whoever is asking. +// +// A tenant of 0 means "every tenant" and is how a Nearle Admin sees the whole +// platform; a store login always sends its own. The scoping is deliberately the +// same shape as the import itself — see the note on ImportScope in the console — +// because a receipt that could be read by the wrong shop would leak what another +// merchant stocks. +// +// The two names are LEFT joined. A tenant or branch can be deleted after the +// upload, and a receipt that vanishes with it would take the audit trail with +// it; joined this way the row survives, just unnamed. +func (r *catalogueUploadRepository) List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) { + var uploads []models.CatalogueUpload + + query := r.db.Table("catalogueuploads"). + Select("catalogueuploads.*, tenants.tenantname, tenantlocations.locationname"). + Joins("left join tenants on tenants.tenantid = catalogueuploads.tenantid"). + Joins("left join tenantlocations on tenantlocations.locationid = catalogueuploads.locationid") + + if tenantID > 0 { + query = query.Where("catalogueuploads.tenantid = ?", tenantID) + } + if locationID > 0 { + query = query.Where("catalogueuploads.locationid = ?", locationID) + } + + if pageNo < 1 { + pageNo = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + + err := query. + Order("catalogueuploads.created DESC"). + Offset((pageNo - 1) * pageSize). + Limit(pageSize). + Find(&uploads).Error + return uploads, err +} + +// UpdateStatus caches what the ingest service last said about a drop. +// +// Only the fields the service owns are written, and they are written by column +// rather than by struct: a zero-valued struct field would otherwise be skipped +// by GORM's non-zero update, and "0 inserted" is a real answer that has to be +// storable. Everything the console owns — tenant, branch, filename, who sent it +// — is deliberately not touched, so a stale browser cannot rewrite the identity +// of a receipt while reporting progress on it. +func (r *catalogueUploadRepository) UpdateStatus(upload *models.CatalogueUpload) error { + if strings.TrimSpace(upload.Batchid) == "" { + return errors.New("batchid is required") + } + fields := map[string]any{ + "laststatus": upload.Laststatus, + "inserted": upload.Inserted, + "backfilled": upload.Backfilled, + "skipped": upload.Skipped, + "rejected": upload.Rejected, + } + // Never blanked. A drop reports `released_to` once and then the drop itself + // is spent; a later poll of the RUN carries no run id of its own, and + // writing that empty value back would lose the only pointer from the id we + // hold to the results. + if strings.TrimSpace(upload.Runid) != "" { + fields["runid"] = upload.Runid + } + return r.db.Model(&models.CatalogueUpload{}). + Where("batchid = ?", upload.Batchid). + Updates(fields).Error +} + +// MarkShelved records the half of the story the ingest service knows nothing +// about: that the products were priced, put on a branch's shelf and given their +// opening stock. +// +// Kept apart from UpdateStatus because the two answer different questions and +// fail independently. "The catalogue has them" and "this shop can sell them" +// are not the same claim, and a receipt that showed only the first would report +// success for products no customer can buy. +func (r *catalogueUploadRepository) MarkShelved(batchID string, shelved, skipped int) error { + if strings.TrimSpace(batchID) == "" { + return errors.New("batchid is required") + } + return r.db.Model(&models.CatalogueUpload{}). + Where("batchid = ?", batchID). + Updates(map[string]any{ + "shelvedcount": shelved, + "skippedcount": skipped, + "shelvedat": gorm.Expr("CURRENT_TIMESTAMP"), + }).Error +} diff --git a/routes/routes.go b/routes/routes.go index 18eda2b..688fe5c 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -20,4 +20,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) { RegisterCustomerRoutes(api, f) RegisterCatalogueRoutes(api, f) RegisterPosRoutes(api, f) + RegisterUploadRoutes(api, f) } diff --git a/routes/uploadroutes.go b/routes/uploadroutes.go new file mode 100644 index 0000000..c634942 --- /dev/null +++ b/routes/uploadroutes.go @@ -0,0 +1,33 @@ +package routes + +import ( + "nearle/facade" + + "github.com/gofiber/fiber/v2" +) + +// Receipts for spreadsheets sent to the catalogue ingest service. +// +// Web only, and that is not an oversight. These are a back-office audit trail — +// who uploaded what, for which shop, and whether it reached the shelf. The +// customer app has no use for any of it, and mounting them under /mob would +// publish one merchant's stocking activity to an endpoint built for shoppers. +func RegisterUploadRoutes(api fiber.Router, f *facade.Facade) { + + uploads := api.Group("/v1/web/uploads") + + // Written the instant a drop is accepted. The batch id is the only + // credential for reading the result back, and the ingest service deletes an + // unreviewed drop after seven days — so this is the call that keeps an + // upload findable at all. + uploads.Post("/record", f.CatalogueUploadController.RecordUpload) + uploads.Get("/list", f.CatalogueUploadController.GetUploads) + + // Progress, cached from whichever browser is polling the ingest service. + uploads.Put("/update", f.CatalogueUploadController.UpdateUpload) + + // The other half: priced, shelved and stocked at a branch. Kept apart from + // /update because the ingest service confirms the shared global catalogue + // and knows nothing about a shop's shelf. + uploads.Put("/shelved", f.CatalogueUploadController.MarkShelved) +} diff --git a/services/catalogueUploadService.go b/services/catalogueUploadService.go new file mode 100644 index 0000000..a90785f --- /dev/null +++ b/services/catalogueUploadService.go @@ -0,0 +1,56 @@ +package services + +import ( + "nearle/models" + "nearle/repositories" +) + +// CatalogueUploadService keeps the record of what was sent to the ingest +// service, for which shop, and what became of it. +// +// Deliberately thin. There is no business rule here yet and inventing one would +// be the wrong move: the ingest service is the authority on a drop's progress, +// and Fiesta's job is to remember the id long enough for somebody to ask it. +// +// In particular this service does NOT call the ingest service itself. It could, +// and it should not: their host is a single CPU-bound container that spends +// minutes per batch on image search, and pointing a Go backend's retry loop at +// it would turn our uptime into theirs. The browser already polls anonymously — +// the batch id is the credential — so the reading happens where the person +// waiting for it is, and Fiesta stores what that reading found. +type CatalogueUploadService interface { + Record(upload *models.CatalogueUpload) error + List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) + UpdateStatus(upload *models.CatalogueUpload) error + MarkShelved(batchID string, shelved, skipped int) error +} + +type catalogueUploadService struct { + repo repositories.CatalogueUploadRepository +} + +func NewCatalogueUploadService(repo repositories.CatalogueUploadRepository) CatalogueUploadService { + return &catalogueUploadService{repo: repo} +} + +func (s *catalogueUploadService) Record(upload *models.CatalogueUpload) error { + if upload.Laststatus == "" { + // The service's own first answer for an accepted drop. Not "queued": + // nothing runs on arrival, and a receipt that said queued would claim + // the pipeline had it when it is in fact waiting for a person. + upload.Laststatus = "pending" + } + return s.repo.Record(upload) +} + +func (s *catalogueUploadService) List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) { + return s.repo.List(tenantID, locationID, pageNo, pageSize) +} + +func (s *catalogueUploadService) UpdateStatus(upload *models.CatalogueUpload) error { + return s.repo.UpdateStatus(upload) +} + +func (s *catalogueUploadService) MarkShelved(batchID string, shelved, skipped int) error { + return s.repo.MarkShelved(batchID, shelved, skipped) +} diff --git a/services/catalogueUpload_test.go b/services/catalogueUpload_test.go new file mode 100644 index 0000000..95c79bc --- /dev/null +++ b/services/catalogueUpload_test.go @@ -0,0 +1,160 @@ +package services + +import ( + "errors" + "testing" + + "nearle/models" + "nearle/repositories" +) + +/* +What these defend. + +A receipt exists for one reason: the ingest service's batch id is the ONLY +credential for reading a result back, it is handed out exactly once, and an +unreviewed drop is deleted after seven days. So the failure that matters is not +a wrong count — it is a receipt that gets stored without the id, or a receipt +whose status is reported as something more finished than it is. + +`pending` in particular is not a formality. Nothing runs on arrival at the +ingest service; a drop waits for an admin to press Start. A receipt that +defaulted to `queued` would claim the pipeline had the file when it is in fact +waiting for a person, and the counts beside it — all zero, because nothing has +run — would read as an import that found nothing. +*/ + +// fakeUploadRepo records what reached the repository. Embedded rather than +// implemented: a method these tests do not exercise is nil and panics loudly if +// the service starts calling it, which is the failure worth having. +type fakeUploadRepo struct { + repositories.CatalogueUploadRepository + + recorded []models.CatalogueUpload + updated []models.CatalogueUpload + shelved []struct { + batchID string + shelved, skipped int + } + err error +} + +func (f *fakeUploadRepo) Record(upload *models.CatalogueUpload) error { + if f.err != nil { + return f.err + } + f.recorded = append(f.recorded, *upload) + return nil +} + +func (f *fakeUploadRepo) UpdateStatus(upload *models.CatalogueUpload) error { + if f.err != nil { + return f.err + } + f.updated = append(f.updated, *upload) + return nil +} + +func (f *fakeUploadRepo) MarkShelved(batchID string, shelved, skipped int) error { + if f.err != nil { + return f.err + } + f.shelved = append(f.shelved, struct { + batchID string + shelved, skipped int + }{batchID, shelved, skipped}) + return nil +} + +func (f *fakeUploadRepo) List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) { + return nil, f.err +} + +// A drop that has only just been accepted has not run, and the receipt has to +// say so. `queued` would be wrong in the specific way that matters: it means +// "approved, waiting for the worker", and this file is waiting for a person. +func TestRecordDefaultsToPendingNotQueued(t *testing.T) { + repo := &fakeUploadRepo{} + svc := NewCatalogueUploadService(repo) + + upload := &models.CatalogueUpload{Batchid: "abc123", Tenantid: 1135} + if err := svc.Record(upload); err != nil { + t.Fatalf("Record: %v", err) + } + + if len(repo.recorded) != 1 { + t.Fatalf("want 1 recorded receipt, got %d", len(repo.recorded)) + } + if got := repo.recorded[0].Laststatus; got != "pending" { + t.Errorf("a just-accepted drop should read pending, got %q", got) + } +} + +// A caller who already knows the status keeps it. The default is a fallback for +// a fresh drop, not an override — a receipt being re-recorded from a poll that +// already saw `running` must not be walked backwards. +func TestRecordKeepsAnExplicitStatus(t *testing.T) { + repo := &fakeUploadRepo{} + svc := NewCatalogueUploadService(repo) + + if err := svc.Record(&models.CatalogueUpload{ + Batchid: "abc123", Tenantid: 1135, Laststatus: "running", + }); err != nil { + t.Fatalf("Record: %v", err) + } + if got := repo.recorded[0].Laststatus; got != "running" { + t.Errorf("want the caller's own status preserved, got %q", got) + } +} + +// A repository failure is the caller's problem, not something to swallow. The +// receipt is the only durable copy of the batch id, so a write that did not +// happen must never look like one that did. +func TestRecordSurfacesRepositoryFailure(t *testing.T) { + boom := errors.New("unique violation") + svc := NewCatalogueUploadService(&fakeUploadRepo{err: boom}) + + err := svc.Record(&models.CatalogueUpload{Batchid: "abc123", Tenantid: 1135}) + if !errors.Is(err, boom) { + t.Fatalf("want the repository error surfaced, got %v", err) + } +} + +// Zero is a real answer. A run that inserted nothing because every product was +// already present is a complete success, and the counts have to be storable as +// zero rather than treated as "not supplied". +func TestUpdateStatusCarriesZeroCounts(t *testing.T) { + repo := &fakeUploadRepo{} + svc := NewCatalogueUploadService(repo) + + if err := svc.UpdateStatus(&models.CatalogueUpload{ + Batchid: "abc123", Laststatus: "done", Inserted: 0, Skipped: 12, + }); err != nil { + t.Fatalf("UpdateStatus: %v", err) + } + if len(repo.updated) != 1 { + t.Fatalf("want 1 update, got %d", len(repo.updated)) + } + if repo.updated[0].Inserted != 0 || repo.updated[0].Skipped != 12 { + t.Errorf("counts mangled: %+v", repo.updated[0]) + } +} + +// Shelving is a separate claim from ingestion and travels separately. The +// ingest service confirms the shared global catalogue, which holds no price and +// no stock; only this records that a shop can actually sell the products. +func TestMarkShelvedPassesBothCounts(t *testing.T) { + repo := &fakeUploadRepo{} + svc := NewCatalogueUploadService(repo) + + if err := svc.MarkShelved("abc123", 18, 2); err != nil { + t.Fatalf("MarkShelved: %v", err) + } + if len(repo.shelved) != 1 { + t.Fatalf("want 1 shelving record, got %d", len(repo.shelved)) + } + got := repo.shelved[0] + if got.batchID != "abc123" || got.shelved != 18 || got.skipped != 2 { + t.Errorf("want abc123/18/2, got %v", got) + } +}