diff --git a/MOBILE_ORDER_VERIFICATION.md b/MOBILE_ORDER_VERIFICATION.md new file mode 100644 index 0000000..c45c43b --- /dev/null +++ b/MOBILE_ORDER_VERIFICATION.md @@ -0,0 +1,124 @@ +# Order Creation — Mobile Developer Verification Guide + +## Why this exists + +We found that orders placed through the app were being saved with **zero line +items** — the order header (tenant, location, customer) saved fine, but the +`items` array was silently getting dropped somewhere between the app and the +database. Because the stock check only runs over whatever's in `items`, an +order with no items also skipped stock validation entirely. + +The backend now tolerates a few different request shapes and has a stock +check in place, but the app's actual request needs to be verified against +what's below to confirm it lines up. + +## Endpoint + +``` +POST https://fiesta.nearle.app/live/api/v1/mob/orders/createorder +Content-Type: application/json +``` + +## Request shape — items MUST be inside `orders`, not a sibling of it + +**Correct:** +```json +{ + "orders": { + "tenantid": 1135, + "locationid": 1166, + "customerid": 42, + "items": [ + { "productid": 7060, "orderqty": 2, "price": 45.0 } + ] + } +} +``` + +**Also accepted (flat, no wrapper):** +```json +{ + "tenantid": 1135, + "locationid": 1166, + "customerid": 42, + "items": [ + { "productid": 7060, "orderqty": 2, "price": 45.0 } + ] +} +``` + +**This shape used to silently lose the items — avoid it:** +```json +{ + "orders": { "tenantid": 1135, "locationid": 1166 }, + "items": [ { "productid": 7060, "orderqty": 2 } ] +} +``` +`items` as a sibling of `orders` (not nested inside it) is now handled as a +fallback server-side too, but don't rely on the fallback — put `items` inside +`orders` to match the primary/documented shape. + +## Required fields per item + +| field | type | required | notes | +|-------------|--------|----------|-------------------------------------------| +| `productid` | int | yes | must be a real product for the tenant | +| `orderqty` | number | yes | quantity being ordered | +| `price` | number | recommended | unit price at time of order | +| `locationid`| int | no | defaults to the order's own `locationid` if omitted | + +## Expected responses — verify your app handles all of these + +| Scenario | HTTP code | Body (key fields) | +|---|---|---| +| Order succeeds | `200` | `"status": true`, `"details": { "orderheaderid": ..., "items": [...] }` | +| No `tenantid` at all | `409` | `"status": false`, `"message": "Tenant ID is required"` | +| `items` missing/empty | `400` | `"status": false`, `"message": "Order must contain at least one item"` | +| Requested qty > available stock | `409` | `"status": false`, `"message": "insufficient stock for product '': requested X, available Y"` | + +**Important:** a `409` with "insufficient stock" is not a network/server +error — it's the correct, expected response when a customer tries to order +more than what's in stock at that store. The app should catch this +specifically (check the message text, or treat any `409` from this endpoint +as a stock problem) and show the customer a clear "not enough stock" message +rather than a generic error screen. + +## How to verify end-to-end yourselves + +1. Pick a real `tenantid` + `locationid` + `productid` combo you know has + stock (ask backend/ops for current numbers, or check via the merchant + web app's inventory view). +2. Place a normal order for 1 unit through the app. Confirm it returns `200` + and the response's `details.items` array is non-empty. +3. Place an order for a quantity larger than what's currently in stock for + that product/location. Confirm you get a `409` with an "insufficient + stock" message, and that the app surfaces this to the user instead of + silently failing or showing a generic error. +4. Cancel a successful order and confirm a follow-up stock check reflects + the restored quantity (ask backend to check, or place the same + over-quantity order again afterward — it should now succeed if + cancellation restored enough stock). + +## Checking stock before the customer even taps "order" + +`GET /live/api/v1/mob/products/getproductbyvariant` now accepts an optional +`locationid` query param: + +``` +GET /live/api/v1/mob/products/getproductbyvariant?tenantid=1135&variantid=44&locationid=1166 +``` + +When `locationid` is passed, each returned product now carries two extra +live fields: + +| field | meaning | +|---|---| +| `productstock` | live available quantity at that store — same SUM(in)-SUM(out) formula the order stock check uses | +| `locationstatus` | that store's status for this product, e.g. `"outofstock"` or `"available"`/`"Active"` | + +**If `locationid` is omitted, both fields come back empty/zero** — this is +the old behavior preserved for backward compatibility, not new stock data. +Start passing `locationid` (the store the customer is browsing) to get real +numbers, and use it to show "out of stock" / gray out the add-to-cart button +*before* the customer tries to order, instead of only finding out from the +`409` response above. diff --git a/controllers/productController.go b/controllers/productController.go index 4c07482..f26ae61 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -367,8 +367,9 @@ func (ctl *ProductController) GetProductByVariant(c *fiber.Ctx) error { tenantID, _ := strconv.Atoi(c.Query("tenantid")) variantid, _ := strconv.Atoi(c.Query("variantid")) + locationID, _ := strconv.Atoi(c.Query("locationid")) - result, err := ctl.productService.GetProductByVariant(tenantID, variantid) + result, err := ctl.productService.GetProductByVariant(tenantID, variantid, locationID) if err != nil { diff --git a/models/product.go b/models/product.go index 80da380..fb4751e 100644 --- a/models/product.go +++ b/models/product.go @@ -83,6 +83,13 @@ type Products struct { Othercost float64 `json:"othercost,omitempty"` Approve int `json:"approve"` Productstatus string `json:"productstatus" ` + // Populated only by queries scoped to a specific location (e.g. + // GetProductByVariant when locationid is passed): Productstock becomes + // the live SUM(in)-SUM(out) balance from productstocks — the same + // formula CreateOrder's stock check uses — and Locationstatus mirrors + // productlocations.status ("outofstock"/"available") for that store. + // Left at zero values for callers that don't scope to a location. + Locationstatus string `json:"locationstatus,omitempty" gorm:"->"` // Status string `json:"status" gorm:"default:InActive"` // Status string `json:"status" gorm:"-"` } @@ -306,6 +313,15 @@ type Productlocations struct { Status string `json:"status"` } +// ProductLocationRef identifies a single (tenant, location, product) row in +// productlocations — used to reactivate it after new stock arrives, the +// per-location counterpart to CreateOrder's outofstock flag. +type ProductLocationRef struct { + Tenantid int + Locationid int + Productid int +} + type ProductSubcategory struct { Subcatid int `json:"subcatid"` Categoryid int `json:"categoryid"` diff --git a/repositories/productRepository.go b/repositories/productRepository.go index 7b892e5..8a14848 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -22,6 +22,7 @@ type ProductRepository interface { GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) CreateProductStock(stocks []models.Productstock) error UpdateProductStatus(productIDs []int, status string) error + ReactivateProductLocations(refs []models.ProductLocationRef) error CreateProduct(product models.Products) error UpdateProduct(product models.Products) error DeleteProduct(productID int) error @@ -29,7 +30,7 @@ type ProductRepository interface { GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) - GetProductByVariant(tenantid, variantid int) ([]models.Products, error) + GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) GetSubcategories(categoryID int) ([]models.Subcategory, error) GetProducts(params models.ProductFilter) ([]models.Products, error) GetTenantInfo(tenantID, applocationID int) (map[string]interface{}, error) @@ -263,6 +264,23 @@ func (r *productRepository) CreateProductStock(stocks []models.Productstock) err return r.db.Table("productstocks").Create(&stocks).Error } +// ReactivateProductLocations flips productlocations.status back to +// "available" for each ref — the counterpart to CreateOrder flagging a +// location "outofstock" when its stock hits zero. Without this, a store +// that runs out and then restocks via an approved stock request stays +// flagged outofstock forever, since receiving stock only ever added to the +// productstocks ledger and never touched this per-location flag. +func (r *productRepository) ReactivateProductLocations(refs []models.ProductLocationRef) error { + for _, ref := range refs { + if err := r.db.Table("productlocations"). + Where("tenantid = ? AND locationid = ? AND productid = ?", ref.Tenantid, ref.Locationid, ref.Productid). + Update("status", "available").Error; err != nil { + return err + } + } + return nil +} + func (r *productRepository) UpdateProductStatus(productIDs []int, status string) error { return r.db.Table("products"). Where("productid IN ?", productIDs). @@ -567,10 +585,16 @@ func (r *productRepository) FetchFilteredProducts( return results, nil } -func (r *productRepository) GetProductByVariant(tenantid, variantid int) ([]models.Products, error) { +func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) { var data []models.Products + // productstock is a correlated subquery (not a JOIN+GROUP BY) so it can + // coexist with `p.*` without having to enumerate every products column. + // When locationid is 0 (caller didn't scope to a store), both the + // subquery and the productlocations join simply match nothing, so + // Productstock/Locationstatus come back zero-valued — same response + // shape as before this field existed, not an error. err := r.db. Table("products p"). Select(` @@ -578,11 +602,19 @@ func (r *productRepository) GetProductByVariant(tenantid, variantid int) ([]mode c.categoryname, d.subcatname AS subcategoryname, COALESCE(pd.discountvalue, 0) AS discountvalue, - pd.discountid - `). + pd.discountid, + pl.status AS locationstatus, + COALESCE(( + SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) - + SUM(CASE WHEN LOWER(ps.stocktype) = 'out' THEN ps.quantity ELSE 0 END) + FROM productstocks ps + WHERE ps.productid = p.productid AND ps.tenantid = p.tenantid AND ps.locationid = ? + ), 0) AS productstock + `, locationid). Joins("LEFT JOIN productcategories c ON p.categoryid = c.categoryid"). Joins("LEFT JOIN productsubcategories d ON p.subcategoryid = d.subcatid"). Joins("LEFT JOIN productdiscounts pd ON pd.productid = p.productid"). + Joins("LEFT JOIN productlocations pl ON pl.productid = p.productid AND pl.tenantid = p.tenantid AND pl.locationid = ?", locationid). Where("p.tenantid = ? AND p.variants = ?", tenantid, variantid). Order("p.productid DESC"). Scan(&data).Error diff --git a/services/productService.go b/services/productService.go index 7f50c18..d4419a0 100644 --- a/services/productService.go +++ b/services/productService.go @@ -4,6 +4,7 @@ import ( "fmt" "nearle/models" "nearle/repositories" + "strings" "time" ) @@ -23,7 +24,7 @@ type ProductService interface { GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) - GetProductByVariant(tenantid, variantid int) ([]models.Products, error) + GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error) UpdateProductLocation(input models.Productlocations) error CreateProductLocation(input []models.Productlocations) error @@ -78,11 +79,22 @@ func (s *productService) CreateProductStock(stocks []models.Productstock) error idMap := make(map[int]struct{}) var productIDs []int - for _, s := range stocks { - if s.Productid > 0 { - if _, exists := idMap[s.Productid]; !exists { - idMap[s.Productid] = struct{}{} - productIDs = append(productIDs, s.Productid) + locMap := make(map[models.ProductLocationRef]struct{}) + var locRefs []models.ProductLocationRef + for _, stk := range stocks { + if stk.Productid > 0 { + if _, exists := idMap[stk.Productid]; !exists { + idMap[stk.Productid] = struct{}{} + productIDs = append(productIDs, stk.Productid) + } + } + // Only "in" entries mean stock actually arrived — an "out" entry + // (a sale) should never flip a location back to available. + if stk.Productid > 0 && stk.Locationid > 0 && stk.Tenantid > 0 && strings.EqualFold(stk.Stocktype, "in") { + ref := models.ProductLocationRef{Tenantid: stk.Tenantid, Locationid: stk.Locationid, Productid: stk.Productid} + if _, exists := locMap[ref]; !exists { + locMap[ref] = struct{}{} + locRefs = append(locRefs, ref) } } } @@ -93,6 +105,12 @@ func (s *productService) CreateProductStock(stocks []models.Productstock) error } } + if len(locRefs) > 0 { + if err := s.repo.ReactivateProductLocations(locRefs); err != nil { + return err + } + } + return nil } @@ -129,11 +147,11 @@ func (s *productService) FetchFilteredProducts(categoryID, subcategoryID, produc return s.repo.FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID, keyword, productStatus, approve, pageno, pagesize) } -func (s *productService) GetProductByVariant(tenantid, variantid int) ([]models.Products, error) { +func (s *productService) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) { var data []models.Products - result, err := s.repo.GetProductByVariant(tenantid, variantid) + result, err := s.repo.GetProductByVariant(tenantid, variantid, locationid) if err != nil { return nil, err