2614 lines
87 KiB
Go
2614 lines
87 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}
|
||
}
|
||
|
||
// base backs the tenant and location order lists.
|
||
//
|
||
// It now selects the MONEY columns, and their absence is why every order in
|
||
// the console Sales list read as worth nothing. The values were always stored
|
||
// correctly - an order for 3 x 45 carries landingamount 135 on its line and
|
||
// orderamount 135 on its header, and the orderdetails query below has always
|
||
// selected it. This query simply never asked, so the columns scanned as zero
|
||
// and the screen reported that faithfully.
|
||
//
|
||
// It made a real revenue figure indistinguishable from an order that genuinely
|
||
// booked nothing, which is why the historic orders on several tenants all look
|
||
// worthless.
|
||
// ── Why every join below is a LEFT JOIN ──────────────────────────────────────
|
||
//
|
||
// These queries list orders. An order that exists is an order that must be
|
||
// listable, so a lookup that cannot be resolved may blank a column — it must
|
||
// never remove the row.
|
||
//
|
||
// They were INNER JOINs against customers, tenants, tenantlocations,
|
||
// app_location and app_locationconfig, which made each one a silent filter on a
|
||
// column the caller never asked to filter by. An order written with
|
||
// applocationid 0 or customerid 0 was created successfully, deducted stock, and
|
||
// then appeared in no screen that lists orders — not the shopper’s own history,
|
||
// not the shop’s. Measured 2026-09-02: two identical orders one field apart,
|
||
// both in the database, one of them visible.
|
||
//
|
||
// Some of these joins contributed no columns at all to the query they sat in,
|
||
// so they were pure filters by accident.
|
||
//
|
||
// The workaround is still in the tree and shows what this cost: the offline
|
||
// sales importer builds "header scaffolding" and invents a walk-in customer
|
||
// solely so its orders would not vanish (offlineLocationContext,
|
||
// resolveOfflineCustomer). Populating those fields stays good practice, but it
|
||
// is no longer what keeps an order visible.
|
||
//
|
||
// Deliberate filtering is unaffected. A query with a WHERE on a joined table —
|
||
// GetUserOrders filters e.status and e.userid — still excludes rows the join
|
||
// could not resolve, because WHERE runs after the join. GetDistinctLocations
|
||
// keeps its INNER JOIN on purpose: it asks which app locations have orders, so
|
||
// one that cannot be resolved is genuinely not an answer.
|
||
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.orderamount, a.ordervalue, a.taxamount, a.ordercharges,
|
||
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
|
||
LEFT JOIN customers b ON a.customerid = b.customerid
|
||
LEFT JOIN tenants c ON a.tenantid = c.tenantid
|
||
LEFT JOIN tenantlocations d ON a.locationid = d.locationid
|
||
|
||
LEFT JOIN app_location h ON a.applocationid = h.applocationid
|
||
LEFT 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
|
||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||
LEFT 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)
|
||
|
||
// Status filters only when one was asked for.
|
||
//
|
||
// The else branch ran unconditionally, so a request naming no status became
|
||
// `orderstatus = ''` — which matches nothing. That is precisely the
|
||
// console’s default view: All branches, no status chosen. It listed no
|
||
// orders at all, however many the shop had taken.
|
||
//
|
||
// Compared case-insensitively because the ladder is stored capitalised
|
||
// ("Pending") while every literal here is lower case. Postgres agrees:
|
||
// `SELECT 'Pending' IN ('pending','processing','ready')` is false, so
|
||
// "ongoing" matched nothing either.
|
||
query = baseQuery
|
||
if input.Status == "ongoing" {
|
||
query += ` AND LOWER(a.orderstatus) IN ('pending','processing','ready')`
|
||
} else if input.Status != "" {
|
||
query += ` AND LOWER(a.orderstatus) = LOWER(?)`
|
||
params = append(params, input.Status)
|
||
}
|
||
|
||
// A date range narrows the list; it is not a precondition for having one.
|
||
// Applied unconditionally it cast the empty string to a date and answered
|
||
// 500, so the console’s order list failed outright until somebody picked a
|
||
// range.
|
||
if input.Fromdate != "" && input.ToDate != "" {
|
||
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 LOWER(a.orderstatus) = LOWER(?)`
|
||
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
|
||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||
LEFT 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
|
||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||
LEFT 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
|
||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||
LEFT 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
|
||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||
LEFT 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
|
||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||
LEFT 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)), 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)), 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)), 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
|
||
}
|
||
|
||
// An app order depletes the same shelf the counter sells from, so the tills
|
||
// at that outlet need to know as much as they do after a counter sale. This
|
||
// half was the one that would have been forgotten: it is easy to think of
|
||
// the catalogue as a POS concern, and a till overselling something the app
|
||
// just took is exactly the failure the push exists to prevent.
|
||
notifyCatalogueChanged(created.Locationid)
|
||
|
||
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.
|
||
// priceOrderLines fills in any line the client sent without a price, using the
|
||
// merchant's own catalogue, and brings the header totals in line with the
|
||
// result. It mutates data in place and is a no-op for an order that already
|
||
// arrived fully priced.
|
||
//
|
||
// The arithmetic deliberately matches the offline-sales import exactly — gross,
|
||
// minus discount, with tax extracted from the resulting landing amount because
|
||
// shelf prices here are MRP (tax already inside). One convention for both
|
||
// channels, so the same basket rings up the same either way.
|
||
func (r *orderRepository) priceOrderLines(tx *gorm.DB, data *models.Orders, defaultLocID int) error {
|
||
if len(data.Items) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// One catalogue read per outlet, not per line. Items usually share an
|
||
// outlet, but a line may name its own.
|
||
catalogues := make(map[int]map[int]offlineProduct)
|
||
catalogueFor := func(locationID int) (map[int]offlineProduct, error) {
|
||
if c, ok := catalogues[locationID]; ok {
|
||
return c, nil
|
||
}
|
||
c, err := loadCatalogueProducts(tx, data.Tenantid, locationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
catalogues[locationID] = c
|
||
return c, nil
|
||
}
|
||
|
||
var lineTotal, taxTotal float64
|
||
|
||
for i := range data.Items {
|
||
item := &data.Items[i]
|
||
|
||
itemLocID := item.Locationid
|
||
if itemLocID == 0 {
|
||
itemLocID = defaultLocID
|
||
}
|
||
|
||
if item.Price <= 0 {
|
||
catalogue, err := catalogueFor(itemLocID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// A miss can't normally happen — the stock check above already
|
||
// proved the product is stocked here. If it somehow does, leave the
|
||
// line as the client sent it rather than refusing the order: a
|
||
// pricing lookup is not a reason to block a customer's checkout.
|
||
if product, ok := catalogue[item.Productid]; ok {
|
||
item.Price = product.Price
|
||
if item.Taxpercentage <= 0 {
|
||
item.Taxpercentage = product.Taxpercent
|
||
}
|
||
if item.Productname == "" {
|
||
item.Productname = product.Productname
|
||
}
|
||
}
|
||
}
|
||
|
||
gross := item.Price * item.Orderqty
|
||
discount := item.Discountamount
|
||
if discount < 0 {
|
||
discount = 0
|
||
}
|
||
if discount > gross {
|
||
discount = gross
|
||
}
|
||
landing := gross - discount
|
||
|
||
// Only derive what the client didn't state, so a client that does its
|
||
// own (possibly promotional) maths keeps its figures.
|
||
if item.Productsumprice <= 0 {
|
||
item.Productsumprice = gross
|
||
}
|
||
if item.Landingamount <= 0 {
|
||
item.Landingamount = landing
|
||
}
|
||
if item.Taxamount <= 0 && item.Taxpercentage > 0 {
|
||
item.Taxamount = landing - (landing / (1 + item.Taxpercentage/100))
|
||
}
|
||
|
||
lineTotal += item.Landingamount
|
||
taxTotal += item.Taxamount
|
||
}
|
||
|
||
// Header totals are only derived when the client left them empty; an order
|
||
// that states its own total (delivery charges, promotions applied basket-
|
||
// wide) keeps it.
|
||
if data.Orderamount <= 0 {
|
||
data.Orderamount = float32(lineTotal)
|
||
}
|
||
if data.Ordervalue <= 0 {
|
||
data.Ordervalue = float32(lineTotal)
|
||
}
|
||
if data.Taxamount <= 0 {
|
||
data.Taxamount = float32(taxTotal)
|
||
}
|
||
if data.Itemcount <= 0 {
|
||
data.Itemcount = len(data.Items)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
|
||
// An order has to name the outlet it is placed at, and one that did not was
|
||
// rejected as though the shop were empty.
|
||
//
|
||
// Stock is held per outlet: availableStock filters `locationid = ?`, so with
|
||
// 0 it matches no ledger row and returns 0 for every product. Each line then
|
||
// failed the check below with "insufficient stock ... available 0" — for a
|
||
// product sitting on the shelf with 25 of them. The message named the wrong
|
||
// thing entirely and sent people hunting for stock that was already there,
|
||
// which is the most expensive kind of error: confidently wrong.
|
||
//
|
||
// Checked per line, because a line may carry its own outlet and otherwise
|
||
// falls back to the header's.
|
||
if err := assertOutletNamed(lines); err != nil {
|
||
tx.Rollback()
|
||
return models.Orders{}, err
|
||
}
|
||
|
||
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 1b: Price the lines the client left unpriced.
|
||
//
|
||
// Line prices arrive from the client, and a client that sends none books the
|
||
// order at zero — which is exactly what happened to every catalogue-imported
|
||
// product, whose per-store price was never set: real orders were written
|
||
// with price 0 and orderamount 0, so a delivered sale recorded no revenue.
|
||
//
|
||
// Only lines the client left at or below zero are filled. A line that came
|
||
// with a price keeps it, because variants, addons and promotions legitimately
|
||
// charge something other than the shelf price and this is not the place to
|
||
// second-guess them.
|
||
if err := r.priceOrderLines(tx, &data, locID); 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) {
|
||
return loadCatalogueProducts(r.db, tenantID, locationID)
|
||
}
|
||
|
||
// loadCatalogueProducts is the shared price/tax lookup: the merchant's own
|
||
// selling price for every product stocked at one outlet, preferring the
|
||
// per-store productlocations.price and falling back to the master
|
||
// products.retailprice. Both order paths price from this one query so an online
|
||
// order and a counter sale can never disagree about what a product costs.
|
||
//
|
||
// Takes its handle so a caller inside a transaction reads through that
|
||
// transaction — createOrderTx has already locked these product rows, and
|
||
// reading around the lock would defeat the point.
|
||
func loadCatalogueProducts(db *gorm.DB, tenantID, locationID int) (map[int]offlineProduct, error) {
|
||
rows := make([]offlineProduct, 0)
|
||
err := 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.orderamount, 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
|
||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||
LEFT 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]
|
||
|
||
// The header already carries the order total, and it is the only place
|
||
// that has one. This used to overwrite it with the first LINE’s
|
||
// Orderamount and Totaltaxamount — columns that do not exist on the
|
||
// orderdetails table, so both scanned as zero and clobbered a correct
|
||
// value. Every order in a shopper’s history read ₹0 as a result, however
|
||
// much they had actually spent.
|
||
//
|
||
// Taking the first line’s figure could not have been right even if the
|
||
// columns existed: one line is not the order.
|
||
}
|
||
|
||
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 LOWER(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
|
||
// Narrows the list rather than gating it — same reasoning as
|
||
// GetTenantOrders above, where empty dates cast to a date and answered 500.
|
||
if input.Fromdate != "" && input.ToDate != "" {
|
||
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
|
||
}
|