Copy all source code from the fundamentals course
This commit is contained in:
parent
e8e7a3ee6f
commit
63cf21c7e3
10
.pre-commit-config.yaml
Normal file
10
.pre-commit-config.yaml
Normal 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
|
|
@ -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"
|
0
src/retailtwin/__init__.py
Normal file
0
src/retailtwin/__init__.py
Normal file
30
src/retailtwin/api/README.md
Normal file
30
src/retailtwin/api/README.md
Normal 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
|
0
src/retailtwin/api/__init__.py
Normal file
0
src/retailtwin/api/__init__.py
Normal file
0
src/retailtwin/api/composition.py
Normal file
0
src/retailtwin/api/composition.py
Normal file
138
src/retailtwin/api/db.py
Normal file
138
src/retailtwin/api/db.py
Normal 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)}</>"
|
||||
)
|
43
src/retailtwin/api/main.py
Normal file
43
src/retailtwin/api/main.py
Normal 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)
|
7
src/retailtwin/api/settings.py
Normal file
7
src/retailtwin/api/settings.py
Normal 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")
|
107
src/retailtwin/api/static/detail.html
Normal file
107
src/retailtwin/api/static/detail.html
Normal 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>
|
117
src/retailtwin/api/static/index.html
Normal file
117
src/retailtwin/api/static/index.html
Normal 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>
|
107
src/retailtwin/api/static/search.html
Normal file
107
src/retailtwin/api/static/search.html
Normal 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
269
src/retailtwin/bootstrap.py
Normal 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()
|
0
src/retailtwin/cli/__init__.py
Normal file
0
src/retailtwin/cli/__init__.py
Normal file
68
src/retailtwin/cli/data.py
Normal file
68
src/retailtwin/cli/data.py
Normal 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
109
src/retailtwin/cli/db.py
Normal 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
|
0
src/retailtwin/cli/pos/__init__.py
Normal file
0
src/retailtwin/cli/pos/__init__.py
Normal file
217
src/retailtwin/cli/pos/main.py
Normal file
217
src/retailtwin/cli/pos/main.py
Normal 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)
|
61
src/retailtwin/cli/pos/models.py
Normal file
61
src/retailtwin/cli/pos/models.py
Normal 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
166
src/retailtwin/cli/stock.py
Normal 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)
|
0
src/retailtwin/cli/tasks/__init__.py
Normal file
0
src/retailtwin/cli/tasks/__init__.py
Normal file
0
src/retailtwin/cli/tasks/main.py
Normal file
0
src/retailtwin/cli/tasks/main.py
Normal file
0
src/retailtwin/cli/tasks/models.py
Normal file
0
src/retailtwin/cli/tasks/models.py
Normal file
11
src/retailtwin/data/discounts.csv
Normal file
11
src/retailtwin/data/discounts.csv
Normal 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.
|
451
src/retailtwin/data/products.csv
Normal file
451
src/retailtwin/data/products.csv
Normal 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.
|
1001
src/retailtwin/data/random_people.csv
Normal file
1001
src/retailtwin/data/random_people.csv
Normal file
File diff suppressed because it is too large
Load diff
160
src/retailtwin/models.py
Normal file
160
src/retailtwin/models.py
Normal 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()
|
44
src/retailtwin/simulation.py
Normal file
44
src/retailtwin/simulation.py
Normal 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)
|
0
src/retailtwin/sql/__init__.py
Normal file
0
src/retailtwin/sql/__init__.py
Normal file
9
src/retailtwin/sql/add_item_to_cart.sql
Normal file
9
src/retailtwin/sql/add_item_to_cart.sql
Normal 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);
|
||||
$$;
|
6
src/retailtwin/sql/checkout_cart.sql
Normal file
6
src/retailtwin/sql/checkout_cart.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
create or replace procedure checkout_cart(cart integer)
|
||||
language sql
|
||||
as
|
||||
$$
|
||||
|
||||
$$;
|
15
src/retailtwin/sql/fetch_from_shelf.sql
Normal file
15
src/retailtwin/sql/fetch_from_shelf.sql
Normal 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;
|
||||
$$;
|
14
src/retailtwin/sql/price_on_location.sql
Normal file
14
src/retailtwin/sql/price_on_location.sql
Normal 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
|
||||
$$;
|
27
src/retailtwin/sql/random_available_on_location.sql
Normal file
27
src/retailtwin/sql/random_available_on_location.sql
Normal 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;
|
||||
$$;
|
38
src/retailtwin/sql/random_cart_on_location.sql
Normal file
38
src/retailtwin/sql/random_cart_on_location.sql
Normal 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
|
||||
;
|
||||
$$;
|
22
src/retailtwin/sql/retire_batch_from_shelves.sql
Normal file
22
src/retailtwin/sql/retire_batch_from_shelves.sql
Normal 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;
|
||||
$$;
|
7
src/retailtwin/sql/select_random_customer.sql
Normal file
7
src/retailtwin/sql/select_random_customer.sql
Normal 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;
|
8
src/retailtwin/sql/select_random_location.sql
Normal file
8
src/retailtwin/sql/select_random_location.sql
Normal 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;
|
16
src/retailtwin/sql/stock_on_location.sql
Normal file
16
src/retailtwin/sql/stock_on_location.sql
Normal 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
|
||||
$$
|
37
src/retailtwin/sql/sync.py
Normal file
37
src/retailtwin/sql/sync.py
Normal 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()
|
3
src/retailtwin/sql/views/README.md
Normal file
3
src/retailtwin/sql/views/README.md
Normal 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
|
18
src/retailtwin/sql/views/inventory.sql
Normal file
18
src/retailtwin/sql/views/inventory.sql
Normal 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
|
||||
)
|
13
src/retailtwin/sql/views/stores.sql
Normal file
13
src/retailtwin/sql/views/stores.sql
Normal 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'
|
||||
)
|
16
src/retailtwin/sql/views/warehouse_stock.sql
Normal file
16
src/retailtwin/sql/views/warehouse_stock.sql
Normal 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
5
src/retailtwin/utils.py
Normal 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
36
src/retailtwin/views.py
Normal 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))
|
Loading…
Reference in a new issue