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 }