Copy all source code from the fundamentals course

This commit is contained in:
Borrell.Guillem@bcg.com 2023-09-18 09:32:03 +02:00
parent e8e7a3ee6f
commit 63cf21c7e3
46 changed files with 3444 additions and 0 deletions

10
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,10 @@
repos:
- repo: https://github.com/psf/black
rev: 22.10.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.0.278
hooks:
- id: ruff

View file

@ -7,6 +7,44 @@ name = "retailtwin"
authors = [{name = "Guillem Borrell", email = "borrell.guillem@bcg.com"}]
readme = "README.md"
dynamic = ["version", "description"]
dependencies = [
"duckdb",
"pydantic",
"typer",
"pyyaml",
"pydantic-settings",
"polars",
"pyarrow",
"sqlalchemy[asyncio] > 2.0.13",
"adbc-driver-postgresql",
"adbc-driver-sqlite",
"prompt_toolkit",
"asyncpg",
"pydantic-settings"
]
[project.urls]
Home = "https://github.com/Borrell-Guillem_bcgprod/retailtwin"
[project.optional-dependencies]
doc = [
"mkdocs-material",
"pymdown-extensions",
"mkdocstrings[python-legacy]>=0.18",
"mkdocs-gen-files",
"markdown-include",
"mkdocs-with-pdf",
"mkdocs-literate-nav"]
dev = [
"black",
"ruff",
"pre-commit"
]
[project.scripts]
retailtwin = "retailtwin.cli.data:app"
stock = "retailtwin.cli.stock:app"
pos = "retailtwin.cli.pos.main:app"
tasks = "retailtwin.cli.tasks.main:app"

View file

View file

@ -0,0 +1,30 @@
# Super simple API-based stock terminal
Run the backend with
```bash
uvicorn dengfun.retail.api.main:app
```
Use Caddy to reverse proxy and the following Caddyfile. Paths are static so you have to run caddy from the root of the package
```
:80 {
handle_path /api/v1/* {
reverse_proxy localhost:8000
}
file_server {
root src/dengfun/retail/api/static
}
}
```
If you execute
```bash
caddy run -c Caddyfile
```
You should be able to browse the application at http://127.0.0.1, and reach the api docs at http://127.0.0.1/api/v1/docs
Caddy can be installed in Windows with Chocolatey

View file

View file

138
src/retailtwin/api/db.py Normal file
View file

@ -0,0 +1,138 @@
import os
from operator import itemgetter
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import select, desc
from dengfun.retail.views import get_inventory_view, get_stores_view
from dengfun.retail.api.settings import Settings
async def get_session():
try:
engine = create_async_engine(Settings().db_uri.unicode_string())
yield async_sessionmaker(engine, expire_on_commit=False)
except: # noqa
await engine.dispose()
def detail_button(batch: int):
return "".join(
[
f'<a href="detail.html?batch={batch}">'
f'<button class="btn btn-secondary" type="button">🔍</button>'
f"</a>"
]
)
async def get_inventory(
store: int,
page: int,
pagesize: int,
sortby: str,
root_path: str,
async_session: async_sessionmaker[AsyncSession] = None,
) -> str:
"""Get inventory for a given location
Args:
store (int): Location id
page (int): Page number for pagination
pagesize (int): Page size for pagination
sortby (str): Column name used for sorting
root_path (str): Root path of the API
async_session (async_sessionmaker[AsyncSession], optional):
SQLAlchemy async session. Defaults to None.
Returns:
str: HTML table rows with the content
"""
output = list()
inventory = get_inventory_view()
# Selects the column from which to sort by, and if asc or desc
col_reversed = sortby.startswith("-")
col_selector = itemgetter(sortby.strip("-"))
async with async_session() as session:
stmt = (
select(
inventory.c[
"upc",
"batch",
"name",
"package",
"received",
"best_until",
"quantity",
]
)
.where(inventory.c.location == store)
.limit(pagesize)
.offset(pagesize * page)
.order_by(
desc(col_selector(inventory.c))
if col_reversed
else col_selector(inventory.c)
)
)
result = await session.execute(stmt)
query_params = " ".join(
[
f'js:{{page: "{page + 1}",'
f'pagesize: "{pagesize}",'
f'sortby: "{sortby}",'
'store: document.getElementById("store").value}'
]
)
# Turn the results in a sequence of HTML table rows
for i, record in enumerate(result.fetchall()):
line = ""
cells = (
" ".join(f"<td>{it}</td>" for it in record)
+ f" <td>{detail_button(record[1])}</td>"
)
if i == (pagesize - 1): # Handle last row for infinite scrolling
line = " ".join(
[
f'<tr hx-get="{root_path}/stock"',
f"hx-vals='{query_params}'",
f'hx-trigger="revealed" hx-swap="afterend">{cells}</tr>',
]
)
else:
line = f"<tr>{cells}</tr>"
output.append(line)
return os.linesep.join(output)
async def get_stores(
root_path: str,
async_session: async_sessionmaker[AsyncSession] = None,
):
# Build store options
store_rows = ['<option selected value="1">Choose store</option>']
stores = get_stores_view()
async with async_session() as session:
stmt = select(stores)
result = await session.execute(stmt)
for record in result.fetchall():
store_rows.append(f'<option value="{record[0]}">{record[1]}</option>')
# Wrap with the select tag
query_params = (
'js:{page: "0", '
'pagesize: "20", '
'sortby: "upc", '
'store: document.getElementById("store").value}'
)
return (
f'<select class="form-select" id="store"'
f'hx-get="{root_path}/stock" '
f"hx-vals='{query_params}' "
f'hx-target="#results" '
f">{os.linesep.join(store_rows)}</>"
)

View file

@ -0,0 +1,43 @@
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from dengfun.retail.api.db import get_inventory, get_session, get_stores
app = FastAPI(root_path="/api/v1")
@app.get("/test")
async def test(request: Request):
return {"message": "Hello World", "root_path": request.scope.get("root_path")}
@app.get("/stock", response_class=HTMLResponse)
async def stock(
request: Request,
store: int = 1,
page: int = 0,
pagesize: int = 20,
sortby: str = "upc",
async_session: async_sessionmaker[AsyncSession] = Depends(get_session),
) -> str:
body = await get_inventory(
store=store,
page=page,
pagesize=pagesize,
sortby=sortby,
root_path=request.scope.get("root_path"),
async_session=async_session,
)
return HTMLResponse(body, status_code=200)
@app.get("/stores", response_class=HTMLResponse)
async def stores(
request: Request,
async_session: async_sessionmaker[AsyncSession] = Depends(get_session),
):
body = await get_stores(
root_path=request.scope.get("root_path"),
async_session=async_session,
)
return HTMLResponse(body, status_code=200)

View file

@ -0,0 +1,7 @@
from pydantic import PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
db_uri: PostgresDsn
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

View file

@ -0,0 +1,107 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Latest compiled and minified CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
<title>Retail digital twin web terminal</title>
</head>
<html>
<body>
<div class="container-fluid">
<div hx-get="/api/v1/stores" hx-trigger="load">
</div>
<table class="table">
<thead>
<tr>
<th>upc
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"upc", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-upc", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>batch</th>
<th>name
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"name", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-name", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>package</th>
<th>received
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"received", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-received", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>best_until
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"best_until", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-best_until", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>quantity
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"quantity", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-quantity", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>detail</th>
</tr>
</thead>
<tbody id="results" hx-get="/api/v1/stock?page=0&pagesize=20&sortby=upc&store=1" hx-trigger="load">
</tbody>
</table>
</div>
<script src="https://code.jquery.com/jquery-3.7.0.slim.min.js"
integrity="sha256-tG5mcZUtJsZvyKAxYLVXrmjKBVLd6VpVccqz/r4ypFE=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"
integrity="sha384-Rx+T1VzGupg4BHQYs2gCW9It+akI2MM/mndMCy36UVfodzcJcF0GGLxZIzObiEfa"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.4"
integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,117 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Latest compiled and minified CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
<title>Retail digital twin web terminal</title>
</head>
<html>
<body>
<div class="container-fluid">
<div>
<div class="p-2">
<button class="btn btn-secondary">Home</button>
<button class="btn btn-secondary">Query UPC</button>
<button class="btn btn-secondary">Search</button>
<button class="btn btn-secondary">Warehouse stock</button>
</div>
</div>
</div>
<div class="container-fluid">
<div hx-get="/api/v1/stores" hx-trigger="load">
</div>
<table class="table">
<thead>
<tr>
<th>upc
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"upc", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-upc", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>batch</th>
<th>name
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"name", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-name", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>package</th>
<th>received
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"received", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-received", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>best_until
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"best_until", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-best_until", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>quantity
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"quantity", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-quantity", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>detail</th>
</tr>
</thead>
<tbody id="results" hx-get="/api/v1/stock?page=0&pagesize=20&sortby=upc&store=1" hx-trigger="load">
</tbody>
</table>
</div>
<script src="https://code.jquery.com/jquery-3.7.0.slim.min.js"
integrity="sha256-tG5mcZUtJsZvyKAxYLVXrmjKBVLd6VpVccqz/r4ypFE=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"
integrity="sha384-Rx+T1VzGupg4BHQYs2gCW9It+akI2MM/mndMCy36UVfodzcJcF0GGLxZIzObiEfa"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.4"
integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,107 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Latest compiled and minified CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
<title>Retail digital twin web terminal</title>
</head>
<html>
<body>
<div class="container-fluid">
<div hx-get="/api/v1/stores" hx-trigger="load">
</div>
<table class="table">
<thead>
<tr>
<th>upc
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"upc", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-upc", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>batch</th>
<th>name
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"name", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-name", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>package</th>
<th>received
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"received", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-received", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>best_until
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"best_until", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-best_until", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>quantity
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"quantity", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
<button hx-get="/api/v1/stock"
hx-vals='js:{page: "0", pagesize: "20", sortby:"-quantity", store:document.getElementById("store").value}'
hx-trigger="click" hx-target="#results">
</button>
</th>
<th>actions</th>
</tr>
</thead>
<tbody id="results" hx-get="/api/v1/stock?page=0&pagesize=20&sortby=upc&store=1" hx-trigger="load">
</tbody>
</table>
</div>
<script src="https://code.jquery.com/jquery-3.7.0.slim.min.js"
integrity="sha256-tG5mcZUtJsZvyKAxYLVXrmjKBVLd6VpVccqz/r4ypFE=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"
integrity="sha384-Rx+T1VzGupg4BHQYs2gCW9It+akI2MM/mndMCy36UVfodzcJcF0GGLxZIzObiEfa"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.4"
integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
crossorigin="anonymous"></script>
</body>
</html>

