Files
backend_fiesta/repositories/posRepository.go
Suriya ec672a3087 Add an HTTP heartbeat, and file bills under the till that rang them
Two faults found while checking whether today's live bills had landed. They
had — 17 of them, complete — but both of these were sitting in the same data.

**Health existed only over MQTT.** The consumer subscribes to the health topic
and has done since startup, but a terminal on the HTTP route has no way to
reach it. Today's terminal was on HTTP, so it reported nothing and the board
showed "online 0 of 1" while the till was demonstrably alive and selling.
POST /pos/health now takes the same payload the broker carries, into the same
Redis record, so the board cannot tell the two routes apart and does not need
to. It answers 202 and swallows failures: a till that cannot say how it is must
still sell.

**terminalid was empty on 16 of 17 bills.** The consumer backfills a missing
terminal code from the topic, but onto the batch, while the row was built from
the order — the two never met, and importPosOrder was not handed the batch's
value at all. Over HTTP there was no topic to fall back on either. So the
invoice numbers read INV-2608-T5EDD-000NN while the column they should have
matched was blank, and `byterminal` on the sales summary grouped almost
everything under "". The bill's own terminal now wins with the batch's as the
fallback, trimmed, so whitespace is not mistaken for a code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30

830 lines
29 KiB
Go

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.
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"
}
}