Adding all files

This commit is contained in:
Guillem Borrell 2024-05-08 21:55:08 +00:00
commit fbd8d11fe5
52 changed files with 3838 additions and 0 deletions

62
.github/workflows/docs.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: Build documentation
on:
push:
branches:
- "main"
env:
PYTHON_VERSION: 3.9
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build-docs:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: checkout
uses: actions/checkout@v3
- id: setup-python
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYTHON_VERSION }}
- id: install-deps-and-build
name: Install dependencies and test
run: |
pip install -e .
pip install -e .[doc]
mkdocs build --site-dir build
- name: Setup Pages
id: configure-pages
uses: actions/configure-pages@v3
- name: Upload artifact
id: upload-artifact
uses: actions/upload-pages-artifact@v1
with:
# Upload entire repository
path: './build'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2
- name: Upload PDF
id: upload-pdf
uses: actions/upload-artifact@v3
with:
name: document.pdf
path: site/pdf/document.pdf

163
.gitignore vendored Normal file
View file

@ -0,0 +1,163 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
*.db

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

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

8
Caddyfile Normal file
View file

@ -0,0 +1,8 @@
:80 {
handle_path /api/v1/* {
reverse_proxy localhost:8000
}
file_server {
root src/retailtwin/api/static
}
}

43
README.md Normal file
View file

@ -0,0 +1,43 @@
# retailtwin
Implementation of a digital twin of a retail corporation that operates a chain of grocery stores will be described. The implementation is of course limited but comprehensive enough to get some key insights about why corporate IT looks the way it looks. If anyone intends to create antifragile data systems it's important to study how fragile systems come to be on the first place.
## Bootstrap the database
First create a postgresql database.
```bash
createdb -h localhost -U postgresuser retail
```
In this case we decided to call the database `retail` and we created it in the same computer we will be running the digital twin. Then sync the data models in the freshly created database with:
```bash
retailtwin init postgresql://postgresuser:password@localhost/retail
```
Then we can populate the database with dummy data with the `bootstrap` subcommand:
```bash
retailtwin bootstrap postgresql://postgresuser:password@localhost/retail
```
Finally we can create all the necessary functions, procedures, and triggers with
```bash
retailtwin sync postgresql://postgresuser:password@localhost/retail
```
## Terminals
There are currently three available terminals to operate with the digital twin:
### Stocking terminal
```bash
stock [DB_URI] [Store location]
```
```bash
stock postgresql://postgresuser:password@localhost/retail 1
```

3
docs/index.md Normal file
View file

@ -0,0 +1,3 @@
# Transactional databases and core applications
This document is an essay that supports a course

57
mkdocs.yml Normal file
View file

@ -0,0 +1,57 @@
site_name: Transactional databases and core applications
site_author: Guillem Borrell PhD
site_url: https://git.guillemborrell.es/guillem/PyConES24
copyright: © Guillem Borrell Nogueras
repo_url: https://git.guillemborrell.es/guillem/PyConES24
edit_uri: edit/main/docs/
nav:
- "Introduction": "index.md"
theme:
name: "material"
palette:
# Palette toggle for light mode
- media: "(prefers-color-scheme)"
primary: teal
toggle:
icon: material/brightness-auto
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: teal
toggle:
icon: material/brightness-7
name: Switch to dark mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: teal
toggle:
icon: material/brightness-4
name: Switch to light mode
features:
- content.action.edit
- content.code.copy
icon:
edit: material/pencil
view: material/eye
plugins:
- search
markdown_extensions:
- md_in_html
- admonition
- tables
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
- markdown_include.include:
base_path: docs

55
pyproject.toml Normal file
View file

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

View file

@ -0,0 +1,8 @@
"""
`retailtwin` is a simple digital twin of a grocery store chain.
Its goal is to showcase the key aspects in automation of enterprise
transactional systems.
"""
__version__ = '0.0.1'

View file

@ -0,0 +1,30 @@
# Super simple API-based stock terminal
Run the backend with
```bash
DB_URI=postgresql+asyncpg://user:password@server/dbname uvicorn retailtwin.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/retailtwin/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 with Chocolatey on Windows, and with Homebrew on mac.

View file

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

View file

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

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

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

View file

View file

@ -0,0 +1,246 @@
"""
Terminal that mimics what a Point of Sale may operate like.
"""
import typer
import polars as pl
from retailtwin.cli.db import query_local_batches
from retailtwin.cli.pos.models import Base, Sync, Direction, Cart, Item, ItemOnCart
from retailtwin.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.
* **n**: Opens a new cart
* **l**: Lists carts
* **r**: Refreshes with the central database
* **q**: Quits the terminal
"""
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("unitprice"),
pl.col("discount_definition"),
pl.col("discount_name"),