269
src/retailtwin/bootstrap.py Normal file
View file

@ -0,0 +1,269 @@
import json
import string
from datetime import datetime, timedelta
from itertools import repeat
from pathlib import Path
from random import choice, choices, randint
from uuid import uuid4
import polars as pl
from sqlalchemy import select
from sqlalchemy.orm import Session
import dengfun
from dengfun.retail.utils import db_uri_from_session
from dengfun.retail.models import (
Discount,
Item,
ItemBatch,
ItemOnShelf,
Location,
LocationType,
Provider,
TaskOwner,
)
# Some configuration parameters.
PACKAGE_ROOT = Path(dengfun.__file__).parent / "retail"
PRODUCT_LIST_FILE = "data/products.csv"
DISCOUNT_LIST_FILE = "data/discounts.csv"
RANDOM_PEOPLE_FILE = "data/random_people.csv"
NUM_LOCATIONS = 100
LOCATION_TYPES = ["store", "warehouse"]
LOCATION_TYPES_WEIGHTS = [10, 1]
NUM_CUSTOMERS = 1_000_000
CUSTOMER_BATCH_SIZE = 10_000
# Some utility functions
def read_products() -> pl.DataFrame:
"""Load and sanitize the dummy product list stored in a file as part of the package
Returns:
pl.DataFrame: Polars dataframe with the products
"""
return (
pl.read_csv((PACKAGE_ROOT / PRODUCT_LIST_FILE).resolve())
.with_columns(
product=pl.col("product").str.strip(" "),
package=pl.col(" package").str.strip(" "),
price=pl.col(" price").str.strip(" ").str.strip("$"),
provider=pl.col(" provider").str.strip(" "),
)
.select(
[pl.col("product"), pl.col("package"), pl.col("provider"), pl.col("price")]
)
)
def read_discounts() -> pl.DataFrame:
"""Load the discount list stored in a file as part of the package
Returns:
pl.DataFrame: _description_
"""
return pl.read_csv((PACKAGE_ROOT / DISCOUNT_LIST_FILE).resolve())
def read_people() -> pl.DataFrame:
"""Load the dummy customer list stored in a file as part of the package
Returns:
pl.DataFrame: _description_
"""
return pl.read_csv((PACKAGE_ROOT / RANDOM_PEOPLE_FILE).resolve())
def bootstrap_discounts(session: Session):
"""Load the discount table into the database
Args:
session (Session): SQLAlchemy ORM session
"""
for discount in read_discounts().iter_rows():
session.add(Discount(name=discount[0], definition=json.loads(discount[1])))
session.commit()
def bootstrap_providers(session: Session):
"""Load the providers table with data from the product list. Only the name changes,
and other provider information is filled with the same data or mocked.
Args:
session (Session): SQLAlchemy ORM session
"""
ascii_upper = [s.upper() for s in string.ascii_letters]
for provider in read_products().select(pl.col("provider").unique()).to_series():
session.add(
Provider(
name=provider,
address="Fake address street, number XX",
phone="+1 555 555 55 55",
vat=f"{randint(10000000,99999999)}{choice(ascii_upper)}",
)
)
session.commit()
def bootstrap_items(session: Session):
"""Load data into the Items table from the product list
Args:
session (Session): SQLAlchemy ORM sessoin
"""
for data in read_products().select(pl.all()).iter_rows():
volume = randint(2, 10)
provider = session.scalar(select(Provider).where(Provider.name == data[2])).id
session.add(
Item(
name=data[0],
upc=randint(0, 999999999999),
package=data[1],
current=True,
provider=provider,
volume_unpacked=volume,
volume_packed=volume - 1,
)
)
session.commit()
def bootstrap_locations(session: Session):
"""Create NUM_LOCATIONS of LOCATION_TYPES, with LOCATION_TYPES_WEIGHTS providing
the proportion of each location. If location is of type warehouse, capacity is
around 10 times bigger.
Args:
session (Session): SQLAlchemy ORM session
"""
for i, ltype in enumerate(LOCATION_TYPES):
session.add(LocationType(name=ltype, retail=(ltype == "store")))
session.commit()
for i in range(NUM_LOCATIONS):
c = choices(LOCATION_TYPES, LOCATION_TYPES_WEIGHTS)
loc = session.scalar(select(LocationType).where(LocationType.name == c[0]))
session.add(
Location(
loctype=loc.id,
name=f"Location {i} {loc.name}",
capacity=randint(50_000, 100_000)
* (1 + 9 * (int(loc.name == "warehouse"))),
)
)
session.commit()
def bootstrap_clients(session: Session):
"""Load data into the customers table in batches.
Args:
session (Session): SQLAlchemy ORM session
"""
connection_uri = db_uri_from_session(session)
people = read_people()
for i in range(NUM_CUSTOMERS // CUSTOMER_BATCH_SIZE):
print(f"Write batch {i} of {NUM_CUSTOMERS // CUSTOMER_BATCH_SIZE}")
data = (
pl.DataFrame(
{
"name": people.select(pl.col("name"))
.sample(CUSTOMER_BATCH_SIZE, with_replacement=True, shuffle=True)
.to_series(),
"middlename": people.select(pl.col("middlename"))
.sample(CUSTOMER_BATCH_SIZE, with_replacement=True, shuffle=True)
.to_series(),
"surname": people.select(pl.col("surname"))
.sample(CUSTOMER_BATCH_SIZE, with_replacement=True, shuffle=True)
.to_series(),
}
)
.with_columns(
name=pl.concat_str(
[pl.col("name"), pl.col("middlename"), pl.col("surname")],
separator=" ",
)
)
.select(pl.col("name"))
)
pl.DataFrame(
{
"document": [
f"{str(randint(0, 99999999)).zfill(8)}"
for _ in range(CUSTOMER_BATCH_SIZE)
],
"info": [json.dumps({"name": name[0]}) for name in data.iter_rows()],
},
).write_database(
"customers", connection_uri, if_exists="append", engine="sqlalchemy"
)
def bootstrap_stock(session: Session):
"""Load items as stock in each location. The total weight is estimated to not to
stock over capacity.
Args:
session (Session): SQLAlchemy ORM session
"""
products = read_products()
db_uri = db_uri_from_session(session)
items_df = pl.read_database("select * from items", db_uri, engine="adbc")
locations_df = pl.read_database("select * from locations", db_uri, engine="adbc")
discounts = list(
pl.read_database("select id from discounts", db_uri, engine="adbc")
.select(pl.col("id"))
.to_series()
) + [None]
weights = [1 for _ in range(len(discounts) - 1)] + [50]
average_volume_unpacked = items_df.select(pl.col("volume_unpacked").mean())[0, 0]
for location in locations_df.iter_rows():
print(f"Stocking location {location[0]} from {len(locations_df)}")
stock_quantity = int(location[3] / average_volume_unpacked / len(products))
with_prices = items_df.join(
products,
left_on=["name", "package"],
right_on=["product", "package"],
)
# Create the batch too
for lot_id, unit_price, sku in zip(
repeat(str(uuid4())[:23]),
with_prices.select([pl.col("price")]).to_series(),
with_prices.select([pl.col("sku")]).to_series(),
):
batch = ItemBatch(
sku=sku,
lot=lot_id,
order=None,
received=datetime.now(),
unit_cost=unit_price, # TODO: There's no margin for the moment
price=unit_price,
best_until=datetime.now() + timedelta(days=30),
quantity=stock_quantity,
)
session.add(batch)
session.commit()
random_discount = choices(discounts, weights, k=1)[0]
on_shelf = ItemOnShelf(
batch=batch.id,
discount=random_discount if random_discount else None,
quantity=stock_quantity,
location=location[0],
)
session.add(on_shelf)
session.commit()
def bootstrap_taskowners(session: Session):
for name in ["stocker", "cashier", "manager", "warehouse"]:
taskowner = TaskOwner(name=name)
session.add(taskowner)
session.commit()

View file

View file

@ -0,0 +1,68 @@
import typer
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from dengfun.retail.bootstrap import (
bootstrap_clients,
bootstrap_discounts,
bootstrap_items,
bootstrap_locations,
bootstrap_providers,
bootstrap_stock,
bootstrap_taskowners,
)
from dengfun.retail.models import Base
from dengfun.retail.sql.sync import funcandproc
app = typer.Typer()
@app.command()
def init(db_uri: str):
"""Persist the schema on the database
Args:
db_uri (str): SQLAlchemy db uri
"""
engine = create_engine(db_uri, echo=True)
Base.metadata.create_all(engine)
@app.command()
def sync(db_uri: str):
"""Sync the functions, procedures, triggers and views
Args:
db_uri (str): SQLAlchemy db uri
"""
funcandproc(db_uri)
@app.command()
def bootstrap(db_uri: str):
"""Populate the databse with data. Only execute on an empty database
Args:
db_uri (str): Sqlalchemy db uri
"""
engine = create_engine(db_uri)
with Session(engine) as session:
print("Discounts...")
bootstrap_discounts(session)
print("Providers...")
bootstrap_providers(session)
print("Locations...")
bootstrap_locations(session)
print("Items...")
bootstrap_items(session)
print("Clients...")
bootstrap_clients(session)
print("Stockage...")
bootstrap_stock(session)
print("TaskOwners")
bootstrap_taskowners(session)
if __name__ == "__main__":
app()

109
src/retailtwin/cli/db.py Normal file
View file

@ -0,0 +1,109 @@
"""
Several utilities to fetch data from the database
"""
import polars as pl
from rich.table import Table
from typing import Tuple
def fetch_from_db(db_uri: str, location: int) -> Tuple[pl.DataFrame]:
"""Fetcn basic information about inventory from the database
Args:
db_uri (str): SQLAlchemy db URI
location (int): Location ID for the store or warehouse
Returns:
Tuple[pl.DataFrame]: Two polars dataframe continig all items,
and the local stock respectively
"""
items = (
query_items(db_uri)
.select(pl.all())
.select([pl.col("upc"), pl.col("name"), pl.col("package")])
.with_columns(pl.col("upc").cast(str))
)
local_stock = query_local_batches(db_uri, location).with_columns(
pl.col("upc").cast(str),
pl.col("batch").cast(str),
pl.col("quantity").cast(str),
pl.col("received").cast(str),
pl.col("best_until").cast(str),
)
return items, local_stock
def df_to_table(df: pl.DataFrame, title="Items") -> Table:
"""Process a Polars dataframe and create a Rich table from it
Args:
df (pl.DataFrame): The polars dataframe
title (str, optional): Title for the displayed table. Defaults to "Items".
Returns:
Table: The Rich table to be displayed
"""
table = Table(title=title)
for column in df.columns:
table.add_column(str(column))
for row in df.iter_rows():
table.add_row(*row)
return table
def query_items(db_uri: str) -> pl.DataFrame:
"""Query all available items from the database that can be kept in stock
Args:
db_uri (str): SQLAlchemy database URI
Returns:
pl.DataFrame: Polars dataframe with the items
"""
return pl.read_database(
"select * from items where current = true", db_uri, engine="adbc"
)
def query_local_batches(db_uri: str, location: int) -> pl.DataFrame:
"""Query all batches stored in location
Args:
db_uri (str): SQLAlchemy database URI
location (int): Id of the current location
Returns:
pl.DataFrame: Polars Dataframe with the batches in stock
"""
return pl.read_database(
f"select * from inventory where location = {location}",
db_uri,
engine="adbc",
)
def query_warehouse_stock(db_uri: str, item: int) -> pl.DataFrame:
"""Returns stock available for an item in all warehouses
Args:
db_uri (str): SQLAlchemy database URI
item (int): Id of the item
Returns:
pl.DataFrame: Polars Dataframe with the stock stock
"""
return pl.read_database(
f"select * from warehouse_stock where upc = {item}",
db_uri,
engine="adbc",
)
def sync_pos_schema():
pass

View file

View file

@ -0,0 +1,217 @@
"""
Terminal that mimics what a Point of Sale may operate like.
"""
import typer
import polars as pl
from dengfun.retail.cli.db import query_local_batches
from dengfun.retail.cli.pos.models import Base, Sync, Direction, Cart, Item
from dengfun.retail.utils import db_uri_from_session
from datetime import datetime
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
# from prompt_toolkit.shortcuts import clear
from rich.console import Console
from rich.markdown import Markdown
console = Console()
HELP = """
# Retail twin POS
This is a simple terminal that simulates a dumb PoS with a single-line screen.
"""
CACHE_FILE_URL = "sqlite:///pos.db"
CONNECT_ERROR_MESSAGE = """
# ERROR
Could not sync to the central database.
The terminal will try to operate without a connection.
"""
def sync(session: Session, db_remote_uri: str, location: int):
db_local_uri = db_uri_from_session(session)
# Fetch data from the remote database
try:
inventory = query_local_batches(db_remote_uri, location)
except: # noqa: E722
console.print(Markdown(CONNECT_ERROR_MESSAGE))
return
# The price and the discount is fixed by the latest received batch
items = (
inventory.select(
[
pl.col("upc"),
pl.col("price"),
pl.col("discount_definition").alias("discount"),
pl.col("received"),
pl.col("received").max().over("upc").alias("max_received"),
]
)
.filter(pl.col("received") == pl.col("max_received"))
.select([pl.col("upc"), pl.col("price"), pl.col("discount")])
.with_columns(pl.col("price").str.strip().cast(pl.Float32) * 100)
.with_columns(pl.col("price").cast(pl.Int32))
)
items.write_database(
"items", db_local_uri, if_exists="replace", engine="sqlalchemy"
)
# Sync all customers
(
pl.read_database("select * from customers", db_remote_uri, engine="adbc")
.select([pl.col("id"), pl.col("document")])
.write_database(
"customers", db_local_uri, if_exists="replace", engine="sqlalchemy"
)
)
# Add last sync
sync = Sync(direction=Direction.pull)
session.add(sync)
session.commit()
# Sync unsynced carts
unsynced_carts = pl.read_database(
"select * from carts where synced = false and total_amount is not null",
db_local_uri,
engine="adbc",
)
if not unsynced_carts.is_empty():
(
unsynced_carts.select(
[pl.col("checkout_dt").alias("checkout"), pl.col("customer")]
)
.with_columns(location=location)
.write_database(
"carts", db_remote_uri, if_exists="append", engine="sqlalchemy"
)
)
# Query for items to sync
query = """
select
c.id as cart,
i.upc as upc,
i.quantity
from
itemsoncart i
join
carts c
on
i.cart = c.id
where
c.synced = false"""
pl.read_database(query, db_local_uri, engine="adbc").write_database(
"itemsoncart", db_remote_uri, engine="sqlalchemy"
)
# Set synced to cart at the end of the process
for cart in session.scalars(
select(Cart).filter(Cart.synced == False) # noqa: E712
).all():
cart.synced = True
session.commit()
def load_items(session: Session):
return [str(it) for it in session.scalars(select(Item.upc)).all()]
def handle_command(command: str, cart_id: int, item_id: int, **kwargs):
print(command, cart_id, item_id)
session = kwargs["session"]
if command == "n": # Start a new cart
cart = Cart(checkin_dt=datetime.now(), synced=False)
session.add(cart)
session.commit()
return cart.id, "", ""
# Handle adding a new item
elif cart_id is not None:
# Check if the upc is in the database
if session.scalar(select(Item).filter(Item.upc == int(command))).one_or_none():
return cart_id, item_id, ""
else:
return cart_id, "", "ERROR: Code not found"
else:
cart_id, item_id if item_id else "", ""
def main(db_uri: str, location: int):
console.print("Fetching data...")
# Create the schema of the local database
local_engine = create_engine(CACHE_FILE_URL)
Base.metadata.create_all(local_engine)
with Session(local_engine) as session:
sync(session, db_uri, location)
# Default completer with barcodes
completer = WordCompleter(load_items(session))
# Update the local copy of the data.
console.print(Markdown(HELP))
term = PromptSession()
# State of the pos
cart_id = ""
item_id = ""
error = ""
while True:
if cart_id and item_id:
if error:
prompt = f"[{item_id}# quantity] !{error}!>"
else:
prompt = f"[{item_id}# quantity]>"
elif cart_id and not item_id:
prompt = f"[{cart_id}# code]> "
else:
prompt = "#> "
try:
text = term.prompt(prompt, completer=completer)
except KeyboardInterrupt:
continue
except EOFError:
break
else:
if text in ["x", "q"]:
break
elif text == "h":
console.print(Markdown(HELP))
elif text == "r":
console.print("Refreshing...")
sync(session, db_uri, location)
completer = WordCompleter(load_items(session))
else:
cart_id, item_id, error = handle_command(
text,
cart_id,
item_id,
location=location,
completer=completer,
db_remote_uri=db_uri,
session=session,
)
print("GoodBye!")
app = typer.run(main)

View file

@ -0,0 +1,61 @@
from enum import Enum
from datetime import datetime
from decimal import Decimal
from typing import Dict
from sqlalchemy import ForeignKey, Numeric, JSON
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
__all__ = ["Direction", "PaymentType", "Base", "Sync", "Cart", "Item", "ItemOnCart"]
class Direction(Enum):
push = 0
pull = 1
class PaymentType(Enum):
cash = 0
card = 1
class Base(DeclarativeBase):
pass
class Sync(Base):
__tablename__ = "syncs"
id: Mapped[int] = mapped_column(primary_key=True)
direction: Mapped[Direction]
sync_dt: Mapped[datetime] = mapped_column(default=datetime.now())
class Item(Base):
__tablename__ = "items"
upc: Mapped[int] = mapped_column(primary_key=True)
unitprice: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=False)
discount: Mapped[Dict[str, Dict[str, int]]] = mapped_column(JSON, nullable=True)
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
document: Mapped[str] = mapped_column(nullable=False)
class Cart(Base):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(primary_key=True)
checkin_dt: Mapped[datetime] = mapped_column(default=datetime.now())
checkout_dt: Mapped[datetime] = mapped_column(nullable=True)
total_amount: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=True)
payment_type: Mapped[PaymentType] = mapped_column(nullable=True)
customer: Mapped[int] = mapped_column(ForeignKey("customers.id"), nullable=True)
synced: Mapped[bool]
class ItemOnCart(Base):
__tablename__ = "itemsoncart"
id: Mapped[int] = mapped_column(primary_key=True)
cart: Mapped[int] = mapped_column(ForeignKey("carts.id"))
upc: Mapped[int] = mapped_column(ForeignKey("items.upc"))
quantity: Mapped[int]

