A till holds every bill in its own SQLite database and keeps it for seven days after we acknowledge it, marking one synced only when its id comes back in an ack. Everything here follows from that. Silence is not acceptance, so a failing ingest publishes nothing at all and the terminal simply sends again. A duplicate is a success, because at-least-once delivery means a lost ack legitimately re-delivers bills we already hold, and calling those failures would strand a day of takings on the till. Deduplication is a unique index on the terminal's UUID plus an advisory lock held for the transaction. Bills land in pos_orders / pos_order_items rather than orders: a counter bill carries a cashier, a terminal, a rounding adjustment, promos, loyalty movement and a payment split that orders has nowhere to put, and forcing one into the other loses whatever does not fit. Stock is *not* split — a counter sale writes the same productstocks rows an app order does, through helpers extracted from createOrderTx so the rule that prevents overselling has one implementation rather than two. GetRevenueSummary and GetSalesSummary were extended to union the new table in; any new report has to remember the same. Terminal health goes to Redis under a 90-second TTL, sharing the instance the express backend uses. A heartbeat is a fact with an expiry date: a till that loses power stops refreshing and ages off the board by itself, where a Postgres row would need ~288k writes a day and a reaper. Proven end to end against the live estate before commit: a bill over HTTP and one over the real Mosquitto broker, the same bill three times producing one row and one stock movement, and a heartbeat arriving on the health endpoint. All probe data was removed afterwards. Four things that only surfaced against real data. An unset jsonb column failed the very first bill. Product SKUs are unusable as barcodes — 6,245 products share 93 SKUs and "1" covers 5,794 of them — against the till's unique index, so barcodes fall back to the product id. A taxpercent of -1 exists and would have put negative GST in a filed slab. And a product with id 0 exists, which can never be billed and is now skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
701 lines
23 KiB
Go
701 lines
23 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)
|
|
}
|
|
|
|
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, 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.
|
|
func (r *posRepository) importPosOrder(
|
|
ctx *offlineLocationContext,
|
|
products map[int]offlineProduct,
|
|
batchID 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,
|
|
Terminalid: order.Terminalid,
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Catalogue answers a terminal's morning pull.
|
|
//
|
|
// Always a full snapshot today, and it says so. The terminal withdraws every
|
|
// product a snapshot omits, so answering a change set with is_delta false would
|
|
// empty the shelf — declaring false here is only safe because this really does
|
|
// return everything stocked at the outlet.
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
rows := make([]row, 0)
|
|
err = r.db.Raw(`
|
|
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 = ?
|
|
ORDER BY a.productid
|
|
LIMIT ? OFFSET ?`,
|
|
ctx.Tenantid, ctx.Locationid, pageSize+1, page*pageSize,
|
|
).Scan(&rows).Error
|
|
if 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 {
|
|
// A productid of zero is bad data, not a product — live data has at
|
|
// least one, almost certainly an insert that never got a sequence
|
|
// value. Sending it would put a row on the till that can never be
|
|
// billed, because the ingest refuses any line whose id is not positive.
|
|
if p.Productid <= 0 {
|
|
continue
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
return &models.PosCatalogueResponse{
|
|
// A revision the terminal stores and sends back on its next pull. Tied
|
|
// to the outlet and the moment, so a shop that has pulled today can be
|
|
// told it is already current.
|
|
Revision: fmt.Sprintf("loc%d-%s", ctx.Locationid, time.Now().UTC().Format("20060102T150405")),
|
|
Isdelta: false,
|
|
Hasmore: hasMore,
|
|
Products: products,
|
|
Customers: make([]models.PosCatalogueCustomer, 0),
|
|
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"
|
|
}
|
|
}
|