A till holds every bill in its own SQLite database and keeps it for seven days after we acknowledge it, marking one synced only when its id comes back in an ack. Everything here follows from that. Silence is not acceptance, so a failing ingest publishes nothing at all and the terminal simply sends again. A duplicate is a success, because at-least-once delivery means a lost ack legitimately re-delivers bills we already hold, and calling those failures would strand a day of takings on the till. Deduplication is a unique index on the terminal's UUID plus an advisory lock held for the transaction. Bills land in pos_orders / pos_order_items rather than orders: a counter bill carries a cashier, a terminal, a rounding adjustment, promos, loyalty movement and a payment split that orders has nowhere to put, and forcing one into the other loses whatever does not fit. Stock is *not* split — a counter sale writes the same productstocks rows an app order does, through helpers extracted from createOrderTx so the rule that prevents overselling has one implementation rather than two. GetRevenueSummary and GetSalesSummary were extended to union the new table in; any new report has to remember the same. Terminal health goes to Redis under a 90-second TTL, sharing the instance the express backend uses. A heartbeat is a fact with an expiry date: a till that loses power stops refreshing and ages off the board by itself, where a Postgres row would need ~288k writes a day and a reaper. Proven end to end against the live estate before commit: a bill over HTTP and one over the real Mosquitto broker, the same bill three times producing one row and one stock movement, and a heartbeat arriving on the health endpoint. All probe data was removed afterwards. Four things that only surfaced against real data. An unset jsonb column failed the very first bill. Product SKUs are unusable as barcodes — 6,245 products share 93 SKUs and "1" covers 5,794 of them — against the till's unique index, so barcodes fall back to the product id. A taxpercent of -1 exists and would have put negative GST in a filed slab. And a product with id 0 exists, which can never be billed and is now skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2317 lines
75 KiB
Go
2317 lines
75 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 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 {
|
|
return fail("could not commit bill %s: %v", billLabel, err)
|
|
}
|
|
|
|
return models.OfflineSaleResult{
|
|
Billno: billLabel,
|
|
Status: models.OfflineSaleImported,
|
|
Orderid: created.Orderid,
|
|
Orderheaderid: created.Orderheaderid,
|
|
Itemcount: len(items),
|
|
Amount: orderAmount,
|
|
Message: fmt.Sprintf("imported as order %s", created.Orderid),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|