166
src/retailtwin/cli/stock.py Normal file
View file

@ -0,0 +1,166 @@
# ruff: noqa: E501
import typer
import polars as pl
from sqlalchemy import create_engine, text as query
from dengfun.retail.cli.db import query_warehouse_stock, df_to_table, fetch_from_db
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.shortcuts import clear
from rich.console import Console
from rich.markdown import Markdown
console = Console()
HELP = """
# Retail twin stock management CLI
This is a simple terminal to manage stock. Enter a single-letter command followed by <Enter>. The available commands are:
* **l**: Lists all the current items stocked in any location.
* **s**: Enters search mode. Search an item by name.
* **q**: Store query mode. Queries the stock of an item by UPC in the current location.
* **w**: Warehouse query mode. Queries the stock of an item by UPC in all warehouses. Requires connection to the database.
* **c**: Cancel mode. Retires a batch giving a UPC. Requires connection to the database.
* **b**: Batch mode. Requests a given quantity from an item to the warehouse. Requires connection to the database.
* **r**: Refresh data from the stock database.
* **h**: Print this help message.
* **x**: Exit this terminal.
"""
def handle_command(command: str, **kwargs):
"""_summary_
Args:
command (str): Single letter command
"""
items = kwargs["items"]
session = kwargs["session"]
item_completer = kwargs["completer"]
location = kwargs["location"]
local_stock = kwargs["local_stock"]
db_uri = kwargs["db_uri"]
if command == "l":
console.print(df_to_table(items))
elif command == "s":
# Command opens a new prompt
completer = WordCompleter(
items.select(pl.col("name")).to_series().str.to_lowercase().to_list()
)
text = session.prompt("s> ", completer=completer)
console.print(
df_to_table(
items.select(pl.all()).filter(
pl.col("name").str.to_lowercase().str.contains(text)
)
)
)
elif command == "q": # Query stock in current location
upc = session.prompt("q> ", completer=item_completer)
df = local_stock.select(
[
pl.col("upc"),
pl.col("name"),
pl.col("package"),
pl.col("price"),
pl.col("best_until"),
pl.col("quantity"),
]
).filter(pl.col("upc") == upc)
console.print(
df_to_table(
df.with_columns(pl.col("price").cast(str)),
title=f"Item {upc} on location {location}",
)
)
elif command == "w": # Query stock in warehouses
text = session.prompt("w> ", completer=item_completer)
console.print(
df_to_table(
query_warehouse_stock(db_uri, text).with_columns(
pl.col("received").cast(str),
pl.col("best_until").cast(str),
pl.col("upc").cast(str),
pl.col("quantity").cast(str),
),
title=f"Warehouse stock for item {text}",
)
)
elif command == "c": # Retire a batch from the shelves.
batches = local_stock.select(pl.col("batch")).to_series().to_list()
batchid = session.prompt("c [enter batch id]> ")
if batchid in batches:
console.print(
df_to_table(
local_stock.select(pl.all()).filter(pl.col("batch") == batchid),
title=f"Batch {batchid} on location {location}",
)
)
text = session.prompt(f"c [Remove batch {batchid}? (Y/N)]> ")
if text.lower() == "y":
engine = create_engine(db_uri)
conn = engine.connect()
conn.execute(
query(f"select retire_batch_from_shelves({batchid}, {location})")
)
conn.commit()
else:
console.print("Aborted")
else:
console.print(f"Error, batch {batchid} doesn't exist")
else:
console.print(f"Command {command} not supported")
def main(db_uri: str, location: int):
# Fetch all data necessary from the database
console.print("Fetching data...")
items, local_stock = fetch_from_db(db_uri, location)
clear()
console.print(Markdown(HELP))
# Default completer with barcodes
completer = WordCompleter(
items.select(pl.col("upc")).to_series().cast(str).to_list()
)
session = PromptSession()
while True:
try:
text = session.prompt(
"#> ",
)
except KeyboardInterrupt:
continue
except EOFError:
break
else:
if text == "x":
break
elif text == "h":
console.print(Markdown(HELP))
elif text == "r":
console.print("Refreshing...")
items, local_stock = fetch_from_db(db_uri, location)
else:
handle_command(
text,
items=items,
location=location,
session=session,
completer=completer,
local_stock=local_stock,
db_uri=db_uri,
)
print("GoodBye!")
app = typer.run(main)

