new api for offline sales
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -371,6 +372,72 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Tenantid <= 0 || input.Locationid <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid and locationid are 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 {
|
func (ctl *OrderController) GetCustomerOrders(c *fiber.Ctx) error {
|
||||||
customerID := c.Query("customerid")
|
customerID := c.Query("customerid")
|
||||||
tenantID := c.Query("tenantid")
|
tenantID := c.Query("tenantid")
|
||||||
|
|||||||
@@ -309,6 +309,40 @@ func (ctl *ProductController) GetLocationProducts(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSaleTemplate serves the data the web app turns into the offline-sales
|
||||||
|
// spreadsheet. Both tenantid and locationid are required rather than defaulted:
|
||||||
|
// a template generated against the wrong outlet would carry productids the
|
||||||
|
// import then rejects, which is a confusing failure a missing parameter should
|
||||||
|
// not be able to cause.
|
||||||
|
func (ctl *ProductController) GetSaleTemplate(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
|
|
||||||
|
if tenantID <= 0 || locationID <= 0 {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": false,
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid and locationid are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.productService.GetSaleTemplate(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": false,
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": true,
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"details": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *ProductController) GetLocationProductSummary(c *fiber.Ctx) error {
|
func (ctl *ProductController) GetLocationProductSummary(c *fiber.Ctx) error {
|
||||||
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
|
|||||||
@@ -439,6 +439,77 @@ type Ordersequences struct {
|
|||||||
Paymentprefix string `json:"paymentprefix" gorm:"default:PAY"`
|
Paymentprefix string `json:"paymentprefix" gorm:"default:PAY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Offline (in-store) sales import ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A sale rung up at the counter never passes through the app, so nothing
|
||||||
|
// deducts its stock. These types carry a spreadsheet of such sales into the
|
||||||
|
// same order path online orders use, so one ledger remains the single source
|
||||||
|
// of truth for stock and one revenue figure covers both channels.
|
||||||
|
|
||||||
|
// OfflineSaleItem is one spreadsheet row. Only Productid and Qtysold are
|
||||||
|
// required; the rest fall back to the product's own pricing when left blank.
|
||||||
|
// Productname is carried for verification against Productid, not for matching
|
||||||
|
// (see SaleTemplateRow for why a name can't be a key).
|
||||||
|
type OfflineSaleItem struct {
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
Productname string `json:"productname"`
|
||||||
|
Qtysold float64 `json:"qtysold"`
|
||||||
|
Unitprice float64 `json:"unitprice"`
|
||||||
|
Discountamount float64 `json:"discountamount"`
|
||||||
|
Taxpercent float64 `json:"taxpercent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineSaleBill is one counter bill — the rows of a spreadsheet grouped by
|
||||||
|
// their billno. Billno is what makes a re-upload of the same file safe: it is
|
||||||
|
// recorded on the order and refused if it is already present for this outlet.
|
||||||
|
type OfflineSaleBill struct {
|
||||||
|
Billno string `json:"billno"`
|
||||||
|
Saledate string `json:"saledate"`
|
||||||
|
Paymentmode string `json:"paymentmode"`
|
||||||
|
Customername string `json:"customername"`
|
||||||
|
Customermobile string `json:"customermobile"`
|
||||||
|
Remarks string `json:"remarks"`
|
||||||
|
Items []OfflineSaleItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineSalesUpload is the request body. Locationid is the outlet the sales
|
||||||
|
// belong to and is authorised server-side against Tenantid — a store user
|
||||||
|
// editing the spreadsheet cannot post sales into another branch.
|
||||||
|
type OfflineSalesUpload struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
Bills []OfflineSaleBill `json:"bills"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcomes a single bill can have. A bill is all-or-nothing: it either commits
|
||||||
|
// with its stock movement or it leaves nothing behind.
|
||||||
|
const (
|
||||||
|
OfflineSaleImported = "imported"
|
||||||
|
OfflineSaleDuplicate = "duplicate"
|
||||||
|
OfflineSaleFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OfflineSaleResult reports one bill's fate. Bills are independent, so a file
|
||||||
|
// with one bad bill still imports the rest and names exactly what it skipped.
|
||||||
|
type OfflineSaleResult struct {
|
||||||
|
Billno string `json:"billno"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Orderid string `json:"orderid"`
|
||||||
|
Orderheaderid int `json:"orderheaderid"`
|
||||||
|
Itemcount int `json:"itemcount"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineSalesUploadResponse struct {
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
Duplicate int `json:"duplicate"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Totalamount float64 `json:"totalamount"`
|
||||||
|
Results []OfflineSaleResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
type TenantRevenueSummary struct {
|
type TenantRevenueSummary struct {
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
Tenantname string `json:"tenantname"`
|
Tenantname string `json:"tenantname"`
|
||||||
|
|||||||
@@ -327,6 +327,40 @@ type ProductLocationRef struct {
|
|||||||
Productid int
|
Productid int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet:
|
||||||
|
// a product actually stocked at one outlet, with the numbers the person at the
|
||||||
|
// till needs to see before they type a sold quantity against it.
|
||||||
|
//
|
||||||
|
// Productid is the only field that identifies the product. It cannot be
|
||||||
|
// productsku: across the live catalogue 6,245 products share just 93 distinct
|
||||||
|
// sku values (one tenant has 463 products all carrying sku "1"), and 154 are
|
||||||
|
// blank, so a sku is not a key. Productname is nearly unique per tenant but
|
||||||
|
// not reliably ("rice" appears 6 times for one tenant), so it travels as a
|
||||||
|
// human-readable confirmation only and is never matched on. That is why the
|
||||||
|
// spreadsheet has to be generated from this endpoint rather than typed from
|
||||||
|
// scratch — the productid column is filled in for the user.
|
||||||
|
type SaleTemplateRow struct {
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
Productname string `json:"productname"`
|
||||||
|
Productunit string `json:"productunit"`
|
||||||
|
Unitvalue string `json:"unitvalue"`
|
||||||
|
Categoryname string `json:"categoryname"`
|
||||||
|
Currentstock int `json:"currentstock"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Taxpercent float64 `json:"taxpercent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaleTemplate is the payload the web app turns into an .xlsx workbook. The
|
||||||
|
// tenant/location identity travels with it so the generated file records which
|
||||||
|
// outlet it belongs to, and the upload can be checked against the file it came
|
||||||
|
// from instead of trusting a hand-typed location.
|
||||||
|
type SaleTemplate struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Locationname string `json:"locationname"`
|
||||||
|
Products []SaleTemplateRow `json:"products"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProductSubcategory struct {
|
type ProductSubcategory struct {
|
||||||
Subcatid int `json:"subcatid"`
|
Subcatid int `json:"subcatid"`
|
||||||
Categoryid int `json:"categoryid"`
|
Categoryid int `json:"categoryid"`
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
"log"
|
"log"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -25,6 +27,7 @@ type OrderRepository interface {
|
|||||||
GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error)
|
GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error)
|
||||||
UpdateOrder(order *models.Orders) error
|
UpdateOrder(order *models.Orders) error
|
||||||
CreateOrder(order models.Orders) (models.Orders, error)
|
CreateOrder(order models.Orders) (models.Orders, error)
|
||||||
|
UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error)
|
||||||
GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error)
|
GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error)
|
||||||
GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error)
|
GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error)
|
||||||
GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error)
|
GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error)
|
||||||
@@ -1167,7 +1170,50 @@ func nextSequenceNo(tx *gorm.DB, tid int, prefix string) (string, error) {
|
|||||||
|
|
||||||
func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error) {
|
func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error) {
|
||||||
tx := r.db.Begin()
|
tx := r.db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return models.Orders{}, tx.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := r.createOrderTx(tx, data)
|
||||||
|
if err != nil {
|
||||||
|
// createOrderTx has already rolled back — see its contract.
|
||||||
|
return models.Orders{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return models.Orders{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.reloadOrder(created.Orderheaderid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadOrder re-reads a committed order with its line items, so callers hand
|
||||||
|
// back the row as the database actually stored it (defaults applied, sequence
|
||||||
|
// number assigned) rather than the struct they submitted.
|
||||||
|
func (r *orderRepository) reloadOrder(orderHeaderID int) (models.Orders, error) {
|
||||||
|
var order models.Orders
|
||||||
|
if err := r.db.Where("orderheaderid = ?", orderHeaderID).First(&order).Error; err != nil {
|
||||||
|
return models.Orders{}, err
|
||||||
|
}
|
||||||
|
var items []models.OrderDetail
|
||||||
|
if err := r.db.Table("orderdetails").Where("orderheaderid = ?", orderHeaderID).Find(&items).Error; err == nil {
|
||||||
|
order.Items = items
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createOrderTx is everything an order needs between BEGIN and COMMIT: the
|
||||||
|
// per-product row locks, the stock pre-check, the sequence allocation, the
|
||||||
|
// header, the line items, the "out" ledger entries and the availability
|
||||||
|
// re-sync. It is split out from CreateOrder so a batch importer can run the
|
||||||
|
// identical, already-hardened path inside a transaction of its own rather than
|
||||||
|
// reimplementing stock deduction and drifting from it.
|
||||||
|
//
|
||||||
|
// Contract: on failure it has already rolled tx back, and the caller must not
|
||||||
|
// use tx again. On success tx is left open and uncommitted, so the caller can
|
||||||
|
// include its own work — a duplicate-bill guard, an advisory lock — in the same
|
||||||
|
// transaction as the order that work protects.
|
||||||
|
func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models.Orders, error) {
|
||||||
locID := data.Locationid
|
locID := data.Locationid
|
||||||
if locID == 0 {
|
if locID == 0 {
|
||||||
locID = data.Applocationid
|
locID = data.Applocationid
|
||||||
@@ -1309,20 +1355,567 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error)
|
|||||||
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
|
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deliberately not committed: the caller owns the transaction boundary.
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offline (in-store) sales import ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
// offlineDeliveryType tags an order that was rung up at the counter rather
|
||||||
|
// than placed through the app. It keeps offline sales out of the dispatch
|
||||||
|
// and delivery pipeline (which selects on 'B'/'C') while leaving them fully
|
||||||
|
// visible to revenue and stock reporting.
|
||||||
|
offlineDeliveryType = "OFFLINE"
|
||||||
|
|
||||||
|
// offlineRemarkPrefix namespaces the bill reference stored in
|
||||||
|
// orders.remarks. That stored value is what makes re-uploading the same
|
||||||
|
// spreadsheet a no-op instead of double-counting the sales.
|
||||||
|
offlineRemarkPrefix = "OFFLINE:"
|
||||||
|
)
|
||||||
|
|
||||||
|
// offlinePaymentTypes maps the spreadsheet's human payment mode onto the
|
||||||
|
// numeric orders.paymenttype. The paymenttype master table is empty in
|
||||||
|
// production, so these mirror the values live traffic already uses (42 is by
|
||||||
|
// far the most common, 64 next, then 43) and are the one place to change if
|
||||||
|
// that mapping is ever formalised.
|
||||||
|
var offlinePaymentTypes = map[string]int{
|
||||||
|
"cash": 42,
|
||||||
|
"card": 43,
|
||||||
|
"upi": 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const offlinePaymentTypeDefault = 42
|
||||||
|
|
||||||
|
// offlineLocationContext is the header scaffolding an order needs in order to
|
||||||
|
// be readable afterwards. It matters more than it looks: the order-listing
|
||||||
|
// query INNER JOINs customers, tenants, tenantlocations, app_location and
|
||||||
|
// app_locationconfig, so an imported order with a zero applocationid or
|
||||||
|
// customerid would be written successfully and then be invisible in every
|
||||||
|
// screen that lists orders.
|
||||||
|
type offlineLocationContext struct {
|
||||||
|
Tenantid int
|
||||||
|
Locationid int
|
||||||
|
Locationname string
|
||||||
|
Applocationid int
|
||||||
|
Moduleid int
|
||||||
|
Configid int
|
||||||
|
Partnerid int
|
||||||
|
Categoryid int
|
||||||
|
Subcategoryid int
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOfflineLocationContext authorises the outlet and works out that
|
||||||
|
// scaffolding. Ownership is checked here, not in the caller: this is the single
|
||||||
|
// point where "locationid belongs to tenantid" is established, so a store user
|
||||||
|
// who edits the locationid in their spreadsheet cannot post sales into another
|
||||||
|
// branch.
|
||||||
|
//
|
||||||
|
// applocationid comes from tenantlocations, which is authoritative. The
|
||||||
|
// remaining ids are taken from the most recent real order at the same outlet,
|
||||||
|
// because tenantlocations carries 0 for moduleid/partnerid at outlets whose
|
||||||
|
// live orders nonetheless use non-zero values — copying a known-good order's
|
||||||
|
// scaffolding is what guarantees the joins resolve.
|
||||||
|
func (r *orderRepository) resolveOfflineLocationContext(tenantID, locationID int) (*offlineLocationContext, error) {
|
||||||
|
if tenantID <= 0 || locationID <= 0 {
|
||||||
|
return nil, errors.New("tenantid and locationid are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var loc struct {
|
||||||
|
Locationname string
|
||||||
|
Applocationid int
|
||||||
|
Moduleid int
|
||||||
|
Partnerid int
|
||||||
|
}
|
||||||
|
err := r.db.Raw(`
|
||||||
|
SELECT COALESCE(locationname, '') AS locationname,
|
||||||
|
COALESCE(applocationid, 0) AS applocationid,
|
||||||
|
COALESCE(moduleid, 0) AS moduleid,
|
||||||
|
COALESCE(partnerid, 0) AS partnerid
|
||||||
|
FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
||||||
|
tenantID, locationID,
|
||||||
|
).Scan(&loc).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(loc.Locationname) == "" {
|
||||||
|
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &offlineLocationContext{
|
||||||
|
Tenantid: tenantID,
|
||||||
|
Locationid: locationID,
|
||||||
|
Locationname: strings.TrimSpace(loc.Locationname),
|
||||||
|
Applocationid: loc.Applocationid,
|
||||||
|
Moduleid: loc.Moduleid,
|
||||||
|
Partnerid: loc.Partnerid,
|
||||||
|
Configid: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
var prev struct {
|
||||||
|
Applocationid int
|
||||||
|
Moduleid int
|
||||||
|
Configid int
|
||||||
|
Partnerid int
|
||||||
|
Categoryid int
|
||||||
|
Subcategoryid int
|
||||||
|
}
|
||||||
|
err = r.db.Raw(`
|
||||||
|
SELECT COALESCE(applocationid, 0) AS applocationid,
|
||||||
|
COALESCE(moduleid, 0) AS moduleid,
|
||||||
|
COALESCE(configid, 0) AS configid,
|
||||||
|
COALESCE(partnerid, 0) AS partnerid,
|
||||||
|
COALESCE(categoryid, 0) AS categoryid,
|
||||||
|
COALESCE(subcategoryid, 0) AS subcategoryid
|
||||||
|
FROM orders
|
||||||
|
WHERE tenantid = ? AND locationid = ?
|
||||||
|
ORDER BY orderheaderid DESC LIMIT 1`,
|
||||||
|
tenantID, locationID,
|
||||||
|
).Scan(&prev).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fill from the previous order where it actually knows better; never
|
||||||
|
// let a zero from it wipe a good value taken from tenantlocations.
|
||||||
|
if prev.Applocationid > 0 {
|
||||||
|
ctx.Applocationid = prev.Applocationid
|
||||||
|
}
|
||||||
|
if prev.Moduleid > 0 {
|
||||||
|
ctx.Moduleid = prev.Moduleid
|
||||||
|
}
|
||||||
|
if prev.Configid > 0 {
|
||||||
|
ctx.Configid = prev.Configid
|
||||||
|
}
|
||||||
|
if prev.Partnerid > 0 {
|
||||||
|
ctx.Partnerid = prev.Partnerid
|
||||||
|
}
|
||||||
|
ctx.Categoryid = prev.Categoryid
|
||||||
|
ctx.Subcategoryid = prev.Subcategoryid
|
||||||
|
|
||||||
|
if ctx.Applocationid <= 0 {
|
||||||
|
return nil, fmt.Errorf("outlet '%s' has no applocationid configured; an offline sale imported against it would not appear in any order list", ctx.Locationname)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineProduct is what the importer needs to know about one product to price
|
||||||
|
// a line and to confirm the product really is stocked at this outlet.
|
||||||
|
type offlineProduct struct {
|
||||||
|
Productid int
|
||||||
|
Productname string
|
||||||
|
Productunit string
|
||||||
|
Price float64
|
||||||
|
Taxpercent float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOfflineProducts indexes every product stocked at the outlet by productid.
|
||||||
|
// Loaded once per upload rather than per line: a spreadsheet of a few hundred
|
||||||
|
// rows would otherwise issue a query per row.
|
||||||
|
//
|
||||||
|
// Membership in this map is the ownership check for a line. A productid absent
|
||||||
|
// from it is either another tenant's product or not stocked here, and either
|
||||||
|
// way the line is refused — so a hand-edited productid cannot reach into a
|
||||||
|
// catalogue the uploader has no claim on.
|
||||||
|
func (r *orderRepository) loadOfflineProducts(tenantID, locationID int) (map[int]offlineProduct, error) {
|
||||||
|
rows := make([]offlineProduct, 0)
|
||||||
|
err := r.db.Raw(`
|
||||||
|
SELECT a.productid,
|
||||||
|
COALESCE(a.productname, '') AS productname,
|
||||||
|
COALESCE(a.productunit, '') AS productunit,
|
||||||
|
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
|
||||||
|
COALESCE(a.taxpercent, 0) AS taxpercent
|
||||||
|
FROM products a
|
||||||
|
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
|
WHERE a.tenantid = ? AND b.locationid = ?`,
|
||||||
|
tenantID, locationID,
|
||||||
|
).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
index := make(map[int]offlineProduct, len(rows))
|
||||||
|
for _, p := range rows {
|
||||||
|
index[p.Productid] = p
|
||||||
|
}
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineDateLayouts are the formats a spreadsheet cell realistically arrives
|
||||||
|
// in. Day-first layouts precede month-first ones because the stores using this
|
||||||
|
// are Indian and write 03-07-2026 meaning 3 July.
|
||||||
|
var offlineDateLayouts = []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04",
|
||||||
|
"2006-01-02",
|
||||||
|
"02-01-2006 15:04:05",
|
||||||
|
"02-01-2006 15:04",
|
||||||
|
"02-01-2006",
|
||||||
|
"02/01/2006 15:04:05",
|
||||||
|
"02/01/2006 15:04",
|
||||||
|
"02/01/2006",
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOfflineSaleDate resolves the sale timestamp, falling back to now when
|
||||||
|
// the cell is blank. An unparseable value is an error rather than a silent
|
||||||
|
// fallback: importing a sale under the wrong date corrupts every daily revenue
|
||||||
|
// figure that reads it, so it is better to reject the bill and say so.
|
||||||
|
func parseOfflineSaleDate(raw string) (time.Time, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return time.Now(), nil
|
||||||
|
}
|
||||||
|
for _, layout := range offlineDateLayouts {
|
||||||
|
if t, err := time.Parse(layout, raw); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("unrecognised saledate %q (use YYYY-MM-DD or DD-MM-YYYY)", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineBillReference is the value written to orders.remarks and matched
|
||||||
|
// against on re-upload.
|
||||||
|
func offlineBillReference(billno string) string {
|
||||||
|
return offlineRemarkPrefix + strings.ToUpper(strings.TrimSpace(billno))
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineBillKey is the bill number to dedupe on. When the spreadsheet supplies
|
||||||
|
// one it is used as given. When it does not, a hash of the bill's own contents
|
||||||
|
// stands in, which makes a re-upload of an unnumbered bill still recognisable
|
||||||
|
// as the same sale — the alternative (a counter or a timestamp) would let the
|
||||||
|
// same file import twice and double-deduct the stock.
|
||||||
|
//
|
||||||
|
// The trade-off is deliberate: two genuinely separate sales of the identical
|
||||||
|
// basket, on the same date, both with no bill number, hash alike and the second
|
||||||
|
// is reported as a duplicate. Putting bill numbers in the sheet distinguishes
|
||||||
|
// them, and the result names the collision rather than hiding it.
|
||||||
|
func offlineBillKey(bill models.OfflineSaleBill, saleDate time.Time) string {
|
||||||
|
if b := strings.TrimSpace(bill.Billno); b != "" {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
h := fnv.New64a()
|
||||||
|
fmt.Fprintf(h, "%s|%s|%s|", saleDate.Format("2006-01-02"), strings.TrimSpace(bill.Customermobile), strings.TrimSpace(bill.Paymentmode))
|
||||||
|
for _, it := range bill.Items {
|
||||||
|
fmt.Fprintf(h, "%d:%.3f:%.2f;", it.Productid, it.Qtysold, it.Unitprice)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("AUTO-%s-%X", saleDate.Format("20060102"), h.Sum64())
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOfflineCustomer finds or creates the customer to attach the sale to.
|
||||||
|
// A customer is not optional: the order-listing query INNER JOINs customers, so
|
||||||
|
// an order with customerid 0 is written and then never shown.
|
||||||
|
//
|
||||||
|
// With a mobile number the sale is attached to that shopper, so their offline
|
||||||
|
// and app purchases sit under one customer. Without one it goes to a single
|
||||||
|
// per-outlet walk-in record, keyed on a sentinel contactno so repeated imports
|
||||||
|
// reuse it instead of accumulating a customer row per bill.
|
||||||
|
func (r *orderRepository) resolveOfflineCustomer(tx *gorm.DB, ctx *offlineLocationContext, name, mobile string) (int, error) {
|
||||||
|
mobile = strings.TrimSpace(mobile)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
|
||||||
|
contactno := mobile
|
||||||
|
if contactno == "" {
|
||||||
|
contactno = fmt.Sprintf("OFFLINE-%d", ctx.Locationid)
|
||||||
|
if name == "" {
|
||||||
|
name = "Walk-in Customer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Offline Customer"
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing int
|
||||||
|
err := tx.Raw(
|
||||||
|
`SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`,
|
||||||
|
contactno, ctx.Applocationid,
|
||||||
|
).Scan(&existing).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if existing > 0 {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// status 0 mirrors every existing customer row in production, including
|
||||||
|
// ones actively placing orders — a different value here would make the
|
||||||
|
// imported customer behave unlike all the others.
|
||||||
|
var created int
|
||||||
|
err = tx.Raw(`
|
||||||
|
INSERT INTO customers (configid, firstname, lastname, contactno, applocationid, locationid, status, created, updated)
|
||||||
|
VALUES (?, ?, '', ?, ?, ?, 0, NOW(), NOW())
|
||||||
|
RETURNING customerid`,
|
||||||
|
ctx.Configid, name, contactno, ctx.Applocationid, ctx.Locationid,
|
||||||
|
).Scan(&created).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if created <= 0 {
|
||||||
|
return 0, errors.New("failed to create customer for offline sale")
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadOfflineSales imports a spreadsheet of counter sales as real orders.
|
||||||
|
//
|
||||||
|
// Each bill is its own transaction, so one bad bill cannot undo the others and
|
||||||
|
// the response says exactly which ones landed. Inside that transaction the bill
|
||||||
|
// runs through createOrderTx — the same code path an app order takes — so stock
|
||||||
|
// deduction, the per-product row locks that stop overselling, the ledger "out"
|
||||||
|
// entries, the sequence allocation and the availability re-sync are shared with
|
||||||
|
// online orders rather than reimplemented alongside them.
|
||||||
|
//
|
||||||
|
// Deduplication is per bill and holds an advisory lock for the duration of the
|
||||||
|
// transaction that writes the order. The lock is what makes it a real guarantee
|
||||||
|
// instead of a race: two uploads of the same file arriving together would
|
||||||
|
// otherwise both read "not yet imported" and both commit.
|
||||||
|
func (r *orderRepository) UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error) {
|
||||||
|
if len(input.Bills) == 0 {
|
||||||
|
return nil, errors.New("no sales rows found in the upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
products, err := r.loadOfflineProducts(input.Tenantid, input.Locationid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(products) == 0 {
|
||||||
|
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &models.OfflineSalesUploadResponse{Results: make([]models.OfflineSaleResult, 0, len(input.Bills))}
|
||||||
|
|
||||||
|
for _, bill := range input.Bills {
|
||||||
|
result := r.importOfflineBill(ctx, products, input.Userid, bill)
|
||||||
|
resp.Results = append(resp.Results, result)
|
||||||
|
|
||||||
|
switch result.Status {
|
||||||
|
case models.OfflineSaleImported:
|
||||||
|
resp.Imported++
|
||||||
|
resp.Totalamount += result.Amount
|
||||||
|
case models.OfflineSaleDuplicate:
|
||||||
|
resp.Duplicate++
|
||||||
|
default:
|
||||||
|
resp.Failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// importOfflineBill commits one bill or leaves nothing behind. It returns a
|
||||||
|
// result rather than an error because a partial import is the useful outcome
|
||||||
|
// for a spreadsheet: the caller reports what failed and the operator fixes
|
||||||
|
// those rows without re-importing what already succeeded.
|
||||||
|
func (r *orderRepository) importOfflineBill(
|
||||||
|
ctx *offlineLocationContext,
|
||||||
|
products map[int]offlineProduct,
|
||||||
|
userID int,
|
||||||
|
bill models.OfflineSaleBill,
|
||||||
|
) models.OfflineSaleResult {
|
||||||
|
billLabel := strings.TrimSpace(bill.Billno)
|
||||||
|
|
||||||
|
fail := func(format string, args ...any) models.OfflineSaleResult {
|
||||||
|
return models.OfflineSaleResult{
|
||||||
|
Billno: billLabel,
|
||||||
|
Status: models.OfflineSaleFailed,
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bill.Items) == 0 {
|
||||||
|
return fail("bill has no sold items")
|
||||||
|
}
|
||||||
|
|
||||||
|
saleDate, err := parseOfflineSaleDate(bill.Saledate)
|
||||||
|
if err != nil {
|
||||||
|
return fail("%s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
billKey := offlineBillKey(bill, saleDate)
|
||||||
|
if billLabel == "" {
|
||||||
|
billLabel = billKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and price the line items before opening a transaction: a sheet with
|
||||||
|
// a bad productid should be rejected without having touched the database.
|
||||||
|
items := make([]models.OrderDetail, 0, len(bill.Items))
|
||||||
|
var orderAmount, taxTotal float64
|
||||||
|
|
||||||
|
for _, raw := range bill.Items {
|
||||||
|
if raw.Productid <= 0 {
|
||||||
|
return fail("a row is missing productid")
|
||||||
|
}
|
||||||
|
product, ok := products[raw.Productid]
|
||||||
|
if !ok {
|
||||||
|
return fail("product %d is not stocked at %s", raw.Productid, ctx.Locationname)
|
||||||
|
}
|
||||||
|
if raw.Qtysold <= 0 {
|
||||||
|
return fail("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Qtysold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sheet carries productname purely so a person can read it. It is
|
||||||
|
// checked against productid and reported on mismatch, but productid
|
||||||
|
// always wins — a name is not unique enough to resolve against.
|
||||||
|
if n := strings.TrimSpace(raw.Productname); n != "" && !strings.EqualFold(n, product.Productname) {
|
||||||
|
return fail("row for productid %d says '%s' but that id is '%s'; the template may be out of date", raw.Productid, n, product.Productname)
|
||||||
|
}
|
||||||
|
|
||||||
|
unitPrice := raw.Unitprice
|
||||||
|
if unitPrice <= 0 {
|
||||||
|
unitPrice = product.Price
|
||||||
|
}
|
||||||
|
taxPercent := raw.Taxpercent
|
||||||
|
if taxPercent <= 0 {
|
||||||
|
taxPercent = product.Taxpercent
|
||||||
|
}
|
||||||
|
|
||||||
|
gross := unitPrice * raw.Qtysold
|
||||||
|
discount := raw.Discountamount
|
||||||
|
if discount < 0 {
|
||||||
|
discount = 0
|
||||||
|
}
|
||||||
|
if discount > gross {
|
||||||
|
return fail("product '%s' has a discount of %.2f on a line worth %.2f", product.Productname, discount, gross)
|
||||||
|
}
|
||||||
|
// landing is the money actually taken at the counter, and it is what
|
||||||
|
// revenue is summed from. Tax is treated as already inside that price
|
||||||
|
// (retail prices here are MRP), so extracting it cannot change what the
|
||||||
|
// customer is recorded as having paid.
|
||||||
|
landing := gross - discount
|
||||||
|
taxAmount := 0.0
|
||||||
|
if taxPercent > 0 {
|
||||||
|
taxAmount = landing - (landing / (1 + taxPercent/100))
|
||||||
|
}
|
||||||
|
|
||||||
|
orderAmount += landing
|
||||||
|
taxTotal += taxAmount
|
||||||
|
|
||||||
|
items = append(items, models.OrderDetail{
|
||||||
|
Tenantid: ctx.Tenantid,
|
||||||
|
Locationid: ctx.Locationid,
|
||||||
|
Productid: raw.Productid,
|
||||||
|
Productname: product.Productname,
|
||||||
|
Orderqty: raw.Qtysold,
|
||||||
|
Supplyqty: raw.Qtysold,
|
||||||
|
Price: unitPrice,
|
||||||
|
Unitname: product.Productunit,
|
||||||
|
Discountamount: discount,
|
||||||
|
Landingamount: landing,
|
||||||
|
Taxpercentage: taxPercent,
|
||||||
|
Taxamount: taxAmount,
|
||||||
|
Productsumprice: gross,
|
||||||
|
Itemstatus: "delivered",
|
||||||
|
Delivered: saleDate.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentType := offlinePaymentTypeDefault
|
||||||
|
if mode := strings.ToLower(strings.TrimSpace(bill.Paymentmode)); mode != "" {
|
||||||
|
if mapped, ok := offlinePaymentTypes[mode]; ok {
|
||||||
|
paymentType = mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := strings.TrimSpace(bill.Remarks)
|
||||||
|
if notes == "" {
|
||||||
|
notes = fmt.Sprintf("Offline counter sale (bill %s)", billLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := r.db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fail("could not start a transaction: %v", tx.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held until this transaction ends, so a concurrent upload of the same bill
|
||||||
|
// waits here and then sees the committed row rather than racing past the
|
||||||
|
// duplicate check below.
|
||||||
|
lockKey := fmt.Sprintf("offlinesale:%d:%d:%s", ctx.Tenantid, ctx.Locationid, strings.ToUpper(billKey))
|
||||||
|
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fail("could not lock bill %s: %v", billLabel, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reference := offlineBillReference(billKey)
|
||||||
|
var already int
|
||||||
|
err = tx.Raw(
|
||||||
|
`SELECT COALESCE(COUNT(*), 0) FROM orders WHERE tenantid = ? AND locationid = ? AND remarks = ?`,
|
||||||
|
ctx.Tenantid, ctx.Locationid, reference,
|
||||||
|
).Scan(&already).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fail("could not check whether bill %s was already imported: %v", billLabel, err)
|
||||||
|
}
|
||||||
|
if already > 0 {
|
||||||
|
tx.Rollback()
|
||||||
|
return models.OfflineSaleResult{
|
||||||
|
Billno: billLabel,
|
||||||
|
Status: models.OfflineSaleDuplicate,
|
||||||
|
Message: fmt.Sprintf("bill %s was already imported for this outlet; stock was not deducted again", billLabel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, err := r.resolveOfflineCustomer(tx, ctx, bill.Customername, bill.Customermobile)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fail("could not resolve the customer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := models.Orders{
|
||||||
|
Tenantid: ctx.Tenantid,
|
||||||
|
Locationid: ctx.Locationid,
|
||||||
|
Applocationid: ctx.Applocationid,
|
||||||
|
Moduleid: ctx.Moduleid,
|
||||||
|
Configid: ctx.Configid,
|
||||||
|
Partnerid: ctx.Partnerid,
|
||||||
|
Categoryid: ctx.Categoryid,
|
||||||
|
Subcategoryid: ctx.Subcategoryid,
|
||||||
|
Customerid: customerID,
|
||||||
|
Orderdate: saleDate.Format("2006-01-02 15:04:05"),
|
||||||
|
// Already handed over at the counter, so the sale is complete on
|
||||||
|
// arrival and never enters the dispatch pipeline.
|
||||||
|
Orderstatus: "delivered",
|
||||||
|
Delivered: saleDate.Format("2006-01-02 15:04:05"),
|
||||||
|
Deliverytime: saleDate.Format("2006-01-02 15:04:05"),
|
||||||
|
Deliverytype: offlineDeliveryType,
|
||||||
|
Orderamount: float32(orderAmount),
|
||||||
|
Taxamount: float32(taxTotal),
|
||||||
|
Ordervalue: float32(orderAmount),
|
||||||
|
Itemcount: len(items),
|
||||||
|
Paymenttype: paymentType,
|
||||||
|
Paymentstatus: 1,
|
||||||
|
Ordernotes: notes,
|
||||||
|
Remarks: reference,
|
||||||
|
Tenantuserid: userID,
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := r.createOrderTx(tx, order)
|
||||||
|
if err != nil {
|
||||||
|
// createOrderTx has already rolled back. Its stock message is the one
|
||||||
|
// worth surfacing — it names the product and the shortfall.
|
||||||
|
return fail("%s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return models.Orders{}, err
|
return fail("could not commit bill %s: %v", billLabel, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var order models.Orders
|
return models.OfflineSaleResult{
|
||||||
if err := r.db.Where("orderheaderid = ?", data.Orderheaderid).First(&order).Error; err != nil {
|
Billno: billLabel,
|
||||||
return models.Orders{}, err
|
Status: models.OfflineSaleImported,
|
||||||
|
Orderid: created.Orderid,
|
||||||
|
Orderheaderid: created.Orderheaderid,
|
||||||
|
Itemcount: len(items),
|
||||||
|
Amount: orderAmount,
|
||||||
|
Message: fmt.Sprintf("imported as order %s", created.Orderid),
|
||||||
}
|
}
|
||||||
var items []models.OrderDetail
|
|
||||||
if err := r.db.Table("orderdetails").Where("orderheaderid = ?", data.Orderheaderid).Find(&items).Error; err == nil {
|
|
||||||
order.Items = items
|
|
||||||
}
|
|
||||||
|
|
||||||
return order, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *orderRepository) getOrderDetailsByHeaderID(orderHeaderID int) ([]models.OrderDetails, float64, float64, error) {
|
func (r *orderRepository) getOrderDetailsByHeaderID(orderHeaderID int) ([]models.OrderDetails, float64, float64, error) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ProductRepository interface {
|
|||||||
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
||||||
GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error)
|
GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error)
|
||||||
GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error)
|
GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error)
|
||||||
|
GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error)
|
||||||
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
||||||
GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error)
|
GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error)
|
||||||
GetSubcategories(categoryID int) ([]models.Subcategory, error)
|
GetSubcategories(categoryID int) ([]models.Subcategory, error)
|
||||||
@@ -502,6 +503,71 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSaleTemplate lists every product stocked at one outlet, with its live
|
||||||
|
// ledger balance, so the web app can generate a pre-filled offline-sales
|
||||||
|
// spreadsheet. It deliberately returns the whole catalogue for the outlet
|
||||||
|
// unpaged — a spreadsheet the user is meant to fill in and hand back is only
|
||||||
|
// useful if it contains every product they could have sold.
|
||||||
|
//
|
||||||
|
// The balance is the same SUM(in) - SUM(out) expression CreateOrder validates
|
||||||
|
// against, so the "currentstock" the user reads in the sheet is exactly the
|
||||||
|
// number the import will later check their quantity against. LOWER() covers
|
||||||
|
// the mixed-case stocktype values in production ('out', 'IN', 'in').
|
||||||
|
//
|
||||||
|
// A location that does not belong to the tenant yields no template rather than
|
||||||
|
// another tenant's catalogue: the caller treats that as "not your outlet".
|
||||||
|
func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) {
|
||||||
|
if tenantID <= 0 || locationID <= 0 {
|
||||||
|
return nil, errors.New("tenantid and locationid are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var loc struct {
|
||||||
|
Locationname string
|
||||||
|
}
|
||||||
|
err := r.db.Raw(
|
||||||
|
`SELECT locationname FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
||||||
|
tenantID, locationID,
|
||||||
|
).Scan(&loc).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(loc.Locationname) == "" {
|
||||||
|
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]models.SaleTemplateRow, 0)
|
||||||
|
query := `
|
||||||
|
SELECT a.productid,
|
||||||
|
a.productname,
|
||||||
|
COALESCE(a.productunit, '') AS productunit,
|
||||||
|
COALESCE(a.unitvalue, '') AS unitvalue,
|
||||||
|
COALESCE(d.categoryname, '') AS categoryname,
|
||||||
|
COALESCE(SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity ELSE 0 END) -
|
||||||
|
SUM(CASE WHEN LOWER(c.stocktype) = 'out' THEN c.quantity ELSE 0 END), 0) AS currentstock,
|
||||||
|
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
|
||||||
|
COALESCE(a.taxpercent, 0) AS taxpercent
|
||||||
|
FROM products a
|
||||||
|
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
|
LEFT JOIN productstocks c
|
||||||
|
ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid
|
||||||
|
LEFT JOIN productcategories d ON a.categoryid = d.categoryid
|
||||||
|
WHERE a.approve = 1 AND a.tenantid = ? AND b.locationid = ?
|
||||||
|
GROUP BY a.productid, a.productname, a.productunit, a.unitvalue, d.categoryname,
|
||||||
|
b.price, a.retailprice, a.taxpercent
|
||||||
|
ORDER BY a.productname ASC`
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, tenantID, locationID).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.SaleTemplate{
|
||||||
|
Tenantid: tenantID,
|
||||||
|
Locationid: locationID,
|
||||||
|
Locationname: strings.TrimSpace(loc.Locationname),
|
||||||
|
Products: rows,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *productRepository) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) {
|
func (r *productRepository) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) {
|
||||||
data := make([]models.ProductSummary, 0)
|
data := make([]models.ProductSummary, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func RegisterOrderRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
orders.Get("/getorderdetails", f.OrderController.GetOrderDetails)
|
orders.Get("/getorderdetails", f.OrderController.GetOrderDetails)
|
||||||
orders.Put("/updateorder", f.OrderController.UpdateOrder)
|
orders.Put("/updateorder", f.OrderController.UpdateOrder)
|
||||||
orders.Post("/createorder", f.OrderController.CreateOrderv3)
|
orders.Post("/createorder", f.OrderController.CreateOrderv3)
|
||||||
|
orders.Post("/uploadofflinesales", f.OrderController.UploadOfflineSales)
|
||||||
|
|
||||||
reports := api.Group("/v1/web/reports")
|
reports := api.Group("/v1/web/reports")
|
||||||
reports.Get("/sales-summary", f.OrderController.GetSalesSummary)
|
reports.Get("/sales-summary", f.OrderController.GetSalesSummary)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
products.Get("/getstockstatement", f.ProductController.GetStockStatement)
|
products.Get("/getstockstatement", f.ProductController.GetStockStatement)
|
||||||
products.Get("/getlocationproducts", f.ProductController.GetLocationProducts)
|
products.Get("/getlocationproducts", f.ProductController.GetLocationProducts)
|
||||||
products.Get("/getlocationproductsummary", f.ProductController.GetLocationProductSummary)
|
products.Get("/getlocationproductsummary", f.ProductController.GetLocationProductSummary)
|
||||||
|
products.Get("/getsaletemplate", f.ProductController.GetSaleTemplate)
|
||||||
products.Get("/getallproducts", f.ProductController.GetAllProducts)
|
products.Get("/getallproducts", f.ProductController.GetAllProducts)
|
||||||
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
||||||
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type OrderService interface {
|
|||||||
GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error)
|
GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error)
|
||||||
UpdateOrder(order *models.Orders) error
|
UpdateOrder(order *models.Orders) error
|
||||||
CreateOrder(order models.Orders) (models.Orders, error)
|
CreateOrder(order models.Orders) (models.Orders, error)
|
||||||
|
UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error)
|
||||||
GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error)
|
GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error)
|
||||||
GetTenantLocationOrders(input models.DeliveryQuery) ([]models.OrderInfo, error)
|
GetTenantLocationOrders(input models.DeliveryQuery) ([]models.OrderInfo, error)
|
||||||
GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error)
|
GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error)
|
||||||
@@ -81,6 +82,10 @@ func (s *orderService) CreateOrder(order models.Orders) (models.Orders, error) {
|
|||||||
return s.repo.CreateOrder(order)
|
return s.repo.CreateOrder(order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *orderService) UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error) {
|
||||||
|
return s.repo.UploadOfflineSales(input)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *orderService) GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error) {
|
func (s *orderService) GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error) {
|
||||||
return s.repo.GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword, pageSize, offset)
|
return s.repo.GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword, pageSize, offset)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type ProductService interface {
|
|||||||
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
||||||
GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error)
|
GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error)
|
||||||
GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error)
|
GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error)
|
||||||
|
GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error)
|
||||||
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
||||||
GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error)
|
GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error)
|
||||||
GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error)
|
GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error)
|
||||||
@@ -140,6 +141,10 @@ func (s *productService) GetLocationProductSummary(tenantID, locationID int) ([]
|
|||||||
return s.repo.GetLocationProductSummary(tenantID, locationID)
|
return s.repo.GetLocationProductSummary(tenantID, locationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *productService) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) {
|
||||||
|
return s.repo.GetSaleTemplate(tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *productService) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus,
|
func (s *productService) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus,
|
||||||
approve string, pageno, pagesize int) ([]models.Tenantproducts, error) {
|
approve string, pageno, pagesize int) ([]models.Tenantproducts, error) {
|
||||||
return s.repo.FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID, keyword, productStatus, approve, pageno, pagesize)
|
return s.repo.FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID, keyword, productStatus, approve, pageno, pagesize)
|
||||||
|
|||||||
Reference in New Issue
Block a user