Adding all files
This commit is contained in:
commit
fbd8d11fe5
62
.github/workflows/docs.yml
vendored
Normal file
62
.github/workflows/docs.yml
vendored
Normal 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
163
.gitignore
vendored
Normal 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
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
|
8
Caddyfile
Normal file
8
Caddyfile
Normal 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
43
README.md
Normal 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
3
docs/index.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Transactional databases and core applications
|
||||
|
||||
This document is an essay that supports a course
|
57
mkdocs.yml
Normal file
57
mkdocs.yml
Normal 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
55
pyproject.toml
Normal 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"
|
8
src/retailtwin/__init__.py
Normal file
8
src/retailtwin/__init__.py
Normal 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'
|
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
|
||||
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.
|
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 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)}</>"
|
||||
)
|
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 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)
|
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 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()
|
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 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
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
246
src/retailtwin/cli/pos/main.py
Normal file
246
src/retailtwin/cli/pos/main.py
Normal 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"),
|
||||