View file

View file

View file

View file

@ -0,0 +1,11 @@
name,definition
2x1,{}
3x2,{}
percent_discount_10,{"discount": 10}
percent_discount_15,{"discount": 15}
percent_discount_20,{"discount": 20}
percent_discount_25,{"discount": 25}
percent_discount_30,{"discount": 30}
percent_discount_50,{"discount": 50}
second_unit_half,{}
second_unit_same_provider_half,{}
Can't render this file because it contains an unexpected character in line 4 and column 22.

View file

@ -0,0 +1,451 @@
product, package, price, provider
Whole grain bread, 1 loaf, $3.50, Healthy Grains
Pure spring water, 24 pack, $4.99, Mountain Springs
Organic eggs, dozen, $4.20, Happy Chicken
Italian spaghetti, 500g, $1.20, Mamma Italia
Corn flakes, 500g, $2.99, Golden Harvest
Habanero hot sauce, 150ml, $6.95, Fire Blast
Canned tuna, 180g, $1.75, Ocean's Finest
Full cream yogurt, 1kg, $2.75, Creamy Dreams
Fruit jam, 340g, $3.50, Sweet Orchard
Olive oil, 1 liter, $8.00, Mediterranean Gold
Banana chips, 200g, $2.69, Tropical Crunch
Almond milk, 1 liter, $3.20, Nutty Delights
Peanut butter, 500g, $3.89, Nutty Spread
Smoked salmon, 300g, $8.99, Fresh Waters
White wine, 750ml, $10.99, Vineyard Bliss
Whole wheat flour, 1kg, $2.15, Mill Power
Raisin granola, 500g, $3.79, Morning Fuel
Instant coffee, 200g, $4.99, Sunrise Roast
Cheddar cheese, 450g, $5.75, Cheesy Valley
Organic baby carrots, 450g, $2.70, Bunny Love
Wasabi peas, 200g, $2.30, Atomic Snacks
Jasmine rice, 2kg, $4.80, Oriental Gourmet
Gluten-free breadsticks, 200g, $3.00, Healthy Crunch
Lime soda, 2 liters, $1.89, Zest Fizz
Oatmeal cookies, 400g, $3.99, Grandma's Love
Quinoa cereal, 500g, $3.80, Natural Glow
Cooking spray, 200ml, $2.90, Kitchen Ease
Blueberry jam, 340g, $3.00, Berry Bliss
Refined coconut oil, 500ml, $4.50, Tropical magic
Cranberry juice, 1 liter, $3.49, Ruby Splash
Honey Nuts Cereal, 16 oz box, $3.60, BeeGood
Tomato Ketchup, plastic bottle, $1.80, Reddrop
Whole Grain Bread, plastic wrapped, $2.65, DeliLoaf
Coffee Beans, 1lb bag, $9.95, Queen's Brew
Citrus Hand Soap, 16 oz bottle, $2.75, CitrusSplash
Almond Butter, glass jar, $7.99, Nutti Delight
Organic Pasta, 1kg packet, $3.50, Pastaroma
Frozen Pizza, 14", $8.40, Pizzani
Extra Virgin Olive Oil, 500ml bottle, $6.60, Oliva Bella
Cherry Cola Soda, 12 pack cans, $5.39, BubblFizz
Spinach Tortilla Wraps, pack of 10, $2.35, DreamWraps
Jasmine Rice, 1kg bag, $4.90, Oriental Delights
Natural Peanut, large bag, $4.79, NutLand
Blueberry Fruit Bars, box of 6, $3.99, Berrylicious
Popcorn Kernels, 1lb bag, $2.20, Pop-Go
White Vinegar, 1 litre bottle, $1.90, CrispClear
Island Blend Coffee, 1lb bag, $11.80, Paradise Grind
Raspberry Jam, 12 oz jar, $3.20, Berry Habit
Dishwashing Liquid, 32 oz bottle, $2.75, SparkleClean
Classic Mayonnaise, 16 oz jar, $3.99, BestoMayo
Quinoa Flakes, 270g box, $4.20, QuinoaQuench
Tea Bags, box of 20, $2.89, DreamSip
Cream Crackers, 400g box, $1.70, Snappy
Bourbon Biscuits, pack of 12, $3.45, SweetBite
Avocado Oil, 250ml bottle, $7.20, GreenSqueeze
Thai Curry Paste, 14 oz jar, $4.30, ThaiSpice
Toilet Paper, 6-roll pack, $4.99, SoftCushion
Spaghetti Sauce, 24 oz jar, $2.79, Itali Aroma
Natural Juice, 64 oz bottle, $4.45, RainBow Splash
Strawberry Yogurt, 6 oz tub, $1.59, Creamy Spoon
Toilet Paper, 12 pack, $7.50, Cloud Soft
Energy Drink, 16 fl oz can, $2.85, Thunderbolts
Orange Juice, 64 fl oz, $3.50, Juicy Morning
Dog Food, 15 lb bag, $22.75, Paws'n'Tails
Toothpaste, 5.5 oz tube, $3.95, BrilliantSmiles
Breakfast Cereal, 12 oz box, $3.65, Morning Sunrise
Canned Beans, 15 oz can, $1.35, Trusty Beans
Ground Coffee, 1 lb, $7.50, Bright Morning Brew
Hand Soap, 12.5 fl oz pump bottle, $2.95, Foam'n'Fresh
Shampoo, 16 fl oz bottle, $5.75, Lustrous Locks
Olive Oil, 16.9 fl oz bottle, $8.95, SunGold Medley
Sugar, 5 lb bag, $2.95, Sweet Crystals
Chips, 11 oz packet, $3.50, Crunch Supreme
Laundry Detergent, 100 fl oz bottle, $9.50, Clean Vibrance
Bread, loaves, $2.25, Grain Bliss Bakery
Pasta Sauce, 24 oz jar, $3.15, Bella Nona's
Baby Wipes, 72 wipes pack, $3.25, CuddlePure
Frozen Pizza, 22 oz box, $5.95, TasteFest Pizzeria
Yogurt, 6 oz cup, $0.85, CreamHill Dairy
Instant Noodles, 3 oz packet, $0.35, Super Bowl
BBQ Sauce, 18 oz bottle, $3.75, Smoky Journey
Flour, 5 lb bag, $2.50, Home Baker's Friend
Soda, 12 pack cans, $4.95, Fizz and Pop
Vegetable Stock, 32 fl oz carton, $2.75, RichGarden Flavor
Quinoa, 16 oz bag, $4.95, Noble Grains
Protein Bar, 1.7 oz bar, $1.50, Power Fuel
Honey, 8 oz jar, $6.50, Busy Bees Natural
Aluminum Foil, 100 sq ft roll, $4.25, Silver Sheen
Mustard, 14 oz bottle, $1.95, Zesty Expressions
Ice Cream, 1.5 qt, $5.75, Frosty Delights
Granola bars, 5 pack, $4.10, Crunchy Delight
Frozen Pizza, 450g, $5.30, Pizza Fantastica
Rice Cakes, 10 units, $1.85, Crispy Joy
Cereal, 1kg, $3.59, Morning Whisk
Oatmeal, 500g, $2.75, FarmFresh Foods
Pasta sauce, 500ml, $3.90, Bella Cucina
Fruit yogurt, 150g, $0.70, Dairy Pearl
Potato Chips, 200g, $1.80, CrunchCraze Snacks
Canned Tuna, 150g, $1.50, Ocean's Bounty
Laundry Detergent, 1L, $6.99, SwiftClean
Organic Honey, 450g, $6.95, Bee Sweet
Olive Oil, 1L, $8.10, Mediterranean Magic
Protein Shake, 330ml, $2.50, Muscle Boost
Multi-vitamin juice, 1L, $3.90, Vital Sip
Soda, 2L, $1.99, FizzPop
Cheese Slices, 200g, $3.25, Cheesy Delights
Instant Noodles, 80g pack, $0.65, Noodle Express
Shaving Cream, 200ml, $4.30, SmoothMen
Body Wash, 400ml, $5.10, Fresh Waves
Toothpaste, 100g, $2.70, SparkleSmile
Whole Grain Bread, 500g, $2.50, Baker's Choice
Chocolate spread, 400g, $4.20, ChocoDream
Baby feeding bottles, 2 pack, $10.99, BabyCaring
Dog food, 2kg, $14.50, PetLove
Almond Milk, 1L, $3.10, NutriNatural
Coffee Beans, 250g, $6.30, Brew Bliss
Fruit Preserves, 450g, $3.55, Sweet Mama
Ice Cream, 2L, $7.99, Creamy Glaze
Baby Diapers, 50 pack, $22.50, ComfyKids
Solar-Powered Flashlight, 1 piece, $15.99, EcoShine
Wholegrain Bread, 500 grams, $4.99, Earthy Crunch
Organic Peanut Butter, 340 grams jar, $5.49, Nutty's Best
Chunky Tomato Sauce, 700 ml glass jar, $3.39, Bella Italia
Extra Virgin Olive Oil, 1 liter bottle, $9.99, Pure & Simple
Organic Brown Rice, 1 kilogram bag, $4.79, Grain Goodness
Greek Style Yogurt, 500 gram plastic tub, $3.49, Dairy's Pride
Gourmet Dark Chocolate, 200 gram bar, $3.99, Choconilla
Sparkling Natural Mineral Water, 1 liter bottle, $1.99, Bubbling Oasis
Frozen Blueberries, 500 gram bag, $4.89, Frosty Berries
Granulated Sugar, 1 kilogram bag, $2.39, Sweet Essentials
Smoked Salmon, 200 gram pack, $8.49, Ocean Delicacy
Shredded Mozzarella Cheese, 200 gram bag, $3.19, Cheesy Wonders
Cold Pressed Almond Oil, 500 ml bottle, $14.49, Nudges of Nature
Multi-grain bread, 1 lb bag, $2.75, Grain Masters
Canned corn, 15 oz can, $1.45, Kernel's Best
Organic chicken, 1lb bag vacuum-packed, $6.50, Free Roam Farms
Tomato Sauce, 24 oz jar, $3.00, Mama's Homemade
Broccoli florets, 1 lb bag, $2.50, Green Heaven Farms
Whole wheat pasta, 16 oz box, $2.80, Pasta Delight
Strawberry jam, 16 oz jar, $3.75, Sweet Berry
Natural peanut butter, 18 oz jar, $4.00, Nutty Delish
Toothpaste, 8 oz tube, $3.50, Fresh Minty
Bathroom tissue, 12 rolls, $10.00, Soft&Silky
Olive oil, 750 ml bottle, $7.50, Greek Gold
Almond milk, 64 fl oz carton, $3.50, Nutrich
Canned tuna, 5 oz can, $1.75, Ocean Fresh
Brown rice, 2 lb bag, $3.00, Healthy Grains
Granola bars, box of 6, $4.25, Crunchy Munchy
Apple cider, 64 fl oz bottle, $3.50, Gold Orchard
Organic eggs, dozen, $4.00, Farm Fresh
Shredded mozzarella, 8 oz bag, $2.75, Cheese Haven
Cereal, 14 oz box, $3.50, Breakfast Bites
Frozen pizza, 22 oz box, $5.00, Pizzeria Delights
Cheddar cheese, 8 oz pack, $3.80, Diary Delicacies
Vanilla yogurt, 32 oz tub, $3.00, Yogurt Bliss
Cranberry juice, 64 fl oz bottle, $3.75, Berry Fresh
Mild salsa, 16 oz jar, $2.50, Fiesta Flavors
Ground coffee, 12 oz bag, $6.00, Brew Bonanza
Green tea bags, box of 20, $4.00, Zen Harmony
White wine, 750 ml bottle, $15.00, Vineyard's Finest
Skinless chicken breast, 1 lb bag vacuum-packed, $6.00, Featherlite Farms
Strawberries, 1 lb box, $3.50, Fresh and Dewy
Apple pie, 24 oz box, $5.00, Granny's Bake Shop
Green Tea Bags, box of 20, $4.19, Golden Leaf
Gluten-Free Pasta, 500 gram box, $3.89, Happy Harvest
Cake Mix, 360 gram box, $3.39, Baking Bliss
Premium Dog Food, 2 kilogram bag, $8.99, Bow-wow Bites
Raw Honey, 450 gram jar, $6.49, Bee's Bounty
Breakfast Cereal, 700 gram box, $3.99, Sunrise Crunch
Organic Coconut Milk, 400 ml can, $2.29, Coco's Charm
French Roast Coffee, 250 gram pack, $5.99, Morning Bliss
Crunchy Peanut Brittle, 200 gram pack, $3.69, Candy Lovin'
Roasted Hazelnut Spread, 400 gram jar, $4.99, Nutty Spread
Baby Wipes, 50 count pack, $2.99, Baby's Care
Instant Oatmeal, 1 kilogram bag, $3.49, Oaty Delight
Premium Ice Cream, 500 ml tub, $4.89, Dreamy Scoops
Chicken Broth, 1 liter tetra pack, $2.89, Country Kitchen
Fair Trade Bananas, 1 kilogram, $2.49, Plantain Planet
Organic Eggs, Box of 12, $4.99, Healthy Yolks
Barbecue Sauce, 500 ml bottle, $3.99, Grill Master
Salted Pretzels, 300g bag, $1.99, Pretzel Kingdom
Orchard Apple Juice, 1 liter, $3.50, Apple Core Naturals
Whole Wheat Bread, 1 loaf, $2.80, Wheat Wonder
Peach Yoghurt, 500g pot, $1.70, Naturals Yoghurt Co.
Organic Strawberry Jam, 450g jar, $4.35, Berry's Best
Mexican Style Salsa, 250g jar, $2.50, Southwest Spices
Cheesy Pita Chips, 230g bag, $1.75, Pita Pirates
Frozen Pepperoni Pizza, 16 inch, $5.50, Pizzeria Pronto
Clarified Butter Spread, 355g tub, $5.20, Golden Glow
Lemon Lime Soda, 2 liters, $1.99, Citrus Burst
Herb and Garlic Olive Oil, 500ml container, $3.80, Olive Oasis
BBQ Rib-flavored Potato Chips, 150g bag, $2.00, Crispy Canyon
All-Purpose Cleaner, 32 fl oz, $3.99, Germ Go Away
Double Chocolate Cookies, 350g box, $3.50, Sweet Temptations
Oatmeal Cereal, 750g box, $4.00, Sunshine Grains
Raspberry Iced Tea, 1.5 liter, $2.50, Summer Quench
Smoked Tuna in Can, 185g, $3.20, SeaFlavors
Almond Protein Bar, 75g pack, $2.10, Nourish and Energize
Vanilla Ice Cream, 2 liter carton, $4.99, Polar Temptations
Light Soy Sauce, 200ml bottle, $2.55, Eastern Essence
Spiced Pumpkin Soup, 400g can, $3.75, Easy Eats
Bacon Cheeseburger Frozen Entree, 350g box, $6.99, MealBox
Pistachio Nuts, 250g pouch, $5.99, Nutty Nature
Ginger Beer, 330ml bottle, $2.30, Zest & Spice
Cherry Blossom Room Spray, 300ml canister, $3.65, Home Comforts
Honey Roasted Peanut Butter, 340g jar, $3.89, Nutty Delight
White Basmati Rice, 1kg bag, $3.55, Eastern Grains
Rainbow Pasta, 500g bag, $2.25, Wiggly Noodles
Sea Salt and Caramel Popcorn, 200g bag, $1.79, PopBurst
All-Natural Cat Food, 1.5kg bag, $14.99, Kitty Pride
Organic tomato sauce, 24 oz jar, $6.35, Farm Fresh
Chocolate chip cereal, 15.5 oz box, $4.99, Morning Delights
Green tea bags, 20 pack, $3.65, Tranquil Leaves
Garlic herb bread, 1 loaf, $3.50, Rustic Bakery
Oatmeal raisin cookies, 12 count bag, $4.85, Gluten Free Galore
Extra virgin olive oil, 1 liter, $12.75, Olive Grove Industries
Dark chocolate almond bar, 100 grams, $2.75, Chocolate Euphoria
Natural almond butter, 16 oz jar, $7.99, Nutty Spreadables
Lemon verbena soap, bar, $4.85, Fresh Fields
Balsamic vinegar, 500 ml bottle, $7.35, Vine Magic
Organic frozen berries, 16 oz bag, $5.49, Nature's Sweetness
Gluten-free pasta, 500 grams package, $3.29, Grain Perfect
Spicy curry sauce, 14 oz jar, $4.49, Taste of India
Vanilla almond milk, 1 litre, $3.99, Silk Delicacies
Honey roasted peanuts, 16 oz can, $3.95, Crunchy Essentials
Cucumber melon lotion, 12 fl oz, $7.99, Fresh Skin
Organic brown rice, 2 lb bag, $4.99, Healthy Harvest
Apple cinnamon granola, 500 grams box, $5.99, Sunrise Cereals
Canned black beans, 15 oz can, $1.99, Bountiful Pantry
Quinoa chips, 150 grams bag, $2.79, Snack Revolution
Aloe vera shampoo, 16 fl oz bottle, $8.99, Pure Tresses
Stainless steel cleaner, 14 oz can, $5.75, Sparkle Shine
Pure maple syrup, 350 ml bottle, $14.99, Maple Haven
Mint dental floss, 50 m box, $2.79, Smile Bright
Unsalted mixed nuts, 16 oz jar, $6.85, Nutty Fixes
Lavender hand sanitizer, 2 oz bottle, $1.49, Germ Guardian
Soy-based vegan cheese, 200 grams package, $5.25, Veganed Cheese
Brown sugar glazed donuts, 6 count box, $4.49, Glaze Heaven
Basil pesto, 8 oz jar, $6.49, Flavors of Italy
Kale and spinach chips, 85 grams bag, $3.85, Power Green Crunchies
Diet Pasta, 500g box, $3.00, Slim Noodles
Acai Berry Juice, 1 liter, $4.99, Jungle Vitality
Vanilla Cashew Butter, 450g jar, $5.75, Nutty Naturals
Eco-friendly Toilet Paper, 12 roll pack, $7.99, Green Soft
Non-alcoholic Whisky, 700ml bottle, $25, Spiritless Spirits
Vegan Pork Sausages, 500g pack, $6.99, Plant Farm
Sugar-Free Chocolate Chip Cookies, 200g box, $4.50, Sweetless Snacks
Organic Coconut Oil, 500ml jar, $8.99, Pure Islands
Healthy Ramen Noodles, 400g pack, $2.20, Shinshi Ramen
Matcha Green Tea Dust, 100g box, $15.99, Orient Essence
Whole Grain Microwavable Rice, 500g pouch, $2.50, Quick Grains
Thai Curry Sauce, 200g bottle, $3.99, Taste of Thailand
Gluten-Free Bread Loaf, 400g pack, $4.70, Wheatless Wonders
Bioactive Honey, 300g jar, $10.99, Nature's Elite
Vegetable Protein Powder, 1kg pouch, $15.00, V-Protein
Low-Carb Ice cream, 500ml tub, $5.50, Guiltless Pleasures
Organic Chia Seeds, 500g bag, $6.50, Chia City
Smoked Vegan Cheese, 200g pack, $7.00, Plant Delights
Dairy-Free Butter, 250g tub, $3.99, Butter Bliss
Frozen Quinoa Mix, 400g bag, $2.99, Quick Health
Plant-Based Burger Patties, 450g box, $7.50, Green Butcher
Biotin Shampoo, 250ml bottle, $8.99, Healthy Hair
Soy Wax Candles, pack of 6, $11.00, Gentle Glow
Fit Soda, 6 cans pack, $4.99, Fresh Fizz
Sugar-Free Gum, 32 pieces pack, $2.50, Fresh Breeze
Air-Fried Popcorn, 300g bag, $3.99, Puffy Snacks
Anti-Aging Cream, 50g box, $25.00, Ageless Beauty
Activated Charcoal Toothpaste, 75ml, $7.00, Dazzle Smile
DIY Cake Mix, 600g box, $3.00, Bake Joy
Pet-friendly Bug Spray, 200ml bottle, $9.99, Bug Busters
Organic tomato sauce, 500g jar, $4.30, Tomatoes Forever
Whole grain bread, plastic bag, $2.75, WholeFood Delights
Organic eggs, dozen pack, $4.50, Local Farm Fresh
Salmon filet, vacuum pack, $8.30, Seascape Delicacy
Vegan lasagna, 800g tray, $6.50, Veggie Magic
Deluxe Mixed Nuts, 300g tin, $9.89, Nutty Goodness
Apple juice, 2 liters, $1.99, Apple a Day
Creamy peanut butter, 1 kilogram jar, $4.50, Peanut Nutopia
Fresh coffee beans, 500g package, $5.99, Moon Roast
Natural honey, 450g jar, $8.30, Nature's Gold
Strawberry jam, 500g jar, $3.99, Berry Nice
Whole grain pasta, 750g bag, $2.20, Italian Pasta King
Granola breakfast cereals, 600g box, $4.75, Breakfast Sunrise
Red Wine, 750ml bottle, $6.75, Summer Vinery
Bourbon whiskey, 700ml bottle, $27.99, Old Kentucky Treasure
Vegetarian hotdogs, pack of 8, $3.99, Plant Power
Gluten-free pizza, 350g box, $5.50, Bella Gluten-Free
Dairy-free chocolate, 100g bar, $1.80, Vegan Choco Dreams
Sparkling water, 1 liter, $0.99, Fizz Harmony
Vegetable stock cubes, pack of 6, $1.55, Veggie Secret
Coconut milk, 1 liter, $3.20, Tropical Bliss
Canned chickpeas, 400g tin, $0.85, Ideal Ingredient
Soya sauce, 150ml bottle, $3.50, Eastern Traditions
Canned pineapple, 567g tin, $1.35, Tropical Delight
Balsamic vinegar, 250ml bottle, $4.25, Olive Leaf
Baked beans, 415g tin, $0.95, Wholesome Meal
Premium dog food, 2kg bag, $9.95, Happy Hound
Organic olive oil, 500ml bottle, $7.50, Italian Olive Breeze
Popcorn kernels, 500g bag, $1.60, Popcorn Fiesta
Entire wheat flour, 5kg sack, $6.50, Healthy Harvest
Apple cider vinegar, 500 ml, $3.50, Golden Apple Inc.
Chocolate cookies, 250g package, $5.25, Choco Delight Co.
Goat cheese, 200g package, $6.90, Alpine Farms
Organic honey, 450g jar, $7.95, Bee Happy Inc.
Ice cream sandwich, pack of 6, $4.25, Cool Bites Co.
Pasta sauce, 700g jar, $3.80, Mama's Kitchen
Quinoa grains, 500g bag, $4.20, Nature's Bounty
Green tea bags, pack of 50, $5.60, Zen Moments Inc.
Whole wheat bread, 500g loaf, $2.95, Baker's Gold
Almond milk, 1 liter, $2.70, Nutty Goodness Inc.
Peanut butter, 500g jar, $3.70, Nutty Spread Co.
Instant coffee, 200g jar, $4.45, Espresso Express
Coconut water, 1 liter, $2.80, Tropical Bliss
Gluten-free pasta, 500g pack, $3.60, Pure Eats Inc.
Granola cereal, 700g box, $6.20, Crunchy Mornings Co.
Organic apple juice, 1 liter, $4.00, Fruity Sip Inc.
Vegetable broth, 1 liter carton, $2.95, Veggie Delights
Garlic bread, pack of 2, $3.40, Artisan Bakeries
Caramel popcorn, 200g bag, $3.80, Poppy's Popcorn
Chia seeds, 300g package, $5.45, Healthy Living
Dark chocolate bar, 100g, $2.30, Cocoa Luxe Co.
Vanilla soy milk, 1 liter, $3.10, Soy-n-Joy Inc.
Dehydrated mango strips, 200g bag, $6.50, Tropical Snacks
Crispy wheat crackers, 400g box, $4.00, Snack & Happy
Spicy salsa sauce, 350g jar, $3.75, Fiesty Flavors Inc.
Black olive tapenade, 200g jar, $5.20, Mediterranean Olives Co.
Cherry-flavored yogurt, pack of 4, $3.95, Happy Spoon Inc.
Gluten free almond cookies, 300g box, $5.60, Gluten Free Galore
Artichoke hearts in brine, 400g jar, $4.30, Garden's Treasure
GINGER-INFUSED WATER, 1 liter, $3.20, Zing by Nature
Multigrain Bread, 680g Bag, $3.20, Baker's Select
Organic Apples, 1lb Bag, $2.99, Eden Orchard
Soy Milk, 1.89 liters, $3.99, Soya Goodness
Roasted Almonds, 250g Packet, $5.50, Nutty Planet
Quinoa Pasta, 500g Packet, $3.70, Vital Vittles
Tomato Ketchup, 450g Bottle, $2.95, Flavor Blast
Sweet corn Soup, 500ml Can, $1.79, Campbell Fresh
Parmesan Cheese, 8 oz Pack, $4.70, Dairy Delight
Sparkling Water, 1.25 liters, $1.95, Aquatic Waves
Tamari Sauce, 300ml Bottle, $3.49, Taste of Japan
Jalapeno Salsa, 450g Jar, $4.15, Fiesta Flavors
Wheat Cereal, 15.5 oz Box, $3.70, Morning Munchie
Fruit Bars, 10 Bars Box, $4.20, Nutriloops
Whole Grain Crackers, 250g Box, $3.15, Davis Mill
Organic Tea bags, 25 count Box, $5.99, Tealicious
Veggie Chips, 5 oz Bag, $2.95, Crispy Curls
Apple Juice, 2 liters, $2.99, Eden Orchard
Chia Seeds, 1lbs Bag, $5.50, Nature's Bounty
Maple Syrup, 500ml Bottle, $6.99, Sweet Forest
Coconut Oil, 500ml Jar, $7.99, Coco Heaven
Dark Chocolate Bar, 3.5 oz Packet, $2.75, ChocoTreat
Pure Honey, 500g Jar, $7.50, Bee's Gold
Green Olive Spread, 180g Jar, $4.99, Taste of Mediterranean
Marinated Artichokes, 1lb Jar, $5.99, Bella Italia
Walnut Butter, 10 oz Jar, $6.49, Nutty Delight
Pumpkin Spice Coffee, 12 oz Bag, $7.99, Brew Morning
Low-Fat Yogurt, 1lbs Tub, $2.69, Dairy Good
Gluten-Free Cookies, 5.3 oz Box, $4.15, GlutenNoMore
Frozen Veggie Pizza, 405g Box, $6.89, Trattoria Prima
Lemon Soda, 2 liters, $2.10, Fizzy Drops
Crunchy chocolate cookies, 250 grams, $3.79, Coco Delights
Organic apple cider, 1 liter, $4.55, Orchard Bliss
Roasted almond butter, 400 grams, $6.10, Almo Naturals
Gluten-free pasta, 500 grams, $2.75, Gluten-Free Goodies
Low-sodium canned tomatoes, 794 grams, $1.50, Health Plus
Premium black coffee beans, 500 grams, $7.75, Brew Master
Red velvet ice cream, 1 liter, $5.49, Cream Heaven
Green tea bags, pack of 25, $4.29, Zen Infusions
Honey oat cereal, 450 grams, $3.99, Sunny Mornings
Fresh corn tortillas, pack of 10, $1.15, Fiesta Time
Extra virgin olive oil, 500 ml, $6.40, Oliva Pura
Dried figs snack pack, 200 grams, $3.75, Nature's Treat
Toasted sesame seeds, 300 grams, $3.25, Oriental Accents
Garlic salt seasoning, 120 grams, $2.65, Kitchen Staples
Cinnamon roll mix, 400 grams, $3.79, Baker's Joy
Roasted seaweed sheets, pack of 25, $4.99, Sea Harvest
Organic protein bars, pack of 6, $7.50, Protein Power
Vegetable stock cubes, pack of 12, $2.00, Soup Supreme
Low-fat Greek yogurt, 500 grams, $3.90, Greek Delight
Brown sugar, 500 grams, $1.65, Sweet Harmony
Shredded coconut, 300 grams, $2.80, Tropical Essence
Quinoa and oat granola, 400 grams, $5.50, Healthy Start
Apple cider vinegar, 500 ml, $2.65, Pure Benefits
Ghee clarified butter, 500 grams, $8.25, Golden Glow
Frozen mixed berries, 500 grams, $4.00, Fruit Symphony
Whole wheat bread, 400 grams, $2.45, Wholesome Loaf
Ranch dressing, 350 ml, $3.15, Salad Sensations
Sparkling water, 1.5 liters, $1.40, Crystal Flow
Dark chocolate bar, 100 grams, $2.85, Choco Dream
Purple grape juice, 1 liter, $3.60, Vineyard Fresh
Oatmeal Cereal, 500 grams box, $3.50, Oats Supreme
Banana Yogurt, 200 grams, $1.25, Banana Bungalow
Freshly Baked Bread, 500 grams, $2.10, BakeMaster
Spaghetti Pasta, 1 kilogram, $1.99, Pasta Paradiso
Whole wheat Flour, 2 kilograms, $3.75, Whole Wonder
Extra Virgin Olive Oil, 1 liter bottle, $7.59, Oil Royale
Low-Fat Cheese, 250 grams, $3.99, Cheese Cloud
Almond Butter, 400 grams jar, $6.99, Almond Appeal
Raspberry Jam, 300 grams jar, $2.25, Berry Dream
Green Tea Bags, 20 pack, $3.50, Tea Travels
Natural Mineral Water, 1 liter, $0.95, Aqua Pure
Red Table Wine, 750 ml, $9.14, Vine Veneration
Organic Brown Eggs, dozen, $3.50, Orchid Organics
Choco-Chip Cookies, 450 grams, $4.99, Crunchy Comfort
Salted Pretzels, 200 grams bag, $1.70, Snack Sensation
Quinoa Crackers, 300 grams, $2.95, Earth Eats
Spicy Tomato Sauce, 500 grams, $3.39, Spice Surprise
Orchard Apple Juice, 1 liter, $4.00, Apple Ambrosia
Sunflower Honey, 250 grams, $3.79, Sunny Fields
Root Salad Mix, 500 grams, $2.50, Veggie Victory
Coconut Milk, 250 ml, $1.50, Coconut Crown
Vanilla Ice Cream, 1 liter, $4.50, Vanilla Valley
Dark Roast Coffee, 500 grams, $9.79, Morning Majesty
Premium Dog Food, 3 kilogram, $15.99, Royal Bark
Aluminium Foil, 20 meters, $1.70, Shiny Shield
Crisp Pineapple Slices, 800 grams can, $2.99, Tropical Thrill
Rosemary Plant Herbs, 20 grams, $2.29, Herb Highness
Unsalted Pistachios, 500 grams, $8.50, Nature's Nut
Full Cream Yogurt, 500 grams, $3.29, Spoonful of Silk
Low-Carb Protein Bars, box of 6, $9.99, Fitness Fuel
Wholegrain Cereal, boxed, $4.00, Grainy Goodness
Organic Pasta, bag, $3.10, Green Earth Produce
Unsalted Butter, wrapped, $1.99, Creamy Classics
Blueberry Jam, jar, $4.50, Berry Delight
Dark Chocolate Bar, wrapped, $2.30, ChocoLux
Garlic Bread, bag, $1.50, Baked Best
Coconut Water, 1 liter, $3.20, Tropical Drops
Raspberry Yoghurt, tub, $2.75, Smooth Fridge
Almond Milk, carton, $2.48, NutriNuts
Microwave Popcorn, bag, $1.80, Poppable Joy
Olive Oil, 500 ml, $6.99, Golden Harvest
Sesame Crackers, box, $3.30, Snackers Galore
Apple juice, 1 liter, $3.10, Juicy Burst
Vegetable Chips, bag, $2.50, Crunchy Nature
Gluten-free Pancake Mix, N/A, $4.80, Good Belly
BBQ Sauce, bottle, $2.99, Southern Flavor
Strawberry Cheesecake Ice Cream, tub, $5.00, Sundae Bliss
Pack of Hotdogs, sealed, $4.25, Grill Masters
Pack of Blue Cheese, plastic wrap, $3.95, Cheese Gallery
Shrimp, Frozen Bag, $6.45, Sea Treasure
Sriracha Mayonnaise, jar, $3.00, Spicy Moments
Passionfruit Soda, can, $1.10, Fizzed Out
Low-fat Cottage Cheese, tub, $2.75, HealthSure
Alcohol Wipes, pack, $1.70, CleanQuick
Disposable Razors, pack, $3.80, Smooth Shave
Multi-grain Bread, bag, $2.80, Baked Delite
Roasted Cashew Nuts, bag, $3.50, Nutty Crunch
Toothpaste, 100g tube, $2.25, Sparkling Smile
Aluminum Foil, roll, $2.00, Silver Sheen
Dish Soap, bottle, $2.99, CleanDish
Can't render this file because it contains an unexpected character in line 39 and column 17.

