package repositories import ( "encoding/json" "errors" "fmt" "strconv" "strings" "time" "nearle/models" "gorm.io/gorm" ) // Ingestion for the Nearle POS terminal. // // Bills arrive here already rung up and paid for — the till is the system of // record until we say otherwise, and it holds its own copy for a week on the // strength of our acknowledgement. Two consequences shape everything below. // // **A duplicate is a success.** Delivery is at-least-once: a lost ack makes a // terminal re-send bills that are already banked. Reporting those as failures // would strand a day of takings on the till for ever. So a bill we already hold // is accepted, silently, without touching stock again. // // **Acknowledge only after the commit.** A bill named in the ack is one the // terminal is entitled to delete. Saying so before the transaction lands would // trade a real sale for a queue position. // // The commit itself is deliberately not reimplemented here. Each bill runs // through createOrderTx, the same path an app order and a spreadsheet import // take, so stock deduction, the per-product row locks that prevent overselling, // the ledger entries and sequence allocation stay shared rather than forked. type PosRepository interface { IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) // Reading counter sales back out. Without these a committed bill is // unreachable from every screen in the product. Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) SaleDetail(locationID int, reference string) (*models.PosOrders, error) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) } type posRepository struct { db *gorm.DB // Held rather than embedded so the order machinery is reached explicitly. orders *orderRepository } func NewPosRepository(db *gorm.DB) PosRepository { return &posRepository{db: db, orders: &orderRepository{db: db}} } // resolvePosStore turns the terminal's store_id into an authorised outlet. // // The till sends a location and nothing else. The tenant is looked up from it // here and never accepted from the wire: a terminal that could name its own // tenant could post sales into somebody else's books. func (r *posRepository) resolvePosStore(storeID string) (*offlineLocationContext, error) { locationID, err := strconv.Atoi(strings.TrimSpace(storeID)) if err != nil || locationID <= 0 { return nil, fmt.Errorf("store_id %q is not a location id; configure the terminal's Store ID with the numeric locationid", storeID) } var tenantID int err = r.db.Raw( `SELECT COALESCE(MIN(tenantid), 0) FROM tenantlocations WHERE locationid = ?`, locationID, ).Scan(&tenantID).Error if err != nil { return nil, err } if tenantID <= 0 { return nil, fmt.Errorf("no outlet is registered with locationid %d", locationID) } return r.orders.resolveOfflineLocationContext(tenantID, locationID) } // IngestOrders commits a batch of counter bills and reports what landed. // // A failure to resolve the outlet at all returns an error rather than an ack, // so the terminal treats the outcome as unknown and retries. A failure on one // bill is reported against that bill alone and the rest still commit. func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) { ack := models.NewPosAck(batch.Batchid) if len(batch.Orders) == 0 { return ack, nil } ctx, err := r.resolvePosStore(batch.Storeid) if err != nil { return nil, err } products, err := r.orders.loadOfflineProducts(ctx.Tenantid, ctx.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) } for _, order := range batch.Orders { if strings.TrimSpace(order.Id) == "" { // Nothing to key on, so it can never be deduplicated. Refusing it // is safer than admitting a bill that would double on every retry. ack.Reject("", "order is missing its id") continue } if reason := r.importPosOrder(ctx, products, batch.Batchid, batch.Terminalid, order); reason != "" { ack.Reject(order.Id, reason) continue } ack.Accept(order.Id) } return ack, nil } // importPosOrder commits one bill, or leaves nothing behind. // // Returns an empty string on success — including the case where the bill was // already held, which is a success from the terminal's point of view. // // The bill lands in pos_orders at full fidelity, and the stock it consumed goes // through the same productstocks ledger an app order uses. Those two facts pull // in opposite directions and both matter: the bill is its own kind of document // and deserves its own table, but stock is one number per shelf and must not be // tracked twice. // batchTerminal is the terminal the whole batch came from, used when a bill // does not name one itself. Over MQTT the consumer fills it in from the topic; // over HTTP the terminal sends it once at the top of the batch rather than // repeating it on every bill. func (r *posRepository) importPosOrder( ctx *offlineLocationContext, products map[int]offlineProduct, batchID string, batchTerminal string, order models.PosOrder, ) string { if len(order.Items) == 0 { return "bill has no items" } saleDate, err := parsePosSaleDate(order.Createdat) if err != nil { return err.Error() } // The till has already apportioned bill-level discounts across its lines to // get the tax right, but it sends each line at its own pre-apportionment // value. Left alone, the item rows would sum to the subtotal while the // header carried the total, and every report that adds up lines would // disagree with the one that reads the header. // // So the lines are scaled onto what was actually collected. The till's own // figures stay authoritative for the bill as a whole; this only decides how // that whole is attributed across the lines inside it. netAmount := order.Total - order.Roundoff lineSum := 0.0 taxSum := 0.0 for _, item := range order.Items { lineSum += item.Linetotal taxSum += item.Tax } amountFactor := 1.0 if lineSum > 0 && netAmount > 0 { amountFactor = netAmount / lineSum } taxFactor := 1.0 if taxSum > 0 && order.Tax > 0 { taxFactor = order.Tax / taxSum } items := make([]models.PosOrderItems, 0, len(order.Items)) lines := make([]stockLine, 0, len(order.Items)) var taxTotal float64 for _, raw := range order.Items { productID, err := strconv.Atoi(strings.TrimSpace(raw.Productid)) if err != nil || productID <= 0 { return fmt.Sprintf("line '%s' has product_id %q, which is not a catalogue id", raw.Name, raw.Productid) } // Membership of this map is the ownership check. A product absent from // it is either another tenant's or not stocked here, and either way the // bill is refused rather than posted against a catalogue it has no // claim on. product, ok := products[productID] if !ok { return fmt.Sprintf("product %d is not stocked at %s", productID, ctx.Locationname) } if raw.Quantity <= 0 { return fmt.Sprintf("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Quantity) } landing := raw.Linetotal * amountFactor taxAmount := raw.Tax * taxFactor gross := raw.Unitprice * raw.Quantity discount := gross - landing if discount < 0 { discount = 0 } taxTotal += taxAmount items = append(items, models.PosOrderItems{ Tenantid: ctx.Tenantid, Locationid: ctx.Locationid, Productid: productID, Productname: product.Productname, Barcode: raw.Barcode, Unitname: product.Productunit, Quantity: raw.Quantity, Unitprice: raw.Unitprice, Discountamount: discount, Gstrate: raw.Gstrate, Taxamount: taxAmount, Linetotal: landing, }) lines = append(lines, stockLine{ Productid: productID, Locationid: ctx.Locationid, Productname: product.Productname, Quantity: raw.Quantity, }) } paymentMode := "cash" if len(order.Payments) > 0 { // The largest tender names the bill. A split paid mostly by card with // ten rupees of change in cash is a card sale in every report anyone // actually reads — the full split is kept in Paymentsjson regardless. largest := order.Payments[0] for _, p := range order.Payments[1:] { if p.Amount > largest.Amount { largest = p } } if m := strings.ToLower(strings.TrimSpace(largest.Method)); m != "" { paymentMode = m } } tx := r.db.Begin() if tx.Error != nil { return fmt.Sprintf("could not start a transaction: %v", tx.Error) } // Held for the life of the transaction, so a redelivery arriving at the // same moment waits here and then sees the committed row rather than racing // past the check below and banking the sale twice. The unique index on // terminalorderid would catch it either way; this turns a constraint // violation into an orderly "already held". lockKey := "possale:" + strings.ToUpper(strings.TrimSpace(order.Id)) if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil { tx.Rollback() return fmt.Sprintf("could not lock bill %s: %v", order.Invoicenumber, err) } var already int err = tx.Raw( `SELECT COALESCE(COUNT(*), 0) FROM pos_orders WHERE terminalorderid = ?`, strings.TrimSpace(order.Id), ).Scan(&already).Error if err != nil { tx.Rollback() return fmt.Sprintf("could not check whether bill %s was already held: %v", order.Invoicenumber, err) } if already > 0 { // Already banked. Accepted, not rejected — this is the ordinary result // of a lost ack, and calling it a failure would leave the till holding // a bill we have had all along. Stock is deliberately untouched. tx.Rollback() return "" } // Locks first, then availability, then the writes — the same order an app // order takes, and the reason two tills selling the last unit cannot both // succeed. if err := lockStockRows(tx, ctx.Tenantid, lines); err != nil { tx.Rollback() return err.Error() } if err := assertStockAvailable(tx, ctx.Tenantid, lines, func(l stockLine) int { return roundStockQty(l.Quantity) }); err != nil { tx.Rollback() return err.Error() } customerID, err := r.orders.resolveOfflineCustomer(tx, ctx, posCustomerName(order), posCustomerMobile(order)) if err != nil { tx.Rollback() return fmt.Sprintf("could not resolve the customer: %v", err) } bill := models.PosOrders{ Terminalorderid: strings.TrimSpace(order.Id), Invoicenumber: order.Invoicenumber, Tenantid: ctx.Tenantid, Locationid: ctx.Locationid, // The bill's own terminal wins; the batch's is the fallback. Without // this the column was empty on every bill that arrived over HTTP — // the invoice number carried the code and the column did not, so // per-terminal reconciliation had nothing to group on. Terminalid: posTerminalFor(order.Terminalid, batchTerminal), Cashiername: order.Cashier, Customerid: customerID, Customermobile: posCustomerMobile(order), Customername: posCustomerName(order), Billedat: saleDate, // The day the sale was rung, not the day it arrived. A till that was // offline overnight uploads yesterday's bills this morning, and every // daily figure has to follow the sale rather than the upload. Businessdate: saleDate.Format("2006-01-02"), Subtotal: order.Subtotal, Discount: order.Discount, Taxamount: taxTotal, Roundoff: order.Roundoff, Total: order.Total, Pointsearned: order.Pointsearned, Pointsredeemed: order.Pointsredeemed, Itemcount: len(items), Paymentmode: paymentMode, Paymentsjson: posJSON(order.Payments), Promosjson: posJSON(order.Promos), // Every jsonb column must carry valid JSON. Left at Go's zero value an // empty string reaches Postgres and the whole insert fails with // "invalid input syntax for type json" — taking the bill down with it. Taxbreakdownjson: posJSON(order.Taxbreakdown), Batchid: batchID, Receivedat: time.Now(), } if err := tx.Create(&bill).Error; err != nil { tx.Rollback() return fmt.Sprintf("could not write bill %s: %v", order.Invoicenumber, err) } for i := range items { items[i].Posorderid = bill.Posorderid if err := tx.Create(&items[i]).Error; err != nil { tx.Rollback() return fmt.Sprintf("could not write a line of bill %s: %v", order.Invoicenumber, err) } // The same ledger an app order writes to. A second stock ledger for // counter sales would mean the catalogue pull sends a till figures that // ignore the till's own trading. if err := recordStockOut(tx, ctx.Tenantid, lines[i], roundStockQty(lines[i].Quantity)); err != nil { tx.Rollback() return fmt.Sprintf("could not deduct stock for bill %s: %v", order.Invoicenumber, err) } } if err := tx.Commit().Error; err != nil { return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err) } return "" } // posJSON encodes a payload column. // // Falls back to a JSON null rather than failing the bill: these columns exist // to be read back later, and losing one is not a reason to refuse a sale the // shopper has already paid for. func posJSON(v any) string { body, err := json.Marshal(v) if err != nil || len(body) == 0 { return "null" } return string(body) } // posTerminalFor picks which terminal code to file a bill under. // // Trimmed before the emptiness test: a terminal sending `" "` is saying nothing, // and treating that as a real code would file bills under a blank that looks // identical to the missing value this exists to fix. func posTerminalFor(orderTerminal, batchTerminal string) string { if t := strings.TrimSpace(orderTerminal); t != "" { return t } return strings.TrimSpace(batchTerminal) } func posCustomerName(order models.PosOrder) string { if order.Customer == nil { return "" } return order.Customer.Name } func posCustomerMobile(order models.PosOrder) string { if order.Customer == nil { return "" } return order.Customer.Mobile } // parsePosSaleDate reads the till's timestamp. // // The terminal sends ISO-8601. A blank one falls back to now; an unparseable // one is refused, because importing a sale under the wrong date corrupts every // daily revenue figure that reads it. // parsePosSaleDate reads the moment a bill was rung. // // The order of these layouts is load-bearing, and the two zoned ones must stay // first. A terminal that sends its offset — `2026-08-05T00:30:00+05:30` — gets // both readings right: the instant is correct, and Format("2006-01-02") still // yields the till's own trading day rather than UTC's. // // The two bare layouts exist for terminals built before the offset was added, // which are still in the field. `time.Parse` fills an absent zone with UTC, so // those bills record an instant wrong by the offset — a Coimbatore wall clock // read as though it were London. That is not recoverable here: nothing in the // payload says which zone it came from. Their business date is still right, // which is why the daily figures held up while billedat did not, and why these // are tolerated rather than refused. func parsePosSaleDate(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return time.Now(), nil } for _, layout := range []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02 15:04:05", } { if t, err := time.Parse(layout, raw); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("unrecognised created_at %q", raw) } // IngestCustomers records shoppers registered at a till. // // Insert-if-absent, never an update. A registration is replayed freely, and a // profile corrected at head office must not be reverted by a terminal replaying // what it captured months ago. func (r *posRepository) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) { ack := models.NewPosAck(batch.Batchid) if len(batch.Customers) == 0 { return ack, nil } ctx, err := r.resolvePosStore(batch.Storeid) if err != nil { return nil, err } for _, customer := range batch.Customers { mobile := strings.TrimSpace(customer.Mobile) if strings.TrimSpace(customer.Id) == "" || mobile == "" { ack.Reject(customer.Id, "registration is missing its id or mobile number") continue } if err := r.upsertPosCustomer(ctx, customer, mobile); err != nil { ack.Reject(customer.Id, err.Error()) continue } ack.Accept(customer.Id) } return ack, nil } // upsertPosCustomer attaches the shopper to this outlet's app location. // // Matched on contactno, which is what the rest of the system already keys a // shopper on, so a shopper registered at a till and one who installed the app // end up as one row rather than two. func (r *posRepository) upsertPosCustomer( ctx *offlineLocationContext, customer models.PosCustomer, mobile string, ) error { name := strings.TrimSpace(customer.Name) if name == "" { name = "Counter Customer" } var existing int err := r.db.Raw( `SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`, mobile, ctx.Applocationid, ).Scan(&existing).Error if err != nil { return err } if existing > 0 { // Already known. Accepted without a write — the terminal's copy is not // newer than ours in any way we can establish. return nil } // status 0 mirrors every other customer row in production, including ones // actively placing orders. A different value here would make a shopper // registered at the counter behave unlike all the others. var created int err = r.db.Raw(` INSERT INTO customers (configid, firstname, lastname, contactno, email, gender, dob, applocationid, locationid, status, created, updated) VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, NOW(), NOW()) RETURNING customerid`, ctx.Configid, name, mobile, strings.TrimSpace(customer.Email), strings.TrimSpace(customer.Gender), strings.TrimSpace(customer.Dateofbirth), ctx.Applocationid, ctx.Locationid, ).Scan(&created).Error if err != nil { return err } if created <= 0 { return errors.New("failed to create the customer row") } return nil } // posRevisionLayout is the timestamp inside a catalogue revision. // // The revision is the terminal's memory of when it last pulled: it stores what // we send and hands it back on the next request, and the time encoded in it is // the cutoff for what has changed since. Colons are avoided so the whole string // stays safe in a URL query without escaping. const posRevisionLayout = "20060102T150405Z" // posRevisionFor mints the revision a terminal will send back to us. func posRevisionFor(locationID int, at time.Time) string { return fmt.Sprintf("loc%d-%s", locationID, at.UTC().Format(posRevisionLayout)) } // posRevisionCutoff reads the timestamp back out of a revision. // // Returns the zero time when the revision is missing, malformed, or belongs to // a different outlet — and a zero cutoff means "send everything". Falling back // to a full snapshot is the only safe direction: answering an unreadable // revision with a *delta* would leave the terminal quietly missing every change // it had not already seen, with nothing to indicate it. func posRevisionCutoff(locationID int, revision string) time.Time { revision = strings.TrimSpace(revision) prefix := fmt.Sprintf("loc%d-", locationID) if !strings.HasPrefix(revision, prefix) { return time.Time{} } at, err := time.Parse(posRevisionLayout, strings.TrimPrefix(revision, prefix)) if err != nil { return time.Time{} } return at } // Catalogue answers a terminal's pull, as a snapshot or as a change set. // // ### The rule this function exists to keep // // A response with `is_delta: false` is treated as a full snapshot, and the // terminal **withdraws every product the response does not mention**. So a // filtered result labelled `false` empties the shop's shelf. // // The two are therefore decided together, from one value: a zero cutoff means // no filter and `is_delta: false`; a non-zero cutoff means filtered and // `is_delta: true`. There is no path through this function that filters without // also setting the flag. // // ### What counts as a change // // A product is included when any of three things moved since the cutoff: the // product row itself (name, tax, brand), its row at this location (price, // availability), or its stock ledger. Stock is included because a shop's count // drifts from the till's on every sale rung elsewhere, and a delta that omitted // it would let that drift persist until someone forced a full pull. // // ### What a delta cannot do // // A product *deleted* from productlocations leaves no tombstone, so a change set // cannot know to withdraw it. Only a full snapshot collects those. A terminal // should pull without a revision periodically — the morning import is the // natural moment — and this is why. func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) { ctx, err := r.resolvePosStore(storeID) if err != nil { return nil, err } if pageSize <= 0 || pageSize > 1000 { pageSize = 500 } if page < 0 { page = 0 } // The single decision. Everything downstream reads this rather than // re-deriving it, so the filter and the flag cannot disagree. cutoff := posRevisionCutoff(ctx.Locationid, since) isDelta := !cutoff.IsZero() type row struct { Productid int Productname string Productsku string Categoryname string Productunit string Productbrand string Price float64 Retailprice float64 Taxpercent float64 Stock float64 Status string } // A product counts as changed if the product row, its row at this location, // or its stock ledger moved. Written as one predicate so a delta cannot // miss a price change simply because the product row was untouched. changed := "" params := []interface{}{ctx.Tenantid, ctx.Locationid} if isDelta { changed = `AND ( a.updated >= ? OR b.updated >= ? OR EXISTS (SELECT 1 FROM productstocks s2 WHERE s2.productid = a.productid AND s2.tenantid = a.tenantid AND s2.locationid = b.locationid AND (s2.stockdate >= ? OR s2.updated >= ?)) )` params = append(params, cutoff, cutoff, cutoff, cutoff) } rows := make([]row, 0) query := fmt.Sprintf(` SELECT a.productid, COALESCE(a.productname, '') AS productname, COALESCE(a.productsku, '') AS productsku, COALESCE(c.categoryname, '') AS categoryname, COALESCE(a.productunit, '') AS productunit, COALESCE(a.productbrand, '') AS productbrand, CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price, COALESCE(a.retailprice, 0) AS retailprice, COALESCE(a.taxpercent, 0) AS taxpercent, COALESCE(( SELECT SUM(CASE WHEN LOWER(s.stocktype) = 'in' THEN s.quantity ELSE 0 END) - SUM(CASE WHEN LOWER(s.stocktype) = 'out' THEN s.quantity ELSE 0 END) FROM productstocks s WHERE s.productid = a.productid AND s.tenantid = a.tenantid AND s.locationid = b.locationid ), 0) AS stock, COALESCE(b.status, '') AS status FROM products a INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid LEFT JOIN productcategories c ON a.categoryid = c.categoryid WHERE a.tenantid = ? AND b.locationid = ? AND a.productid > 0 %s ORDER BY a.productid LIMIT ? OFFSET ?`, changed) // One row past the page, purely so has_more can be answered without a // second count query. params = append(params, pageSize+1, page*pageSize) if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil { return nil, err } // One row beyond the page was requested purely to answer has_more without a // second count query. hasMore := len(rows) > pageSize if hasMore { rows = rows[:pageSize] } products := make([]models.PosCatalogueProduct, 0, len(rows)) for _, p := range rows { // Rows with productid <= 0 are excluded in SQL rather than here. Live // data has at least one — almost certainly an insert that never got a // sequence value — and it can never be billed, because the ingest // refuses any line whose id is not positive. Filtering it in the query // also keeps pagination exact: skipped after the LIMIT, it would eat a // slot and hand back a short page. mrp := p.Retailprice if mrp <= p.Price { mrp = 0 } // Indian GST is 0/5/12/18/28, but the column holds 3, 4, 6, 7, 9, 10, // 15 and even -1 across live data. A negative rate would put negative // tax on a bill and a negative figure in a slab on a filed return, so // it is floored here rather than trusted. gstRate := p.Taxpercent / 100 if gstRate < 0 { gstRate = 0 } // Withdrawn from sale when there is no selling price. Neither // productlocations.price nor retailprice is set on much of the estate — // only productcost is — and a till that can ring an item up at ₹0 is // worse than one that cannot ring it up at all. Pricing the product // makes it sellable; nothing here needs changing. sellable := p.Price > 0 && !strings.EqualFold(strings.TrimSpace(p.Status), "outofstock") products = append(products, models.PosCatalogueProduct{ Id: strconv.Itoa(p.Productid), Name: p.Productname, Barcode: posBarcode(p.Productid, p.Productsku), Sku: p.Productsku, Category: posCategory(p.Categoryname), Price: p.Price, Mrp: mrp, Stock: p.Stock, Unit: posUnit(p.Productunit), Gstrate: gstRate, Brand: p.Productbrand, Isactive: sellable, }) } // The revision only advances on the final page. // // A terminal that gives up half way through a paginated pull — a dropped // connection, a till switched off — must not be left holding a revision // that claims it has seen pages it never received. Every one of those // products would then be excluded from the next delta and stay stale // indefinitely, with nothing anywhere to indicate it. // // So mid-pull we echo back whatever the terminal already had: unchanged if // it sent one, empty if it did not, and empty means the next pull is a full // snapshot. Both are recoverable; a prematurely advanced revision is not. // // The stamp is taken a second in the past. A product written during the // same second this query ran could otherwise land on the wrong side of the // next cutoff and be skipped for good — overlapping by a second costs one // redundant row and cannot lose one. revision := strings.TrimSpace(since) if !hasMore { revision = posRevisionFor(ctx.Locationid, time.Now().Add(-time.Second)) } return &models.PosCatalogueResponse{ Revision: revision, // Decided with the filter, never separately. False here would tell the // terminal to withdraw every product this response omits. Isdelta: isDelta, Hasmore: hasMore, Products: products, Customers: make([]models.PosCatalogueCustomer, 0), // A product deleted from productlocations leaves no tombstone, so a // change set cannot know to withdraw it. Only a full snapshot collects // those, which is why a terminal should pull without a revision // periodically. Retiredids: make([]string, 0), }, nil } // posBarcode decides what the till scans this product by. // // The terminal holds a **unique** index on barcode, so whatever this returns has // to be distinct across the whole catalogue or the import fails outright. // // `products.productsku` cannot be trusted for that. Measured against live data: // 6,245 products carry only 93 distinct SKUs, and the single value "1" is used // by 5,794 of them. Mapping SKU straight to barcode would collapse most of the // catalogue onto one row. // // So a SKU is used only when it looks like a real scannable code — 8 to 14 // digits, the shape of an EAN-8, UPC-A or EAN-13 — and otherwise the product id // stands in. The id is unique by construction, which keeps the import working // today; the day real barcodes are populated, scanning starts working on its own // with no change here. // // Until then, scanning a physical barcode at the till will not find anything. // That is a data problem, not a code one. func posBarcode(productID int, sku string) string { sku = strings.TrimSpace(sku) if len(sku) >= 8 && len(sku) <= 14 { digitsOnly := true for _, r := range sku { if r < '0' || r > '9' { digitsOnly = false break } } if digitsOnly { return sku } } return strconv.Itoa(productID) } // posCategory maps a category name onto one of the terminal's fixed buckets. // // The till ships a closed enum, so anything unrecognised has to land somewhere; // grocery is the catch-all it already uses for uncategorised stock. func posCategory(name string) string { switch strings.ToLower(strings.TrimSpace(name)) { case "dairy": return "dairy" case "fruits", "fruit": return "fruits" case "vegetables", "vegetable": return "vegetables" case "beverages", "beverage", "drinks": return "beverages" case "snacks", "snack": return "snacks" case "personal care", "personalcare": return "personalCare" case "household", "home care", "homecare": return "household" default: return "grocery" } } // posUnit maps a unit of measure onto the terminal's enum, defaulting to pieces. func posUnit(unit string) string { switch strings.ToLower(strings.TrimSpace(unit)) { case "kg", "kilogram", "kilo": return "kilogram" case "g", "gram", "grams": return "gram" case "l", "litre", "liter": return "litre" case "ml", "millilitre", "milliliter": return "millilitre" case "pack", "packet": return "pack" default: return "piece" } }