The ingest only ever wrote. A bill that reached pos_orders was safe and completely unreachable — no screen in the product could show it, and the only way to see a day's counter takings was to query the database by hand. Three endpoints: a paged bill list, one bill with its lines, and a summary split the three ways somebody actually asks for — by tender for reconciling a drawer, by day for a chart, by till for an outlet running several counters. locationid is required on all of them and is the authorisation boundary, so a caller cannot page through another shop's takings by omitting a parameter. Fetching a bill under the wrong outlet returns 404 even when the reference is a real one. Dates match businessdate rather than arrival, because a till that was offline overnight uploads yesterday's bills this morning and they belong to yesterday. The list is ordered by billedat for the same reason — sorting by arrival would interleave a recovered backlog through today. Unlike the ingest handlers these answer in the usual envelope: they are read by the web app, not by a terminal, and nothing about them is bound to the till's contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
707 lines
23 KiB
Go
707 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)
|
|
|
|
// 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, 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"
|
|
}
|
|
}
|