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 }