package models import "time" type ProductSubCategory struct { Subcatid int `json:"subcatid"` Categoryid int `json:"categoryid,omitempty"` Tenantid int `json:"tenantid,omitempty"` Subcatname string `json:"subcatname,omitempty"` Image string `json:"image,omitempty"` Status string `json:"status,omitempty"` Sortorder int `json:"sortorder,omitempty"` Createdby int `json:"createdby,omitempty"` Created time.Time `json:"created,omitempty"` Updated time.Time `json:"updated,omitempty"` } type Productcount struct { Total int `json:"total"` Available int `json:"available"` Outofstock int `json:"outofstock"` } // ProductCategoryUpdate is one row of a bulk re-filing: which product, which // category it sits in, and — the field that actually decides what a shopper // sees — which subcategory. // // Both are here because they do different jobs. GetProducts filters on // categoryid (the customer app asks for 2), while GetProductsBySubcategory // GROUPS on subcategoryid, so the aisle heading in the app comes from the // second and the first only decides whether the product is returned at all. // A caller re-filing products into aisles sends categoryid 2 unchanged and the // subcategory it worked out. type ProductCategoryUpdate struct { Productid int `json:"productid"` Categoryid int `json:"categoryid"` // Optional: 0 leaves whatever the product already has. Subcategoryid int `json:"subcategoryid"` } type ProductCategory struct { Categoryid int `json:"categoryid"` Moduleid int `json:"moduleid"` Tenantid int `json:"tenantid,omitempty"` Categorytypeid int `json:"categorytypeid,omitempty"` Categoryname string `json:"categoryname,omitempty"` Image string `json:"image,omitempty"` Catalougecategoryid int `json:"catalougecategoryid,omitempty"` Sortorder int `json:"sortorder,omitempty"` Status string `json:"status"` Createdby int `json:"createdby,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } // One size of one product — "500ml" hanging under the parent "Coke". // // The table always had the two columns that make this a relationship rather // than a list of words: `productid` names the PARENT, `variantproductid` names // the product a shopper actually orders when they pick this size. Neither was // mapped here, so nothing could read or write them: `createproductvariant` // wrote rows with no parent, and `getproductvariants` returned a flat // per-tenant list of names. Measured 2026-09-02 across ten tenants: two rows // existed on the whole platform, named "testing" and "demo", attached to // nothing. // // The consequence is the ordering bug. With no way to attach a variant to a // product, the app had only `products.variants` — a bare group number, 0 on // every product — to go on. type Productvariant struct { Variantid int `json:"variantid" gorm:"primaryKey;autoIncrement"` Tenantid int `json:"tenantid"` // The parent. A variant is meaningless without one, so this is what // AddProductVariant refuses to accept as zero. Productid int `json:"productid"` // The product to actually put in the basket for this size. It is a real // product row, so it carries its own price, stock and barcode — which is // why a variant does not need to duplicate any of them. Variantproductid int `json:"variantproductid"` Variantname string `json:"variantname"` Varianttype string `json:"varianttype,omitempty"` // A per-variant price override. 0 means "no override, use the product’s // own price" — which is a real answer, so it is always emitted. With // omitempty the key vanished at 0 and a client reading `price` got // undefined, then rendered it. Price float64 `json:"price"` Categoryid int `json:"categoryid" gorm:"default:0"` Categoryname string `json:"categoryname" gorm:"-"` Subcategoryid int `json:"subcategoryid"` Status string `json:"status" gorm:"default:Active"` // Read-only, from the variant's own product row. The app needs a name and a // price to draw a size picker; without these it would have to fetch each // variant separately to render one screen. Variantproductname string `json:"variantproductname" gorm:"->"` Variantprice float64 `json:"variantprice" gorm:"->"` Variantstock int `json:"variantstock" gorm:"->"` // The size, as the product itself records it. Without these the only way to // label a size was to join the parent’s unit fields to the variant’s name, // which is how "null kg" reaches a screen. Variantunitvalue string `json:"variantunitvalue" gorm:"->"` Variantproductunit string `json:"variantproductunit" gorm:"->"` } type Products struct { Productid int `json:"productid" gorm:"primaryKey;autoIncrement"` AppLocationid int `json:"applocationid" gorm:"column:applocationid"` Productlocationid int `json:"productlocationid" gorm:"->"` Tenantid int `json:"tenantid,omitempty"` Categoryid int `json:"categoryid"` Categoryname string `json:"categoryname" gorm:"->"` Subcategoryid int `json:"subcategoryid,omitempty"` Subcategoryname string `json:"Subcategoryname" gorm:"->"` Catalogueid int `json:"catalogueid,omitempty"` // The catalogue's own stable key for this product, e.g. `cheetos_chips_2d6bf74f`. // // `catalogueid` cannot do this job and never could. The catalogue is // rebuilt by scrape and every row is renumbered when it is: pepsico's live // ids run 3, 6, 9 … 27, 30, so a product imported when it was id 26 now // points at nothing. Eleven of the nineteen links on the platform were // dangling this way, and none could be repaired — the re-scrape had also // changed the pack sizes, so the product they named no longer existed. // // `image_id` is the key the catalogue itself deduplicates on and it // survives both. Written on import; `catalogueid` is kept beside it for // rows imported before this column existed, and as the id the import call // still addresses. Imageid string `json:"imageid,omitempty" gorm:"column:imageid"` Addonid int `json:"addonid,omitempty"` Discountid int `json:"discountid"` Discountvalue float64 `json:"discountvalue"` Pricingid int `json:"pricingid,omitempty"` Productname string `json:"productname,omitempty"` Productimage string `json:"productimage,omitempty"` // Every photo the product has, as a JSON array of URLs. // // `Productimage` above stays the first of these and is left untouched: every // existing reader — the store catalogue, the customer app, the POS catalogue // pull, each order line — reads that column, and repointing them all at an // array is a far larger change than giving the extra photos somewhere to live. // // Before this the import kept `Images[0]` and discarded the rest, so a product // with ten photos in the global catalogue arrived in a shop with one. 90 of // nestle's 123 products have more than one. // // Held as a string rather than a []string because GORM's raw scan-into-struct // silently drops slice-kind destination fields — the same reason // `catalogueProductColumns` casts its text[] columns to text. Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"` // The catalogue's own record of this product, as it stood at import. // // Holds the fields the snapshot does not have columns for — fssai_license, // highlights, nutrients, providers, price_range, variant_key, title, // sku_source, search_query — so the console can show them without asking // the catalogue again. It asked on every drawer open, and got nothing back // the moment a re-scrape retired the source row, taking a licence number // and a nutrition panel off a product the shop was still selling. // // Empty for anything that did not come from the catalogue: a sheet-imported // product has no such record, and the drawer falls back to the live lookup // for those exactly as before. // // A string for the same reason `Productimages` is one — GORM's raw // scan-into-struct silently drops slice- and map-kind destination fields. Cataloguefacts string `json:"cataloguefacts,omitempty" gorm:"column:cataloguefacts;type:jsonb"` // The nutrition panel, for the product screen in the customer app. // // `gorm:"-"`: not a column. It is unpacked from Cataloguefacts above, which // is where the import snapshots it — a second column holding the same facts // is a second thing to keep in step, and this one has no writer of its own. // // ABSENT rather than null when a product has no nutrition. Most products on // the platform have none today, and `"nutrition": null` on every row of a // mobile response is payload spent saying nothing. An app should read a // missing key as "not known", never as "this food has no nutrition". // // Set by the service, not the repository — see decorateNutrition. Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"` // The health score, for the same product screen and from the same record. // // `gorm:"-"`, absent when there is nothing safe to show, and independent of // Nutrition above — a product can be scored with no figures published, and // carry figures with no score. // // WITHHELD on anything that is not food. The upstream per-product endpoint // is not gated for edibility and has rated insecticide 80/100; see // models.IsEdible. Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"` Productdesc string `json:"productdesc,omitempty"` Productsku string `json:"productsku,omitempty"` Brandid int `json:"brandid,omitempty"` Productbrand string `json:"productbrand,omitempty"` Productunit string `json:"productunit"` Unitvalue string `json:"unitvalue"` Toppicks string `json:"toppicks,omitempty"` Productcost float64 `json:"productcost,omitempty"` Taxamount float64 `json:"taxamount,omitempty"` Taxpercent float64 `json:"taxpercent,omitempty"` Producttax int `json:"producttax" gorm:"default:0"` Productstock int `json:"productstock" gorm:"default:0"` Productcombo int `json:"productcombo" gorm:"default:0"` Variants int `json:"variants" gorm:"default:0"` // The sizes hanging under this product, so one call draws the whole screen. // // A separate field from `Variants` above, which is the legacy group NUMBER // and stays exactly as it was — renaming it would break every caller, and it // is still what an old client reads. This is the list. // // Always present, and EMPTY for a product with no sizes. That is the case // that matters: an empty list is a complete answer meaning "order this one // directly", which is what lets the app proceed instead of stalling on a // choice that does not exist. Variantoptions []Productvariant `json:"variantoptions" gorm:"-"` Quantity int `json:"quantity"` // Price is the EFFECTIVE selling price at the location a query was scoped // to: productlocations.price when the store has set one, otherwise the // master Retailprice below. Read-only — it is computed by the query, never // written through this struct. Location-scoped endpoints must expose it, or // a price the admin sets per store can never reach the customer app: they // returned only Retailprice, which the admin catalogue never writes. // Same meaning as Locationproducts.Price, so both product feeds agree. Price float64 `json:"price" gorm:"->"` Retailprice float64 `json:"retailprice"` Diffprice float64 `json:"diffprice,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"` Othercost float64 `json:"othercost,omitempty"` Approve int `json:"approve"` Productstatus string `json:"productstatus" ` // Populated only by queries scoped to a specific location (e.g. // GetProductByVariant when locationid is passed): Productstock becomes // the live SUM(in)-SUM(out) balance from productstocks — the same // formula CreateOrder's stock check uses — and Locationstatus mirrors // productlocations.status ("outofstock"/"available") for that store. // Left at zero values for callers that don't scope to a location. Locationstatus string `json:"locationstatus,omitempty" gorm:"->"` // Status string `json:"status" gorm:"default:InActive"` // Status string `json:"status" gorm:"-"` } type Locationproducts struct { Productid int `json:"productid"` AppLocationid int `json:"applocationid" gorm:"column:applocationid"` Productlocationid int `json:"productlocationid" gorm:"->"` Tenantid int `json:"tenantid,omitempty"` Categoryid int `json:"categoryid"` Categoryname string `json:"categoryname" gorm:"->"` Subcategoryid int `json:"subcategoryid,omitempty"` Subcategoryname string `json:"Subcategoryname" gorm:"->"` Catalogueid int `json:"catalogueid,omitempty"` Addonid int `json:"addonid,omitempty"` Discountid int `json:"discountid,omitempty"` Pricingid int `json:"pricingid,omitempty"` Productname string `json:"productname,omitempty"` Productimage string `json:"productimage,omitempty"` Productdesc string `json:"productdesc,omitempty"` Productsku string `json:"productsku,omitempty"` // Three columns this read used to leave in the table. // // All three are stored on `products` and none of them reached the store // catalogue screen, because this struct had no field to scan them into — // so the console could not use what the import had gone to the trouble of // saving: // // Imageid the catalogue's durable key, and what HealthScorePanel // joins on. Absent, the panel reads it as "this product // never came from the catalogue" and renders nothing — for // EVERY product, including ones that plainly did. // Productimages the rest of a product's photos. `imagesOf()` parses this // and always got undefined, so the gallery fell back to // the single `productimage` and the extra images — 90 of // nestle's 123 products have them — were never shown. // Cataloguefacts the licence, nutrition, highlights, providers and price // range kept at import so they survive a re-scrape. Imageid string `json:"imageid,omitempty"` Productimages string `json:"productimages,omitempty"` Cataloguefacts string `json:"cataloguefacts,omitempty"` Brandid int `json:"brandid,omitempty"` Productbrand string `json:"productbrand,omitempty"` Productunit string `json:"productunit"` Unitvalue string `json:"unitvalue"` Toppicks string `json:"toppicks,omitempty"` Productcost float64 `json:"productcost,omitempty"` Taxamount float64 `json:"taxamount,omitempty"` Taxpercent float64 `json:"taxpercent,omitempty"` Producttax int `json:"producttax" gorm:"default:0"` Productstock int `json:"productstock" gorm:"default:0"` Productcombo int `json:"productcombo" gorm:"default:0"` Variants int `json:"variants" gorm:"default:0"` Quantity int `json:"quantity"` // Price is the per-store selling price from productlocations.price — the one // CreateProductLocation upserts. Read-only here: it comes from the joined // productlocations row, not from products. Without it a store could set a // price and never read it back, so the UI always showed the master price. Price float64 `json:"price" gorm:"->"` Retailprice float64 `json:"retailprice"` Diffprice float64 `json:"diffprice,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"` Othercost float64 `json:"othercost,omitempty"` Approve int `json:"approve" gorm:"default:0"` // Productstatus string `json:"productstatus" gorm:"default:available"` Status string `json:"status" gorm:"default:outofstock"` // Set only when the admin has released this product to the shops; NULL // while it sits in the admin catalogue awaiting a price. This — not // `Status` — is what a store view filters on. See models.Productlocations. Publishedat *time.Time `json:"publishedat"` } type Productstocks struct { Productstockid int `json:"productstockid" gorm:"Primary_Key"` Locationid int `json:"locationid"` Tenantid int `json:"tenantid"` Stockdate string `json:"stockdate"` Productid int `json:"productid"` Quantity int `json:"quantity"` Stocktype string `json:"stocktype"` Status string `json:"status"` AppLocationid int `json:"applocationid"` Categoryid int `json:"categoryid"` Categoryname string `json:"categoryname" gorm:"->"` Subcategoryid int `json:"subcategoryid,omitempty"` Subcategoryname string `json:"Subcategoryname" ` Productname string `json:"productname,omitempty"` Productimage string `json:"productimage,omitempty"` Productdesc *string `json:"productdesc,omitempty"` Productsku *string `json:"productsku,omitempty"` Brandid *int `json:"brandid,omitempty"` Productbrand *string `json:"productbrand,omitempty"` Productunit *string `json:"productunit,omitempty"` Unitvalue *string `json:"unitvalue,omitempty"` Toppicks *string `json:"toppicks,omitempty"` Productcost *float64 `json:"productcost,omitempty"` Taxamount *float64 `json:"taxamount,omitempty"` Taxpercent *float64 `json:"taxpercent,omitempty"` Producttax *int `json:"producttax,omitempty"` Productstock *int `json:"productstock,omitempty"` Productcombo *int `json:"productcombo,omitempty"` Variants int `json:"variants"` Retailprice float64 `json:"retailprice,omitempty"` Diffprice float64 `json:"diffprice,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"` Othercost float64 `json:"othercost,omitempty"` Approve *int `json:"approve"` } type Productstock struct { Productstockid int `json:"productstockid" gorm:"Primary_Key"` Tenantid int `json:"tenantid"` Stockdate time.Time `json:"stockdate"` Locationid int `json:"locationid"` Productid int `json:"productid"` Quantity int `json:"quantity"` Stocktype string `json:"stocktype"` Status string `json:"status"` } type Productstockstatement struct { Productid int `json:"productid"` Productname string `json:"productname"` Productimage string `json:"productimage"` Categoryid int `json:"categoryid"` Subcategoryid int `json:"subcategoryid"` Productunit string `json:"productunit"` Unitvalue string `json:"unitvalue"` Productcost float32 `json:"productcost"` Taxpercent float32 `json:"taxpercent"` Taxamount float32 `json:"taxamount"` Retailprice float32 `json:"retailprice"` Tenantid int `json:"tenantid"` Locationid int `json:"locationid"` Opening int `json:"opening"` Credit int `json:"credit"` Debit int `json:"debit"` Closing int `json:"closing"` } type ProductSummary struct { Subcategoryid int `json:"subcategoryid"` Subcategroyname string `json:"subcategroyname"` Image string `json:"image"` Productcount int `json:"productcount"` } type Tenantproducts struct { Tenant TenantInfo `json:"tenant"` Products []Products `json:"products"` // Locationproducts []Locationproducts `json:"locationproducts"` } type TenantInfo struct { Tenantid int `json:"tenantid"` Tenantname string `json:"tenantname"` Userfcmtoken string `json:"userfcmtoken"` Address string `json:"address"` Licenseno string `json:"licenseno"` Primaryemail string `json:"primaryemail"` Primarycontact string `json:"primarycontact"` Pickuplocationid int `json:"pickuplocationid"` Applocationid int `json:"applocationid"` Suburb string `json:"suburb"` City string `json:"city"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` Postcode string `json:"postcode"` Tenantimage string `json:"tenantimage"` Locationid int `json:"locationid"` Locationname string `json:"locationname"` Subcategoryid int `json:"subcategoryid"` Categoryid int `json:"categoryid"` Registrationno string `json:"registrationno"` Orderscount int `json:"orderscount"` // Products []Products `json:"products" gorm:"-"` ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"` } type SubcategoryProductResponse struct { SubcategoryID int `json:"subcategoryid"` SubcategoryName string `json:"subcategoryname"` Image string `json:"image"` Products []Products `json:"products"` } type ProductFilter struct { CategoryID int TenantID int AppLocationID int ProductID int Keyword string LocationID int } type Subcategory struct { Subcategoryid int `json:"subcategoryid" gorm:"column:subcatid"` Subcategoryname string `json:"subcategoryname" gorm:"column:subcatname"` Categoryid int `json:"categoryid" gorm:"column:categoryid"` Image string `json:"image" gorm:"column:image"` } // TenantCategory is a categoryid actually in use by a tenant's own products, // with a best-effort name. Used instead of the global productcategories list // for the import category picker, since that master table is missing rows // for categoryids that are nonetheless in real use (e.g. categoryid 2). type TenantCategory struct { Categoryid int `json:"categoryid"` Categoryname string `json:"categoryname"` } // ImportedCatalogueRef identifies a catalogue product a tenant has already // imported. Brand is always included, even when a caller filtered by a // single brand, because a bare catalogueid is ambiguous across brand tables. type ImportedCatalogueRef struct { Brand string `json:"brand"` Catalogueid int64 `json:"catalogueid"` // The stable key, when the product carries one. // // A browse screen should match on this in preference to the id: the id is // renumbered by every re-scrape, so a tick placed by catalogueid lands on // whatever product now holds that number, or on nothing at all. Empty for a // product imported before the column existed. Imageid string `json:"imageid"` } // ImportCatalogueProductRequest is the payload for importing a product from // the global catalogue (CatalogueDB) into a tenant's own store catalogue. // Brand+Catalogueid is the bridge key back to CatalogueDB: a catalogue row's // bare id is only unique within its brand table, so both are required. type ImportCatalogueProductRequest struct { Tenantid int `json:"tenantid"` Locationid int `json:"locationid"` Brand string `json:"brand"` Catalogueid int64 `json:"catalogueid"` Categoryid int `json:"categoryid"` Subcategoryid int `json:"subcategoryid"` Quantity int `json:"quantity"` Stocktype string `json:"stocktype"` Status string `json:"status"` Retailprice float64 `json:"retailprice"` Productcost float64 `json:"productcost"` Taxpercent float64 `json:"taxpercent"` } type Productlocations struct { Productlocationid int `json:"productlocationid" gorm:"Primary_Key"` Tenantid int `json:"tenantid"` Locationid int `json:"locationid"` Productid int `json:"productid"` Catlougeid int `json:"catlougeid"` Minquantity int `json:"minquantity" gorm:"default:0"` Maxquantity int `json:"maxquantity" gorm:"default:0"` Price float32 `json:"price" gorm:"default:0.0"` Quantity int `json:"quantity" gorm:"<-:false"` Stocktype string `json:"stocktype" gorm:"<-:false"` Status string `json:"status"` // When this product was released to the store, and the only thing that // decides whether a store user can see it. // // NULL means the admin has imported it but not published it: it belongs to // the admin catalogue alone. Deliberately NOT another `Status` value — // syncProductLocationStatus rewrites that column on every stock movement, // which is why the import's 'Draft' survived on one row out of 6,755. // // Read-only through this struct (`<-:false`). It is set by Publish and // cleared by Unpublish, never as a side effect of an ordinary // product-location write: setting a price must not be able to release a // product to every shop in the tenant. Publishedat *time.Time `json:"publishedat" gorm:"column:publishedat;<-:false"` } // ProductLocationRef identifies a single (tenant, location, product) row in // productlocations — used to reactivate it after new stock arrives, the // per-location counterpart to CreateOrder's outofstock flag. type ProductLocationRef struct { Tenantid int Locationid int Productid int } // SaleTemplateRow is one line of the downloadable offline-sales spreadsheet: a // product stocked at one branch, with the numbers the person at the till needs // to see before typing a sold quantity against it. // // Tenantid and Locationid ride on every row because one workbook covers every // branch a merchant runs. The row's own Locationid decides which branch's stock // its sale comes out of — a tenant-level import would be wrong, since the same // product is held separately at each outlet. // // Productid is the only field that identifies the product. It cannot be // productsku: across the live catalogue 6,245 products share just 93 distinct // sku values (one tenant has 463 products all carrying sku "1"), and 154 are // blank, so a sku is not a key. Productname is nearly unique per tenant but not // reliably ("rice" appears 6 times for one tenant), so it travels as a // human-readable confirmation only and is never matched on. That is why the // spreadsheet has to be generated from this endpoint rather than typed from // scratch — productid and locationid are filled in for the user. type SaleTemplateRow struct { Tenantid int `json:"tenantid"` Locationid int `json:"locationid"` Locationname string `json:"locationname"` Productid int `json:"productid"` Productname string `json:"productname"` Productunit string `json:"productunit"` Unitvalue string `json:"unitvalue"` Categoryname string `json:"categoryname"` Currentstock int `json:"currentstock"` Price float64 `json:"price"` Taxpercent float64 `json:"taxpercent"` } // SaleTemplateLocation is one branch covered by the workbook, so the sheet can // list what it spans and the UI can summarise it without walking every row. type SaleTemplateLocation struct { Locationid int `json:"locationid"` Locationname string `json:"locationname"` Productcount int `json:"productcount"` } // SaleTemplate is the payload the web app turns into an .xlsx workbook. // // Locationid is 0 when the template spans every branch of the tenant, which is // the normal case for an owner or admin. A store user gets a template for their // own branch only, and it is then the single entry in Locations. type SaleTemplate struct { Tenantid int `json:"tenantid"` Locationid int `json:"locationid"` Locations []SaleTemplateLocation `json:"locations"` Products []SaleTemplateRow `json:"products"` } type ProductSubcategory struct { Subcatid int `json:"subcatid"` Categoryid int `json:"categoryid"` Tenantid int `json:"tenantid"` Subcatname string `json:"subcatname"` Status string `json:"status"` Sortorder int `json:"sortorder"` Createdby int `json:"createdby"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` Image string `json:"image"` }