From 583cd89063dedd180eeaf2b3cdd94e22fb5d015a Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 30 Jul 2026 17:25:09 +0530 Subject: [PATCH] new api for offline sales --- controllers/orderController.go | 67 ++++ controllers/productController.go | 34 ++ models/order.go | 71 ++++ models/product.go | 34 ++ repositories/orderRepository.go | 613 +++++++++++++++++++++++++++++- repositories/productRepository.go | 66 ++++ routes/orderroutes.go | 1 + routes/productroutes.go | 1 + services/orderService.go | 5 + services/productService.go | 5 + 10 files changed, 887 insertions(+), 10 deletions(-) diff --git a/controllers/orderController.go b/controllers/orderController.go index a311a35..fa074fc 100644 --- a/controllers/orderController.go +++ b/controllers/orderController.go @@ -1,6 +1,7 @@ package controllers import ( + "fmt" "log" "nearle/models" "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 { customerID := c.Query("customerid") tenantID := c.Query("tenantid") diff --git a/controllers/productController.go b/controllers/productController.go index f26ae61..09b03b1 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -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 { tenantID, _ := strconv.Atoi(c.Query("tenantid")) locationID, _ := strconv.Atoi(c.Query("locationid")) diff --git a/models/order.go b/models/order.go index 73b8d67..22ae0d2 100644 --- a/models/order.go +++ b/models/order.go @@ -439,6 +439,77 @@ type Ordersequences struct { 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 { Tenantid int `json:"tenantid"` Tenantname string `json:"tenantname"` diff --git a/models/product.go b/models/product.go index 08ed611..6114127 100644 --- a/models/product.go +++ b/models/product.go @@ -327,6 +327,40 @@ type ProductLocationRef struct { 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 { Subcatid int `json:"subcatid"` Categoryid int `json:"categoryid"` diff --git a/repositories/orderRepository.go b/repositories/orderRepository.go index 72d619b..ae9b81f 100644 --- a/repositories/orderRepository.go +++ b/repositories/orderRepository.go @@ -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) { diff --git a/repositories/productRepository.go b/repositories/productRepository.go index 1f48a6a..c1373c8 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -29,6 +29,7 @@ type ProductRepository interface { GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, 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) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) GetSubcategories(categoryID int) ([]models.Subcategory, error) @@ -502,6 +503,71 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor 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) { data := make([]models.ProductSummary, 0) diff --git a/routes/orderroutes.go b/routes/orderroutes.go index a44451c..132bfcb 100644 --- a/routes/orderroutes.go +++ b/routes/orderroutes.go @@ -24,6 +24,7 @@ func RegisterOrderRoutes(api fiber.Router, f *facade.Facade) { orders.Get("/getorderdetails", f.OrderController.GetOrderDetails) orders.Put("/updateorder", f.OrderController.UpdateOrder) orders.Post("/createorder", f.OrderController.CreateOrderv3) + orders.Post("/uploadofflinesales", f.OrderController.UploadOfflineSales) reports := api.Group("/v1/web/reports") reports.Get("/sales-summary", f.OrderController.GetSalesSummary) diff --git a/routes/productroutes.go b/routes/productroutes.go index 40986a7..976894f 100644 --- a/routes/productroutes.go +++ b/routes/productroutes.go @@ -23,6 +23,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) { products.Get("/getstockstatement", f.ProductController.GetStockStatement) products.Get("/getlocationproducts", f.ProductController.GetLocationProducts) products.Get("/getlocationproductsummary", f.ProductController.GetLocationProductSummary) + products.Get("/getsaletemplate", f.ProductController.GetSaleTemplate) products.Get("/getallproducts", f.ProductController.GetAllProducts) products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation) products.Post("/createproductlocation", f.ProductController.CreateProductLocation) diff --git a/services/orderService.go b/services/orderService.go index e35426d..16e364a 100644 --- a/services/orderService.go +++ b/services/orderService.go @@ -18,6 +18,7 @@ type OrderService 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) GetTenantLocationOrders(input models.DeliveryQuery) ([]models.OrderInfo, 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) } +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) { return s.repo.GetCustomerOrdersv3(customerID, tenantID, moduleID, fromDate, toDate, orderStatus, keyword, pageSize, offset) } diff --git a/services/productService.go b/services/productService.go index db736ad..3dec710 100644 --- a/services/productService.go +++ b/services/productService.go @@ -22,6 +22,7 @@ type ProductService interface { GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, 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) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, 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) } +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, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) { return s.repo.FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID, keyword, productStatus, approve, pageno, pagesize)