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"),
|
||||
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("unitprice"), pl.col("discount")])
|
||||
.with_columns(pl.col("unitprice").str.strip().cast(pl.Float32) * 100)
|
||||
.with_columns(pl.col("unitprice").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 checkout(session: Session, cart_id: int):
|
||||
return None
|
||||
|
||||
|
||||
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):
|
||||
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, "", ""
|
||||
|
||||
elif command == "l": # List carts
|
||||
carts = pl.read_database(
|
||||
"select * from carts", db_uri_from_session(session), engine="adbc"
|
||||
)
|
||||
print(carts)
|
||||
return "", "", ""
|
||||
|
||||
# Handle pushing items to the cart
|
||||
elif cart_id is not None and item_id:
|
||||
session.add(ItemOnCart(cart=cart_id, upc=item_id, quantity=command))
|
||||
print(f"{item_id} -- {command}")
|
||||
return cart_id, None, ""
|
||||
|
||||
# Handle adding a new item
|
||||
elif cart_id is not None:
|
||||
# Check if the upc is in the database
|
||||
if command == "e":
|
||||
error = checkout(session=session, cart_id=cart_id)
|
||||
return None, None, error
|
||||
else:
|
||||
if session.scalar(select(Item).filter(Item.upc == int(command))):
|
||||
item_id = command
|
||||
print(f"selecting {item_id}")
|
||||
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, e for chekout]> "
|
||||
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(cart_id, item_id, error)
|
||||
|
||||
print("GoodBye!")
|
||||
|
||||
|
||||
app = typer.run(main)
|
64
src/retailtwin/cli/pos/models.py
Normal file
64
src/retailtwin/cli/pos/models.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
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_name: Mapped[str]
|
||||
discount_definition: 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 retailtwin.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("unitprice"),
|
||||
pl.col("best_until"),
|
||||
pl.col("quantity"),
|
||||
]
|
||||
).filter(pl.col("upc") == upc)
|
||||
console.print(
|
||||
df_to_table(
|
||||
df.with_columns(pl.col("unitprice").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 retailtwin.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, upc integer, quantity integer)
|
||||
language sql
|
||||
as
|
||||
$$
|
||||
INSERT INTO itemsoncart
|
||||
("cart", "upc", "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 retailtwin
|
||||
|
||||
PACKAGE_ROOT = Path(retailtwin.__file__).parent
|
||||
|
||||
|
||||
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 if exists {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
|
19
src/retailtwin/sql/views/inventory.sql
Normal file
19
src/retailtwin/sql/views/inventory.sql
Normal file
|
@ -0,0 +1,19 @@
|
|||
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.name as discount_name,
|
||||
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 retailtwin.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