File diff suppressed because it is too large Load diff

160
src/retailtwin/models.py Normal file
View file

@ -0,0 +1,160 @@
from datetime import datetime
from decimal import Decimal
from typing import Dict
from sqlalchemy import JSON, BigInteger, ForeignKey, Numeric, String
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
__all__ = [
"Base",
"Item",
"Provider",
"Order",
"LocationType",
"Location",
"ItemBatch",
"Discount",
"Customer",
"Cart",
"ItemOnShelf",
]
class Base(AsyncAttrs, DeclarativeBase):
pass
class Provider(Base):
__tablename__ = "providers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
address: Mapped[str]
phone: Mapped[str]
vat: Mapped[str]
def __repr__(self) -> str:
return f"<Provider ({self.name}) at {hex(id(self))}>"
class Item(Base):
__tablename__ = "items"
sku: Mapped[int] = mapped_column(primary_key=True)
upc: Mapped[int] = mapped_column(BigInteger, nullable=False)
provider: Mapped[int] = mapped_column(ForeignKey("providers.id"))
name: Mapped[str] = mapped_column(nullable=False)
package: Mapped[str] = mapped_column(unique=False)
current: Mapped[bool] = mapped_column(
comment="True if the item can be still requested to a provider, "
"False if it has been discontinued"
)
# unit_weight: Mapped[int] = mapped_column(
# comment="Unit weight in grams not including additional packaging"
# ) To be added as exercise
volume_unpacked: Mapped[int] = mapped_column(
comment="Volume of the item unpacked in cubic decimeters"
)
volume_packed: Mapped[int] = mapped_column(
comment="Volume of each unit item when packaged in cubic decimeters"
)
def __repr__(self) -> str:
return f"<Item {self.name} at {hex(id(self))}>"
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[int] = mapped_column(ForeignKey("items.sku"))
placed: Mapped[datetime]
qty: Mapped[int] = mapped_column(nullable=False)
provider: Mapped[int] = mapped_column(ForeignKey("providers.id"))
class LocationType(Base):
__tablename__ = "locationtypes"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
retail: Mapped[bool]
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True)
loctype: Mapped[int] = mapped_column(ForeignKey("locationtypes.id"))
name: Mapped[str]
capacity: Mapped[int]
class ItemBatch(Base):
__tablename__ = "batches"
id: Mapped[int] = mapped_column(primary_key=True)
lot: Mapped[str] = mapped_column(String(128), nullable=False)
order: Mapped[str] = mapped_column(ForeignKey("orders.id"), nullable=True)
received: Mapped[datetime] = mapped_column(nullable=True)
unit_cost: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=False)
best_until: Mapped[datetime]
quantity: Mapped[int]
sku: Mapped[id] = mapped_column(ForeignKey("items.sku"))
item: Mapped[Item] = relationship()
class Discount(Base):
__tablename__ = "discounts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True, nullable=False)
definition: Mapped[Dict[str, Dict[str, int]]] = mapped_column(JSON, nullable=True)
def __repr__(self) -> str:
return f"Type: {self.name} at {id(self)}"
class TaskOwner(Base):
__tablename__ = "taskowners"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True, nullable=False)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
taskowner: Mapped[int] = mapped_column(ForeignKey("taskowners.id"), nullable=False)
location: Mapped[int] = mapped_column(ForeignKey("locations.id"), nullable=False)
sku: Mapped[int] = mapped_column(ForeignKey("items.sku"), nullable=False)
description: Mapped[str]
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
document: Mapped[str] = mapped_column(nullable=False)
info: Mapped[Dict[str, str]] = mapped_column(JSON, nullable=True)
class Cart(Base):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(primary_key=True)
checkout: Mapped[datetime] = mapped_column(nullable=True)
location: Mapped[int] = mapped_column(ForeignKey("locations.id"), nullable=False)
customer: Mapped[int] = mapped_column(ForeignKey("customers.id"), nullable=True)
class ItemOnCart(Base):
__tablename__ = "itemsoncart"
id: Mapped[int] = mapped_column(primary_key=True)
cart: Mapped[int] = mapped_column(ForeignKey("carts.id"), nullable=False)
upc: Mapped[int] = mapped_column(nullable=False) # Not FK to avoid sync issues
quantity: Mapped[int] = mapped_column(nullable=True)
unitprice: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=True)
discount: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=True)
class ItemOnShelf(Base):
__tablename__ = "itemsonshelf"
id: Mapped[int] = mapped_column(primary_key=True)
batch: Mapped[int] = mapped_column(ForeignKey("batches.id"), nullable=True)
discount: Mapped[int] = mapped_column(ForeignKey("discounts.id"), nullable=True)
quantity: Mapped[int] = mapped_column(nullable=False)
location: Mapped[int] = mapped_column(ForeignKey("locations.id"), nullable=False)
batches: Mapped[ItemBatch] = relationship()

