fix in the product import
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -691,3 +692,64 @@ func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
||||
"status": true,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishProduct releases a product from the admin catalogue to every outlet.
|
||||
//
|
||||
// The two rules this enforces — a price is required, and publishing covers the
|
||||
// whole tenant — live in the repository, so they hold for any caller rather
|
||||
// than only for the console form that happens to ask nicely.
|
||||
func (ctl *ProductController) PublishProduct(c *fiber.Ctx) error {
|
||||
var input struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Productid int `json:"productid"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
outlets, err := ctl.productService.PublishProduct(input.Tenantid, input.Productid, input.Price)
|
||||
if err != nil {
|
||||
// 400 rather than 500: every failure here is something the caller can
|
||||
// act on — no price, no outlets, or a product that is not theirs.
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true,
|
||||
"message": fmt.Sprintf("Published to %d outlet(s)", outlets),
|
||||
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
|
||||
})
|
||||
}
|
||||
|
||||
// UnpublishProduct withdraws a product from every shop, keeping its rows.
|
||||
func (ctl *ProductController) UnpublishProduct(c *fiber.Ctx) error {
|
||||
var input struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Productid int `json:"productid"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
outlets, err := ctl.productService.UnpublishProduct(input.Tenantid, input.Productid)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true,
|
||||
"message": fmt.Sprintf("Withdrawn from %d outlet(s)", outlets),
|
||||
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
|
||||
})
|
||||
}
|
||||
|
||||
24
main.go
24
main.go
@@ -78,6 +78,30 @@ func main() {
|
||||
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
||||
}
|
||||
|
||||
// When a product became visible to a store, and the only thing that decides
|
||||
// whether it is.
|
||||
//
|
||||
// `productlocations.status` cannot do this job and was never able to:
|
||||
// syncProductLocationStatus overwrites it with 'available'/'outofstock' on
|
||||
// every stock movement, so the 'Draft' the import wrote survived on exactly
|
||||
// one row out of 6,755. A separate column is untouched by that, and "when
|
||||
// was this published" is worth knowing regardless.
|
||||
//
|
||||
// The backfill is not optional and must land in the same deploy. Membership
|
||||
// of a store catalogue is currently the *existence* of the row, so switching
|
||||
// the read to `publishedat IS NOT NULL` without this empties every shop on
|
||||
// the platform at once.
|
||||
if err := db.DB.Exec(
|
||||
`ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat timestamp`).Error; err != nil {
|
||||
log.Fatal("could not add productlocations.publishedat:", err)
|
||||
}
|
||||
if err := db.DB.Exec(`
|
||||
UPDATE productlocations
|
||||
SET publishedat = COALESCE(created, NOW())
|
||||
WHERE publishedat IS NULL`).Error; err != nil {
|
||||
log.Fatal("could not backfill productlocations.publishedat:", err)
|
||||
}
|
||||
|
||||
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
||||
|
||||
routes.RegisterRoutes(app, f)
|
||||
|
||||
@@ -161,6 +161,11 @@ type Locationproducts struct {
|
||||
Approve int `json:"approve" gorm:"default:0"`
|
||||
// Productstatus string `json:"productstatus" gorm:"default:available"`
|
||||
Status string `json:"status" gorm:"default:outofstock"`
|
||||
|
||||
// Set only when the admin has released this product to the shops; NULL
|
||||
// while it sits in the admin catalogue awaiting a price. This — not
|
||||
// `Status` — is what a store view filters on. See models.Productlocations.
|
||||
Publishedat *time.Time `json:"publishedat"`
|
||||
}
|
||||
|
||||
type Productstocks struct {
|
||||
@@ -341,6 +346,20 @@ type Productlocations struct {
|
||||
Quantity int `json:"quantity" gorm:"<-:false"`
|
||||
Stocktype string `json:"stocktype" gorm:"<-:false"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// When this product was released to the store, and the only thing that
|
||||
// decides whether a store user can see it.
|
||||
//
|
||||
// NULL means the admin has imported it but not published it: it belongs to
|
||||
// the admin catalogue alone. Deliberately NOT another `Status` value —
|
||||
// syncProductLocationStatus rewrites that column on every stock movement,
|
||||
// which is why the import's 'Draft' survived on one row out of 6,755.
|
||||
//
|
||||
// Read-only through this struct (`<-:false`). It is set by Publish and
|
||||
// cleared by Unpublish, never as a side effect of an ordinary
|
||||
// product-location write: setting a price must not be able to release a
|
||||
// product to every shop in the tenant.
|
||||
Publishedat *time.Time `json:"publishedat" gorm:"column:publishedat;<-:false"`
|
||||
}
|
||||
|
||||
// ProductLocationRef identifies a single (tenant, location, product) row in
|
||||
|
||||
119
repositories/productPublishRepository.go
Normal file
119
repositories/productPublishRepository.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Releasing a product from the admin catalogue to the shops.
|
||||
//
|
||||
// Until this existed there was no such step. The import created a
|
||||
// `productlocations` row and membership of that table *was* publication, so a
|
||||
// product reached every store the moment it was imported — unpriced, because
|
||||
// pricing had not happened yet. That is how the platform ended up with a
|
||||
// catalogue of products a till could not ring up.
|
||||
//
|
||||
// Two rules, both enforced here rather than in the console, because a rule that
|
||||
// only exists in a form is a rule that holds until somebody calls the API:
|
||||
//
|
||||
// 1. **A product cannot be published without a price.** An unpriced product
|
||||
// reaching a shop is the exact failure this separation prevents — the POS
|
||||
// catalogue sends it down as `is_active: false` and it cannot be sold.
|
||||
// 2. **Publishing covers every outlet the tenant runs.** A price is a
|
||||
// business-level decision, and a product live at one branch and absent from
|
||||
// another is a support call nobody can explain.
|
||||
|
||||
// PublishProduct releases one product to every outlet of a tenant at the given
|
||||
// price, creating the store link where it does not exist yet.
|
||||
//
|
||||
// Idempotent: publishing an already-published product re-prices it and leaves
|
||||
// the original `publishedat` alone, so "when did this go live" survives a
|
||||
// second click.
|
||||
func (r *productRepository) PublishProduct(tenantID, productID int, price float64) (int, error) {
|
||||
if tenantID <= 0 || productID <= 0 {
|
||||
return 0, fmt.Errorf("tenantid and productid are required")
|
||||
}
|
||||
if price <= 0 {
|
||||
return 0, fmt.Errorf("set a price before publishing — a product with no price cannot be sold at a till")
|
||||
}
|
||||
|
||||
var affected int
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// The tenant's outlets are read here rather than accepted from the
|
||||
// caller. A console that sent its own list could publish to a subset by
|
||||
// omission and nobody would notice which shop was missing.
|
||||
var locationIDs []int
|
||||
if err := tx.Raw(`
|
||||
SELECT locationid FROM tenantlocations
|
||||
WHERE tenantid = ? AND LOWER(COALESCE(status,'active')) <> 'inactive'
|
||||
ORDER BY locationid`, tenantID).Scan(&locationIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(locationIDs) == 0 {
|
||||
return fmt.Errorf("this business has no active outlet to publish to")
|
||||
}
|
||||
|
||||
// Verify the product belongs to this tenant before writing anything
|
||||
// against it. Everything below is keyed on (tenantid, productid), but a
|
||||
// caller naming another tenant's product would otherwise create rows for
|
||||
// a product that is not theirs.
|
||||
var owned int64
|
||||
if err := tx.Raw(`SELECT COUNT(1) FROM products WHERE productid = ? AND tenantid = ?`,
|
||||
productID, tenantID).Scan(&owned).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if owned == 0 {
|
||||
return fmt.Errorf("product %d does not belong to this business", productID)
|
||||
}
|
||||
|
||||
for _, locationID := range locationIDs {
|
||||
// COALESCE on publishedat keeps the first release date through a
|
||||
// re-publish; NOW() only applies to a row that has never been live.
|
||||
res := tx.Exec(`
|
||||
UPDATE productlocations
|
||||
SET price = ?, publishedat = COALESCE(publishedat, NOW()), updated = NOW()
|
||||
WHERE tenantid = ? AND locationid = ? AND productid = ?`,
|
||||
price, tenantID, locationID, productID)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO productlocations
|
||||
(tenantid, locationid, productid, price, status, publishedat, created, updated)
|
||||
VALUES (?, ?, ?, ?, 'Active', NOW(), NOW(), NOW())`,
|
||||
tenantID, locationID, productID, price).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
affected++
|
||||
}
|
||||
|
||||
// The master price follows, so an outlet that has no row of its own
|
||||
// still reads the right figure through the COALESCE fallback in
|
||||
// GetProducts.
|
||||
return tx.Exec(`UPDATE products SET retailprice = ?, updated = NOW()
|
||||
WHERE productid = ? AND tenantid = ?`,
|
||||
price, productID, tenantID).Error
|
||||
})
|
||||
|
||||
return affected, err
|
||||
}
|
||||
|
||||
// UnpublishProduct withdraws a product from every shop.
|
||||
//
|
||||
// Clears `publishedat` and keeps the rows. Deleting them would lose the price,
|
||||
// the stock ledger's link and any history, and a product pulled from sale for a
|
||||
// week should come back the way it left.
|
||||
func (r *productRepository) UnpublishProduct(tenantID, productID int) (int, error) {
|
||||
if tenantID <= 0 || productID <= 0 {
|
||||
return 0, fmt.Errorf("tenantid and productid are required")
|
||||
}
|
||||
|
||||
res := r.db.Exec(`
|
||||
UPDATE productlocations SET publishedat = NULL, updated = NOW()
|
||||
WHERE tenantid = ? AND productid = ? AND publishedat IS NOT NULL`,
|
||||
tenantID, productID)
|
||||
return int(res.RowsAffected), res.Error
|
||||
}
|
||||
@@ -39,6 +39,11 @@ type ProductRepository interface {
|
||||
CreateProductLocation(input []models.Productlocations) error
|
||||
CreateProductVariant(input models.Productvariant) error
|
||||
DeleteProductLocation(tenantid, locationid, productid int) error
|
||||
|
||||
// Releasing a product from the admin catalogue to every outlet, and pulling
|
||||
// it back. See productPublishRepository.go.
|
||||
PublishProduct(tenantID, productID int, price float64) (int, error)
|
||||
UnpublishProduct(tenantID, productID int) (int, error)
|
||||
FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error)
|
||||
CreateProductReturningID(product models.Products) (int, error)
|
||||
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
||||
@@ -395,7 +400,7 @@ func (r *productRepository) GetStockStatement(tenantID, locationID, subcategoryI
|
||||
// arithmetically identical to closing — the Inventory ledger then showed
|
||||
// the same number in both columns and looked like stock never moved, even
|
||||
// on days with sales.
|
||||
query := `SELECT a.productid,a.productname,a.productimage,a.categoryid,a.subcategoryid,a.productunit,a.unitvalue,a.productcost,a.taxpercent,a.taxamount,a.retailprice,b.tenantid,b.locationid,
|
||||
query := `SELECT a.productid,a.productname,a.productimage,a.categoryid,a.subcategoryid,a.productunit,a.unitvalue,a.productcost,a.taxpercent,a.taxamount,a.retailprice,b.tenantid,b.locationid,b.publishedat,
|
||||
COALESCE( SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END),0 )
|
||||
AS opening,
|
||||
@@ -430,7 +435,7 @@ func (r *productRepository) GetStockStatement(tenantID, locationID, subcategoryI
|
||||
a.productid, a.productname, a.productimage,
|
||||
a.categoryid, a.subcategoryid, a.productunit,
|
||||
a.productcost, a.taxpercent, a.taxamount,
|
||||
a.retailprice, b.tenantid, b.locationid
|
||||
a.retailprice, b.tenantid, b.locationid, b.publishedat
|
||||
ORDER BY a.productid DESC LIMIT ` + strconv.Itoa(pagesize) + ` OFFSET ` + strconv.Itoa(offset)
|
||||
|
||||
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {
|
||||
|
||||
@@ -31,6 +31,12 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
||||
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
||||
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
|
||||
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
|
||||
|
||||
// Releasing a product to the shops. Importing no longer does this: a
|
||||
// product lands in the admin catalogue unpublished and unpriced, and only
|
||||
// this step — which refuses without a price — makes it sellable.
|
||||
products.Post("/publishproduct", f.ProductController.PublishProduct)
|
||||
products.Post("/unpublishproduct", f.ProductController.UnpublishProduct)
|
||||
products.Post("/createproductvariant", f.ProductController.CreateProductVariant)
|
||||
|
||||
products.Post("/createstockrequest", f.StockRequestController.CreateStockRequest)
|
||||
|
||||
@@ -35,6 +35,8 @@ type ProductService interface {
|
||||
ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error
|
||||
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
||||
GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
|
||||
PublishProduct(tenantID, productID int, price float64) (int, error)
|
||||
UnpublishProduct(tenantID, productID int) (int, error)
|
||||
}
|
||||
type productService struct {
|
||||
repo repositories.ProductRepository
|
||||
@@ -315,6 +317,14 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
||||
// column — not products.retailprice — is what the store catalogue, the
|
||||
// customer app and each order line read. The result was a catalogue
|
||||
// where nothing had a price and every order booked an amount of 0.
|
||||
// `publishedat` is deliberately absent — the field is `<-:false`, so
|
||||
// this row is created unpublished and belongs to the admin catalogue
|
||||
// only. Releasing it to the shops is PublishProduct, which refuses
|
||||
// without a price.
|
||||
//
|
||||
// Importing used to be publication: this row existed, and membership of
|
||||
// this table was the whole test, so a product reached every shop the
|
||||
// moment it was imported and before anyone had priced it.
|
||||
locations = append(locations, models.Productlocations{
|
||||
Tenantid: req.Tenantid,
|
||||
Locationid: req.Locationid,
|
||||
@@ -337,3 +347,15 @@ func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCateg
|
||||
return s.repo.GetTenantCategories(tenantid)
|
||||
}
|
||||
|
||||
|
||||
// PublishProduct releases a product to every outlet the tenant runs.
|
||||
//
|
||||
// The price check lives in the repository rather than here, so it holds for any
|
||||
// caller — the console form is a convenience, not the rule.
|
||||
func (s *productService) PublishProduct(tenantID, productID int, price float64) (int, error) {
|
||||
return s.repo.PublishProduct(tenantID, productID, price)
|
||||
}
|
||||
|
||||
func (s *productService) UnpublishProduct(tenantID, productID int) (int, error) {
|
||||
return s.repo.UnpublishProduct(tenantID, productID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user