2389 lines
77 KiB
Go
2389 lines
77 KiB
Go
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"log"
|
|
"nearle/models"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type OrderRepository interface {
|
|
GetTenantOrders(q models.DeliveryQuery) ([]models.OrderInfo, error)
|
|
GetTenantLocationOrders(input models.DeliveryQuery) ([]models.OrderInfo, error)
|
|
GetPartnerOrders(stat, fdate, tdate string, pid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error)
|
|
GetCustomerOrders(stat, fdate, tdate string, cid, mid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error)
|
|
GetAdminOrders(stat, fdate, tdate string, aid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error)
|
|
GetUserOrders(stat, fdate, tdate string, uid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error)
|
|
GetAllOrders(stat, fdate, tdate string, pageno, pagesize int, keyword string) ([]models.OrderInfo, error)
|
|
GetOrderSummary(tid, pid, cid, aid int, fdate, tdate string) ([]models.Ordersummarydaily, error)
|
|
GetLocationOrderSummary(tenantID int) ([]models.Ordersummarylocation, error)
|
|
GetOrderInsights(tenantID int) ([]models.OrderInsightv1, error)
|
|
GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error)
|
|
UpdateOrder(order *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)
|
|
GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error)
|
|
GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error)
|
|
GetTimeSeries(tenantID, locationID int, granularity, fromDate, toDate string) ([]models.TimeSeriesData, error)
|
|
}
|
|
|
|
type orderRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewOrderRepository(db *gorm.DB) OrderRepository {
|
|
return &orderRepository{db: db}
|
|
}
|
|
|
|
const (
|
|
base = `SELECT DISTINCT a.orderheaderid, a.applocationid, h.locationname AS applocation, a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
|
|
a.orderid, a.orderstatus, a.orderdate, a.ordernotes, a.itemcount, a.deliverytime AS deliverydate,
|
|
a.pending, a.processing, a.ready, a.delivered AS completed, a.cancelled,
|
|
a.deliverycharge, a.kms,
|
|
a.customerid, a.pickuplocationid, a.pickupaddress, a.pickuplat, a.pickuplong,
|
|
a.pickupcustomer, a.pickupcontactno, a.pickuplocation as pickupsuburb, a.pickupcity,
|
|
a.deliveryid AS deliverycustomerid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
|
|
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery, b.customertoken,
|
|
c.tenantname, c.tenanttoken, c.primarycontact AS tenantcontactno, c.postcode AS tenantpostcode, c.suburb AS tenantsuburb, c.city AS tenantcity,
|
|
d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity
|
|
FROM orders a
|
|
INNER JOIN customers b ON a.customerid = b.customerid
|
|
INNER JOIN tenants c ON a.tenantid = c.tenantid
|
|
INNER JOIN tenantlocations d ON a.locationid = d.locationid
|
|
|
|
INNER JOIN app_location h ON a.applocationid = h.applocationid
|
|
INNER JOIN app_locationconfig i ON a.applocationid = i.applocationid`
|
|
|
|
orderdetails = `SELECT DISTINCT a.orderheaderid, a.applocationid,
|
|
a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
|
|
a.orderid, a.orderstatus, a.orderdate, a.ordernotes, a.itemcount, a.deliverytime AS deliverydate,
|
|
a.pending, a.processing, a.ready, a.delivered AS completed, a.cancelled,
|
|
a.deliverycharge, a.kms,
|
|
a.customerid, a.pickupaddress, a.pickuplat, a.pickuplong,
|
|
a.pickupcustomer, a.pickupcontactno, a.pickuplocation as pickupsuburb, a.pickupcity,
|
|
a.deliveryid AS deliverycustomerid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
|
|
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity,a.paymenttype, a.smsdelivery, a.orderamount,
|
|
b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno, b.postcode AS tenantpostcode, b.suburb AS tenantsuburb,b.city AS tenantcity,
|
|
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode, c.suburb AS locationsuburb, c.city AS locationcity,
|
|
d.locationname AS applocation
|
|
FROM orders a
|
|
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
|
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
|
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid`
|
|
)
|
|
|
|
func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) {
|
|
var data []models.OrderInfo
|
|
var query string
|
|
var params []interface{}
|
|
|
|
offset := (input.Pageno - 1) * input.Pagesize
|
|
baseQuery := base + ` WHERE a.tenantid = ?`
|
|
params = append(params, input.Tenantid)
|
|
|
|
if input.Status == "ongoing" {
|
|
query = baseQuery + ` AND a.orderstatus IN ('pending','processing','ready')`
|
|
} else {
|
|
query = baseQuery + ` AND a.orderstatus = ?`
|
|
params = append(params, input.Status)
|
|
}
|
|
|
|
query += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
params = append(params, input.Fromdate, input.ToDate)
|
|
|
|
if input.Keyword != "" {
|
|
query += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
c.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + input.Keyword + "%"
|
|
params = append(params, like, like, like, like, like, like)
|
|
}
|
|
|
|
if input.Configid != 0 {
|
|
query += ` AND a.configid = ?`
|
|
params = append(params, input.Configid)
|
|
}
|
|
|
|
query += ` ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?`
|
|
params = append(params, input.Pagesize, offset)
|
|
|
|
fmt.Println("Executing:", query)
|
|
res := r.db.Raw(query, params...).Find(&data)
|
|
|
|
return data, res.Error
|
|
}
|
|
|
|
func (r *orderRepository) GetPartnerOrders(stat, fdate, tdate string, pid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error) {
|
|
var data []models.OrderInfo
|
|
|
|
if pageno <= 0 {
|
|
pageno = 1
|
|
}
|
|
if pagesize <= 0 {
|
|
pagesize = 10
|
|
}
|
|
offset := (pageno - 1) * pagesize
|
|
|
|
fmt.Println("Getting partner order details")
|
|
|
|
query := orderdetails + ` WHERE a.partnerid = ?`
|
|
params := []interface{}{pid}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
query += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
query += ` AND a.orderstatus = ?`
|
|
params = append(params, stat)
|
|
}
|
|
if keyword != "" {
|
|
query += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
params = append(params, like, like, like, like, like, like)
|
|
}
|
|
|
|
var total int64
|
|
countQuery := `
|
|
SELECT COUNT(DISTINCT a.orderheaderid)
|
|
FROM orders a
|
|
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
|
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
|
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
|
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
|
LEFT JOIN app_users g ON f.userid = g.userid
|
|
WHERE a.partnerid = ?`
|
|
countParams := []interface{}{pid}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
countQuery += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
countParams = append(countParams, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
countQuery += ` AND a.orderstatus = ?`
|
|
countParams = append(countParams, stat)
|
|
}
|
|
if keyword != "" {
|
|
countQuery += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
countParams = append(countParams, like, like, like, like, like, like)
|
|
}
|
|
|
|
r.db.Raw(countQuery, countParams...).Scan(&total)
|
|
|
|
if int64(offset) >= total {
|
|
offset = 0
|
|
pageno = 1
|
|
}
|
|
|
|
query += ` ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?`
|
|
params = append(params, pagesize, offset)
|
|
|
|
fmt.Println("QUERY:", query)
|
|
fmt.Println("PARAMS:", params)
|
|
|
|
r.db.Raw(query, params...).Find(&data)
|
|
fmt.Println("RESULT COUNT:", len(data))
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetCustomerOrders(stat, fdate, tdate string, cid, mid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error) {
|
|
var data []models.OrderInfo
|
|
|
|
if pageno <= 0 {
|
|
pageno = 1
|
|
}
|
|
if pagesize <= 0 {
|
|
pagesize = 10
|
|
}
|
|
offset := (pageno - 1) * pagesize
|
|
|
|
fmt.Println("Getting customer order details")
|
|
|
|
query := orderdetails + ` WHERE a.customerid = ?`
|
|
params := []interface{}{cid}
|
|
|
|
if mid != 0 {
|
|
query += ` AND a.moduleid = ?`
|
|
params = append(params, mid)
|
|
}
|
|
if fdate != "" && tdate != "" {
|
|
query += ` AND a.orderdate::date BETWEEN ? AND ?`
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
query += ` AND a.orderstatus = ?`
|
|
params = append(params, stat)
|
|
}
|
|
if keyword != "" {
|
|
query += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
params = append(params, like, like, like, like, like, like)
|
|
}
|
|
|
|
var total int64
|
|
countQuery := `
|
|
SELECT COUNT(DISTINCT a.orderheaderid)
|
|
FROM orders a
|
|
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
|
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
|
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
|
WHERE a.customerid = ?`
|
|
countParams := []interface{}{cid}
|
|
|
|
if mid != 0 {
|
|
countQuery += ` AND a.moduleid = ?`
|
|
countParams = append(countParams, mid)
|
|
}
|
|
if fdate != "" && tdate != "" {
|
|
countQuery += ` AND a.orderdate::date BETWEEN ? AND ?`
|
|
countParams = append(countParams, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
countQuery += ` AND a.orderstatus = ?`
|
|
countParams = append(countParams, stat)
|
|
}
|
|
if keyword != "" {
|
|
countQuery += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
countParams = append(countParams, like, like, like, like, like, like)
|
|
}
|
|
|
|
r.db.Raw(countQuery, countParams...).Scan(&total)
|
|
|
|
if int64(offset) >= total {
|
|
offset = 0
|
|
pageno = 1
|
|
}
|
|
|
|
query += ` ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?`
|
|
params = append(params, pagesize, offset)
|
|
|
|
fmt.Println("QUERY:", query)
|
|
fmt.Println("PARAMS:", params)
|
|
|
|
res := r.db.Raw(query, params...).Find(&data)
|
|
if res.Error != nil {
|
|
fmt.Println("ERROR:", res.Error)
|
|
}
|
|
fmt.Println("RESULT COUNT:", len(data))
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetAdminOrders(stat, fdate, tdate string, aid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error) {
|
|
var data []models.OrderInfo
|
|
|
|
if pageno <= 0 {
|
|
pageno = 1
|
|
}
|
|
if pagesize <= 0 {
|
|
pagesize = 10
|
|
}
|
|
offset := (pageno - 1) * pagesize
|
|
|
|
fmt.Println("Getting admin order details")
|
|
|
|
query := orderdetails + ` WHERE 1=1`
|
|
params := []interface{}{}
|
|
|
|
if aid != 0 {
|
|
query += ` AND a.applocationid = ?`
|
|
params = append(params, aid)
|
|
}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
query += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
|
|
if stat != "" {
|
|
query += ` AND a.orderstatus = ?`
|
|
params = append(params, stat)
|
|
}
|
|
|
|
if keyword != "" {
|
|
query += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
params = append(params, like, like, like, like, like, like)
|
|
}
|
|
|
|
var total int64
|
|
countQuery := `
|
|
SELECT COUNT(DISTINCT a.orderheaderid)
|
|
FROM orders a
|
|
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
|
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
|
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
|
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
|
LEFT JOIN app_users g ON f.userid = g.userid
|
|
WHERE 1=1
|
|
`
|
|
countParams := []interface{}{}
|
|
|
|
if aid != 0 {
|
|
countQuery += ` AND a.applocationid = ?`
|
|
countParams = append(countParams, aid)
|
|
}
|
|
if fdate != "" && tdate != "" {
|
|
countQuery += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
countParams = append(countParams, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
countQuery += ` AND a.orderstatus = ?`
|
|
countParams = append(countParams, stat)
|
|
}
|
|
if keyword != "" {
|
|
countQuery += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
countParams = append(countParams, like, like, like, like, like, like)
|
|
}
|
|
|
|
r.db.Raw(countQuery, countParams...).Scan(&total)
|
|
|
|
if int64(offset) >= total {
|
|
offset = 0
|
|
pageno = 1
|
|
}
|
|
|
|
query += ` ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?`
|
|
params = append(params, pagesize, offset)
|
|
|
|
fmt.Println("QUERY:", query)
|
|
fmt.Println("PARAMS:", params)
|
|
|
|
r.db.Raw(query, params...).Find(&data)
|
|
fmt.Println("RESULT COUNT:", len(data))
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetUserOrders(stat, fdate, tdate string, uid, pageno, pagesize int, keyword string) ([]models.OrderInfo, error) {
|
|
var data []models.OrderInfo
|
|
|
|
if pageno <= 0 {
|
|
pageno = 1
|
|
}
|
|
if pagesize <= 0 {
|
|
pagesize = 10
|
|
}
|
|
offset := (pageno - 1) * pagesize
|
|
|
|
fmt.Println("Getting user order details")
|
|
|
|
query := orderdetails + ` WHERE e.status = 'Active' AND e.userid = ?`
|
|
params := []interface{}{uid}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
query += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
query += ` AND a.orderstatus = ?`
|
|
params = append(params, stat)
|
|
}
|
|
if keyword != "" {
|
|
query += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
params = append(params, like, like, like, like, like, like)
|
|
}
|
|
|
|
var total int64
|
|
countQuery := `
|
|
SELECT COUNT(DISTINCT a.orderheaderid)
|
|
FROM orders a
|
|
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
|
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
|
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
|
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
|
LEFT JOIN app_users g ON f.userid = g.userid
|
|
WHERE e.status = 'Active' AND e.userid = ?
|
|
`
|
|
countParams := []interface{}{uid}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
countQuery += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
countParams = append(countParams, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
countQuery += ` AND a.orderstatus = ?`
|
|
countParams = append(countParams, stat)
|
|
}
|
|
if keyword != "" {
|
|
countQuery += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
countParams = append(countParams, like, like, like, like, like, like)
|
|
}
|
|
|
|
r.db.Raw(countQuery, countParams...).Scan(&total)
|
|
|
|
if int64(offset) >= total {
|
|
offset = 0
|
|
pageno = 1
|
|
}
|
|
|
|
query += ` ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?`
|
|
params = append(params, pagesize, offset)
|
|
|
|
fmt.Println("QUERY:", query)
|
|
fmt.Println("PARAMS:", params)
|
|
|
|
r.db.Raw(query, params...).Find(&data)
|
|
fmt.Println("RESULT COUNT:", len(data))
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetAllOrders(stat, fdate, tdate string, pageno, pagesize int, keyword string) ([]models.OrderInfo, error) {
|
|
var data []models.OrderInfo
|
|
|
|
if pageno <= 0 {
|
|
pageno = 1
|
|
}
|
|
if pagesize <= 0 {
|
|
pagesize = 10
|
|
}
|
|
offset := (pageno - 1) * pagesize
|
|
|
|
fmt.Println("Getting all orders")
|
|
|
|
query := orderdetails + ` WHERE 1=1`
|
|
params := []interface{}{}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
query += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
|
|
if stat != "" {
|
|
query += ` AND a.orderstatus = ?`
|
|
params = append(params, stat)
|
|
}
|
|
|
|
if keyword != "" {
|
|
query += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
params = append(params, like, like, like, like, like, like)
|
|
}
|
|
|
|
var total int64
|
|
countQuery := `
|
|
SELECT COUNT(DISTINCT a.orderheaderid)
|
|
FROM orders a
|
|
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
|
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
|
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
|
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
|
LEFT JOIN app_users g ON f.userid = g.userid
|
|
WHERE 1=1
|
|
`
|
|
countParams := []interface{}{}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
countQuery += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
|
countParams = append(countParams, fdate, tdate)
|
|
}
|
|
if stat != "" {
|
|
countQuery += ` AND a.orderstatus = ?`
|
|
countParams = append(countParams, stat)
|
|
}
|
|
if keyword != "" {
|
|
countQuery += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
b.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
like := "%" + keyword + "%"
|
|
countParams = append(countParams, like, like, like, like, like, like)
|
|
}
|
|
|
|
r.db.Raw(countQuery, countParams...).Scan(&total)
|
|
|
|
if int64(offset) >= total {
|
|
offset = 0
|
|
pageno = 1
|
|
}
|
|
|
|
query += ` ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?`
|
|
params = append(params, pagesize, offset)
|
|
|
|
fmt.Println("QUERY:", query)
|
|
fmt.Println("PARAMS:", params)
|
|
|
|
r.db.Raw(query, params...).Find(&data)
|
|
fmt.Println("RESULT COUNT:", len(data))
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetOrderSummary(tid, pid, cid, lid int, fdate, tdate string) ([]models.Ordersummarydaily, error) {
|
|
var data []models.Ordersummarydaily
|
|
|
|
// Base SELECT
|
|
const base = `
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN o.orderstatus = 'created' THEN 1 ELSE 0 END) AS created,
|
|
SUM(CASE WHEN o.orderstatus = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
SUM(CASE WHEN o.orderstatus = 'processing' THEN 1 ELSE 0 END) AS processing,
|
|
SUM(CASE WHEN o.orderstatus = 'delivered' THEN 1 ELSE 0 END) AS delivered,
|
|
SUM(CASE WHEN o.orderstatus = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
|
|
t.tenantid,
|
|
t.tenantname
|
|
FROM orders o
|
|
INNER JOIN tenants t ON o.tenantid = t.tenantid
|
|
`
|
|
|
|
var params []interface{}
|
|
var q1 string
|
|
|
|
// Apply filters (at least one scoping id is required — enforced by the caller)
|
|
switch {
|
|
case tid != 0:
|
|
q1 = base + " WHERE o.configid = 1 AND o.tenantid = ?"
|
|
params = append(params, tid)
|
|
case pid != 0:
|
|
q1 = base + " WHERE o.configid = 1 AND o.partnerid = ?"
|
|
params = append(params, pid)
|
|
case cid != 0:
|
|
q1 = base + " WHERE o.configid = 1 AND o.customerid = ?"
|
|
params = append(params, cid)
|
|
case lid != 0:
|
|
q1 = base + " WHERE o.configid = 1 AND o.locationid = ?"
|
|
params = append(params, lid)
|
|
default:
|
|
return nil, fmt.Errorf("at least one of tenantid, partnerid, customerid or locationid is required")
|
|
}
|
|
|
|
// Date filter
|
|
if fdate != "" && tdate != "" {
|
|
q1 += " AND o.orderdate::date BETWEEN ? AND ?"
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
|
|
// Group by tenant
|
|
q1 += " GROUP BY t.tenantid, t.tenantname"
|
|
|
|
// Debug
|
|
fmt.Println("Executing GetOrderSummary query:", q1)
|
|
|
|
if err := r.db.Raw(q1, params...).Scan(&data).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetLocationOrderSummary(tenantID int) ([]models.Ordersummarylocation, error) {
|
|
var data []models.Ordersummarylocation
|
|
var params []interface{}
|
|
|
|
q1 := `
|
|
SELECT
|
|
l.locationid,
|
|
l.locationname,
|
|
COALESCE(COUNT(o.orderid), 0) AS total,
|
|
COALESCE(SUM(CASE WHEN o.orderstatus = 'created' THEN 1 ELSE 0 END), 0) AS created,
|
|
COALESCE(SUM(CASE WHEN o.orderstatus = 'pending' THEN 1 ELSE 0 END), 0) AS pending,
|
|
COALESCE(SUM(CASE WHEN o.orderstatus = 'processing' THEN 1 ELSE 0 END), 0) AS processing,
|
|
COALESCE(SUM(CASE WHEN o.orderstatus = 'delivered' THEN 1 ELSE 0 END), 0) AS delivered,
|
|
COALESCE(SUM(CASE WHEN o.orderstatus = 'cancelled' THEN 1 ELSE 0 END), 0) AS cancelled
|
|
FROM tenantlocations l
|
|
LEFT JOIN orders o
|
|
ON l.locationid = o.locationid
|
|
AND l.tenantid = o.tenantid
|
|
AND o.configid = 1
|
|
`
|
|
|
|
if tenantID != 0 {
|
|
q1 += " WHERE l.tenantid = ?"
|
|
params = append(params, tenantID)
|
|
}
|
|
|
|
q1 += " GROUP BY l.locationid, l.locationname ORDER BY l.locationid"
|
|
|
|
fmt.Println("Executing query:", q1)
|
|
|
|
if err := r.db.Raw(q1, params...).Scan(&data).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error) {
|
|
var summary models.TenantRevenueSummary
|
|
|
|
// 1. If tid is 0 and lid is not 0, lookup the tenantid from tenantlocations
|
|
if tid == 0 && lid != 0 {
|
|
var tenantLoc struct {
|
|
Tenantid int
|
|
}
|
|
if err := r.db.Table("tenantlocations").Select("tenantid").Where("locationid = ?", lid).Scan(&tenantLoc).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
tid = tenantLoc.Tenantid
|
|
}
|
|
|
|
if tid == 0 {
|
|
return nil, fmt.Errorf("tenant ID is required or could not be determined")
|
|
}
|
|
|
|
summary.Tenantid = tid
|
|
|
|
// 2. Fetch the tenant name
|
|
var tenant struct {
|
|
Tenantname string
|
|
}
|
|
if err := r.db.Table("tenants").Select("tenantname").Where("tenantid = ?", tid).Scan(&tenant).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
summary.Tenantname = tenant.Tenantname
|
|
|
|
// 3. Fetch overall revenue for this tenant
|
|
overallQuery := `
|
|
SELECT COALESCE(SUM(orderamount), 0) AS overall_revenue
|
|
FROM orders
|
|
WHERE tenantid = ? AND orderstatus = 'delivered' AND configid = 1
|
|
`
|
|
var overallParams []interface{}
|
|
overallParams = append(overallParams, tid)
|
|
|
|
if lid != 0 {
|
|
overallQuery += " AND locationid = ?"
|
|
overallParams = append(overallParams, lid)
|
|
}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
overallQuery += " AND orderdate::date BETWEEN ? AND ?"
|
|
overallParams = append(overallParams, fdate, tdate)
|
|
}
|
|
|
|
var overallRev float64
|
|
if err := r.db.Raw(overallQuery, overallParams...).Scan(&overallRev).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Counter sales live in their own table, so every figure that reads
|
|
// `orders` alone understates a shop that runs a till. Added here rather
|
|
// than by rewriting the query above: the join and the dynamic parameters
|
|
// are load-bearing for app orders and not worth disturbing.
|
|
posTotal, posByLocation, err := r.posRevenue(tid, lid, fdate, tdate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
summary.OverallRevenue = overallRev + posTotal
|
|
|
|
// 4. Fetch revenue details by location
|
|
locationQuery := `
|
|
SELECT
|
|
l.locationid,
|
|
l.locationname,
|
|
COALESCE(SUM(o.orderamount), 0) AS revenue
|
|
FROM tenantlocations l
|
|
LEFT JOIN orders o
|
|
ON l.locationid = o.locationid
|
|
AND o.orderstatus = 'delivered'
|
|
AND o.configid = 1
|
|
`
|
|
var locParams []interface{}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
locationQuery += " AND o.orderdate::date BETWEEN ? AND ?"
|
|
locParams = append(locParams, fdate, tdate)
|
|
}
|
|
|
|
locationQuery += " WHERE l.tenantid = ?"
|
|
locParams = append(locParams, tid)
|
|
|
|
if lid != 0 {
|
|
locationQuery += " AND l.locationid = ?"
|
|
locParams = append(locParams, lid)
|
|
}
|
|
|
|
locationQuery += " GROUP BY l.locationid, l.locationname ORDER BY l.locationid"
|
|
|
|
var locRevenues []models.LocationRevenueDetails
|
|
if err := r.db.Raw(locationQuery, locParams...).Scan(&locRevenues).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if locRevenues == nil {
|
|
locRevenues = []models.LocationRevenueDetails{}
|
|
}
|
|
|
|
// The location list comes from tenantlocations, so an outlet that trades
|
|
// only through its counter still has a row here — it just has zero app
|
|
// revenue against it. Adding rather than replacing keeps both visible.
|
|
for i := range locRevenues {
|
|
locRevenues[i].Revenue += posByLocation[locRevenues[i].Locationid]
|
|
}
|
|
|
|
summary.LocationRevenue = locRevenues
|
|
|
|
return &summary, nil
|
|
}
|
|
|
|
// posSalesTotals returns counter-sale revenue and bill count, overall and by
|
|
// day, scoped exactly as GetSalesSummary scopes app orders.
|
|
func (r *orderRepository) posSalesTotals(tid, lid int, fdate, tdate string) (
|
|
struct {
|
|
Revenue float64
|
|
Orders int
|
|
},
|
|
[]models.SalesSummaryChartData,
|
|
error,
|
|
) {
|
|
var totals struct {
|
|
Revenue float64
|
|
Orders int
|
|
}
|
|
|
|
where := "tenantid = ?"
|
|
params := []interface{}{tid}
|
|
|
|
if lid != 0 {
|
|
where += " AND locationid = ?"
|
|
params = append(params, lid)
|
|
}
|
|
if fdate != "" && tdate != "" {
|
|
where += " AND businessdate BETWEEN ? AND ?"
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
|
|
totalsQuery := fmt.Sprintf(
|
|
`SELECT COALESCE(SUM(total), 0) AS revenue, COUNT(posorderid) AS orders
|
|
FROM pos_orders WHERE %s`, where)
|
|
if err := r.db.Raw(totalsQuery, params...).Scan(&totals).Error; err != nil {
|
|
return totals, nil, err
|
|
}
|
|
|
|
dailyQuery := fmt.Sprintf(
|
|
`SELECT businessdate AS date, COALESCE(SUM(total), 0) AS revenue,
|
|
COUNT(posorderid) AS orders
|
|
FROM pos_orders WHERE %s
|
|
GROUP BY businessdate ORDER BY businessdate ASC`, where)
|
|
|
|
var daily []models.SalesSummaryChartData
|
|
if err := r.db.Raw(dailyQuery, params...).Scan(&daily).Error; err != nil {
|
|
return totals, nil, err
|
|
}
|
|
|
|
return totals, daily, nil
|
|
}
|
|
|
|
// mergePosIntoChart folds counter sales into the app-order series by date.
|
|
//
|
|
// A day present in one and not the other has to appear rather than be dropped:
|
|
// a shop that sells only over the counter has no app orders at all, and an
|
|
// inner join on date would show it an empty chart.
|
|
func mergePosIntoChart(
|
|
app []models.SalesSummaryChartData,
|
|
pos []models.SalesSummaryChartData,
|
|
) []models.SalesSummaryChartData {
|
|
if len(pos) == 0 {
|
|
return app
|
|
}
|
|
|
|
// Dates arrive in two shapes — the app series casts a timestamp, the POS
|
|
// series stores a plain YYYY-MM-DD string — so both are trimmed to ten
|
|
// characters before being matched, or every day would appear twice.
|
|
dayOf := func(s string) string {
|
|
if len(s) >= 10 {
|
|
return s[:10]
|
|
}
|
|
return s
|
|
}
|
|
|
|
index := make(map[string]int, len(app))
|
|
merged := make([]models.SalesSummaryChartData, 0, len(app)+len(pos))
|
|
for _, row := range app {
|
|
index[dayOf(row.Date)] = len(merged)
|
|
merged = append(merged, row)
|
|
}
|
|
|
|
for _, row := range pos {
|
|
day := dayOf(row.Date)
|
|
if at, ok := index[day]; ok {
|
|
merged[at].Revenue += row.Revenue
|
|
merged[at].Orders += row.Orders
|
|
continue
|
|
}
|
|
index[day] = len(merged)
|
|
merged = append(merged, models.SalesSummaryChartData{
|
|
Date: day,
|
|
Revenue: row.Revenue,
|
|
Orders: row.Orders,
|
|
})
|
|
}
|
|
|
|
sort.Slice(merged, func(a, b int) bool {
|
|
return dayOf(merged[a].Date) < dayOf(merged[b].Date)
|
|
})
|
|
|
|
return merged
|
|
}
|
|
|
|
// posRevenue totals counter sales, overall and per location.
|
|
//
|
|
// Scoped the same way GetRevenueSummary scopes app orders — tenant, optional
|
|
// location, optional date range — so the two halves of a figure always cover
|
|
// the same ground. Dates match on businessdate, which is the day the sale was
|
|
// rung rather than the day it reached us: a till that was offline overnight
|
|
// uploads yesterday's bills this morning, and they belong to yesterday.
|
|
func (r *orderRepository) posRevenue(tid, lid int, fdate, tdate string) (float64, map[int]float64, error) {
|
|
query := `SELECT locationid, COALESCE(SUM(total), 0) AS revenue
|
|
FROM pos_orders WHERE tenantid = ?`
|
|
params := []interface{}{tid}
|
|
|
|
if lid != 0 {
|
|
query += " AND locationid = ?"
|
|
params = append(params, lid)
|
|
}
|
|
if fdate != "" && tdate != "" {
|
|
query += " AND businessdate BETWEEN ? AND ?"
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
query += " GROUP BY locationid"
|
|
|
|
var rows []struct {
|
|
Locationid int
|
|
Revenue float64
|
|
}
|
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
total := 0.0
|
|
byLocation := make(map[int]float64, len(rows))
|
|
for _, row := range rows {
|
|
total += row.Revenue
|
|
byLocation[row.Locationid] = row.Revenue
|
|
}
|
|
|
|
return total, byLocation, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error) {
|
|
var locations []models.OrderInsight
|
|
|
|
q1 := `
|
|
SELECT DISTINCT a.applocationid, b.locationname
|
|
FROM orders a
|
|
INNER JOIN app_location b ON a.applocationid = b.applocationid
|
|
WHERE b.status = 'Active'`
|
|
|
|
if err := r.db.Raw(q1).Scan(&locations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return locations, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error) {
|
|
var summary models.SalesSummaryResponse
|
|
|
|
whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1"
|
|
var params []interface{}
|
|
params = append(params, tid)
|
|
|
|
if lid != 0 {
|
|
whereClause += " AND locationid = ?"
|
|
params = append(params, lid)
|
|
}
|
|
|
|
if fdate != "" && tdate != "" {
|
|
whereClause += " AND orderdate::date BETWEEN ? AND ?"
|
|
params = append(params, fdate, tdate)
|
|
}
|
|
|
|
totalsQuery := fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue,
|
|
COUNT(orderheaderid) AS total_orders
|
|
FROM orders
|
|
WHERE %s`, whereClause)
|
|
|
|
var result struct {
|
|
TotalRevenue float64
|
|
TotalOrders int
|
|
}
|
|
|
|
if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Counter sales, folded in before the average is taken — computing it from
|
|
// app orders alone and then adding POS revenue would report an average
|
|
// order value no order ever had.
|
|
posTotals, posDaily, err := r.posSalesTotals(tid, lid, fdate, tdate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
summary.TotalRevenue = result.TotalRevenue + posTotals.Revenue
|
|
summary.TotalOrders = result.TotalOrders + posTotals.Orders
|
|
|
|
if summary.TotalOrders > 0 {
|
|
summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders)
|
|
}
|
|
|
|
chartQuery := fmt.Sprintf(`
|
|
SELECT
|
|
CAST(orderdate AS DATE) AS date,
|
|
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS revenue,
|
|
COUNT(orderheaderid) AS orders
|
|
FROM orders
|
|
WHERE %s
|
|
GROUP BY CAST(orderdate AS DATE)
|
|
ORDER BY date ASC`, whereClause)
|
|
|
|
var chartData []models.SalesSummaryChartData
|
|
if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if chartData == nil {
|
|
chartData = []models.SalesSummaryChartData{}
|
|
}
|
|
|
|
// Merged by day. A shop that only trades over the counter would otherwise
|
|
// show a flat line at zero on every chart in the product.
|
|
chartData = mergePosIntoChart(chartData, posDaily)
|
|
summary.ChartData = chartData
|
|
|
|
var topLocations []models.SalesSummaryTopLocation
|
|
if lid == 0 {
|
|
topWhere := "o.tenantid = ? AND o.orderstatus IN ('delivered', 'completed') AND o.configid = 1"
|
|
var topParams []interface{}
|
|
topParams = append(topParams, tid)
|
|
if fdate != "" && tdate != "" {
|
|
topWhere += " AND o.orderdate::date BETWEEN ? AND ?"
|
|
topParams = append(topParams, fdate, tdate)
|
|
}
|
|
|
|
cleanTopLocQuery := fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(l.locationname, 'Unknown') AS locationname,
|
|
COALESCE(SUM(COALESCE(o.ordervalue, 0) + COALESCE(o.orderamount, 0) + COALESCE(o.deliveryamt, 0)), 0) AS revenue
|
|
FROM orders o
|
|
LEFT JOIN tenantlocations l ON o.locationid = l.locationid
|
|
WHERE %s
|
|
GROUP BY l.locationid, l.locationname
|
|
ORDER BY revenue DESC
|
|
LIMIT 5`, topWhere)
|
|
|
|
if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if topLocations == nil {
|
|
topLocations = []models.SalesSummaryTopLocation{}
|
|
}
|
|
summary.TopLocations = topLocations
|
|
|
|
return &summary, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetMonthlyOrders(applocationid string) (*models.Ordermonths, error) {
|
|
var orderMonths models.Ordermonths
|
|
|
|
q2 := `
|
|
SELECT
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 1 THEN 1 ELSE 0 END), 0) AS jan,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 2 THEN 1 ELSE 0 END), 0) AS feb,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 3 THEN 1 ELSE 0 END), 0) AS mar,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 4 THEN 1 ELSE 0 END), 0) AS apr,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 5 THEN 1 ELSE 0 END), 0) AS may,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 6 THEN 1 ELSE 0 END), 0) AS jun,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 7 THEN 1 ELSE 0 END), 0) AS jul,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 8 THEN 1 ELSE 0 END), 0) AS aug,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 9 THEN 1 ELSE 0 END), 0) AS sep,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 10 THEN 1 ELSE 0 END), 0) AS oct,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 11 THEN 1 ELSE 0 END), 0) AS nov,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 12 THEN 1 ELSE 0 END), 0) AS dece
|
|
FROM orders a
|
|
WHERE a.applocationid = ?
|
|
AND EXTRACT(YEAR FROM a.orderdate) = EXTRACT(YEAR FROM CURRENT_DATE)`
|
|
|
|
if err := r.db.Raw(q2, applocationid).Scan(&orderMonths).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &orderMonths, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetOrderInsights(tenantID int) ([]models.OrderInsightv1, error) {
|
|
var locations []models.OrderInsightv1
|
|
var params []interface{}
|
|
|
|
// ✅ Query 1: Get all locations (even without orders)
|
|
q1 := `SELECT b.locationid, b.locationname
|
|
FROM tenantlocations b
|
|
WHERE b.status = 'Active'`
|
|
|
|
if tenantID != 0 {
|
|
q1 += " AND b.tenantid = ?"
|
|
params = append(params, tenantID)
|
|
}
|
|
|
|
if err := r.db.Raw(q1, params...).Scan(&locations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// ✅ Query 2: Monthly order counts per location
|
|
q2 := `SELECT
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 1 THEN 1 ELSE 0 END), 0) AS jan,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 2 THEN 1 ELSE 0 END), 0) AS feb,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 3 THEN 1 ELSE 0 END), 0) AS mar,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 4 THEN 1 ELSE 0 END), 0) AS apr,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 5 THEN 1 ELSE 0 END), 0) AS may,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 6 THEN 1 ELSE 0 END), 0) AS jun,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 7 THEN 1 ELSE 0 END), 0) AS jul,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 8 THEN 1 ELSE 0 END), 0) AS aug,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 9 THEN 1 ELSE 0 END), 0) AS sep,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 10 THEN 1 ELSE 0 END), 0) AS oct,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 11 THEN 1 ELSE 0 END), 0) AS nov,
|
|
COALESCE(SUM(CASE WHEN EXTRACT(MONTH FROM a.orderdate) = 12 THEN 1 ELSE 0 END), 0) AS dece
|
|
FROM orders a
|
|
WHERE a.locationid = ? AND EXTRACT(YEAR FROM a.orderdate) = EXTRACT(YEAR FROM CURRENT_DATE)`
|
|
|
|
if tenantID != 0 {
|
|
q2 += " AND a.tenantid = ?"
|
|
}
|
|
|
|
// ✅ Attach monthly order counts for each location
|
|
for i := range locations {
|
|
var orderMonths models.Ordermonths
|
|
|
|
if tenantID != 0 {
|
|
if err := r.db.Raw(q2, locations[i].Locationid, tenantID).Scan(&orderMonths).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := r.db.Raw(q2, locations[i].Locationid).Scan(&orderMonths).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
locations[i].Ordermonths = &orderMonths
|
|
}
|
|
|
|
return locations, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error) {
|
|
var details []models.OrderDetails
|
|
|
|
query := `
|
|
SELECT a.orderdetailid, a.orderheaderid, a.tenantid, a.locationid, a.productid, a.productname, a.productdescription, a.supplyqty, a.balanceqty,
|
|
a.orderqty, a.price, a.unitid, a.unitname, a.productaddonid, a.addontypeid, a.productmapid, a.productvariantid, a.productaddondescription,
|
|
a.discountid, a.discountname, a.discountcode, a.discountterms, a.discountpercentage, a.discountamount, a.landingamount, a.taxpercentage,
|
|
a.taxamount, a.productsumprice, a.itemstatus, a.delivered, COALESCE(b.orderamount, 0) as orderamount, COALESCE(b.taxamount, 0) as totaltaxamount,
|
|
c.productimage
|
|
FROM orderdetails a
|
|
LEFT JOIN orders b ON b.orderheaderid = a.orderheaderid
|
|
LEFT JOIN products c ON a.productid = c.productid
|
|
WHERE a.orderheaderid = ?
|
|
`
|
|
|
|
if err := r.db.Raw(query, orderHeaderID).Scan(&details).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return details, nil
|
|
}
|
|
|
|
// syncProductLocationStatus re-derives productlocations.status for one
|
|
// product at one outlet from the live productstocks balance — "available"
|
|
// above zero, "outofstock" at or below it. It runs inside the caller's
|
|
// transaction so the flag is committed together with the ledger entry that
|
|
// moved it, and it is the same rule productRepository.SyncProductLocationStatus
|
|
// applies on the receiving side; both paths agreeing is what stops the flag
|
|
// drifting away from the ledger over time.
|
|
//
|
|
// Status errors are deliberately swallowed: an order must not fail because a
|
|
// display flag could not be refreshed, and the next ledger entry re-derives it.
|
|
func syncProductLocationStatus(tx *gorm.DB, tenantid, locationid, productid int) {
|
|
if tenantid <= 0 || locationid <= 0 || productid <= 0 {
|
|
return
|
|
}
|
|
if err := tx.Exec(`
|
|
UPDATE productlocations
|
|
SET status = CASE WHEN (
|
|
SELECT COALESCE(
|
|
SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) -
|
|
SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END), 0)
|
|
FROM productstocks
|
|
WHERE productid = ? AND tenantid = ? AND locationid = ?
|
|
) > 0 THEN 'available' ELSE 'outofstock' END
|
|
WHERE tenantid = ? AND locationid = ? AND productid = ?`,
|
|
productid, tenantid, locationid,
|
|
tenantid, locationid, productid).Error; err != nil {
|
|
log.Println("syncProductLocationStatus:", err)
|
|
}
|
|
}
|
|
|
|
func (r *orderRepository) UpdateOrder(order *models.Orders) error {
|
|
tx := r.db.Begin()
|
|
|
|
// Handle stock restoration on order cancellation
|
|
var existingOrder models.Orders
|
|
if err := tx.Where("orderheaderid = ?", order.Orderheaderid).First(&existingOrder).Error; err == nil {
|
|
newStatus := strings.ToLower(strings.TrimSpace(order.Orderstatus))
|
|
oldStatus := strings.ToLower(strings.TrimSpace(existingOrder.Orderstatus))
|
|
|
|
if newStatus == "cancelled" && oldStatus != "cancelled" {
|
|
var items []models.OrderDetail
|
|
if err := tx.Table("orderdetails").Where("orderheaderid = ?", order.Orderheaderid).Find(&items).Error; err == nil {
|
|
for _, item := range items {
|
|
itemLocID := item.Locationid
|
|
if itemLocID == 0 {
|
|
itemLocID = existingOrder.Locationid
|
|
}
|
|
qty := int(item.Orderqty)
|
|
if qty <= 0 {
|
|
qty = 1
|
|
}
|
|
|
|
restoredStock := models.Productstock{
|
|
Tenantid: existingOrder.Tenantid,
|
|
Stockdate: time.Now(),
|
|
Locationid: itemLocID,
|
|
Productid: item.Productid,
|
|
Quantity: qty,
|
|
Stocktype: "in",
|
|
Status: "Active",
|
|
}
|
|
if err := tx.Table("productstocks").Create(&restoredStock).Error; err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
// Re-derive availability from the restored balance rather
|
|
// than assuming "available": cancelling one line of a
|
|
// heavily oversold product can leave it still at or below
|
|
// zero, in which case it must stay flagged outofstock.
|
|
syncProductLocationStatus(tx, existingOrder.Tenantid, itemLocID, item.Productid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Where("orderheaderid = ?", order.Orderheaderid).Updates(order).Error; err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return tx.Commit().Error
|
|
}
|
|
|
|
// nextSequenceNo claims the next order (or invoice) number for a tenant and
|
|
// returns it formatted as tenantid-subprefix+seqno (e.g. 916-2024115209).
|
|
//
|
|
// It runs inside the caller's transaction and both reads and increments the
|
|
// counter in a single UPDATE ... RETURNING, which is what makes the number
|
|
// unique. The previous implementation split this into getSequenceno (read
|
|
// MAX+1) and updateSeqno (increment), both on r.db rather than the order's
|
|
// transaction, so two concurrent orders read the same value before either
|
|
// wrote, and a rolled-back order still consumed a number.
|
|
//
|
|
// Three further defects that produced duplicate ids in production:
|
|
//
|
|
// - A NULL orderseqno made COALESCE(MAX(orderseqno) + 1, 1) evaluate
|
|
// NULL + 1 = NULL, falling through to a hardcoded "<tenantid>-1"; the
|
|
// increment then computed NULL + 1 = NULL as well, so the counter could
|
|
// never leave NULL. Every order such a tenant ever placed was numbered
|
|
// "<tenantid>-1" — 108 orders share "1135-1" today. A NULL is now seeded
|
|
// from the tenant's existing order count, which is at least as high as any
|
|
// number already handed out, so recovery never reissues a used id.
|
|
//
|
|
// - Tenants with more than one ordersequences row (tenant 1135 has ~25) hit
|
|
// a GROUP BY that returned several rows, of which Scan silently kept the
|
|
// first, while the increment updated every row. The counter is now pinned
|
|
// to the tenant's lowest sequenceid, so reads and writes address the same
|
|
// row whatever duplicates exist.
|
|
//
|
|
// - A tenant with no row at all fell back to "<tenantid>-1" forever, since
|
|
// nothing created one. The row is now created on first use.
|
|
func nextSequenceNo(tx *gorm.DB, tid int, prefix string) (string, error) {
|
|
var field string
|
|
switch prefix {
|
|
case "ORD":
|
|
field = "orderseqno"
|
|
case "INV":
|
|
field = "invoiceseqno"
|
|
default:
|
|
return "", fmt.Errorf("invalid prefix: %s", prefix)
|
|
}
|
|
|
|
// field is not user input — it comes from the switch above.
|
|
formatted := fmt.Sprintf(`CONCAT(tenantid, '-',
|
|
CASE WHEN subprefix IS NULL OR CAST(subprefix AS TEXT) IN ('0', '0.0', '')
|
|
THEN '' ELSE CAST(subprefix AS TEXT) END,
|
|
%s)`, field)
|
|
|
|
var seq string
|
|
err := tx.Raw(fmt.Sprintf(`
|
|
UPDATE ordersequences
|
|
SET %s = COALESCE(%s, (SELECT COUNT(*) FROM orders WHERE tenantid = ?)) + 1,
|
|
updated = NOW()
|
|
WHERE sequenceid = (SELECT MIN(sequenceid) FROM ordersequences WHERE tenantid = ?)
|
|
RETURNING %s`, field, field, formatted), tid, tid).Scan(&seq).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if seq != "" {
|
|
return seq, nil
|
|
}
|
|
|
|
// No counter row for this tenant yet — create one, seeded past whatever
|
|
// their existing orders already used.
|
|
err = tx.Raw(fmt.Sprintf(`
|
|
INSERT INTO ordersequences (tenantid, %s, created, updated)
|
|
VALUES (?, (SELECT COUNT(*) FROM orders WHERE tenantid = ?) + 1, NOW(), NOW())
|
|
RETURNING %s`, field, formatted), tid, tid).Scan(&seq).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if seq == "" {
|
|
return "", fmt.Errorf("could not allocate %s sequence for tenant %d", prefix, tid)
|
|
}
|
|
return seq, nil
|
|
}
|
|
|
|
func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error) {
|
|
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
|
|
if locID == 0 {
|
|
locID = data.Applocationid
|
|
}
|
|
|
|
// 🛠️ Step 0: Lock every (tenantid, locationid, productid) row this order
|
|
// touches, then check availability under those locks.
|
|
//
|
|
// Both now live in stockLedger.go, shared with the POS ingest — one
|
|
// implementation of the rule that stops overselling, rather than one per
|
|
// caller waiting to drift out of step with the others. Behaviour here is
|
|
// unchanged, including legacyOrderQty's truncate-then-floor-at-1, which is
|
|
// what this path has always done.
|
|
lines := make([]stockLine, 0, len(data.Items))
|
|
for _, item := range data.Items {
|
|
itemLocID := item.Locationid
|
|
if itemLocID == 0 {
|
|
itemLocID = locID
|
|
}
|
|
lines = append(lines, stockLine{
|
|
Productid: item.Productid,
|
|
Locationid: itemLocID,
|
|
Productname: item.Productname,
|
|
Quantity: item.Orderqty,
|
|
})
|
|
}
|
|
|
|
if err := lockStockRows(tx, data.Tenantid, lines); err != nil {
|
|
tx.Rollback()
|
|
return models.Orders{}, err
|
|
}
|
|
|
|
// 🛠️ Step 1: Pre-validate stock availability for all items before placing order
|
|
if err := assertStockAvailable(tx, data.Tenantid, lines, func(l stockLine) int {
|
|
return legacyOrderQty(l.Quantity)
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return models.Orders{}, err
|
|
}
|
|
|
|
// 🛠️ Step 2: Create Order Header
|
|
// Claimed inside tx so the row lock on the counter holds until commit:
|
|
// concurrent orders queue for it instead of reading the same number, and a
|
|
// rollback releases it rather than burning it.
|
|
orderid, err := nextSequenceNo(tx, data.Tenantid, "ORD")
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return models.Orders{}, fmt.Errorf("failed to allocate order number: %w", err)
|
|
}
|
|
data.Orderid = orderid
|
|
|
|
if err := tx.Create(&data).Error; err != nil {
|
|
tx.Rollback()
|
|
return models.Orders{}, err
|
|
}
|
|
|
|
// 🛠️ Step 3: Insert Order Details & Record "out" stock deduction
|
|
for _, item := range data.Items {
|
|
item.Orderheaderid = data.Orderheaderid
|
|
item.Tenantid = data.Tenantid
|
|
itemLocID := item.Locationid
|
|
if itemLocID == 0 {
|
|
itemLocID = locID
|
|
}
|
|
item.Locationid = itemLocID
|
|
|
|
if err := tx.Table("orderdetails").Create(&item).Error; err != nil {
|
|
tx.Rollback()
|
|
return models.Orders{}, err
|
|
}
|
|
|
|
// Writes the "out" entry and re-derives the location's availability
|
|
// flag from the balance it produced.
|
|
if err := recordStockOut(
|
|
tx,
|
|
data.Tenantid,
|
|
stockLine{Productid: item.Productid, Locationid: itemLocID},
|
|
legacyOrderQty(item.Orderqty),
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return models.Orders{}, err
|
|
}
|
|
}
|
|
|
|
// 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 input.Tenantid <= 0 {
|
|
return nil, errors.New("tenantid is required")
|
|
}
|
|
if len(input.Bills) == 0 {
|
|
return nil, errors.New("no sales rows found in the upload")
|
|
}
|
|
|
|
// When the caller pins the upload to one branch, that branch is resolved
|
|
// (and authorised) up front so an outlet the merchant does not own fails
|
|
// the whole request rather than each bill in turn.
|
|
if input.Locationid > 0 {
|
|
if _, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
contexts := make(map[int]*offlineLocationContext)
|
|
catalogues := make(map[int]map[int]offlineProduct)
|
|
|
|
resolve := func(locationID int) (*offlineLocationContext, map[int]offlineProduct, error) {
|
|
if ctx, ok := contexts[locationID]; ok {
|
|
return ctx, catalogues[locationID], nil
|
|
}
|
|
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, locationID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
products, err := r.loadOfflineProducts(input.Tenantid, locationID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(products) == 0 {
|
|
return nil, nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
|
|
}
|
|
contexts[locationID] = ctx
|
|
catalogues[locationID] = products
|
|
return ctx, products, nil
|
|
}
|
|
|
|
resp := &models.OfflineSalesUploadResponse{Results: make([]models.OfflineSaleResult, 0, len(input.Bills))}
|
|
|
|
record := func(result models.OfflineSaleResult) {
|
|
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++
|
|
}
|
|
}
|
|
|
|
for _, bill := range input.Bills {
|
|
billLocation := bill.Locationid
|
|
if billLocation <= 0 {
|
|
billLocation = input.Locationid
|
|
}
|
|
|
|
if billLocation <= 0 {
|
|
record(models.OfflineSaleResult{
|
|
Billno: strings.TrimSpace(bill.Billno),
|
|
Status: models.OfflineSaleFailed,
|
|
Message: "no locationid on these rows — the sheet must say which branch the sale belongs to",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// A pinned upload refuses bills for anywhere else. This is what keeps a
|
|
// store user inside their own branch: editing the locationid column in
|
|
// the spreadsheet changes nothing, because the pin is set from their
|
|
// session and not from the file.
|
|
if input.Locationid > 0 && billLocation != input.Locationid {
|
|
record(models.OfflineSaleResult{
|
|
Locationid: billLocation,
|
|
Billno: strings.TrimSpace(bill.Billno),
|
|
Status: models.OfflineSaleFailed,
|
|
Message: fmt.Sprintf("this upload is limited to outlet %d, but these rows are for outlet %d", input.Locationid, billLocation),
|
|
})
|
|
continue
|
|
}
|
|
|
|
ctx, products, err := resolve(billLocation)
|
|
if err != nil {
|
|
record(models.OfflineSaleResult{
|
|
Locationid: billLocation,
|
|
Billno: strings.TrimSpace(bill.Billno),
|
|
Status: models.OfflineSaleFailed,
|
|
Message: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
record(r.importOfflineBill(ctx, products, input.Userid, bill))
|
|
}
|
|
|
|
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{
|
|
Locationid: ctx.Locationid,
|
|
Locationname: ctx.Locationname,
|
|
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{
|
|
Locationid: ctx.Locationid,
|
|
Locationname: ctx.Locationname,
|
|
Billno: billLabel,
|
|
Status: models.OfflineSaleDuplicate,
|
|
Message: fmt.Sprintf("bill %s was already imported for %s; stock was not deducted again", billLabel, ctx.Locationname),
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return fail("could not commit bill %s: %v", billLabel, err)
|
|
}
|
|
|
|
return models.OfflineSaleResult{
|
|
Locationid: ctx.Locationid,
|
|
Locationname: ctx.Locationname,
|
|
Billno: billLabel,
|
|
Status: models.OfflineSaleImported,
|
|
Orderid: created.Orderid,
|
|
Orderheaderid: created.Orderheaderid,
|
|
Itemcount: len(items),
|
|
Amount: orderAmount,
|
|
Message: fmt.Sprintf("imported as order %s at %s", created.Orderid, ctx.Locationname),
|
|
}
|
|
}
|
|
|
|
func (r *orderRepository) getOrderDetailsByHeaderID(orderHeaderID int) ([]models.OrderDetails, float64, float64, error) {
|
|
var details []models.OrderDetails
|
|
var orderAmount float64
|
|
var totalTaxAmount float64
|
|
|
|
query := `
|
|
SELECT a.orderdetailid,a.orderheaderid,a.tenantid,a.locationid,a.productid,a.productname,a.productdescription,a.supplyqty,a.balanceqty,a.orderqty,a.price,a.unitid,
|
|
a.unitname,a.productaddonid,a.addontypeid,a.productmapid,a.productvariantid,a.productaddondescription,a.discountid,a.discountname,a.discountcode,a.discountterms,
|
|
a.discountpercentage,a.discountamount,a.landingamount,a.taxpercentage,a.taxamount,a.productsumprice,a.itemstatus,a.delivered,COALESCE(b.orderamount, 0) as orderamount,
|
|
COALESCE(b.taxamount, 0) as totaltaxamount,c.productimage
|
|
FROM orderdetails a
|
|
LEFT JOIN orders b ON b.orderheaderid = a.orderheaderid
|
|
LEFT JOIN products c ON a.productid = c.productid
|
|
WHERE a.orderheaderid = ?`
|
|
|
|
err := r.db.Raw(query, orderHeaderID).Scan(&details).Error
|
|
if err != nil {
|
|
return nil, 0, 0, err
|
|
}
|
|
|
|
if len(details) > 0 {
|
|
orderAmount = details[0].Orderamount
|
|
totalTaxAmount = details[0].Totaltaxamount
|
|
}
|
|
|
|
return details, orderAmount, totalTaxAmount, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error) {
|
|
|
|
orders := make([]models.CustomerOrder, 0)
|
|
|
|
baseQuery := `
|
|
SELECT a.orderheaderid, a.applocationid, a.tenantid, a.locationid, a.partnerid, a.configid,
|
|
a.categoryid, a.subcategoryid, a.moduleid, a.orderid, a.orderstatus, a.orderdate,
|
|
a.ordernotes, a.itemcount, a.deliverytime, a.pending, a.processing, a.ready,
|
|
a.delivered, a.cancelled, a.deliverycharge, a.kms, a.customerid, a.pickupaddress,
|
|
a.pickuplat, a.pickuplong, a.pickupcustomer, a.pickupcontactno, a.pickuplocation AS pickupsuburb,
|
|
a.pickupcity, a.deliveryid AS deliverycustomerid, a.deliveryaddress, a.deliverylat,
|
|
a.deliverylong, a.deliverytype, a.deliverycustomer, a.deliverycontactno,
|
|
a.deliverylocation AS deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery,
|
|
a.taxamount,
|
|
b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno,
|
|
b.postcode AS tenantpostcode, b.suburb AS tenantsuburb, b.city AS tenantcity, b.registrationno,
|
|
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode,
|
|
c.suburb AS locationsuburb, c.city AS locationcity,
|
|
d.locationname AS applocation
|
|
FROM orders a
|
|
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
|
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
|
WHERE 1=1
|
|
`
|
|
|
|
params := []interface{}{}
|
|
|
|
if customerID != "" {
|
|
baseQuery += " AND a.customerid = ?"
|
|
params = append(params, customerID)
|
|
}
|
|
|
|
if tenantID != "" && tenantID != "0" {
|
|
baseQuery += " AND a.tenantid = ?"
|
|
params = append(params, tenantID)
|
|
}
|
|
|
|
if moduleID != "" {
|
|
baseQuery += " AND a.moduleid = ?"
|
|
params = append(params, moduleID)
|
|
}
|
|
|
|
if fromDate != "" && toDate != "" {
|
|
baseQuery += " AND DATE(a.orderdate) BETWEEN ? AND ?"
|
|
params = append(params, fromDate, toDate)
|
|
}
|
|
|
|
if orderStatus != "" {
|
|
baseQuery += " AND a.orderstatus = ?"
|
|
params = append(params, orderStatus)
|
|
}
|
|
|
|
if keyword != "" {
|
|
baseQuery += " AND EXISTS (SELECT 1 FROM orderdetails od WHERE od.orderheaderid = a.orderheaderid AND od.productname LIKE ?)"
|
|
params = append(params, "%"+keyword+"%")
|
|
}
|
|
|
|
baseQuery += " ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?"
|
|
params = append(params, pageSize, offset)
|
|
|
|
if err := r.db.Raw(baseQuery, params...).Scan(&orders).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 🚀 NO LOOPING YET — Now fetch all details in one query
|
|
return r.attachOrderDetails(orders)
|
|
}
|
|
|
|
func (r *orderRepository) attachOrderDetails(orders []models.CustomerOrder) ([]models.CustomerOrder, error) {
|
|
|
|
if len(orders) == 0 {
|
|
return orders, nil
|
|
}
|
|
|
|
orderIDs := make([]int, 0, len(orders))
|
|
for _, o := range orders {
|
|
orderIDs = append(orderIDs, o.Orderheaderid)
|
|
}
|
|
|
|
var details []models.OrderDetails
|
|
query := `
|
|
SELECT a.*, p.productimage
|
|
FROM orderdetails a
|
|
LEFT JOIN products p ON a.productid = p.productid
|
|
WHERE a.orderheaderid IN ?
|
|
`
|
|
|
|
if err := r.db.Raw(query, orderIDs).Scan(&details).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// map orderheaderid → []details
|
|
detailMap := map[int][]models.OrderDetails{}
|
|
for _, d := range details {
|
|
detailMap[d.Orderheaderid] = append(detailMap[d.Orderheaderid], d)
|
|
}
|
|
|
|
for i := range orders {
|
|
orders[i].OrderDetails = detailMap[orders[i].Orderheaderid]
|
|
|
|
if len(orders[i].OrderDetails) > 0 {
|
|
orders[i].Orderamount = orders[i].OrderDetails[0].Orderamount
|
|
orders[i].Totaltaxamount = orders[i].OrderDetails[0].Totaltaxamount
|
|
}
|
|
}
|
|
|
|
return orders, nil
|
|
}
|
|
|
|
func (r *orderRepository) GetTenantLocationOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) {
|
|
|
|
var data []models.OrderInfo
|
|
var params []interface{}
|
|
|
|
offset := (input.Pageno - 1) * input.Pagesize
|
|
|
|
// Start building SQL
|
|
query := base + `
|
|
WHERE a.tenantid = ?
|
|
AND a.locationid = ?
|
|
`
|
|
params = append(params, input.Tenantid, input.Locationid)
|
|
|
|
// Status filter
|
|
if input.Status == "ongoing" {
|
|
query += ` AND a.orderstatus IN ('pending','processing','ready')`
|
|
} else if input.Status != "" {
|
|
query += ` AND a.orderstatus = ?`
|
|
params = append(params, input.Status)
|
|
}
|
|
|
|
// Applocation filter
|
|
if input.Applocationid != 0 {
|
|
query += ` AND a.applocationid = ?`
|
|
params = append(params, input.Applocationid)
|
|
}
|
|
|
|
// Config filter
|
|
if input.Configid != 0 {
|
|
query += ` AND a.configid = ?`
|
|
params = append(params, input.Configid)
|
|
}
|
|
|
|
// Date filter
|
|
query += ` AND DATE(a.deliverytime) BETWEEN ? AND ?`
|
|
params = append(params, input.Fromdate, input.ToDate)
|
|
|
|
// Keyword filter
|
|
if input.Keyword != "" {
|
|
like := "%" + input.Keyword + "%"
|
|
query += ` AND (
|
|
a.pickupcustomer LIKE ? OR
|
|
c.tenantname LIKE ? OR
|
|
a.deliverycustomer LIKE ? OR
|
|
a.pickupcontactno LIKE ? OR
|
|
a.deliverycontactno LIKE ? OR
|
|
a.orderid LIKE ?
|
|
)`
|
|
params = append(params, like, like, like, like, like, like)
|
|
}
|
|
|
|
// Sorting + pagination
|
|
query += ` ORDER BY a.orderheaderid DESC LIMIT ? OFFSET ?`
|
|
params = append(params, input.Pagesize, offset)
|
|
|
|
fmt.Println("Executing Tenant+Location SQL:", query)
|
|
|
|
result := r.db.Raw(query, params...).Find(&data)
|
|
return data, result.Error
|
|
}
|
|
|
|
func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, fromDate, toDate string) ([]models.TimeSeriesData, error) {
|
|
var data []models.TimeSeriesData
|
|
|
|
var dateFunc, dateFuncO2 string
|
|
switch granularity {
|
|
case "month":
|
|
dateFunc = "TO_CHAR(o.orderdate::date, 'YYYY-MM')"
|
|
dateFuncO2 = "TO_CHAR(o2.orderdate::date, 'YYYY-MM')"
|
|
case "year":
|
|
dateFunc = "TO_CHAR(o.orderdate::date, 'YYYY')"
|
|
dateFuncO2 = "TO_CHAR(o2.orderdate::date, 'YYYY')"
|
|
case "day":
|
|
fallthrough
|
|
default:
|
|
dateFunc = "TO_CHAR(o.orderdate::date, 'YYYY-MM-DD')"
|
|
dateFuncO2 = "TO_CHAR(o2.orderdate::date, 'YYYY-MM-DD')"
|
|
}
|
|
|
|
var locFilter, dateFilter string
|
|
var params []interface{}
|
|
|
|
// subquery params
|
|
params = append(params, tenantID)
|
|
if locationID != 0 {
|
|
locFilter = " AND o2.locationid = ?"
|
|
params = append(params, locationID)
|
|
}
|
|
if fromDate != "" && toDate != "" {
|
|
dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?"
|
|
params = append(params, fromDate, toDate)
|
|
}
|
|
|
|
// main query params
|
|
params = append(params, tenantID)
|
|
if locationID != 0 {
|
|
params = append(params, locationID)
|
|
}
|
|
if fromDate != "" && toDate != "" {
|
|
params = append(params, fromDate, toDate)
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
%s AS label,
|
|
COUNT(o.orderheaderid) AS orders,
|
|
COALESCE(SUM(o.orderamount), 0) AS revenue,
|
|
COALESCE(SUM(CASE WHEN o.orderstatus = 'cancelled' THEN 1 ELSE 0 END), 0) AS cancelled,
|
|
COALESCE(SUM(CASE WHEN o.orderstatus IN ('delivered', 'completed') THEN 1 ELSE 0 END), 0) AS delivered,
|
|
COALESCE(MAX(sku_counts.activeskus), 0) AS activeskus
|
|
FROM orders o
|
|
LEFT JOIN (
|
|
SELECT %s AS label, COUNT(DISTINCT d.productid) AS activeskus
|
|
FROM orders o2
|
|
JOIN orderdetails d ON o2.orderheaderid = d.orderheaderid
|
|
WHERE o2.tenantid = ? AND o2.configid = 1
|
|
%s
|
|
%s
|
|
GROUP BY %s
|
|
) sku_counts ON %s = sku_counts.label
|
|
WHERE o.tenantid = ?
|
|
AND o.configid = 1
|
|
`, dateFunc, dateFuncO2, locFilter, dateFilter, dateFuncO2, dateFunc)
|
|
|
|
if locationID != 0 {
|
|
query += " AND o.locationid = ?"
|
|
}
|
|
|
|
if fromDate != "" && toDate != "" {
|
|
query += " AND o.orderdate::date BETWEEN ? AND ?"
|
|
}
|
|
|
|
query += fmt.Sprintf(" GROUP BY %s ORDER BY %s", dateFunc, dateFunc)
|
|
|
|
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if data == nil {
|
|
data = []models.TimeSeriesData{}
|
|
}
|
|
|
|
return data, nil
|
|
}
|