View file

@ -0,0 +1,44 @@
from datetime import datetime
from random import choice
import polars as pl
from scipy.stats.distributions import lognorm
from sqlalchemy.orm import Session
from dengfun.retail.models import Cart
def cart(session: Session) -> Cart:
db_uri = str(session.get_bind().url.render_as_string(hide_password=False))
customer = pl.read_database("select random_customer()", db_uri, engine="adbc")[0, 0]
location = pl.read_database("select random_location()", db_uri, engine="adbc")[0, 0]
cart = Cart(customer=customer, location=location, checkout=datetime.now())
session.add(cart)
session.commit()
# Budget can be a small or a large expenditure
budget = choice([10 * lognorm.rvs(0.25), 100 * lognorm.rvs(0.25)])
items = pl.read_database("select * from items", db_uri, engine="adbc")
spent = 0
while spent > budget:
quantity = choice([1, 2, 3, 4])
item = items.sample(1, shuffle=True)
price = pl.read_database(
"select price_on_location({id}, {qty})", db_uri, engine="adbc"
)[0, 0]
session.execute(
f"add_item_to_cart({cart.id}, {item.sku}, {quantity}, {location})"
)
session.commit()
spent += price
if __name__ == "__main__":
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
with Session(
create_engine("postgresql://postgres:postgres@localhost/retail", echo=True)
) as session:
cart(session)

