changes on agent
This commit is contained in:
132
controllers/catalogueUploadController.go
Normal file
132
controllers/catalogueUploadController.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
@@ -9,18 +9,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Facade struct {
|
type Facade struct {
|
||||||
UserController *controllers.UserController
|
UserController *controllers.UserController
|
||||||
ProductController *controllers.ProductController
|
ProductController *controllers.ProductController
|
||||||
OrderController *controllers.OrderController
|
OrderController *controllers.OrderController
|
||||||
DeliveriesController *controllers.DeliveriesController
|
DeliveriesController *controllers.DeliveriesController
|
||||||
UtilsController *controllers.UtilsController
|
UtilsController *controllers.UtilsController
|
||||||
TenantController *controllers.TenantController
|
TenantController *controllers.TenantController
|
||||||
PartnerController *controllers.PartnerController
|
PartnerController *controllers.PartnerController
|
||||||
CustomerController *controllers.CustomerController
|
CustomerController *controllers.CustomerController
|
||||||
StockRequestController *controllers.StockRequestController
|
StockRequestController *controllers.StockRequestController
|
||||||
CatalogueController *controllers.CatalogueController
|
CatalogueController *controllers.CatalogueController
|
||||||
PosController *controllers.PosController
|
PosController *controllers.PosController
|
||||||
LiveController *controllers.LiveController
|
LiveController *controllers.LiveController
|
||||||
|
CatalogueUploadController *controllers.CatalogueUploadController
|
||||||
|
|
||||||
// Held so the NATS consumer can reach the ingest without going through
|
// Held so the NATS consumer can reach the ingest without going through
|
||||||
// HTTP. Unexported: everything else should use the controller.
|
// HTTP. Unexported: everything else should use the controller.
|
||||||
@@ -100,20 +101,29 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
// subscribers.
|
// subscribers.
|
||||||
liveController := controllers.NewLiveController(posService)
|
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{
|
return &Facade{
|
||||||
UserController: userController,
|
UserController: userController,
|
||||||
ProductController: productController,
|
ProductController: productController,
|
||||||
OrderController: orderController,
|
OrderController: orderController,
|
||||||
DeliveriesController: deliveriesController,
|
DeliveriesController: deliveriesController,
|
||||||
UtilsController: utilsController,
|
UtilsController: utilsController,
|
||||||
TenantController: tenantController,
|
TenantController: tenantController,
|
||||||
PartnerController: partnerController,
|
PartnerController: partnerController,
|
||||||
CustomerController: customerController,
|
CustomerController: customerController,
|
||||||
StockRequestController: stockRequestController,
|
StockRequestController: stockRequestController,
|
||||||
CatalogueController: catalogueController,
|
CatalogueController: catalogueController,
|
||||||
PosController: posController,
|
PosController: posController,
|
||||||
LiveController: liveController,
|
LiveController: liveController,
|
||||||
posService: posService,
|
CatalogueUploadController: catalogueUploadController,
|
||||||
|
posService: posService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
55
main.go
55
main.go
@@ -102,6 +102,61 @@ func main() {
|
|||||||
log.Fatal("could not backfill productlocations.publishedat:", err)
|
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)
|
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
||||||
|
|
||||||
routes.RegisterRoutes(app, f)
|
routes.RegisterRoutes(app, f)
|
||||||
|
|||||||
93
models/catalogueupload.go
Normal file
93
models/catalogueupload.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
150
repositories/catalogueUploadRepository.go
Normal file
150
repositories/catalogueUploadRepository.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -20,4 +20,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
|||||||
RegisterCustomerRoutes(api, f)
|
RegisterCustomerRoutes(api, f)
|
||||||
RegisterCatalogueRoutes(api, f)
|
RegisterCatalogueRoutes(api, f)
|
||||||
RegisterPosRoutes(api, f)
|
RegisterPosRoutes(api, f)
|
||||||
|
RegisterUploadRoutes(api, f)
|
||||||
}
|
}
|
||||||
|
|||||||
33
routes/uploadroutes.go
Normal file
33
routes/uploadroutes.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
56
services/catalogueUploadService.go
Normal file
56
services/catalogueUploadService.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
160
services/catalogueUpload_test.go
Normal file
160
services/catalogueUpload_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user