An admin sets a shop up from a browser; a supervisor adds a cashier at the counter. Both had to be possible, and only the second one was. So the console gets createposuser / updateposuser / getposusers / deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the same service methods `/pos/users` calls. Not a parallel implementation: a supervisor created from a browser is the same row, with the same PIN rules, the same duplicate check and the same identity-column allocation, as one created at a till. Two paths writing one table is precisely how the two stop matching, and this codebase already had that happen once. configid is inferred rather than asked for. It is a number nobody looks up, it varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting it wrong creates somebody who cannot sign into the portal their colleagues use and is invisible to half the platform's queries. /posroles is served rather than left to the console to hardcode. A console that knew supervisor was 7 would be wrong the day that changed and would have no way to find out. The outlet is the real difference between the two doors. A terminal proves it with a signed token; the console asserts it, and is checked against the tenant before anything is written. That is weaker, and it is worth being plain about: these mint till credentials on an unauthenticated request, exactly like every other route in the /v1/web and /v1/mob groups, because there is no auth middleware on the web API at all. Documented as the weakest point in the design and flagged to move behind a session guard once the console can hold one. The terminal routes are untouched by it. Proven in a rolled-back transaction against live data: the console creates a supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true, the till's /pos/staff sees them alongside the two created at the counter, and 0451, 1234 and a duplicate PIN are each refused with the same message the terminal gives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
860 lines
30 KiB
Go
860 lines
30 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)
|
|
|
|
// Sign-in. The outlet a terminal bills for is decided here, from the user's
|
|
// own record, rather than being named by the till and believed.
|
|
PosLogin(req models.PosLoginRequest) (*models.PosSession, error)
|
|
PosLocationAllowed(tenantID, locationID int) (bool, error)
|
|
PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error)
|
|
|
|
// Till staff, managed by the shop. Tenant and location are always the
|
|
// caller's own, taken from their session token — no argument here can name
|
|
// somebody else's outlet.
|
|
CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
|
|
UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
|
|
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
|
|
DeactivatePosUser(tenantID, locationID, userID int) error
|
|
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
|
|
PosConfigidFor(tenantID int) int
|
|
|
|
// 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"
|
|
}
|
|
}
|