This commit is contained in:
2026-08-28 11:56:57 +05:30
parent edb9c2803f
commit 46da26c452
4 changed files with 366 additions and 11 deletions

View File

@@ -23,6 +23,7 @@ type ProductRepository interface {
CreateProductStock(stocks []models.Productstock) error
UpdateProductStatus(productIDs []int, status string) error
SyncProductLocationStatus(refs []models.ProductLocationRef) error
EnsureProductLocation(refs []models.ProductLocationRef) error
CreateProduct(product models.Products) error
UpdateProduct(product models.Products) error
DeleteProduct(productID int) error
@@ -49,6 +50,7 @@ type ProductRepository interface {
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error
UpdateProductCategory(productid, categoryid, subcategoryid int) error
}
type productRepository struct {
@@ -299,6 +301,49 @@ func (r *productRepository) CreateProductStock(stocks []models.Productstock) err
return r.db.Table("productstocks").Create(&stocks).Error
}
// EnsureProductLocation puts a product on an outlet's shelf if it is not there
// already, and leaves it completely alone if it is.
//
// Receiving stock used to write the ledger and nothing else. The ledger is not
// what the customer app reads: GetProducts joins productlocations and then
// filters `pl.locationid = ?`, so a product with no row for that outlet is
// dropped by the join no matter how much stock arrived. A shop could request a
// product, have it approved, receive it, watch the stock rise in the console —
// and the product was still not for sale, with nothing anywhere saying why.
//
// SyncProductLocationStatus below cannot cover this: it is an UPDATE, so with
// no row to update it succeeds having changed nothing.
//
// INSERT ... SELECT ... WHERE NOT EXISTS rather than an upsert, deliberately.
// An upsert here would carry a price, and the only price this code could supply
// is the master retailprice — which would overwrite an outlet's own, carefully
// different, price every time a delivery arrived. Existing rows must not be
// touched; this only ever creates the missing one.
//
// The seeded status is "outofstock" because it is true at the instant of the
// insert. The caller runs SyncProductLocationStatus immediately after, which
// derives the real value from the ledger, so a genuine receipt corrects it in
// the same call.
func (r *productRepository) EnsureProductLocation(refs []models.ProductLocationRef) error {
for _, ref := range refs {
if err := r.db.Exec(`
INSERT INTO productlocations (tenantid, locationid, productid, price, status)
SELECT ?, ?, ?, COALESCE(p.retailprice, 0), 'outofstock'
FROM products p
WHERE p.productid = ? AND p.tenantid = ?
AND NOT EXISTS (
SELECT 1 FROM productlocations pl
WHERE pl.tenantid = ? AND pl.locationid = ? AND pl.productid = ?
)`,
ref.Tenantid, ref.Locationid, ref.Productid,
ref.Productid, ref.Tenantid,
ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil {
return err
}
}
return nil
}
// SyncProductLocationStatus recomputes productlocations.status for each ref
// from the productstocks ledger: "available" when the live SUM(in)-SUM(out)
// balance is positive, "outofstock" when it is not.
@@ -1124,6 +1169,31 @@ func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCa
// UpdateProductPricing updates only the pricing fields on a product
// snapshot, used when a catalogue product is re-imported with new pricing.
// UpdateProductCategory files a product under a category after the fact.
//
// It exists because nothing else could. `UpdateProduct` writes only
// productlocations.status despite its name, and re-importing a product the
// tenant already has took the `existing != nil` branch, which corrected the
// pricing and left the category as it was. So a product imported with
// categoryid 0 was permanently invisible to the customer app — the endpoint it
// browses rejects categoryid 0 outright — with no API able to repair it. Seven
// products across three tenants were in that state, six outlets showing an
// empty shop to shoppers while the console listed their stock.
//
// A zero is never written. Callers pass whatever the import request carried,
// and a request that omits the category must not erase one that is already
// correct — the guard belongs here rather than in each caller.
func (r *productRepository) UpdateProductCategory(productid, categoryid, subcategoryid int) error {
if categoryid <= 0 {
return nil
}
updates := map[string]interface{}{"categoryid": categoryid}
if subcategoryid > 0 {
updates["subcategoryid"] = subcategoryid
}
return r.db.Table("products").Where("productid = ?", productid).Updates(updates).Error
}
func (r *productRepository) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error {
return r.db.Table("products").
Where("productid = ?", productid).