View file

View file

@ -0,0 +1,9 @@
create or replace procedure add_item_to_cart(cart integer, sku integer, quantity integer)
language sql
as
$$
INSERT INTO itemsoncart
("cart", "sku", "quantity")
values
($1, $2, $3);
$$;

View file

@ -0,0 +1,6 @@
create or replace procedure checkout_cart(cart integer)
language sql
as
$$
$$;

View file

@ -0,0 +1,15 @@
create or replace function fetch_from_shelf(sku integer, loc integer)
returns integer
language sql as
$$
select
min(itemsonshelf.id) as id
from
"batches"
join
itemsonshelf
on
"batches".id = itemsonshelf.batch
where
itemsonshelf.location = $2 and "batches".sku = $1;
$$;

View file

@ -0,0 +1,14 @@
create or replace function price_on_location(sku integer, loc integer)
returns Numeric(9,2)
language sql as
$$
select max(price) as unit_price -- Maximum price for all batches.
from -- Only discounts can reduce
"batches" -- the price of an item on the cart
join
itemsonshelf
on
"batches".id = itemsonshelf.batch
where
itemsonshelf.location = $2 and "batches".sku = $1
$$;

View file

@ -0,0 +1,27 @@
create or replace function random_available_on_location(loc integer)
returns integer
language sql as
$$
with valid as
(
select
batch
from
itemsonshelf
where
itemsonshelf.location = $1 and itemsonshelf.quantity > 0
order by
random()
limit 1
)
select
"batches".sku as sku
from
valid
join
"batches"
on
"batches".id = valid.batch
limit
1;
$$;

