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"}]
|
authors = [{name = "Guillem Borrell", email = "borrell.guillem@bcg.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
dynamic = ["version", "description"]
|
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]
|
[project.urls]
|
||||||
Home = "https://github.com/Borrell-Guillem_bcgprod/retailtwin"
|
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