new api for offline sales

This commit is contained in:
2026-07-30 17:25:09 +05:30
parent a11c4843ca
commit 583cd89063
10 changed files with 887 additions and 10 deletions

View File

@@ -1,7 +1,9 @@
package repositories
import (
"errors"
"fmt"
"hash/fnv"
"log"
"nearle/models"
"sort"
@@ -25,6 +27,7 @@ type OrderRepository interface {
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)
@@ -1167,7 +1170,50 @@ func nextSequenceNo(tx *gorm.DB, tid int, prefix string) (string, error) {
func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error) {
tx := r.db.Begin()
if tx.Error != nil {
return models.Orders{}, tx.Error
}
created, err := r.createOrderTx(tx, data)
if err != nil {
// createOrderTx has already rolled back — see its contract.
return models.Orders{}, err
}
if err := tx.Commit().Error; err != nil {
return models.Orders{}, err
}
return r.reloadOrder(created.Orderheaderid)
}
// reloadOrder re-reads a committed order with its line items, so callers hand
// back the row as the database actually stored it (defaults applied, sequence
// number assigned) rather than the struct they submitted.
func (r *orderRepository) reloadOrder(orderHeaderID int) (models.Orders, error) {
var order models.Orders
if err := r.db.Where("orderheaderid = ?", orderHeaderID).First(&order).Error; err != nil {
return models.Orders{}, err
}
var items []models.OrderDetail
if err := r.db.Table("orderdetails").Where("orderheaderid = ?", orderHeaderID).Find(&items).Error; err == nil {
order.Items = items
}
return order, nil
}
// createOrderTx is everything an order needs between BEGIN and COMMIT: the
// per-product row locks, the stock pre-check, the sequence allocation, the
// header, the line items, the "out" ledger entries and the availability
// re-sync. It is split out from CreateOrder so a batch importer can run the
// identical, already-hardened path inside a transaction of its own rather than
// reimplementing stock deduction and drifting from it.
//
// Contract: on failure it has already rolled tx back, and the caller must not
// use tx again. On success tx is left open and uncommitted, so the caller can
// include its own work — a duplicate-bill guard, an advisory lock — in the same
// transaction as the order that work protects.
func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models.Orders, error) {
locID := data.Locationid
if locID == 0 {
locID = data.Applocationid
@@ -1309,20 +1355,567 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error)
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
}
// Deliberately not committed: the caller owns the transaction boundary.
return data, nil
}
// ── Offline (in-store) sales import ───────────────────────────────────────────
const (
// offlineDeliveryType tags an order that was rung up at the counter rather
// than placed through the app. It keeps offline sales out of the dispatch
// and delivery pipeline (which selects on 'B'/'C') while leaving them fully
// visible to revenue and stock reporting.
offlineDeliveryType = "OFFLINE"
// offlineRemarkPrefix namespaces the bill reference stored in
// orders.remarks. That stored value is what makes re-uploading the same
// spreadsheet a no-op instead of double-counting the sales.
offlineRemarkPrefix = "OFFLINE:"
)
// offlinePaymentTypes maps the spreadsheet's human payment mode onto the
// numeric orders.paymenttype. The paymenttype master table is empty in
// production, so these mirror the values live traffic already uses (42 is by
// far the most common, 64 next, then 43) and are the one place to change if
// that mapping is ever formalised.
var offlinePaymentTypes = map[string]int{
"cash": 42,
"card": 43,
"upi": 64,
}
const offlinePaymentTypeDefault = 42
// offlineLocationContext is the header scaffolding an order needs in order to
// be readable afterwards. It matters more than it looks: the order-listing
// query INNER JOINs customers, tenants, tenantlocations, app_location and
// app_locationconfig, so an imported order with a zero applocationid or
// customerid would be written successfully and then be invisible in every
// screen that lists orders.
type offlineLocationContext struct {
Tenantid int
Locationid int
Locationname string
Applocationid int
Moduleid int
Configid int
Partnerid int
Categoryid int
Subcategoryid int
}
// resolveOfflineLocationContext authorises the outlet and works out that
// scaffolding. Ownership is checked here, not in the caller: this is the single
// point where "locationid belongs to tenantid" is established, so a store user
// who edits the locationid in their spreadsheet cannot post sales into another
// branch.
//
// applocationid comes from tenantlocations, which is authoritative. The
// remaining ids are taken from the most recent real order at the same outlet,
// because tenantlocations carries 0 for moduleid/partnerid at outlets whose
// live orders nonetheless use non-zero values — copying a known-good order's
// scaffolding is what guarantees the joins resolve.
func (r *orderRepository) resolveOfflineLocationContext(tenantID, locationID int) (*offlineLocationContext, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, errors.New("tenantid and locationid are required")
}
var loc struct {
Locationname string
Applocationid int
Moduleid int
Partnerid int
}
err := r.db.Raw(`
SELECT COALESCE(locationname, '') AS locationname,
COALESCE(applocationid, 0) AS applocationid,
COALESCE(moduleid, 0) AS moduleid,
COALESCE(partnerid, 0) AS partnerid
FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
tenantID, locationID,
).Scan(&loc).Error
if err != nil {
return nil, err
}
if strings.TrimSpace(loc.Locationname) == "" {
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
}
ctx := &offlineLocationContext{
Tenantid: tenantID,
Locationid: locationID,
Locationname: strings.TrimSpace(loc.Locationname),
Applocationid: loc.Applocationid,
Moduleid: loc.Moduleid,
Partnerid: loc.Partnerid,
Configid: 1,
}
var prev struct {
Applocationid int
Moduleid int
Configid int
Partnerid int
Categoryid int
Subcategoryid int
}
err = r.db.Raw(`
SELECT COALESCE(applocationid, 0) AS applocationid,
COALESCE(moduleid, 0) AS moduleid,
COALESCE(configid, 0) AS configid,
COALESCE(partnerid, 0) AS partnerid,
COALESCE(categoryid, 0) AS categoryid,
COALESCE(subcategoryid, 0) AS subcategoryid
FROM orders
WHERE tenantid = ? AND locationid = ?
ORDER BY orderheaderid DESC LIMIT 1`,
tenantID, locationID,
).Scan(&prev).Error
if err != nil {
return nil, err
}
// Only fill from the previous order where it actually knows better; never
// let a zero from it wipe a good value taken from tenantlocations.
if prev.Applocationid > 0 {
ctx.Applocationid = prev.Applocationid
}
if prev.Moduleid > 0 {
ctx.Moduleid = prev.Moduleid
}
if prev.Configid > 0 {
ctx.Configid = prev.Configid
}
if prev.Partnerid > 0 {
ctx.Partnerid = prev.Partnerid
}
ctx.Categoryid = prev.Categoryid
ctx.Subcategoryid = prev.Subcategoryid
if ctx.Applocationid <= 0 {
return nil, fmt.Errorf("outlet '%s' has no applocationid configured; an offline sale imported against it would not appear in any order list", ctx.Locationname)
}
return ctx, nil
}
// offlineProduct is what the importer needs to know about one product to price
// a line and to confirm the product really is stocked at this outlet.
type offlineProduct struct {
Productid int
Productname string
Productunit string
Price float64
Taxpercent float64
}
// loadOfflineProducts indexes every product stocked at the outlet by productid.
// Loaded once per upload rather than per line: a spreadsheet of a few hundred
// rows would otherwise issue a query per row.
//
// Membership in this map is the ownership check for a line. A productid absent
// from it is either another tenant's product or not stocked here, and either
// way the line is refused — so a hand-edited productid cannot reach into a
// catalogue the uploader has no claim on.
func (r *orderRepository) loadOfflineProducts(tenantID, locationID int) (map[int]offlineProduct, error) {
rows := make([]offlineProduct, 0)
err := r.db.Raw(`
SELECT a.productid,
COALESCE(a.productname, '') AS productname,
COALESCE(a.productunit, '') AS productunit,
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
COALESCE(a.taxpercent, 0) AS taxpercent
FROM products a
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
WHERE a.tenantid = ? AND b.locationid = ?`,
tenantID, locationID,
).Scan(&rows).Error
if err != nil {
return nil, err
}
index := make(map[int]offlineProduct, len(rows))
for _, p := range rows {
index[p.Productid] = p
}
return index, nil
}
// offlineDateLayouts are the formats a spreadsheet cell realistically arrives
// in. Day-first layouts precede month-first ones because the stores using this
// are Indian and write 03-07-2026 meaning 3 July.
var offlineDateLayouts = []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02 15:04",
"2006-01-02",
"02-01-2006 15:04:05",
"02-01-2006 15:04",
"02-01-2006",
"02/01/2006 15:04:05",
"02/01/2006 15:04",
"02/01/2006",
}
// parseOfflineSaleDate resolves the sale timestamp, falling back to now when
// the cell is blank. An unparseable value is an error rather than a silent
// fallback: importing a sale under the wrong date corrupts every daily revenue
// figure that reads it, so it is better to reject the bill and say so.
func parseOfflineSaleDate(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Now(), nil
}
for _, layout := range offlineDateLayouts {
if t, err := time.Parse(layout, raw); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unrecognised saledate %q (use YYYY-MM-DD or DD-MM-YYYY)", raw)
}
// offlineBillReference is the value written to orders.remarks and matched
// against on re-upload.
func offlineBillReference(billno string) string {
return offlineRemarkPrefix + strings.ToUpper(strings.TrimSpace(billno))
}
// offlineBillKey is the bill number to dedupe on. When the spreadsheet supplies
// one it is used as given. When it does not, a hash of the bill's own contents
// stands in, which makes a re-upload of an unnumbered bill still recognisable
// as the same sale — the alternative (a counter or a timestamp) would let the
// same file import twice and double-deduct the stock.
//
// The trade-off is deliberate: two genuinely separate sales of the identical
// basket, on the same date, both with no bill number, hash alike and the second
// is reported as a duplicate. Putting bill numbers in the sheet distinguishes
// them, and the result names the collision rather than hiding it.
func offlineBillKey(bill models.OfflineSaleBill, saleDate time.Time) string {
if b := strings.TrimSpace(bill.Billno); b != "" {
return b
}
h := fnv.New64a()
fmt.Fprintf(h, "%s|%s|%s|", saleDate.Format("2006-01-02"), strings.TrimSpace(bill.Customermobile), strings.TrimSpace(bill.Paymentmode))
for _, it := range bill.Items {
fmt.Fprintf(h, "%d:%.3f:%.2f;", it.Productid, it.Qtysold, it.Unitprice)
}
return fmt.Sprintf("AUTO-%s-%X", saleDate.Format("20060102"), h.Sum64())
}
// resolveOfflineCustomer finds or creates the customer to attach the sale to.
// A customer is not optional: the order-listing query INNER JOINs customers, so
// an order with customerid 0 is written and then never shown.
//
// With a mobile number the sale is attached to that shopper, so their offline
// and app purchases sit under one customer. Without one it goes to a single
// per-outlet walk-in record, keyed on a sentinel contactno so repeated imports
// reuse it instead of accumulating a customer row per bill.
func (r *orderRepository) resolveOfflineCustomer(tx *gorm.DB, ctx *offlineLocationContext, name, mobile string) (int, error) {
mobile = strings.TrimSpace(mobile)
name = strings.TrimSpace(name)
contactno := mobile
if contactno == "" {
contactno = fmt.Sprintf("OFFLINE-%d", ctx.Locationid)
if name == "" {
name = "Walk-in Customer"
}
}
if name == "" {
name = "Offline Customer"
}
var existing int
err := tx.Raw(
`SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`,
contactno, ctx.Applocationid,
).Scan(&existing).Error
if err != nil {
return 0, err
}
if existing > 0 {
return existing, nil
}
// status 0 mirrors every existing customer row in production, including
// ones actively placing orders — a different value here would make the
// imported customer behave unlike all the others.
var created int
err = tx.Raw(`
INSERT INTO customers (configid, firstname, lastname, contactno, applocationid, locationid, status, created, updated)
VALUES (?, ?, '', ?, ?, ?, 0, NOW(), NOW())
RETURNING customerid`,
ctx.Configid, name, contactno, ctx.Applocationid, ctx.Locationid,
).Scan(&created).Error
if err != nil {
return 0, err
}
if created <= 0 {
return 0, errors.New("failed to create customer for offline sale")
}
return created, nil
}
// UploadOfflineSales imports a spreadsheet of counter sales as real orders.
//
// Each bill is its own transaction, so one bad bill cannot undo the others and
// the response says exactly which ones landed. Inside that transaction the bill
// runs through createOrderTx — the same code path an app order takes — so stock
// deduction, the per-product row locks that stop overselling, the ledger "out"
// entries, the sequence allocation and the availability re-sync are shared with
// online orders rather than reimplemented alongside them.
//
// Deduplication is per bill and holds an advisory lock for the duration of the
// transaction that writes the order. The lock is what makes it a real guarantee
// instead of a race: two uploads of the same file arriving together would
// otherwise both read "not yet imported" and both commit.
func (r *orderRepository) UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error) {
if len(input.Bills) == 0 {
return nil, errors.New("no sales rows found in the upload")
}
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid)
if err != nil {
return nil, err
}
products, err := r.loadOfflineProducts(input.Tenantid, input.Locationid)
if err != nil {
return nil, err
}
if len(products) == 0 {
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
}
resp := &models.OfflineSalesUploadResponse{Results: make([]models.OfflineSaleResult, 0, len(input.Bills))}
for _, bill := range input.Bills {
result := r.importOfflineBill(ctx, products, input.Userid, bill)
resp.Results = append(resp.Results, result)
switch result.Status {
case models.OfflineSaleImported:
resp.Imported++
resp.Totalamount += result.Amount
case models.OfflineSaleDuplicate:
resp.Duplicate++
default:
resp.Failed++
}
}
return resp, nil
}
// importOfflineBill commits one bill or leaves nothing behind. It returns a
// result rather than an error because a partial import is the useful outcome
// for a spreadsheet: the caller reports what failed and the operator fixes
// those rows without re-importing what already succeeded.
func (r *orderRepository) importOfflineBill(
ctx *offlineLocationContext,
products map[int]offlineProduct,
userID int,
bill models.OfflineSaleBill,
) models.OfflineSaleResult {
billLabel := strings.TrimSpace(bill.Billno)
fail := func(format string, args ...any) models.OfflineSaleResult {
return models.OfflineSaleResult{
Billno: billLabel,
Status: models.OfflineSaleFailed,
Message: fmt.Sprintf(format, args...),
}
}
if len(bill.Items) == 0 {
return fail("bill has no sold items")
}
saleDate, err := parseOfflineSaleDate(bill.Saledate)
if err != nil {
return fail("%s", err.Error())
}
billKey := offlineBillKey(bill, saleDate)
if billLabel == "" {
billLabel = billKey
}
// Build and price the line items before opening a transaction: a sheet with
// a bad productid should be rejected without having touched the database.
items := make([]models.OrderDetail, 0, len(bill.Items))
var orderAmount, taxTotal float64
for _, raw := range bill.Items {
if raw.Productid <= 0 {
return fail("a row is missing productid")
}
product, ok := products[raw.Productid]
if !ok {
return fail("product %d is not stocked at %s", raw.Productid, ctx.Locationname)
}
if raw.Qtysold <= 0 {
return fail("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Qtysold)
}
// The sheet carries productname purely so a person can read it. It is
// checked against productid and reported on mismatch, but productid
// always wins — a name is not unique enough to resolve against.
if n := strings.TrimSpace(raw.Productname); n != "" && !strings.EqualFold(n, product.Productname) {
return fail("row for productid %d says '%s' but that id is '%s'; the template may be out of date", raw.Productid, n, product.Productname)
}
unitPrice := raw.Unitprice
if unitPrice <= 0 {
unitPrice = product.Price
}
taxPercent := raw.Taxpercent
if taxPercent <= 0 {
taxPercent = product.Taxpercent
}
gross := unitPrice * raw.Qtysold
discount := raw.Discountamount
if discount < 0 {
discount = 0
}
if discount > gross {
return fail("product '%s' has a discount of %.2f on a line worth %.2f", product.Productname, discount, gross)
}
// landing is the money actually taken at the counter, and it is what
// revenue is summed from. Tax is treated as already inside that price
// (retail prices here are MRP), so extracting it cannot change what the
// customer is recorded as having paid.
landing := gross - discount
taxAmount := 0.0
if taxPercent > 0 {
taxAmount = landing - (landing / (1 + taxPercent/100))
}
orderAmount += landing
taxTotal += taxAmount
items = append(items, models.OrderDetail{
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Productid: raw.Productid,
Productname: product.Productname,
Orderqty: raw.Qtysold,
Supplyqty: raw.Qtysold,
Price: unitPrice,
Unitname: product.Productunit,
Discountamount: discount,
Landingamount: landing,
Taxpercentage: taxPercent,
Taxamount: taxAmount,
Productsumprice: gross,
Itemstatus: "delivered",
Delivered: saleDate.Format("2006-01-02 15:04:05"),
})
}
paymentType := offlinePaymentTypeDefault
if mode := strings.ToLower(strings.TrimSpace(bill.Paymentmode)); mode != "" {
if mapped, ok := offlinePaymentTypes[mode]; ok {
paymentType = mapped
}
}
notes := strings.TrimSpace(bill.Remarks)
if notes == "" {
notes = fmt.Sprintf("Offline counter sale (bill %s)", billLabel)
}
tx := r.db.Begin()
if tx.Error != nil {
return fail("could not start a transaction: %v", tx.Error)
}
// Held until this transaction ends, so a concurrent upload of the same bill
// waits here and then sees the committed row rather than racing past the
// duplicate check below.
lockKey := fmt.Sprintf("offlinesale:%d:%d:%s", ctx.Tenantid, ctx.Locationid, strings.ToUpper(billKey))
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil {
tx.Rollback()
return fail("could not lock bill %s: %v", billLabel, err)
}
reference := offlineBillReference(billKey)
var already int
err = tx.Raw(
`SELECT COALESCE(COUNT(*), 0) FROM orders WHERE tenantid = ? AND locationid = ? AND remarks = ?`,
ctx.Tenantid, ctx.Locationid, reference,
).Scan(&already).Error
if err != nil {
tx.Rollback()
return fail("could not check whether bill %s was already imported: %v", billLabel, err)
}
if already > 0 {
tx.Rollback()
return models.OfflineSaleResult{
Billno: billLabel,
Status: models.OfflineSaleDuplicate,
Message: fmt.Sprintf("bill %s was already imported for this outlet; stock was not deducted again", billLabel),
}
}
customerID, err := r.resolveOfflineCustomer(tx, ctx, bill.Customername, bill.Customermobile)
if err != nil {
tx.Rollback()
return fail("could not resolve the customer: %v", err)
}
order := models.Orders{
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Applocationid: ctx.Applocationid,
Moduleid: ctx.Moduleid,
Configid: ctx.Configid,
Partnerid: ctx.Partnerid,
Categoryid: ctx.Categoryid,
Subcategoryid: ctx.Subcategoryid,
Customerid: customerID,
Orderdate: saleDate.Format("2006-01-02 15:04:05"),
// Already handed over at the counter, so the sale is complete on
// arrival and never enters the dispatch pipeline.
Orderstatus: "delivered",
Delivered: saleDate.Format("2006-01-02 15:04:05"),
Deliverytime: saleDate.Format("2006-01-02 15:04:05"),
Deliverytype: offlineDeliveryType,
Orderamount: float32(orderAmount),
Taxamount: float32(taxTotal),
Ordervalue: float32(orderAmount),
Itemcount: len(items),
Paymenttype: paymentType,
Paymentstatus: 1,
Ordernotes: notes,
Remarks: reference,
Tenantuserid: userID,
Items: items,
}
created, err := r.createOrderTx(tx, order)
if err != nil {
// createOrderTx has already rolled back. Its stock message is the one
// worth surfacing — it names the product and the shortfall.
return fail("%s", err.Error())
}
if err := tx.Commit().Error; err != nil {
return models.Orders{}, err
return fail("could not commit bill %s: %v", billLabel, err)
}
var order models.Orders
if err := r.db.Where("orderheaderid = ?", data.Orderheaderid).First(&order).Error; err != nil {
return models.Orders{}, err
return models.OfflineSaleResult{
Billno: billLabel,
Status: models.OfflineSaleImported,
Orderid: created.Orderid,
Orderheaderid: created.Orderheaderid,
Itemcount: len(items),
Amount: orderAmount,
Message: fmt.Sprintf("imported as order %s", created.Orderid),
}
var items []models.OrderDetail
if err := r.db.Table("orderdetails").Where("orderheaderid = ?", data.Orderheaderid).Find(&items).Error; err == nil {
order.Items = items
}
return order, nil
}
func (r *orderRepository) getOrderDetailsByHeaderID(orderHeaderID int) ([]models.OrderDetails, float64, float64, error) {