The offline-sales import required one workbook per outlet and a store picked in the UI. A merchant running several branches had to download, fill and upload a file per branch, and the picker defaulted to the tenant's first outlet — so an admin who never touched it silently credited the wrong store, which no validation could catch because the file and the selection agreed with each other. One workbook now covers every branch. getsaletemplate takes locationid=0 (the default) to span the tenant, stamping tenantid, locationid and the store name onto every row, and that row's locationid is what decides which branch a sale is deducted from. The INNER JOIN on tenantlocations confines it to outlets the tenant owns, so a template can never disclose another merchant's catalogue. uploadofflinesales accordingly takes locationid on each bill. The locationid on the request itself becomes a scope constraint rather than a destination: left at 0 the bills go where their rows say, and set to a branch it pins the upload there and refuses anything else. That is what holds a store user to their own store — the pin comes from their session, so editing the locationid column in the spreadsheet changes nothing. Every branch referenced is checked against the tenant regardless. Branch context and catalogue are resolved once per branch and reused; a workbook covering six outlets would otherwise re-run both queries for every bill in it. Duplicate detection is now per branch. Bill numbers only have to be unique within a store, since counter books at different outlets routinely restart numbering at 1, and treating a shared number as a repeat would have silently dropped a real sale. Verified against tenant 1087, whose two branches both stock product 6998 at 100 units: a single upload of two bills moved 1097 to 97 and 1135 to 95 independently; the same bill number at both branches imported as two separate orders; an upload pinned to 1097 imported its own bill and refused the 1135 one; a row naming another tenant's outlet was refused; and re-uploading the file deducted nothing. All five test orders were cancelled afterwards and both branches confirmed back at 100. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
591 lines
16 KiB
Go
591 lines
16 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"nearle/models"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/services"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type OrderController struct {
|
|
orderService services.OrderService
|
|
}
|
|
|
|
func NewOrderController(orderService services.OrderService) *OrderController {
|
|
return &OrderController{orderService: orderService}
|
|
}
|
|
|
|
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
|
|
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
|
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
|
cid, _ := strconv.Atoi(c.Query("customerid"))
|
|
mid, _ := strconv.Atoi(c.Query("moduleid"))
|
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
|
uid, _ := strconv.Atoi(c.Query("appuserid"))
|
|
lid, _ := strconv.Atoi(c.Query("locationid"))
|
|
configid, _ := strconv.Atoi(c.Query("configid"))
|
|
|
|
stat := c.Query("status")
|
|
fdate := c.Query("fromdate")
|
|
tdate := c.Query("todate")
|
|
keyword := c.Query("keyword")
|
|
|
|
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
|
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
|
|
|
if pageno <= 0 {
|
|
pageno = 1
|
|
}
|
|
if pagesize <= 0 {
|
|
pagesize = 10
|
|
}
|
|
|
|
// Build dynamic query struct
|
|
query := models.DeliveryQuery{
|
|
Tenantid: tid,
|
|
Partnerid: pid,
|
|
Customerid: cid,
|
|
Moduleid: mid,
|
|
Applocationid: aid,
|
|
Locationid: lid,
|
|
UserID: uid,
|
|
Appuserid: uid,
|
|
Configid: configid,
|
|
Fromdate: fdate,
|
|
ToDate: tdate,
|
|
Status: stat,
|
|
Keyword: keyword,
|
|
Pageno: pageno,
|
|
Pagesize: pagesize,
|
|
}
|
|
|
|
var (
|
|
orders []models.OrderInfo
|
|
err error
|
|
)
|
|
|
|
// --------------------------
|
|
// 🔥 DYNAMIC ROUTING LOGIC
|
|
// --------------------------
|
|
|
|
if tid != 0 && lid != 0 {
|
|
// ⭐ Both tenant & location → special handler
|
|
orders, err = ctl.orderService.GetTenantLocationOrders(query)
|
|
|
|
} else if tid != 0 {
|
|
// Tenant only
|
|
orders, err = ctl.orderService.GetTenantOrders(query)
|
|
|
|
} else if pid != 0 {
|
|
// Partner
|
|
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
|
|
|
|
} else if cid != 0 {
|
|
// Customer
|
|
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
|
|
|
|
} else if aid != 0 {
|
|
// App-location orders
|
|
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
|
|
|
|
} else if uid != 0 {
|
|
// User orders
|
|
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
|
|
|
|
} else {
|
|
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
|
|
// Refuse instead of silently returning every order in the database.
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusBadRequest,
|
|
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
|
|
})
|
|
}
|
|
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"status": true,
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"details": orders,
|
|
})
|
|
}
|
|
|
|
|
|
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
|
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
|
cid, _ := strconv.Atoi(c.Query("customerid"))
|
|
lid, _ := strconv.Atoi(c.Query("locationid"))
|
|
fdate := c.Query("fromdate")
|
|
tdate := c.Query("todate")
|
|
|
|
if tid == 0 && pid == 0 && cid == 0 && lid == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "At least one of tenantid, partnerid, customerid or locationid is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
data, err := ctl.orderService.GetOrderSummary(tid, pid, cid, lid, fdate, tdate)
|
|
if err != nil {
|
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
|
"code": http.StatusConflict,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": data,
|
|
})
|
|
}
|
|
|
|
|
|
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
|
|
tenantIDStr := c.Query("tenantid")
|
|
tenantID, _ := strconv.Atoi(tenantIDStr)
|
|
|
|
if tenantID == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusBadRequest,
|
|
"message": "tenantid is required",
|
|
})
|
|
}
|
|
|
|
data, err := ctl.orderService.GetLocationOrderSummary(tenantID)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": data,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) GetOrderInsights(c *fiber.Ctx) error {
|
|
tenantIDStr := c.Query("tenantid")
|
|
tenantID, _ := strconv.Atoi(tenantIDStr)
|
|
|
|
insights, err := ctl.orderService.GetOrderInsights(tenantID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"status": false,
|
|
"message": "Failed to fetch order insights",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"status": true,
|
|
"message": "Success",
|
|
"details": insights,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) GetOrderDetails(c *fiber.Ctx) error {
|
|
orderHeaderIDStr := c.Query("orderheaderid")
|
|
if orderHeaderIDStr == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"code": 400,
|
|
"message": "orderheaderid is required",
|
|
"status": false,
|
|
"details": []interface{}{},
|
|
})
|
|
}
|
|
|
|
orderHeaderID, err := strconv.Atoi(orderHeaderIDStr)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"code": 400,
|
|
"message": "invalid orderheaderid",
|
|
"status": false,
|
|
"details": []interface{}{},
|
|
})
|
|
}
|
|
|
|
details, err := ctl.orderService.GetOrderDetails(orderHeaderID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": 500,
|
|
"message": "Failed to fetch order details",
|
|
"status": false,
|
|
"details": []interface{}{},
|
|
})
|
|
}
|
|
|
|
var orderAmount, totalTaxAmount float64
|
|
if len(details) > 0 {
|
|
orderAmount = details[0].Orderamount
|
|
totalTaxAmount = details[0].Totaltaxamount
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": 200,
|
|
"pricedetails": fiber.Map{
|
|
"orderamount": orderAmount,
|
|
"totaltaxamount": totalTaxAmount,
|
|
},
|
|
"details": details,
|
|
"message": "Success",
|
|
"status": true,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) UpdateOrder(c *fiber.Ctx) error {
|
|
var order models.Orders
|
|
|
|
if err := c.BodyParser(&order); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "Invalid request body",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if err := ctl.orderService.UpdateOrder(&order); err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"message": "Error updating order",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.Status(http.StatusAccepted).JSON(fiber.Map{
|
|
"code": http.StatusAccepted,
|
|
"message": "Success",
|
|
"status": true,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
|
var data models.Orders
|
|
|
|
// 🛠️ Strategy 1: Try parsing direct object (for Apidog)
|
|
if err := c.BodyParser(&data); err != nil {
|
|
log.Println("BodyParser strategy 1 error:", err)
|
|
}
|
|
|
|
// 🛠️ Strategy 2: If Strategy 1 didn't find a Tenantid, try parsing wrapped object (for Mobile App)
|
|
if data.Tenantid == 0 {
|
|
type OrderWrapper struct {
|
|
Orders models.Orders `json:"orders"`
|
|
}
|
|
var wrapper OrderWrapper
|
|
if err := c.BodyParser(&wrapper); err == nil && wrapper.Orders.Tenantid != 0 {
|
|
data = wrapper.Orders
|
|
}
|
|
}
|
|
|
|
// 🛠️ 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{
|
|
"code": http.StatusConflict,
|
|
"message": "Tenant ID is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if strings.TrimSpace(data.Deliverytime) == "" {
|
|
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
order, err := ctl.orderService.CreateOrder(data)
|
|
if err != nil {
|
|
log.Println("CreateOrder service error:", err)
|
|
statusCode := http.StatusInternalServerError
|
|
if strings.Contains(strings.ToLower(err.Error()), "insufficient stock") {
|
|
statusCode = http.StatusConflict
|
|
}
|
|
return c.Status(statusCode).JSON(fiber.Map{
|
|
"code": statusCode,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Order created successfully",
|
|
"status": true,
|
|
"details": order,
|
|
})
|
|
}
|
|
|
|
// UploadOfflineSales imports a spreadsheet of in-store counter sales.
|
|
//
|
|
// The response is 200 whenever the batch was processed, even if individual
|
|
// bills were rejected, because a partial import is a normal outcome for a
|
|
// spreadsheet and the per-bill results carry the detail. A non-200 means
|
|
// nothing at all was attempted — a malformed body, or an outlet the caller has
|
|
// no claim on.
|
|
func (ctl *OrderController) UploadOfflineSales(c *fiber.Ctx) error {
|
|
var input models.OfflineSalesUpload
|
|
|
|
if err := c.BodyParser(&input); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "could not read the upload: " + err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
// locationid is optional: 0 means the bills carry their own branch, which
|
|
// is how one workbook covers every outlet a merchant runs. Supplying it
|
|
// pins the upload to that branch and rejects anything else in the file.
|
|
if input.Tenantid <= 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "tenantid is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if len(input.Bills) == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "no sales rows found in the upload",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
result, err := ctl.orderService.UploadOfflineSales(input)
|
|
if err != nil {
|
|
log.Println("UploadOfflineSales service error:", err)
|
|
// An outlet the caller doesn't own is a permission problem, not a
|
|
// server fault, and is reported as one so the UI can say so plainly.
|
|
statusCode := http.StatusInternalServerError
|
|
if strings.Contains(strings.ToLower(err.Error()), "does not belong to tenant") {
|
|
statusCode = http.StatusForbidden
|
|
}
|
|
return c.Status(statusCode).JSON(fiber.Map{
|
|
"code": statusCode,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
message := fmt.Sprintf("%d bill(s) imported", result.Imported)
|
|
if result.Duplicate > 0 {
|
|
message += fmt.Sprintf(", %d already imported", result.Duplicate)
|
|
}
|
|
if result.Failed > 0 {
|
|
message += fmt.Sprintf(", %d failed", result.Failed)
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": message,
|
|
"status": true,
|
|
"details": result,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) GetCustomerOrders(c *fiber.Ctx) error {
|
|
customerID := c.Query("customerid")
|
|
tenantID := c.Query("tenantid")
|
|
moduleID := c.Query("moduleid")
|
|
fromDate := c.Query("fromdate")
|
|
toDate := c.Query("todate")
|
|
orderStatus := c.Query("orderstatus")
|
|
keyword := c.Query("keyword")
|
|
|
|
pageNo, _ := strconv.Atoi(c.Query("pageno", "1"))
|
|
pageSize, _ := strconv.Atoi(c.Query("pagesize", "10"))
|
|
|
|
if pageNo < 1 {
|
|
pageNo = 1
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = 10
|
|
}
|
|
offset := (pageNo - 1) * pageSize
|
|
|
|
if customerID == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"status": false,
|
|
"message": "customerid is required",
|
|
})
|
|
}
|
|
|
|
orders, err := ctl.orderService.GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword, pageSize, offset)
|
|
if err != nil {
|
|
log.Println("GetCustomerOrders error:", err)
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"status": false,
|
|
"message": "Failed to fetch customer orders",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"message": "Customer orders fetched successfully",
|
|
"data": orders,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) GetRevenueSummary(c *fiber.Ctx) error {
|
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
|
lid, _ := strconv.Atoi(c.Query("locationid"))
|
|
fdate := c.Query("fromdate")
|
|
tdate := c.Query("todate")
|
|
|
|
if tid == 0 && lid == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "Either tenantid or locationid query parameter is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
data, err := ctl.orderService.GetRevenueSummary(tid, lid, fdate, tdate)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": data,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) GetSalesSummary(c *fiber.Ctx) error {
|
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
|
lid, _ := strconv.Atoi(c.Query("locationid"))
|
|
fdate := c.Query("fromdate")
|
|
tdate := c.Query("todate")
|
|
|
|
if tid == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "tenantid query parameter is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
data, err := ctl.orderService.GetSalesSummary(tid, lid, fdate, tdate)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": data,
|
|
})
|
|
}
|
|
|
|
func (ctl *OrderController) GetTimeSeries(c *fiber.Ctx) error {
|
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
|
lid, _ := strconv.Atoi(c.Query("locationid"))
|
|
granularity := c.Query("granularity")
|
|
fdate := c.Query("fromdate")
|
|
tdate := c.Query("todate")
|
|
|
|
if tid == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "tenantid query parameter is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if granularity == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "granularity query parameter is required (day, month, year)",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
data, err := ctl.orderService.GetTimeSeries(tid, lid, granularity, fdate, tdate)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": data,
|
|
})
|
|
}
|