Compare commits
3 Commits
e48fb4cf57
...
d3a7466f4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a7466f4c | ||
|
|
af55325a5b | ||
|
|
5bd1f52d41 |
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.
|
||||
@@ -303,6 +303,24 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ Strategy 3: some clients send the header under "orders" but the line
|
||||
// items as a SIBLING top-level "items" array rather than nested inside
|
||||
// it. Strategy 2's OrderWrapper only has an "orders" field, so
|
||||
// encoding/json silently drops that sibling key — the order header
|
||||
// parses fine but data.Items ends up empty, which used to let the order
|
||||
// go through with zero items and skip the stock check entirely (the
|
||||
// pre-validation loop below iterates over data.Items). Pick it up here
|
||||
// if strategies 1/2 left Items empty.
|
||||
if len(data.Items) == 0 {
|
||||
type ItemsWrapper struct {
|
||||
Items []models.OrderDetail `json:"items"`
|
||||
}
|
||||
var itemsWrapper ItemsWrapper
|
||||
if err := c.BodyParser(&itemsWrapper); err == nil && len(itemsWrapper.Items) > 0 {
|
||||
data.Items = itemsWrapper.Items
|
||||
}
|
||||
}
|
||||
|
||||
// Double check we have the required ID
|
||||
if data.Tenantid == 0 {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
@@ -312,6 +330,18 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// An order with no line items has nothing to check stock against — the
|
||||
// pre-validation loop in CreateOrder simply wouldn't run, silently
|
||||
// creating a phantom header-only order that never deducted stock.
|
||||
// Reject it outright instead.
|
||||
if len(data.Items) == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "Order must contain at least one item",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if strings.TrimSpace(data.Orderdate) == "" {
|
||||
data.Orderdate = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -350,8 +350,15 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) err
|
||||
var user models.Tenantuser
|
||||
tx := r.db.Begin()
|
||||
|
||||
// Set status BEFORE insert
|
||||
data.Status = "InActive"
|
||||
// Default to Active if the caller didn't specify — matches
|
||||
// tenantlocations' own gorm default and the primary-location behavior
|
||||
// from tenant onboarding. Forcing InActive here used to also block the
|
||||
// spawned manager login (AppLogin checks account status before it ever
|
||||
// gets to the "no password set" branch), so a new store's login could
|
||||
// never reach the password-setup screen.
|
||||
if data.Status == "" {
|
||||
data.Status = "Active"
|
||||
}
|
||||
|
||||
// Step 1: Insert into tenantlocations
|
||||
if err := tx.Create(&data).Error; err != nil {
|
||||
@@ -374,7 +381,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) err
|
||||
user.Locationid = data.Locationid
|
||||
user.Applocationid = data.Applocationid
|
||||
user.Configid = 1
|
||||
user.Status = "InActive"
|
||||
user.Status = data.Status
|
||||
user.Roleid = 0
|
||||
user.Authmode = 0
|
||||
user.Password = ""
|
||||
|
||||
@@ -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