170 lines
6.8 KiB
Go
170 lines
6.8 KiB
Go
package repositories
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"nearle/models"
|
|
|
|
"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, taxPercent 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")
|
|
}
|
|
// Zero is a legitimate rate — plenty of staples are zero-rated — so it
|
|
// cannot double as "not specified". Negative is refused rather than stored:
|
|
// three products already carry taxpercent -1, which yields negative tax
|
|
// under either convention.
|
|
if taxPercent < 0 {
|
|
return 0, fmt.Errorf("tax percentage cannot be negative")
|
|
}
|
|
|
|
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 = ?, taxpercent = ?, updated = NOW()
|
|
WHERE productid = ? AND tenantid = ?`,
|
|
price, taxPercent, 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
|
|
}
|
|
|
|
// PublishPricedLocations releases the rows an import has just created, at the
|
|
// one branch it imported into.
|
|
//
|
|
// The publish gate exists to stop an unpriced product reaching a till that
|
|
// would ring it up at zero. An import from a spreadsheet CARRIES the price — it
|
|
// is on the row this writes — so the gate's condition is already met, and
|
|
// leaving these rows unpublished asks somebody to click a button that can only
|
|
// answer "yes" seventeen times.
|
|
//
|
|
// It was not a harmless extra step. Nothing in the backend filters on
|
|
// `publishedat`, so the products were already on sale in the customer app while
|
|
// the merchant's own store catalogue — the one screen that does read it —
|
|
// showed nothing. The shopkeeper could not see what their shoppers could buy.
|
|
//
|
|
// Three things keep this honest:
|
|
//
|
|
// - `price > 0` is checked in SQL, not by the caller. A row that somehow
|
|
// arrived unpriced stays unpublished, which is the rule the gate is for.
|
|
// - COALESCE keeps the first release date, so re-importing does not rewrite
|
|
// the history of a product that has been on sale for months.
|
|
// - Only the named branch. PublishProduct releases to every outlet a tenant
|
|
// runs and that is right for a deliberate release; an import names one
|
|
// branch, and quietly stocking the others would put products in shops
|
|
// nobody chose.
|
|
func (r *productRepository) PublishPricedLocations(refs []models.ProductLocationRef) error {
|
|
if len(refs) == 0 {
|
|
return nil
|
|
}
|
|
for _, ref := range refs {
|
|
if err := r.db.Exec(`
|
|
UPDATE productlocations
|
|
SET publishedat = COALESCE(publishedat, NOW()), updated = NOW()
|
|
WHERE tenantid = ? AND locationid = ? AND productid = ?
|
|
AND COALESCE(price, 0) > 0`,
|
|
ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|