Sync stock status on restock and expose live stock on getproductbyvariant
Two gaps found while auditing the order/stock flow: - Receiving stock via an approved stock request only updated products.productstatus (a global per-product flag). A location flagged outofstock by CreateOrder never got reactivated, since nothing touched productlocations.status on the way back in. CreateProductStock now reactivates the specific (tenant, location, product) row to "available" for every "in" entry, the counterpart to how it gets flagged out. - getproductbyvariant returned no stock info at all, so the app could only find out a product was unavailable from the 409 at order time. It now accepts an optional locationid and, when passed, returns live productstock (same SUM(in)-SUM(out) formula the order check uses) and locationstatus per product. Omitting locationid keeps the old response shape. Added MOBILE_ORDER_VERIFICATION.md as a handoff doc for the mobile team covering the expected request/response shapes and how to verify their integration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
124
MOBILE_ORDER_VERIFICATION.md
Normal file
124
MOBILE_ORDER_VERIFICATION.md
Normal file
@@ -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 '<name>': 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.
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user