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,6 +1,7 @@
package controllers package controllers
import ( import (
"fmt"
"log" "log"
"nearle/models" "nearle/models"
"net/http" "net/http"
@@ -371,6 +372,72 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
}) })
} }
// UploadOfflineSales imports a spreadsheet of in-store counter sales.
//
// The response is 200 whenever the batch was processed, even if individual
// bills were rejected, because a partial import is a normal outcome for a
// spreadsheet and the per-bill results carry the detail. A non-200 means
// nothing at all was attempted — a malformed body, or an outlet the caller has
// no claim on.
func (ctl *OrderController) UploadOfflineSales(c *fiber.Ctx) error {
var input models.OfflineSalesUpload
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the upload: " + err.Error(),
"status": false,
})
}
if input.Tenantid <= 0 || input.Locationid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid and locationid are required",
"status": false,
})
}
if len(input.Bills) == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "no sales rows found in the upload",
"status": false,
})
}
result, err := ctl.orderService.UploadOfflineSales(input)
if err != nil {
log.Println("UploadOfflineSales service error:", err)
// An outlet the caller doesn't own is a permission problem, not a
// server fault, and is reported as one so the UI can say so plainly.
statusCode := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "does not belong to tenant") {
statusCode = http.StatusForbidden
}
return c.Status(statusCode).JSON(fiber.Map{
"code": statusCode,
"message": err.Error(),
"status": false,
})
}
message := fmt.Sprintf("%d bill(s) imported", result.Imported)
if result.Duplicate > 0 {
message += fmt.Sprintf(", %d already imported", result.Duplicate)
}
if result.Failed > 0 {
message += fmt.Sprintf(", %d failed", result.Failed)
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"message": message,
"status": true,
"details": result,
})
}
func (ctl *OrderController) GetCustomerOrders(c *fiber.Ctx) error { func (ctl *OrderController) GetCustomerOrders(c *fiber.Ctx) error {
customerID := c.Query("customerid") customerID := c.Query("customerid")
tenantID := c.Query("tenantid") tenantID := c.Query("tenantid")

View File

@@ -309,6 +309,40 @@ func (ctl *ProductController) GetLocationProducts(c *fiber.Ctx) error {
}) })
} }
// GetSaleTemplate serves the data the web app turns into the offline-sales
// spreadsheet. Both tenantid and locationid are required rather than defaulted:
// a template generated against the wrong outlet would carry productids the
// import then rejects, which is a confusing failure a missing parameter should
// not be able to cause.
func (ctl *ProductController) GetSaleTemplate(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
locationID, _ := strconv.Atoi(c.Query("locationid"))
if tenantID <= 0 || locationID <= 0 {
return c.JSON(fiber.Map{
"status": false,
"code": http.StatusBadRequest,
"message": "tenantid and locationid are required",
})
}
result, err := ctl.productService.GetSaleTemplate(tenantID, locationID)
if err != nil {
return c.JSON(fiber.Map{
"status": false,
"code": http.StatusInternalServerError,
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"status": true,
"code": http.StatusOK,
"message": "Success",
"details": result,
})
}
func (ctl *ProductController) GetLocationProductSummary(c *fiber.Ctx) error { func (ctl *ProductController) GetLocationProductSummary(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid")) tenantID, _ := strconv.Atoi(c.Query("tenantid"))
locationID, _ := strconv.Atoi(c.Query("locationid")) locationID, _ := strconv.Atoi(c.Query("locationid"))

View File

@@ -439,6 +439,77 @@ type Ordersequences struct {
Paymentprefix string `json:"paymentprefix" gorm:"default:PAY"` Paymentprefix string `json:"paymentprefix" gorm:"default:PAY"`
} }
// ── Offline (in-store) sales import ───────────────────────────────────────────
//
// A sale rung up at the counter never passes through the app, so nothing
// deducts its stock. These types carry a spreadsheet of such sales into the
// same order path online orders use, so one ledger remains the single source
// of truth for stock and one revenue figure covers both channels.
// OfflineSaleItem is one spreadsheet row. Only Productid and Qtysold are
// required; the rest fall back to the product's own pricing when left blank.
// Productname is carried for verification against Productid, not for matching
// (see SaleTemplateRow for why a name can't be a key).
type OfflineSaleItem struct {
Productid int `json:"productid"`
Productname string `json:"productname"`
Qtysold float64 `json:"qtysold"`
Unitprice float64 `json:"unitprice"`
Discountamount float64 `json:"discountamount"`
Taxpercent float64 `json:"taxpercent"`
}
// OfflineSaleBill is one counter bill — the rows of a spreadsheet grouped by
// their billno. Billno is what makes a re-upload of the same file safe: it is
// recorded on the order and refused if it is already present for this outlet.
type OfflineSaleBill struct {
Billno string `json:"billno"`
Saledate string `json:"saledate"`
Paymentmode string `json:"paymentmode"`
Customername string `json:"customername"`
Customermobile string `json:"customermobile"`
Remarks string `json:"remarks"`
Items []OfflineSaleItem `json:"items"`
}
// OfflineSalesUpload is the request body. Locationid is the outlet the sales
// belong to and is authorised server-side against Tenantid — a store user
// editing the spreadsheet cannot post sales into another branch.
type OfflineSalesUpload struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Userid int `json:"userid"`
Bills []OfflineSaleBill `json:"bills"`
}
// Outcomes a single bill can have. A bill is all-or-nothing: it either commits
// with its stock movement or it leaves nothing behind.
const (
OfflineSaleImported = "imported"
OfflineSaleDuplicate = "duplicate"
OfflineSaleFailed = "failed"
)
// OfflineSaleResult reports one bill's fate. Bills are independent, so a file
// with one bad bill still imports the rest and names exactly what it skipped.
type OfflineSaleResult struct {
Billno string `json:"billno"`
Status string `json:"status"`
Orderid string `json:"orderid"`
Orderheaderid int `json:"orderheaderid"`
Itemcount int `json:"itemcount"`
Amount float64 `json:"amount"`
Message string `json:"message"`
}
type OfflineSalesUploadResponse struct {
Imported int `json:"imported"`
Duplicate int `json:"duplicate"`
Failed int `json:"failed"`
Totalamount float64 `json:"totalamount"`
Results []OfflineSaleResult `json:"results"`
}
type TenantRevenueSummary struct { type TenantRevenueSummary struct {
Tenantid int `json:"tenantid"` Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"` Tenantname string `json:"tenantname"`

View File

@@ -327,6 +327,40 @@ type ProductLocationRef struct {
Productid int Productid int
} }
// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet:
// a product actually stocked at one outlet, with the numbers the person at the
// till needs to see before they type a sold quantity against it.
//
// Productid is the only field that identifies the product. It cannot be
// productsku: across the live catalogue 6,245 products share just 93 distinct
// sku values (one tenant has 463 products all carrying sku "1"), and 154 are
// blank, so a sku is not a key. Productname is nearly unique per tenant but
// not reliably ("rice" appears 6 times for one tenant), so it travels as a
// human-readable confirmation only and is never matched on. That is why the
// spreadsheet has to be generated from this endpoint rather than typed from
// scratch — the productid column is filled in for the user.
type SaleTemplateRow struct {
Productid int `json:"productid"`
Productname string `json:"productname"`
Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue"`
Categoryname string `json:"categoryname"`
Currentstock int `json:"currentstock"`
Price float64 `json:"price"`
Taxpercent float64 `json:"taxpercent"`
}
// SaleTemplate is the payload the web app turns into an .xlsx workbook. The
// tenant/location identity travels with it so the generated file records which
// outlet it belongs to, and the upload can be checked against the file it came
// from instead of trusting a hand-typed location.
type SaleTemplate struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Products []SaleTemplateRow `json:"products"`
}
type ProductSubcategory struct { type ProductSubcategory struct {
Subcatid int `json:"subcatid"` Subcatid int `json:"subcatid"`
Categoryid int `json:"categoryid"` Categoryid int `json:"categoryid"`

View File

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

View File

@@ -29,6 +29,7 @@ type ProductRepository interface {
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error)
GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error)
GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error)
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error)
GetSubcategories(categoryID int) ([]models.Subcategory, error) GetSubcategories(categoryID int) ([]models.Subcategory, error)
@@ -502,6 +503,71 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
return data, nil return data, nil
} }
// GetSaleTemplate lists every product stocked at one outlet, with its live
// ledger balance, so the web app can generate a pre-filled offline-sales
// spreadsheet. It deliberately returns the whole catalogue for the outlet
// unpaged — a spreadsheet the user is meant to fill in and hand back is only
// useful if it contains every product they could have sold.
//
// The balance is the same SUM(in) - SUM(out) expression CreateOrder validates
// against, so the "currentstock" the user reads in the sheet is exactly the
// number the import will later check their quantity against. LOWER() covers
// the mixed-case stocktype values in production ('out', 'IN', 'in').
//
// A location that does not belong to the tenant yields no template rather than
// another tenant's catalogue: the caller treats that as "not your outlet".
func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, errors.New("tenantid and locationid are required")
}
var loc struct {
Locationname string
}
err := r.db.Raw(
`SELECT locationname FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
tenantID, locationID,
).Scan(&loc).Error
if err != nil {
return nil, err
}
if strings.TrimSpace(loc.Locationname) == "" {
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
}
rows := make([]models.SaleTemplateRow, 0)
query := `
SELECT a.productid,
a.productname,
COALESCE(a.productunit, '') AS productunit,
COALESCE(a.unitvalue, '') AS unitvalue,
COALESCE(d.categoryname, '') AS categoryname,
COALESCE(SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(c.stocktype) = 'out' THEN c.quantity ELSE 0 END), 0) AS currentstock,
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
COALESCE(a.taxpercent, 0) AS taxpercent
FROM products a
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
LEFT JOIN productstocks c
ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid
LEFT JOIN productcategories d ON a.categoryid = d.categoryid
WHERE a.approve = 1 AND a.tenantid = ? AND b.locationid = ?
GROUP BY a.productid, a.productname, a.productunit, a.unitvalue, d.categoryname,
b.price, a.retailprice, a.taxpercent
ORDER BY a.productname ASC`
if err := r.db.Raw(query, tenantID, locationID).Scan(&rows).Error; err != nil {
return nil, err
}
return &models.SaleTemplate{
Tenantid: tenantID,
Locationid: locationID,
Locationname: strings.TrimSpace(loc.Locationname),
Products: rows,
}, nil
}
func (r *productRepository) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) { func (r *productRepository) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) {
data := make([]models.ProductSummary, 0) data := make([]models.ProductSummary, 0)

View File

@@ -24,6 +24,7 @@ func RegisterOrderRoutes(api fiber.Router, f *facade.Facade) {
orders.Get("/getorderdetails", f.OrderController.GetOrderDetails) orders.Get("/getorderdetails", f.OrderController.GetOrderDetails)
orders.Put("/updateorder", f.OrderController.UpdateOrder) orders.Put("/updateorder", f.OrderController.UpdateOrder)
orders.Post("/createorder", f.OrderController.CreateOrderv3) orders.Post("/createorder", f.OrderController.CreateOrderv3)
orders.Post("/uploadofflinesales", f.OrderController.UploadOfflineSales)
reports := api.Group("/v1/web/reports") reports := api.Group("/v1/web/reports")
reports.Get("/sales-summary", f.OrderController.GetSalesSummary) reports.Get("/sales-summary", f.OrderController.GetSalesSummary)

View File

@@ -23,6 +23,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Get("/getstockstatement", f.ProductController.GetStockStatement) products.Get("/getstockstatement", f.ProductController.GetStockStatement)
products.Get("/getlocationproducts", f.ProductController.GetLocationProducts) products.Get("/getlocationproducts", f.ProductController.GetLocationProducts)
products.Get("/getlocationproductsummary", f.ProductController.GetLocationProductSummary) products.Get("/getlocationproductsummary", f.ProductController.GetLocationProductSummary)
products.Get("/getsaletemplate", f.ProductController.GetSaleTemplate)
products.Get("/getallproducts", f.ProductController.GetAllProducts) products.Get("/getallproducts", f.ProductController.GetAllProducts)
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation) products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
products.Post("/createproductlocation", f.ProductController.CreateProductLocation) products.Post("/createproductlocation", f.ProductController.CreateProductLocation)

View File

@@ -18,6 +18,7 @@ type OrderService interface {
GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error) GetOrderDetails(orderHeaderID int) ([]models.OrderDetails, error)
UpdateOrder(order *models.Orders) error UpdateOrder(order *models.Orders) error
CreateOrder(order models.Orders) (models.Orders, error) CreateOrder(order models.Orders) (models.Orders, error)
UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error)
GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error) GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error)
GetTenantLocationOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) GetTenantLocationOrders(input models.DeliveryQuery) ([]models.OrderInfo, error)
GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error) GetRevenueSummary(tid, lid int, fdate, tdate string) (*models.TenantRevenueSummary, error)
@@ -81,6 +82,10 @@ func (s *orderService) CreateOrder(order models.Orders) (models.Orders, error) {
return s.repo.CreateOrder(order) return s.repo.CreateOrder(order)
} }
func (s *orderService) UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error) {
return s.repo.UploadOfflineSales(input)
}
func (s *orderService) GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error) { func (s *orderService) GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword string, pageSize, offset int) ([]models.CustomerOrder, error) {
return s.repo.GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword, pageSize, offset) return s.repo.GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword, pageSize, offset)
} }

View File

@@ -22,6 +22,7 @@ type ProductService interface {
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error)
GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error)
GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error)
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error)
GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error) GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error)
@@ -140,6 +141,10 @@ func (s *productService) GetLocationProductSummary(tenantID, locationID int) ([]
return s.repo.GetLocationProductSummary(tenantID, locationID) return s.repo.GetLocationProductSummary(tenantID, locationID)
} }
func (s *productService) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) {
return s.repo.GetSaleTemplate(tenantID, locationID)
}
func (s *productService) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, func (s *productService) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus,
approve string, pageno, pagesize int) ([]models.Tenantproducts, error) { approve string, pageno, pagesize int) ([]models.Tenantproducts, error) {
return s.repo.FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID, keyword, productStatus, approve, pageno, pagesize) return s.repo.FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID, keyword, productStatus, approve, pageno, pagesize)