parsePosSaleDate needed no change — RFC3339Nano already accepts the offset the
terminal now sends, and Format("2006-01-02") on a zoned time still yields the
till's own trading day rather than UTC's. But the ordering of those layouts is
load-bearing and nothing said so, and the two bare layouts are a legacy that
should be recognisable as one: they exist for terminals built before the offset,
whose bills record an instant wrong by the offset with nothing in the payload to
recover it from. Two tests pin both halves, including the case that motivated
this — 00:30 IST, where UTC has not yet rolled into the same day.
The GST script writes only the four packaged lines at 1185, which sat at 0 and
were being billed with no tax at all.
It deliberately does not touch the produce at 1135. That was written believing
every rate was 0 — read from a field name that does not exist in the response,
so the check silently returned nothing. The rows in fact hold 8, 12 and 18, and
under Indian GST fresh unbranded fruit and chilled fish are nil-rated, so
several look like overcharging. Every correction there is a reduction of a live
rate, which belongs to whoever signs the returns rather than to a script. They
are reported as REVIEW and left as found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
844 lines
29 KiB
Go
844 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.
|
|
// 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"
|
|
}
|
|
}
|