new api for offline sales
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user