Bridge global catalogue DB to per-store product catalogue
Adds a separate CatalogueDB (pgvector) connection alongside the main nearledb, plus a new catalogue module (repository/service/controller/ routes) to browse it by brand, category, and keyword, with brand optional so the whole ~237-product catalogue can be browsed unfiltered. Adds the actual bridge: importing a catalogue product snapshots it into the tenant's own products table (keyed on brand+catalogueid, since a catalogue row's bare id is only unique within its own brand table), then links it via the existing productlocations upsert. Re-importing tops up stock and refreshes price instead of duplicating. Also adds an imported-refs endpoint so the frontend can badge already-imported items without diffing full product lists, and wires the new AWS S3 image store used to resolve catalogue product photos. Bumps Go/Docker to 1.24 for the AWS SDK dependency this needs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
15
.env
15
.env
@@ -4,3 +4,18 @@ DB_PORT=5433
|
|||||||
DB_NAME=nearledb
|
DB_NAME=nearledb
|
||||||
DB_USER=admin
|
DB_USER=admin
|
||||||
DB_PASSWORD="Package@123#"
|
DB_PASSWORD="Package@123#"
|
||||||
|
|
||||||
|
# --- Catalogue Postgres / pgvector (separate DB, read-only integration) ---
|
||||||
|
CATALOGUE_DB_HOST=31.97.228.132
|
||||||
|
CATALOGUE_DB_PORT=6054
|
||||||
|
CATALOGUE_DB_NAME=pgvector
|
||||||
|
CATALOGUE_DB_USER=admin
|
||||||
|
CATALOGUE_DB_PASSWORD="'Package@321#'"
|
||||||
|
|
||||||
|
# --- DigitalOcean Spaces (S3-compatible), catalogue product images ---
|
||||||
|
USE_S3=true
|
||||||
|
S3_ACCESS_KEY=DO801G8Q8JAZKF49U3WJ
|
||||||
|
S3_SECRET_KEY=lBQExYfkVqH+ybmGVmQH5MkThBbrIohA/VQLgcPUvug
|
||||||
|
S3_ENDPOINT=https://nearle.sgp1.digitaloceanspaces.com
|
||||||
|
S3_BUCKET=nearle
|
||||||
|
S3_REGION=sgp1
|
||||||
|
|||||||
266
CATALOGUE_IMPORT_INTEGRATION.md
Normal file
266
CATALOGUE_IMPORT_INTEGRATION.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# Store Catalogue Import — Frontend Integration Spec
|
||||||
|
|
||||||
|
Backend work is done and verified live. This doc is the handoff: build the
|
||||||
|
Admin Catalogue UI flow (browse global catalogue → choose products → import
|
||||||
|
into a specific store) against the endpoints below.
|
||||||
|
|
||||||
|
## 1. Architecture (why the API looks like this)
|
||||||
|
|
||||||
|
There are two separate Postgres databases that never talk to each other
|
||||||
|
directly:
|
||||||
|
|
||||||
|
- **CatalogueDB** (pgvector) — the global catalogue, one table per brand
|
||||||
|
(`brand_dabur`, `brand_nestle`, `brand_pepsico`, `brand_sakthi`,
|
||||||
|
`brand_manna`, `brand_naga`). ~237 products total today.
|
||||||
|
- **nearledb** — your tenant/store data (`products`, `productlocations`,
|
||||||
|
`productstocks`).
|
||||||
|
|
||||||
|
The backend bridges them using a **composite key: `(brand, catalogueid)`**.
|
||||||
|
A catalogue row's bare `id` is only unique *within its own brand table* —
|
||||||
|
`brand_dabur.id=1` and `brand_nestle.id=1` are different products. Every
|
||||||
|
call that references a catalogue product must send both `brand` and
|
||||||
|
`catalogueid`, never just an id.
|
||||||
|
|
||||||
|
When a product is imported, the backend snapshots it into the tenant's own
|
||||||
|
`products` table (tagged with that brand+catalogueid) and links it to the
|
||||||
|
location via the existing stock/location system. After that, it behaves
|
||||||
|
exactly like a product the tenant created by hand — reading a store's
|
||||||
|
catalogue never touches CatalogueDB again.
|
||||||
|
|
||||||
|
## 2. Endpoints
|
||||||
|
|
||||||
|
Base path: `/live/api/v1` (replace host with your environment's API host).
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/web/catalogue/getproducts` | Browse the global catalogue. Query: `brand` (**optional** — omit to search all brands merged), `category`, `keyword`, `pageno`, `pagesize`. This is the "show everything" entry point. |
|
||||||
|
| `GET` | `/web/catalogue/getbrands` | List brands with product counts, for a brand filter chip row. |
|
||||||
|
| `GET` | `/web/products/getimportedcatalogueproducts` | Query: `tenantid` (required), `brand` (**optional** — omit to check across every brand). Returns `[{brand, catalogueid}, …]` already imported by this tenant, for badging "Imported" in the browser. |
|
||||||
|
| `GET` | `/web/products/getproductsubcategories` | Query: `tenantid`, `categoryid`. Use to populate the category/subcategory picker shown before import (see §4). |
|
||||||
|
| `POST` | `/web/products/importcatalogueproduct` | Body is an **array** — import one or many in a batch. Idempotent: re-importing the same `(tenantid, brand, catalogueid)` tops up stock and updates price instead of duplicating. |
|
||||||
|
| `GET` | `/web/products/getlocationproducts` | Query: `tenantid`, `locationid`, `pageno`, `pagesize`. The store's own catalogue view — what's actually imported. |
|
||||||
|
| `DELETE` | `/web/products/deleteproductlocation` | Body: `tenantid`, `locationid`, `productid`. Unlinks from the store; keeps the product row and order history intact (safely re-importable after). |
|
||||||
|
|
||||||
|
Mobile mirrors exist at `/mob/products/importcatalogueproduct` and
|
||||||
|
`/mob/products/getimportedcatalogueproducts` if the mobile app needs this
|
||||||
|
flow too.
|
||||||
|
|
||||||
|
## 3. Integration flow
|
||||||
|
|
||||||
|
Order matters — each step depends on data fetched in the one before it.
|
||||||
|
|
||||||
|
1. **Show everything first.** Call `catalogue/getproducts` with no `brand`.
|
||||||
|
That's the full catalogue, merged and paginated. Don't gate the list
|
||||||
|
behind a brand selector — brand/category/keyword are filters applied on
|
||||||
|
top of an already-visible list, not a prerequisite to seeing it.
|
||||||
|
2. **Mark what's already imported.** Call
|
||||||
|
`products/getimportedcatalogueproducts?tenantid=` (no `brand`, since the
|
||||||
|
list mixes brands) in parallel with step 1. Build a lookup keyed on
|
||||||
|
`` `${brand}:${catalogueid}` `` and badge matching items as "Imported".
|
||||||
|
3. **Collect what the catalogue can't supply.** The catalogue has no exact
|
||||||
|
price (only a `price_range` display string) and no mapping to this
|
||||||
|
tenant's own categories. Before enabling the Import action on a product,
|
||||||
|
require the store owner to pick `categoryid`/`subcategoryid` (from
|
||||||
|
`getproductsubcategories`) and enter `retailprice`/`productcost`/`taxpercent`.
|
||||||
|
4. **Import.** `POST products/importcatalogueproduct` with the batch. On
|
||||||
|
success, invalidate both the imported-refs query and the store-catalogue
|
||||||
|
query.
|
||||||
|
5. **Show it in the store.** Refetch `products/getlocationproducts` — the
|
||||||
|
imported item now appears like any other product, with live stock
|
||||||
|
computed from the stock ledger.
|
||||||
|
6. **Remove, if needed.** `DELETE products/deleteproductlocation`, then
|
||||||
|
invalidate the same two queries as import.
|
||||||
|
|
||||||
|
## 4. Code
|
||||||
|
|
||||||
|
TypeScript + TanStack Query (React Query), matching the existing admin app
|
||||||
|
pattern of invalidating queries after mutations.
|
||||||
|
|
||||||
|
### `api/catalogue.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const API_BASE = "https://<host>/live/api/v1";
|
||||||
|
|
||||||
|
export interface CatalogueProduct {
|
||||||
|
id: number;
|
||||||
|
brand: string;
|
||||||
|
product_name: string;
|
||||||
|
category?: string;
|
||||||
|
images?: string[];
|
||||||
|
size?: string;
|
||||||
|
product_sku?: string;
|
||||||
|
price_range?: string; // display only — never an exact price
|
||||||
|
}
|
||||||
|
|
||||||
|
// brand omitted → the entire catalogue, all brands merged.
|
||||||
|
export async function getCatalogueProducts(opts: {
|
||||||
|
brand?: string; keyword?: string; pageno?: number; pagesize?: number;
|
||||||
|
} = {}) {
|
||||||
|
const { brand, keyword, pageno = 1, pagesize = 50 } = opts;
|
||||||
|
const url = new URL(`${API_BASE}/web/catalogue/getproducts`);
|
||||||
|
if (brand) url.searchParams.set("brand", brand);
|
||||||
|
if (keyword) url.searchParams.set("keyword", keyword);
|
||||||
|
url.searchParams.set("pageno", String(pageno));
|
||||||
|
url.searchParams.set("pagesize", String(pagesize));
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
return { products: json.details as CatalogueProduct[], total: json.total as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportedRef { brand: string; catalogueid: number; }
|
||||||
|
|
||||||
|
// brand omitted → imported refs across every brand.
|
||||||
|
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||||
|
const url = new URL(`${API_BASE}/web/products/getimportedcatalogueproducts`);
|
||||||
|
url.searchParams.set("tenantid", String(tenantid));
|
||||||
|
if (brand) url.searchParams.set("brand", brand);
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
const refs = json.details as ImportedRef[];
|
||||||
|
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportCatalogueProductRequest {
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
brand: string; // bridge key part 1
|
||||||
|
catalogueid: number; // bridge key part 2 — the catalogue row's `id`
|
||||||
|
categoryid: number; // this tenant's own category
|
||||||
|
subcategoryid: number; // this tenant's own subcategory
|
||||||
|
quantity: number;
|
||||||
|
stocktype: "in" | "out";
|
||||||
|
status: string;
|
||||||
|
retailprice: number;
|
||||||
|
productcost: number;
|
||||||
|
taxpercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
|
||||||
|
const res = await fetch(`${API_BASE}/web/products/importcatalogueproduct`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(items),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.status) throw new Error(json.message);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {
|
||||||
|
const res = await fetch(`${API_BASE}/web/products/deleteproductlocation`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tenantid, locationid, productid }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hooks/useCatalogueImport.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useCatalogueProducts(brand?: string, keyword?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["catalogue", "products", brand ?? "all", keyword ?? ""],
|
||||||
|
queryFn: () => getCatalogueProducts({ brand, keyword, pagesize: 100 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["catalogue", "imported", tenantid, brand ?? "all"],
|
||||||
|
queryFn: () => getImportedCatalogueRefs(tenantid, brand),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportCatalogueProduct(tenantid: number, locationid: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (items: ImportCatalogueProductRequest[]) => importCatalogueProducts(items),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["catalogue", "imported", tenantid] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["store-catalogue", tenantid, locationid] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// brand starts undefined: the screen opens showing the whole catalogue.
|
||||||
|
// Selecting a brand chip narrows it — it's a filter, never a gate.
|
||||||
|
function CatalogueBrowser({ tenantid, locationid }: Props) {
|
||||||
|
const [brand, setBrand] = useState<string | undefined>(undefined);
|
||||||
|
const { data } = useCatalogueProducts(brand);
|
||||||
|
const products = data?.products ?? [];
|
||||||
|
const { data: imported = new Set<string>() } = useImportedCatalogueRefs(tenantid);
|
||||||
|
const importProduct = useImportCatalogueProduct(tenantid, locationid);
|
||||||
|
|
||||||
|
function handleImport(product: CatalogueProduct, form: ImportForm) {
|
||||||
|
importProduct.mutate([{
|
||||||
|
tenantid, locationid,
|
||||||
|
brand: product.brand,
|
||||||
|
catalogueid: product.id,
|
||||||
|
categoryid: form.categoryid,
|
||||||
|
subcategoryid: form.subcategoryid,
|
||||||
|
quantity: form.quantity,
|
||||||
|
stocktype: "in",
|
||||||
|
status: "Active",
|
||||||
|
retailprice: form.retailprice,
|
||||||
|
productcost: form.productcost,
|
||||||
|
taxpercent: form.taxpercent,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{products.map((p) => (
|
||||||
|
<li key={`${p.brand}:${p.id}`}>
|
||||||
|
{p.product_name} ({p.brand})
|
||||||
|
{imported.has(`${p.brand}:${p.id}`)
|
||||||
|
? <span className="badge">Imported</span>
|
||||||
|
: <ImportButton onImport={(form) => handleImport(p, form)} />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Gotchas
|
||||||
|
|
||||||
|
- **Always send `brand` with `catalogueid`.** Ids repeat across brands;
|
||||||
|
either one alone is ambiguous.
|
||||||
|
- **Category/subcategory must already exist for the tenant.** There's no
|
||||||
|
automatic mapping from the catalogue's free-text `category` string to
|
||||||
|
this tenant's `categoryid`/`subcategoryid` yet — the UI must require a
|
||||||
|
pick from `getproductsubcategories` before enabling Import.
|
||||||
|
- **Price is store-set, not catalogue-set.** The catalogue only has a
|
||||||
|
`price_range` display string. `retailprice`/`productcost`/`taxpercent`
|
||||||
|
always come from the store owner's input.
|
||||||
|
- **Re-importing tops up, it doesn't duplicate.** Same
|
||||||
|
`(tenantid, brand, catalogueid)` twice reuses the same product row:
|
||||||
|
quantity adds via the stock ledger, price fields overwrite with whatever
|
||||||
|
was sent that call.
|
||||||
|
- **Delete unlinks, it doesn't erase.** The product row (and any order
|
||||||
|
history referencing it) survives; the item becomes instantly
|
||||||
|
re-importable.
|
||||||
|
- **Known brands today:** `dabur`, `nestle`, `pepsico`, `sakthi`, `manna`,
|
||||||
|
`naga` — pull the live list from `getbrands` rather than hardcoding it.
|
||||||
|
|
||||||
|
## 6. Implementation checklist
|
||||||
|
|
||||||
|
- [ ] API client functions (§4) added to the frontend's API layer, with
|
||||||
|
`API_BASE` pointed at the real environment host.
|
||||||
|
- [ ] Catalogue browse screen: loads with no brand filter (all products),
|
||||||
|
brand/category/keyword as UI filters on top.
|
||||||
|
- [ ] Already-imported badge wired to `getImportedCatalogueRefs`, keyed on
|
||||||
|
`brand:catalogueid`.
|
||||||
|
- [ ] Import action collects `categoryid`, `subcategoryid`, `retailprice`,
|
||||||
|
`productcost`, `taxpercent` from the user before enabling submit.
|
||||||
|
- [ ] Import mutation invalidates both the imported-refs query and the
|
||||||
|
store-catalogue query on success.
|
||||||
|
- [ ] Store catalogue screen (`getlocationproducts`) reflects imports
|
||||||
|
immediately after the above invalidation.
|
||||||
|
- [ ] Remove action wired to `deleteproductlocation`, same invalidation.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# ---------- Build Stage ----------
|
# ---------- Build Stage ----------
|
||||||
FROM golang:1.22 AS builder
|
FROM golang:1.24 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
148
controllers/catalogueController.go
Normal file
148
controllers/catalogueController.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"nearle/repositories"
|
||||||
|
"nearle/services"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CatalogueController struct {
|
||||||
|
catalogueService services.CatalogueService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCatalogueController(catalogueService services.CatalogueService) *CatalogueController {
|
||||||
|
return &CatalogueController{catalogueService: catalogueService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *CatalogueController) GetBrands(c *fiber.Ctx) error {
|
||||||
|
brands, err := ctl.catalogueService.GetBrands()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 500,
|
||||||
|
"message": "Failed to fetch catalogue brands",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": brands,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *CatalogueController) GetCategories(c *fiber.Ctx) error {
|
||||||
|
brand := c.Query("brand")
|
||||||
|
if brand == "" {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 400,
|
||||||
|
"message": "brand is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := ctl.catalogueService.GetCategories(brand)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repositories.ErrUnknownBrand) {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 400,
|
||||||
|
"message": "Unknown brand: " + brand,
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 500,
|
||||||
|
"message": "Failed to fetch categories",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": categories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *CatalogueController) GetProducts(c *fiber.Ctx) error {
|
||||||
|
// brand is an optional filter: omitting it browses the entire catalogue
|
||||||
|
// across every brand, which is the entry point for "show everything,
|
||||||
|
// then let the store owner choose".
|
||||||
|
brand := c.Query("brand")
|
||||||
|
category := c.Query("category")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||||
|
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||||
|
|
||||||
|
products, total, err := ctl.catalogueService.GetProducts(brand, category, keyword, pageno, pagesize)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repositories.ErrUnknownBrand) {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 400,
|
||||||
|
"message": "Unknown brand: " + brand,
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 500,
|
||||||
|
"message": "Failed to fetch catalogue products",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"total": total,
|
||||||
|
"details": products,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *CatalogueController) GetProductBySKU(c *fiber.Ctx) error {
|
||||||
|
brand := c.Query("brand")
|
||||||
|
sku := c.Query("sku")
|
||||||
|
if brand == "" || sku == "" {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 400,
|
||||||
|
"message": "brand and sku are required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
product, err := ctl.catalogueService.GetProductBySKU(brand, sku)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repositories.ErrUnknownBrand) {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 400,
|
||||||
|
"message": "Unknown brand: " + brand,
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 500,
|
||||||
|
"message": "Failed to fetch catalogue product",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if product == nil {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 404,
|
||||||
|
"message": "Product not found",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": product,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -515,6 +515,81 @@ func (ctl *ProductController) CreateProductVariant(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ctl *ProductController) ImportCatalogueProduct(c *fiber.Ctx) error {
|
||||||
|
var data []models.ImportCatalogueProductRequest
|
||||||
|
|
||||||
|
if err := c.BodyParser(&data); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "Invalid request body",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "Request body must contain at least one product",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range data {
|
||||||
|
if req.Tenantid == 0 || req.Locationid == 0 || req.Brand == "" || req.Catalogueid == 0 || req.Categoryid == 0 || req.Subcategoryid == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid, locationid, brand, catalogueid, categoryid, and subcategoryid are required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.productService.ImportCatalogueProduct(data); err != nil {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"status": false,
|
||||||
|
"code": http.StatusConflict,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"status": true,
|
||||||
|
"code": http.StatusCreated,
|
||||||
|
"message": "Success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *ProductController) GetImportedCatalogueProducts(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
// brand is optional: omit it to check imported status across every
|
||||||
|
// brand at once, matching the all-brands catalogue browse view.
|
||||||
|
brand := c.Query("brand")
|
||||||
|
|
||||||
|
if tenantID == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
refs, err := ctl.productService.GetImportedCatalogueRefs(tenantID, brand)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": refs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
||||||
var input struct {
|
var input struct {
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +13,10 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
|
// CatalogueDB is a separate connection to the pgvector/catalogue database.
|
||||||
|
// It is intentionally isolated from DB (nearledb) so catalogue integration
|
||||||
|
// never touches the main database.
|
||||||
|
CatalogueDB *gorm.DB
|
||||||
)
|
)
|
||||||
|
|
||||||
// --------------------
|
// --------------------
|
||||||
@@ -36,6 +41,44 @@ func Connect() {
|
|||||||
|
|
||||||
setupDB(DB)
|
setupDB(DB)
|
||||||
fmt.Println("✅ Database connected")
|
fmt.Println("✅ Database connected")
|
||||||
|
|
||||||
|
connectCatalogueDB()
|
||||||
|
connectImageStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectCatalogueDB opens the read-only connection to the catalogue
|
||||||
|
// (pgvector) database. If its env vars are not set, catalogue endpoints
|
||||||
|
// are simply unavailable — this must never block startup of the main app.
|
||||||
|
func connectCatalogueDB() {
|
||||||
|
host := getEnv("CATALOGUE_DB_HOST", "")
|
||||||
|
if host == "" {
|
||||||
|
fmt.Println("⚠️ Catalogue DB env vars not set, skipping catalogue DB connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built as a URL (not a keyword=value DSN) and percent-encoded via
|
||||||
|
// net/url, because this credential's password contains characters
|
||||||
|
// (quotes, #) that the keyword=value DSN format would misparse as
|
||||||
|
// quoting/comment syntax.
|
||||||
|
dsnURL := url.URL{
|
||||||
|
Scheme: "postgres",
|
||||||
|
User: url.UserPassword(mustEnv("CATALOGUE_DB_USER"), mustEnv("CATALOGUE_DB_PASSWORD")),
|
||||||
|
Host: fmt.Sprintf("%s:%s", host, getEnv("CATALOGUE_DB_PORT", "5432")),
|
||||||
|
Path: "/" + mustEnv("CATALOGUE_DB_NAME"),
|
||||||
|
}
|
||||||
|
q := dsnURL.Query()
|
||||||
|
q.Set("sslmode", "disable")
|
||||||
|
dsnURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
catalogueDB, err := gorm.Open(postgres.Open(dsnURL.String()), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("❌ Could not connect to catalogue database:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDB(catalogueDB)
|
||||||
|
CatalogueDB = catalogueDB
|
||||||
|
fmt.Println("✅ Catalogue database connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupDB(database *gorm.DB) {
|
func setupDB(database *gorm.DB) {
|
||||||
@@ -53,16 +96,17 @@ func setupDB(database *gorm.DB) {
|
|||||||
// --------------------
|
// --------------------
|
||||||
|
|
||||||
func CloseDB() {
|
func CloseDB() {
|
||||||
if DB == nil {
|
if DB != nil {
|
||||||
return
|
if sqlDB, err := DB.DB(); err == nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sqlDB, err := DB.DB()
|
if CatalogueDB != nil {
|
||||||
if err != nil {
|
if sqlDB, err := CatalogueDB.DB(); err == nil {
|
||||||
log.Println("Error retrieving sql.DB:", err)
|
sqlDB.Close()
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
fmt.Println("Connection closed Successfully")
|
fmt.Println("Connection closed Successfully")
|
||||||
sqlDB.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------
|
// --------------------
|
||||||
|
|||||||
155
db/imagestore.go
Normal file
155
db/imagestore.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogueImagesPrefix is where product photos for the brand catalogue
|
||||||
|
// live in the bucket: daily/brands/{brand}/{image_id}/image_NNN.<ext>
|
||||||
|
const catalogueImagesPrefix = "daily/brands/"
|
||||||
|
|
||||||
|
// imageStore caches a public-URL listing of catalogue product images so
|
||||||
|
// GET requests never have to call out to S3 themselves.
|
||||||
|
type imageStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
images map[string]map[string][]string // brand -> image_id -> sorted public URLs
|
||||||
|
client *s3.Client
|
||||||
|
bucket string
|
||||||
|
publicURL string // base URL objects are served from, e.g. https://nearle.sgp1.digitaloceanspaces.com
|
||||||
|
}
|
||||||
|
|
||||||
|
var ImageStore *imageStore
|
||||||
|
|
||||||
|
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
|
||||||
|
// used to resolve catalogue product images. Like the catalogue DB, this must
|
||||||
|
// never block or fail app startup — if S3 env vars are absent, image URLs
|
||||||
|
// are simply omitted from catalogue responses.
|
||||||
|
func connectImageStore() {
|
||||||
|
if getEnv("USE_S3", "") != "true" {
|
||||||
|
fmt.Println("⚠️ S3 not enabled, skipping image store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := getEnv("S3_ENDPOINT", "")
|
||||||
|
bucket := getEnv("S3_BUCKET", "")
|
||||||
|
accessKey := getEnv("S3_ACCESS_KEY", "")
|
||||||
|
secretKey := getEnv("S3_SECRET_KEY", "")
|
||||||
|
region := getEnv("S3_REGION", "")
|
||||||
|
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
|
||||||
|
fmt.Println("⚠️ S3 env vars incomplete, skipping image store")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com).
|
||||||
|
// The SDK's virtual-hosted-style client re-prepends the bucket to whatever
|
||||||
|
// host it's given, so the client must be pointed at the bare region host
|
||||||
|
// instead, or listing requests end up addressed to "nearle.nearle...".
|
||||||
|
regionHost := strings.TrimPrefix(endpoint, "https://")
|
||||||
|
regionHost = strings.TrimPrefix(regionHost, "http://")
|
||||||
|
regionHost = strings.TrimPrefix(regionHost, bucket+".")
|
||||||
|
|
||||||
|
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
||||||
|
awsconfig.WithRegion(region),
|
||||||
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("❌ Could not configure S3 client:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||||
|
o.BaseEndpoint = aws.String("https://" + regionHost)
|
||||||
|
})
|
||||||
|
|
||||||
|
ImageStore = &imageStore{
|
||||||
|
images: make(map[string]map[string][]string),
|
||||||
|
client: client,
|
||||||
|
bucket: bucket,
|
||||||
|
publicURL: strings.TrimSuffix(endpoint, "/"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ImageStore.refresh(); err != nil {
|
||||||
|
log.Println("❌ Initial catalogue image listing failed:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ Catalogue image store loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
go ImageStore.refreshLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *imageStore) refreshLoop() {
|
||||||
|
ticker := time.NewTicker(30 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
if err := s.refresh(); err != nil {
|
||||||
|
log.Println("⚠️ Catalogue image refresh failed:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh lists every object under daily/brands/ and rebuilds the
|
||||||
|
// brand -> image_id -> URLs map from scratch, then swaps it in atomically.
|
||||||
|
func (s *imageStore) refresh() error {
|
||||||
|
next := make(map[string]map[string][]string)
|
||||||
|
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Prefix: aws.String(catalogueImagesPrefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
page, err := paginator.NextPage(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing %s: %w", catalogueImagesPrefix, err)
|
||||||
|
}
|
||||||
|
for _, obj := range page.Contents {
|
||||||
|
key := aws.ToString(obj.Key)
|
||||||
|
// daily/brands/{brand}/{image_id}/image_NNN.ext
|
||||||
|
parts := strings.SplitN(strings.TrimPrefix(key, catalogueImagesPrefix), "/", 3)
|
||||||
|
if len(parts) != 3 || parts[2] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
brand := strings.ToLower(parts[0])
|
||||||
|
imageID := parts[1]
|
||||||
|
|
||||||
|
if next[brand] == nil {
|
||||||
|
next[brand] = make(map[string][]string)
|
||||||
|
}
|
||||||
|
next[brand][imageID] = append(next[brand][imageID], s.publicURL+"/"+key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, byImage := range next {
|
||||||
|
for _, urls := range byImage {
|
||||||
|
sort.Strings(urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.images = next
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImages returns the cached public image URLs for a brand + image_id.
|
||||||
|
// Safe to call even if the image store was never initialized.
|
||||||
|
func GetImages(brand, imageID string) []string {
|
||||||
|
if ImageStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ImageStore.mu.RLock()
|
||||||
|
defer ImageStore.mu.RUnlock()
|
||||||
|
return ImageStore.images[strings.ToLower(brand)][imageID]
|
||||||
|
}
|
||||||
@@ -18,18 +18,30 @@ type Facade struct {
|
|||||||
PartnerController *controllers.PartnerController
|
PartnerController *controllers.PartnerController
|
||||||
CustomerController *controllers.CustomerController
|
CustomerController *controllers.CustomerController
|
||||||
StockRequestController *controllers.StockRequestController
|
StockRequestController *controllers.StockRequestController
|
||||||
|
CatalogueController *controllers.CatalogueController
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFacade(db *gorm.DB) *Facade {
|
// NewFacade wires up modules against the main (nearledb) connection.
|
||||||
|
// catalogueDB is a separate connection to the pgvector catalogue database;
|
||||||
|
// it may be nil if catalogue env vars are not configured, in which case
|
||||||
|
// catalogue endpoints will error at query time rather than at startup.
|
||||||
|
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||||
|
|
||||||
// User Module
|
// User Module
|
||||||
userRepo := repositories.NewUserRepository(db)
|
userRepo := repositories.NewUserRepository(db)
|
||||||
userService := services.NewUserService(userRepo)
|
userService := services.NewUserService(userRepo)
|
||||||
userController := controllers.NewUserController(userService)
|
userController := controllers.NewUserController(userService)
|
||||||
|
|
||||||
|
// Catalogue Module (separate pgvector DB — never the main `db`). Built
|
||||||
|
// before the Product Module because ProductService depends on it to
|
||||||
|
// bridge catalogue imports into a tenant's own product catalogue.
|
||||||
|
catalogueRepo := repositories.NewCatalogueRepository(catalogueDB)
|
||||||
|
catalogueService := services.NewCatalogueService(catalogueRepo)
|
||||||
|
catalogueController := controllers.NewCatalogueController(catalogueService)
|
||||||
|
|
||||||
// Product Module
|
// Product Module
|
||||||
productRepo := repositories.NewProductRepository(db)
|
productRepo := repositories.NewProductRepository(db)
|
||||||
productService := services.NewProductService(productRepo)
|
productService := services.NewProductService(productRepo, catalogueService)
|
||||||
productController := controllers.NewProductController(productService)
|
productController := controllers.NewProductController(productService)
|
||||||
|
|
||||||
// Order Module
|
// Order Module
|
||||||
@@ -77,5 +89,6 @@ func NewFacade(db *gorm.DB) *Facade {
|
|||||||
PartnerController: partnerController,
|
PartnerController: partnerController,
|
||||||
CustomerController: customerController,
|
CustomerController: customerController,
|
||||||
StockRequestController: stockRequestController,
|
StockRequestController: stockRequestController,
|
||||||
|
CatalogueController: catalogueController,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
go.mod
22
go.mod
@@ -1,6 +1,8 @@
|
|||||||
module nearle
|
module nearle
|
||||||
|
|
||||||
go 1.21
|
go 1.24
|
||||||
|
|
||||||
|
toolchain go1.24.0
|
||||||
|
|
||||||
require gorm.io/gorm v1.25.10
|
require gorm.io/gorm v1.25.10
|
||||||
|
|
||||||
@@ -14,6 +16,24 @@ require (
|
|||||||
cloud.google.com/go/storage v1.30.1 // indirect
|
cloud.google.com/go/storage v1.30.1 // indirect
|
||||||
firebase.google.com/go v3.13.0+incompatible // indirect
|
firebase.google.com/go v3.13.0+incompatible // indirect
|
||||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||||
|
github.com/aws/smithy-go v1.27.3 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||||
github.com/gofiber/fiber v1.14.6 // indirect
|
github.com/gofiber/fiber v1.14.6 // indirect
|
||||||
|
|||||||
36
go.sum
36
go.sum
@@ -57,6 +57,42 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
|
|||||||
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 h1:LkBKxAOE5WXjlFuFZqPG1rREnl6I6QCMElcXFDEidos=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
|
||||||
|
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||||
|
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -42,7 +42,7 @@ func main() {
|
|||||||
// Ensure schema is updated
|
// Ensure schema is updated
|
||||||
db.DB.AutoMigrate(&models.StockRequest{})
|
db.DB.AutoMigrate(&models.StockRequest{})
|
||||||
|
|
||||||
f := facade.NewFacade(db.DB)
|
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
||||||
|
|
||||||
routes.RegisterRoutes(app, f)
|
routes.RegisterRoutes(app, f)
|
||||||
|
|
||||||
|
|||||||
86
models/catalogue.go
Normal file
86
models/catalogue.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CatalogueProduct maps to the per-brand tables (brand_dabur, brand_nestle, ...)
|
||||||
|
// in the separate pgvector catalogue database. The `embedding` vector column
|
||||||
|
// is intentionally not mapped here — it is never needed in an API response.
|
||||||
|
type CatalogueProduct struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
ImageID string `json:"image_id,omitempty"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
Size string `json:"size,omitempty"`
|
||||||
|
VariantKey string `json:"variant_key,omitempty"`
|
||||||
|
ProductSKU string `json:"product_sku,omitempty"`
|
||||||
|
SKUSource string `json:"sku_source,omitempty"`
|
||||||
|
PriceRange string `json:"price_range,omitempty"`
|
||||||
|
Providers PGStringArray `json:"providers,omitempty"`
|
||||||
|
FSSAILicense string `json:"fssai_license,omitempty"`
|
||||||
|
Highlights PGStringArray `json:"highlights,omitempty"`
|
||||||
|
Nutrients PGStringArray `json:"nutrients,omitempty"`
|
||||||
|
SearchQuery string `json:"search_query,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogueBrand describes a brand available in the catalogue DB.
|
||||||
|
type CatalogueBrand struct {
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGStringArray is a plain []string used for catalogue array fields
|
||||||
|
// (providers, highlights, nutrients) in JSON responses.
|
||||||
|
type PGStringArray []string
|
||||||
|
|
||||||
|
// ParsePGArray parses a Postgres text[] literal (e.g. "{Amazon,Flipkart}")
|
||||||
|
// into a Go []string. GORM's raw-scan-into-struct silently drops slice-kind
|
||||||
|
// destination fields, so array columns are queried as `col::text` and
|
||||||
|
// converted with this function after scanning, rather than scanned directly.
|
||||||
|
func ParsePGArray(s string) PGStringArray {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || s == "{}" {
|
||||||
|
return PGStringArray{}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
|
||||||
|
s = s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var result PGStringArray
|
||||||
|
var buf strings.Builder
|
||||||
|
inQuotes := false
|
||||||
|
escaped := false
|
||||||
|
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
buf.WriteRune(r)
|
||||||
|
escaped = false
|
||||||
|
case r == '\\':
|
||||||
|
escaped = true
|
||||||
|
case r == '"':
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
case r == ',' && !inQuotes:
|
||||||
|
result = append(result, buf.String())
|
||||||
|
buf.Reset()
|
||||||
|
default:
|
||||||
|
buf.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, buf.String())
|
||||||
|
|
||||||
|
for i, v := range result {
|
||||||
|
if v == "NULL" {
|
||||||
|
result[i] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -256,6 +256,33 @@ type Subcategory struct {
|
|||||||
Image string `json:"image" gorm:"column:image"`
|
Image string `json:"image" gorm:"column:image"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
type Productlocations struct {
|
||||||
Productlocationid int `json:"productlocationid" gorm:"Primary_Key"`
|
Productlocationid int `json:"productlocationid" gorm:"Primary_Key"`
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
|
|||||||
299
repositories/catalogueRepository.go
Normal file
299
repositories/catalogueRepository.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"nearle/db"
|
||||||
|
"nearle/models"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogueBrandTables is the allowlist mapping a brand query param to its
|
||||||
|
// table name in the catalogue DB. Table names cannot be parameterized in SQL,
|
||||||
|
// so every table this package ever touches must come from this fixed map.
|
||||||
|
var catalogueBrandTables = map[string]string{
|
||||||
|
"dabur": "brand_dabur",
|
||||||
|
"manna": "brand_manna",
|
||||||
|
"naga": "brand_naga",
|
||||||
|
"nestle": "brand_nestle",
|
||||||
|
"pepsico": "brand_pepsico",
|
||||||
|
"sakthi": "brand_sakthi",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrUnknownBrand = errors.New("unknown brand")
|
||||||
|
|
||||||
|
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
||||||
|
// scan-into-struct silently drops slice-kind destination fields, so they
|
||||||
|
// are read as text here and parsed into []string in scanProductRow.
|
||||||
|
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
|
||||||
|
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
|
||||||
|
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
|
||||||
|
|
||||||
|
// catalogueProductRow mirrors catalogueProductColumns for scanning; array
|
||||||
|
// columns land here as their raw Postgres text[] literal.
|
||||||
|
type catalogueProductRow struct {
|
||||||
|
ID int64
|
||||||
|
ProductName string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Category string
|
||||||
|
ImageID string
|
||||||
|
Size string
|
||||||
|
VariantKey string
|
||||||
|
ProductSKU string
|
||||||
|
SKUSource string
|
||||||
|
PriceRange string
|
||||||
|
Providers string
|
||||||
|
FSSAILicense string
|
||||||
|
Highlights string
|
||||||
|
Nutrients string
|
||||||
|
SearchQuery string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
||||||
|
return models.CatalogueProduct{
|
||||||
|
ID: row.ID,
|
||||||
|
Brand: brand,
|
||||||
|
ProductName: row.ProductName,
|
||||||
|
Title: row.Title,
|
||||||
|
Description: row.Description,
|
||||||
|
Category: row.Category,
|
||||||
|
ImageID: row.ImageID,
|
||||||
|
Images: db.GetImages(brand, row.ImageID),
|
||||||
|
Size: row.Size,
|
||||||
|
VariantKey: row.VariantKey,
|
||||||
|
ProductSKU: row.ProductSKU,
|
||||||
|
SKUSource: row.SKUSource,
|
||||||
|
PriceRange: row.PriceRange,
|
||||||
|
Providers: models.ParsePGArray(row.Providers),
|
||||||
|
FSSAILicense: row.FSSAILicense,
|
||||||
|
Highlights: models.ParsePGArray(row.Highlights),
|
||||||
|
Nutrients: models.ParsePGArray(row.Nutrients),
|
||||||
|
SearchQuery: row.SearchQuery,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatalogueRepository interface {
|
||||||
|
GetBrands() ([]models.CatalogueBrand, error)
|
||||||
|
GetCategories(brand string) ([]string, error)
|
||||||
|
GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error)
|
||||||
|
GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error)
|
||||||
|
GetProductByID(brand string, id int64) (*models.CatalogueProduct, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type catalogueRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCatalogueRepository takes the dedicated catalogue DB connection
|
||||||
|
// (db.CatalogueDB), never the main nearledb connection.
|
||||||
|
func NewCatalogueRepository(db *gorm.DB) CatalogueRepository {
|
||||||
|
return &catalogueRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableForBrand(brand string) (string, error) {
|
||||||
|
table, ok := catalogueBrandTables[strings.ToLower(strings.TrimSpace(brand))]
|
||||||
|
if !ok {
|
||||||
|
return "", ErrUnknownBrand
|
||||||
|
}
|
||||||
|
return table, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
|
||||||
|
var brands []models.CatalogueBrand
|
||||||
|
|
||||||
|
for brand, table := range catalogueBrandTables {
|
||||||
|
var count int64
|
||||||
|
if err := r.db.Table(table).Count(&count).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("counting %s: %w", table, err)
|
||||||
|
}
|
||||||
|
brands = append(brands, models.CatalogueBrand{Brand: brand, ProductCount: count})
|
||||||
|
}
|
||||||
|
|
||||||
|
return brands, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogueRepository) GetCategories(brand string) ([]string, error) {
|
||||||
|
table, err := tableForBrand(brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories []string
|
||||||
|
query := fmt.Sprintf(`SELECT DISTINCT category FROM %s WHERE category IS NOT NULL ORDER BY category`, table)
|
||||||
|
if err := r.db.Raw(query).Scan(&categories).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProducts lists catalogue products. When brand is empty it browses the
|
||||||
|
// entire catalogue (every brand merged), which is the entry point for "show
|
||||||
|
// everything, then let the store owner choose" — brand/category/keyword are
|
||||||
|
// optional narrowing filters on top of that, not prerequisites.
|
||||||
|
func (r *catalogueRepository) GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||||
|
if pagesize <= 0 {
|
||||||
|
pagesize = 20
|
||||||
|
}
|
||||||
|
if pageno <= 0 {
|
||||||
|
pageno = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if brand != "" {
|
||||||
|
return r.getProductsForBrand(brand, category, keyword, pageno, pagesize)
|
||||||
|
}
|
||||||
|
return r.getProductsAllBrands(category, keyword, pageno, pagesize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogueRepository) getProductsForBrand(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||||
|
table, err := tableForBrand(brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (pageno - 1) * pagesize
|
||||||
|
whereClause, args := catalogueWhereClause(category, keyword)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s`, table, whereClause)
|
||||||
|
if err := r.db.Raw(countQuery, args...).Scan(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []catalogueProductRow
|
||||||
|
dataQuery := fmt.Sprintf(
|
||||||
|
`SELECT %s FROM %s WHERE %s ORDER BY id LIMIT ? OFFSET ?`,
|
||||||
|
catalogueProductColumns, table, whereClause,
|
||||||
|
)
|
||||||
|
dataArgs := append(append([]interface{}{}, args...), pagesize, offset)
|
||||||
|
if err := r.db.Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
brandKey := strings.ToLower(brand)
|
||||||
|
products := make([]models.CatalogueProduct, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
products[i] = row.toModel(brandKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return products, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getProductsAllBrands merges matching products across every brand table so
|
||||||
|
// the catalogue can be browsed without picking a brand first. The whole
|
||||||
|
// catalogue is currently a few hundred rows total, so fetching each brand's
|
||||||
|
// filtered set and merging/sorting in Go is simple and fast; if it grows
|
||||||
|
// much larger this should move to a single UNION ALL query instead.
|
||||||
|
func (r *catalogueRepository) getProductsAllBrands(category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||||
|
whereClause, args := catalogueWhereClause(category, keyword)
|
||||||
|
|
||||||
|
brands := make([]string, 0, len(catalogueBrandTables))
|
||||||
|
for brand := range catalogueBrandTables {
|
||||||
|
brands = append(brands, brand)
|
||||||
|
}
|
||||||
|
sort.Strings(brands)
|
||||||
|
|
||||||
|
var all []models.CatalogueProduct
|
||||||
|
for _, brand := range brands {
|
||||||
|
table := catalogueBrandTables[brand]
|
||||||
|
|
||||||
|
var rows []catalogueProductRow
|
||||||
|
dataQuery := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY id`, catalogueProductColumns, table, whereClause)
|
||||||
|
if err := r.db.Raw(dataQuery, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("querying %s: %w", table, err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
all = append(all, row.toModel(brand))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
return strings.ToLower(all[i].ProductName) < strings.ToLower(all[j].ProductName)
|
||||||
|
})
|
||||||
|
|
||||||
|
total := int64(len(all))
|
||||||
|
offset := (pageno - 1) * pagesize
|
||||||
|
if offset > len(all) {
|
||||||
|
offset = len(all)
|
||||||
|
}
|
||||||
|
end := offset + pagesize
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
return all[offset:end], total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueWhereClause builds the shared category/keyword filter used by
|
||||||
|
// both single-brand and all-brand product queries.
|
||||||
|
func catalogueWhereClause(category, keyword string) (string, []interface{}) {
|
||||||
|
where := []string{"1=1"}
|
||||||
|
args := []interface{}{}
|
||||||
|
|
||||||
|
if category != "" {
|
||||||
|
where = append(where, "category = ?")
|
||||||
|
args = append(args, category)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
where = append(where, "(product_name ILIKE ? OR title ILIKE ? OR description ILIKE ?)")
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
args = append(args, like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(where, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error) {
|
||||||
|
table, err := tableForBrand(brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var row catalogueProductRow
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT %s FROM %s WHERE product_sku = ? LIMIT 1`,
|
||||||
|
catalogueProductColumns, table,
|
||||||
|
)
|
||||||
|
result := r.db.Raw(query, sku).Scan(&row)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
product := row.toModel(strings.ToLower(brand))
|
||||||
|
return &product, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
|
||||||
|
table, err := tableForBrand(brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var row catalogueProductRow
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT %s FROM %s WHERE id = ? LIMIT 1`,
|
||||||
|
catalogueProductColumns, table,
|
||||||
|
)
|
||||||
|
result := r.db.Raw(query, id).Scan(&row)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
product := row.toModel(strings.ToLower(brand))
|
||||||
|
return &product, nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -36,6 +37,10 @@ type ProductRepository interface {
|
|||||||
CreateProductLocation(input []models.Productlocations) error
|
CreateProductLocation(input []models.Productlocations) error
|
||||||
CreateProductVariant(input models.Productvariant) error
|
CreateProductVariant(input models.Productvariant) error
|
||||||
DeleteProductLocation(tenantid, locationid, productid int) error
|
DeleteProductLocation(tenantid, locationid, productid int) error
|
||||||
|
FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error)
|
||||||
|
CreateProductReturningID(product models.Products) (int, error)
|
||||||
|
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
||||||
|
UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type productRepository struct {
|
type productRepository struct {
|
||||||
@@ -753,3 +758,60 @@ func (r *productRepository) DeleteProductLocation(tenantid, locationid, producti
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindTenantProductByCatalogueRef looks up the tenant's existing snapshot of
|
||||||
|
// a catalogue product, keyed on (tenantid, brand, catalogueid) since a
|
||||||
|
// catalogue row's bare id is only unique within its own brand table.
|
||||||
|
func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error) {
|
||||||
|
var product models.Products
|
||||||
|
result := r.db.Table("products").
|
||||||
|
Where("tenantid = ? AND productbrand = ? AND catalogueid = ?", tenantid, brand, catalogueid).
|
||||||
|
First(&product)
|
||||||
|
if result.Error != nil {
|
||||||
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
return &product, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProductReturningID inserts a new product snapshot and returns its
|
||||||
|
// generated productid. Kept separate from CreateProduct so existing callers
|
||||||
|
// of CreateProduct are unaffected.
|
||||||
|
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
||||||
|
if err := r.db.Create(&product).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return product.Productid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImportedCatalogueRefs returns the (brand, catalogueid) pairs this
|
||||||
|
// tenant has already imported, so a catalogue browse screen can mark items
|
||||||
|
// as already-imported without diffing full product lists client-side. Brand
|
||||||
|
// is optional: omitted, it covers every brand at once — needed because the
|
||||||
|
// all-brands browse view mixes products whose bare catalogueid can collide
|
||||||
|
// across brand tables, so brand must travel with every id.
|
||||||
|
func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) {
|
||||||
|
refs := make([]models.ImportedCatalogueRef, 0)
|
||||||
|
query := r.db.Table("products").
|
||||||
|
Select("productbrand AS brand, catalogueid").
|
||||||
|
Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid)
|
||||||
|
if brand != "" {
|
||||||
|
query = query.Where("productbrand = ?", brand)
|
||||||
|
}
|
||||||
|
err := query.Scan(&refs).Error
|
||||||
|
return refs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProductPricing updates only the pricing fields on a product
|
||||||
|
// snapshot, used when a catalogue product is re-imported with new pricing.
|
||||||
|
func (r *productRepository) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error {
|
||||||
|
return r.db.Table("products").
|
||||||
|
Where("productid = ?", productid).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"retailprice": retailprice,
|
||||||
|
"productcost": productcost,
|
||||||
|
"taxpercent": taxpercent,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|||||||
24
routes/catalogueroutes.go
Normal file
24
routes/catalogueroutes.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nearle/facade"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterCatalogueRoutes(api fiber.Router, f *facade.Facade) {
|
||||||
|
|
||||||
|
catalogue := api.Group("/v1/web/catalogue")
|
||||||
|
|
||||||
|
catalogue.Get("/getbrands", f.CatalogueController.GetBrands)
|
||||||
|
catalogue.Get("/getcategories", f.CatalogueController.GetCategories)
|
||||||
|
catalogue.Get("/getproducts", f.CatalogueController.GetProducts)
|
||||||
|
catalogue.Get("/getproduct", f.CatalogueController.GetProductBySKU)
|
||||||
|
|
||||||
|
catalogue = api.Group("/v1/mob/catalogue")
|
||||||
|
|
||||||
|
catalogue.Get("/getbrands", f.CatalogueController.GetBrands)
|
||||||
|
catalogue.Get("/getcategories", f.CatalogueController.GetCategories)
|
||||||
|
catalogue.Get("/getproducts", f.CatalogueController.GetProducts)
|
||||||
|
catalogue.Get("/getproduct", f.CatalogueController.GetProductBySKU)
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
products.Get("/getallproducts", f.ProductController.GetAllProducts)
|
products.Get("/getallproducts", f.ProductController.GetAllProducts)
|
||||||
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
||||||
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
||||||
|
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
||||||
|
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
||||||
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
|
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
|
||||||
products.Post("/createproductvariant", f.ProductController.CreateProductVariant)
|
products.Post("/createproductvariant", f.ProductController.CreateProductVariant)
|
||||||
|
|
||||||
@@ -42,5 +44,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
products.Put("/update", f.ProductController.UpdateProduct)
|
products.Put("/update", f.ProductController.UpdateProduct)
|
||||||
products.Get("/getlocationproducts", f.ProductController.GetLocationProducts)
|
products.Get("/getlocationproducts", f.ProductController.GetLocationProducts)
|
||||||
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
||||||
|
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
||||||
|
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
|||||||
RegisterTenantRoutes(api, f)
|
RegisterTenantRoutes(api, f)
|
||||||
RegisterPartnerRoutes(api, f)
|
RegisterPartnerRoutes(api, f)
|
||||||
RegisterCustomerRoutes(api, f)
|
RegisterCustomerRoutes(api, f)
|
||||||
|
RegisterCatalogueRoutes(api, f)
|
||||||
}
|
}
|
||||||
|
|||||||
42
services/catalogueService.go
Normal file
42
services/catalogueService.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CatalogueService interface {
|
||||||
|
GetBrands() ([]models.CatalogueBrand, error)
|
||||||
|
GetCategories(brand string) ([]string, error)
|
||||||
|
GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error)
|
||||||
|
GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error)
|
||||||
|
GetProductByID(brand string, id int64) (*models.CatalogueProduct, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type catalogueService struct {
|
||||||
|
repo repositories.CatalogueRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCatalogueService(repo repositories.CatalogueRepository) CatalogueService {
|
||||||
|
return &catalogueService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *catalogueService) GetBrands() ([]models.CatalogueBrand, error) {
|
||||||
|
return s.repo.GetBrands()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *catalogueService) GetCategories(brand string) ([]string, error) {
|
||||||
|
return s.repo.GetCategories(brand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *catalogueService) GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||||
|
return s.repo.GetProducts(brand, category, keyword, pageno, pagesize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *catalogueService) GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error) {
|
||||||
|
return s.repo.GetProductBySKU(brand, sku)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *catalogueService) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
|
||||||
|
return s.repo.GetProductByID(brand, id)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"time"
|
"time"
|
||||||
@@ -28,13 +29,16 @@ type ProductService interface {
|
|||||||
CreateProductLocation(input []models.Productlocations) error
|
CreateProductLocation(input []models.Productlocations) error
|
||||||
CreateProductVariant(input models.Productvariant) error
|
CreateProductVariant(input models.Productvariant) error
|
||||||
DeleteProductLocation(tenantid, locationid, productid int) error
|
DeleteProductLocation(tenantid, locationid, productid int) error
|
||||||
|
ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error
|
||||||
|
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
||||||
}
|
}
|
||||||
type productService struct {
|
type productService struct {
|
||||||
repo repositories.ProductRepository
|
repo repositories.ProductRepository
|
||||||
|
catalogueService CatalogueService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProductService(repo repositories.ProductRepository) ProductService {
|
func NewProductService(repo repositories.ProductRepository, catalogueService CatalogueService) ProductService {
|
||||||
return &productService{repo: repo}
|
return &productService{repo: repo, catalogueService: catalogueService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *productService) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) {
|
func (s *productService) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) {
|
||||||
@@ -212,3 +216,75 @@ func (s *productService) DeleteProductLocation(tenantid, locationid, productid i
|
|||||||
return s.repo.DeleteProductLocation(tenantid, locationid, productid)
|
return s.repo.DeleteProductLocation(tenantid, locationid, productid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImportCatalogueProduct bridges a global catalogue product (CatalogueDB) into
|
||||||
|
// a tenant's own store catalogue: it snapshots the catalogue product into the
|
||||||
|
// tenant's `products` table on first import (keyed on brand+catalogueid so
|
||||||
|
// re-imports are recognized), then links it to the location via the existing
|
||||||
|
// CreateProductLocation upsert, which already handles conflicting
|
||||||
|
// (tenantid, locationid, productid) rows and stock-ledger entries.
|
||||||
|
func (s *productService) ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error {
|
||||||
|
locations := make([]models.Productlocations, 0, len(reqs))
|
||||||
|
|
||||||
|
for _, req := range reqs {
|
||||||
|
catalogueProduct, err := s.catalogueService.GetProductByID(req.Brand, req.Catalogueid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if catalogueProduct == nil {
|
||||||
|
return fmt.Errorf("catalogue product not found: brand=%s id=%d", req.Brand, req.Catalogueid)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.repo.FindTenantProductByCatalogueRef(req.Tenantid, req.Brand, req.Catalogueid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
productID := 0
|
||||||
|
if existing != nil {
|
||||||
|
productID = existing.Productid
|
||||||
|
if err := s.repo.UpdateProductPricing(productID, req.Retailprice, req.Productcost, req.Taxpercent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
snapshot := models.Products{
|
||||||
|
Tenantid: req.Tenantid,
|
||||||
|
Categoryid: req.Categoryid,
|
||||||
|
Subcategoryid: req.Subcategoryid,
|
||||||
|
Productname: catalogueProduct.ProductName,
|
||||||
|
Productdesc: catalogueProduct.Description,
|
||||||
|
Productsku: catalogueProduct.ProductSKU,
|
||||||
|
Productbrand: catalogueProduct.Brand,
|
||||||
|
Catalogueid: int(catalogueProduct.ID),
|
||||||
|
Productunit: catalogueProduct.Size,
|
||||||
|
Productcost: req.Productcost,
|
||||||
|
Retailprice: req.Retailprice,
|
||||||
|
Taxpercent: req.Taxpercent,
|
||||||
|
Approve: 1,
|
||||||
|
}
|
||||||
|
if len(catalogueProduct.Images) > 0 {
|
||||||
|
snapshot.Productimage = catalogueProduct.Images[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
productID, err = s.repo.CreateProductReturningID(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locations = append(locations, models.Productlocations{
|
||||||
|
Tenantid: req.Tenantid,
|
||||||
|
Locationid: req.Locationid,
|
||||||
|
Productid: productID,
|
||||||
|
Quantity: req.Quantity,
|
||||||
|
Stocktype: req.Stocktype,
|
||||||
|
Status: req.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.CreateProductLocation(locations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *productService) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) {
|
||||||
|
return s.repo.GetImportedCatalogueRefs(tenantid, brand)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user