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>
210 lines
8.5 KiB
Go
210 lines
8.5 KiB
Go
package models
|
|
|
|
import "time"
|
|
|
|
// Counter sales, stored at the fidelity the till actually rang them.
|
|
//
|
|
// Separate from `orders` on purpose. An app order and a counter bill are
|
|
// different documents: a bill carries a cashier, a terminal, a rounding
|
|
// adjustment, promo campaigns, loyalty movement and a payment split across
|
|
// several tenders, none of which `orders` has anywhere to put. Forcing one into
|
|
// the other's shape loses whichever fields do not fit, and the loss is silent.
|
|
//
|
|
// The cost of the split is that existing revenue queries do not see these rows
|
|
// until they are extended to union them in — done in orderRepository's summary
|
|
// queries, and the thing to remember when adding a new report.
|
|
//
|
|
// Stock is *not* separate: a counter sale writes the same productstocks "out"
|
|
// rows an app order does, through the same helper. Two stock ledgers would mean
|
|
// the catalogue pull sends a till figures that ignore its own sales.
|
|
|
|
// PosOrders is one counter bill.
|
|
type PosOrders struct {
|
|
Posorderid int `json:"posorderid" gorm:"primaryKey;autoIncrement;column:posorderid"`
|
|
|
|
// The UUID minted at the till. Globally unique by construction and the only
|
|
// thing that identifies this bill, so it carries a unique index: delivery is
|
|
// at-least-once and the same bill legitimately arrives more than once.
|
|
Terminalorderid string `json:"terminalorderid" gorm:"column:terminalorderid;uniqueIndex;not null"`
|
|
|
|
// Human-facing, and unique only per terminal — a till that was replaced
|
|
// restarts its own series, so gaps are normal and duplicates across
|
|
// terminals are expected.
|
|
Invoicenumber string `json:"invoicenumber" gorm:"column:invoicenumber;index"`
|
|
|
|
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
|
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
|
|
|
// Which physical till, e.g. "T4A9". Free text: nothing keys on it, but a
|
|
// support call starts with it.
|
|
Terminalid string `json:"terminalid" gorm:"column:terminalid;index"`
|
|
Cashiername string `json:"cashiername" gorm:"column:cashiername"`
|
|
|
|
// Resolved against the customers table. Zero for a walk-in.
|
|
Customerid int `json:"customerid" gorm:"column:customerid;index"`
|
|
Customermobile string `json:"customermobile" gorm:"column:customermobile"`
|
|
Customername string `json:"customername" gorm:"column:customername"`
|
|
|
|
// When the sale was rung, not when it reached us — a till that was offline
|
|
// for a day uploads bills whose Billedat is yesterday, and every daily
|
|
// figure must use this rather than Receivedat.
|
|
Billedat time.Time `json:"billedat" gorm:"column:billedat;index"`
|
|
|
|
// YYYY-MM-DD of Billedat, denormalised so a day's takings are one indexed
|
|
// equality match rather than a range scan with timezone arithmetic.
|
|
Businessdate string `json:"businessdate" gorm:"column:businessdate;index"`
|
|
|
|
Subtotal float64 `json:"subtotal" gorm:"column:subtotal"`
|
|
Discount float64 `json:"discount" gorm:"column:discount"`
|
|
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
|
|
|
|
// The paise adjustment printed on the bill. Kept because total is not
|
|
// derivable from the other columns without it.
|
|
Roundoff float64 `json:"roundoff" gorm:"column:roundoff"`
|
|
|
|
// What the shopper actually paid. The figure every revenue report sums.
|
|
Total float64 `json:"total" gorm:"column:total"`
|
|
|
|
Pointsearned int `json:"pointsearned" gorm:"column:pointsearned"`
|
|
Pointsredeemed int `json:"pointsredeemed" gorm:"column:pointsredeemed"`
|
|
|
|
Itemcount int `json:"itemcount" gorm:"column:itemcount"`
|
|
|
|
// The largest tender, for the common "how did they pay" grouping.
|
|
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode;index"`
|
|
|
|
// The full split, verbatim. A bill can be part cash, part card, part
|
|
// loyalty, and collapsing that to one mode would lose the reconciliation a
|
|
// cashier settles their drawer against.
|
|
Paymentsjson string `json:"paymentsjson" gorm:"column:paymentsjson;type:jsonb"`
|
|
|
|
// Campaigns that fired, stored as amounts rather than rules — a bill read
|
|
// back years later must show what was given, not what today's rules give.
|
|
Promosjson string `json:"promosjson" gorm:"column:promosjson;type:jsonb"`
|
|
|
|
// GST per slab, as printed on the tax invoice.
|
|
Taxbreakdownjson string `json:"taxbreakdownjson" gorm:"column:taxbreakdownjson;type:jsonb"`
|
|
|
|
// Which upload carried this bill, and when it landed. Kept for tracing a
|
|
// terminal's complaint back to a specific batch.
|
|
Batchid string `json:"batchid" gorm:"column:batchid;index"`
|
|
Receivedat time.Time `json:"receivedat" gorm:"column:receivedat"`
|
|
|
|
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
|
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
|
|
|
|
Items []PosOrderItems `json:"items" gorm:"-"`
|
|
}
|
|
|
|
func (PosOrders) TableName() string {
|
|
return "pos_orders"
|
|
}
|
|
|
|
// PosOrderItems is one line of a counter bill.
|
|
type PosOrderItems struct {
|
|
Posorderitemid int `json:"posorderitemid" gorm:"primaryKey;autoIncrement;column:posorderitemid"`
|
|
Posorderid int `json:"posorderid" gorm:"column:posorderid;index"`
|
|
|
|
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
|
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
|
Productid int `json:"productid" gorm:"column:productid;index"`
|
|
|
|
// Snapshotted rather than joined. A product renamed or withdrawn next month
|
|
// must not change what a bill from today says it sold.
|
|
Productname string `json:"productname" gorm:"column:productname"`
|
|
Barcode string `json:"barcode" gorm:"column:barcode"`
|
|
Unitname string `json:"unitname" gorm:"column:unitname"`
|
|
|
|
// Fractional: a counter sells 1.5 kg of onions. Note that productstocks
|
|
// cannot represent that — see roundStockQty.
|
|
Quantity float64 `json:"quantity" gorm:"column:quantity"`
|
|
|
|
Unitprice float64 `json:"unitprice" gorm:"column:unitprice"`
|
|
Discountamount float64 `json:"discountamount" gorm:"column:discountamount"`
|
|
|
|
// Stored as a fraction (0.18), matching how the till holds it.
|
|
Gstrate float64 `json:"gstrate" gorm:"column:gstrate"`
|
|
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
|
|
|
|
// What this line contributed to the bill total, after its share of every
|
|
// discount. The lines sum to the bill's Total less Roundoff.
|
|
Linetotal float64 `json:"linetotal" gorm:"column:linetotal"`
|
|
|
|
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
|
}
|
|
|
|
func (PosOrderItems) TableName() string {
|
|
return "pos_order_items"
|
|
}
|
|
|
|
// PosSalesFilter scopes a query over counter sales.
|
|
//
|
|
// Locationid is required and is the authorisation boundary — every read is
|
|
// scoped to one outlet, so a caller cannot page through another shop's takings
|
|
// by omitting a parameter.
|
|
type PosSalesFilter struct {
|
|
Locationid int
|
|
Fromdate string // YYYY-MM-DD, matched against businessdate
|
|
Todate string
|
|
Terminalid string
|
|
Cashiername string
|
|
Paymentmode string
|
|
Pageno int
|
|
Pagesize int
|
|
}
|
|
|
|
// PosSalesPage is one page of bills, with the total so a caller can paginate
|
|
// without a second request.
|
|
type PosSalesPage struct {
|
|
Total int64 `json:"total"`
|
|
Pageno int `json:"pageno"`
|
|
Pagesize int `json:"pagesize"`
|
|
Bills []PosOrders `json:"bills"`
|
|
}
|
|
|
|
// PosSalesSummary totals a range of counter sales.
|
|
//
|
|
// Deliberately separate from the bill list: a shop settling a till wants the
|
|
// figures, not five hundred rows, and computing them client-side would mean
|
|
// fetching every page first.
|
|
type PosSalesSummary struct {
|
|
Locationid int `json:"locationid"`
|
|
Fromdate string `json:"fromdate"`
|
|
Todate string `json:"todate"`
|
|
Billcount int `json:"billcount"`
|
|
Itemcount int `json:"itemcount"`
|
|
Grosssales float64 `json:"grosssales"`
|
|
Taxcollected float64 `json:"taxcollected"`
|
|
Discount float64 `json:"discountgiven"`
|
|
Roundoff float64 `json:"roundoff"`
|
|
Averagebill float64 `json:"averagebill"`
|
|
|
|
// What a cashier reconciles the drawer against.
|
|
Bypaymentmode []PosPaymentTotal `json:"bypaymentmode"`
|
|
|
|
// One row per trading day, for a chart.
|
|
Byday []PosDayTotal `json:"byday"`
|
|
|
|
// Which tills contributed, so an outlet with several counters can see them
|
|
// apart without a second query.
|
|
Byterminal []PosTerminalTotal `json:"byterminal"`
|
|
}
|
|
|
|
type PosPaymentTotal struct {
|
|
Paymentmode string `json:"paymentmode"`
|
|
Billcount int `json:"billcount"`
|
|
Amount float64 `json:"amount"`
|
|
}
|
|
|
|
type PosDayTotal struct {
|
|
Businessdate string `json:"businessdate"`
|
|
Billcount int `json:"billcount"`
|
|
Amount float64 `json:"amount"`
|
|
}
|
|
|
|
type PosTerminalTotal struct {
|
|
Terminalid string `json:"terminalid"`
|
|
Billcount int `json:"billcount"`
|
|
Amount float64 `json:"amount"`
|
|
}
|