View file

@ -0,0 +1,38 @@
create or replace function random_cart_on_location(loc integer, lim integer)
returns table(sku integer, unit_price numeric(9,2), item_id integer, discount integer)
language sql as
$$
with
items
as
(
select
id,
random_available_on_location($1) as sku
from
generate_series(0, $2) id
),
stock as
(
select
sku,
price_on_location(sku, $1) as unit_price,
fetch_from_shelf(sku, $1) as item_id
from
items
limit
$2
)
select
sku,
unit_price,
item_id,
discount
from
stock
join
itemsonshelf
on
stock.item_id = itemsonshelf.id
;
$$;

View file

@ -0,0 +1,22 @@
CREATE OR REPLACE FUNCTION retire_batch_from_shelves(batch_ integer, location_ integer)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
taskowner integer := id from taskowners where "name" = 'stocker';
sku integer := sku from itemsonshelf s join "batches" b on s.batch = b.id where b.id = batch_;
BEGIN
UPDATE
itemsonshelf
SET
quantity = 0
WHERE
batch = batch_
AND "location" = location_;
INSERT INTO
tasks
("location", sku, taskowner, "description")
VALUES
(location_, sku, taskowner, format('Retire batch %s', batch_));
END;
$$;

View file

@ -0,0 +1,7 @@
create or replace function random_customer()
returns integer
as $$
SELECT id
FROM customers TABLESAMPLE SYSTEM(1)
LIMIT 1;
$$ language sql;

View file

@ -0,0 +1,8 @@
create or replace function random_location()
returns integer
as $$
SELECT id
FROM locations
ORDER BY random()
LIMIT 1;
$$ language sql;

View file

@ -0,0 +1,16 @@
create or replace function stock_on_location(sku integer, loc integer)
returns integer
language sql as
$$
select
-- Sum quantities from all the batches available in location
sum(itemsonshelf.quantity)
from
itemsonshelf
join
"batches"
on
"batches".id = itemsonshelf.batch
where
"batches".sku = $1 and itemsonshelf.location = $2
$$

View file

@ -0,0 +1,37 @@
from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
import dengfun
PACKAGE_ROOT = Path(dengfun.__file__).parent / "retail"
def funcandproc(db_uri: str):
"""Write functions and procedures into the given Postgresql database
Args:
db_uri (str): _description_
"""
engine = create_engine(db_uri, echo=True)
with Session(engine) as session:
# Functions can be redefined in postgresql
for predicate in (PACKAGE_ROOT / "sql").glob("*.sql"):
print(f"Syncing {predicate}")
with predicate.open() as sql:
session.execute(text(sql.read()))
session.commit()
# Views have to be recreated if schema changes
for predicate in (PACKAGE_ROOT / "sql" / "views").glob("*.sql"):
print(f"Syncing view in {predicate}")
# Get view name from the file name
view_name = predicate.stem.removesuffix(".sql")
with predicate.open() as sql:
# First remove the view
session.execute(text(f"drop view {view_name}"))
session.commit()
# And sync it
session.execute(text(sql.read()))
session.commit()

View file

@ -0,0 +1,3 @@
This folder hosts view definitions.
The name of the file must correspond to the name of the view, since it's used to create or update the definition

View file

@ -0,0 +1,18 @@
create or replace view inventory as (
select it.upc as upc,
b.id as batch,
it.name as name,
it.package as package,
to_char(b.price, '999.99') as unitprice,
-- It's fine to encode numerics as strings
date(b.received at time zone 'UTC') as received,
date(b.best_until at time zone 'UTC') as best_until,
i.quantity as quantity,
i."location" as "location",
d.definition::text as discount_definition
from itemsonshelf i
join batches b on i.batch = b.id
join items it on it.sku = b.sku
left join discounts d on i.discount = d.id
where it.current = true
)

View file

@ -0,0 +1,13 @@
create or replace
view stores as (
select
l.id as id,
l."name" as "name"
from
locations l
join locationtypes lt
on
l.loctype = lt.id
where
lt."name" = 'store'
)

View file

@ -0,0 +1,16 @@
create or replace view warehouse_stock as (
select it.upc as upc,
l.name as warehouse,
it.name as name,
it.package as package,
date(b.received) as received,
date(b.best_until) as best_until,
i.quantity as quantity
from itemsonshelf i
join batches b on i.batch = b.id
join items it on it.sku = b.sku
join locations l on l.id = i."location"
join locationtypes l2 on l.loctype = l2.id
where l2.name = 'warehouse'
and it.current = true
)

5
src/retailtwin/utils.py Normal file
View file

@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
def db_uri_from_session(session: Session) -> str:
return str(session.get_bind().url.render_as_string(hide_password=False))

36
src/retailtwin/views.py Normal file
View file

@ -0,0 +1,36 @@
"""
This module contains a set of functions that provide SQLAlchemy table interfaces
to existing views in the database. Make sure these views have been synced after
table creation.
"""
from dengfun.retail.models import Base
from functools import lru_cache
from sqlalchemy import Table, Column, Date, Integer, String, JSON
# Decorate with a cache to prevent sqlalchemy to create the table definition
# after multiple function calls. This is not an issue in production because
# a whole new isntance will be created by the worker generated by gunicorn,
# but this prevents errors during development. This is a way to create a
# pythonic singleton.
@lru_cache
def get_inventory_view() -> Table:
return Table(
"inventory",
Base.metadata,
Column("upc", Integer),
Column("batch", Integer),
Column("name", String),
Column("package", String),
Column("received", Date),
Column("best_until", Date),
Column("quantity", Integer),
Column("location", Integer),
Column("discount_definition", JSON),
)
@lru_cache
def get_stores_view() -> Table:
return Table("stores", Base.metadata, Column("id", Integer), Column("name", String))