Copy all source code from the fundamentals course
This commit is contained in:
		
							parent
							
								
									e8e7a3ee6f
								
							
						
					
					
						commit
						63cf21c7e3
					
				
							
								
								
									
										10
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
repos:
 | 
			
		||||
  - repo: https://github.com/psf/black
 | 
			
		||||
    rev: 22.10.0
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: black
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    # Ruff version.
 | 
			
		||||
    rev: v0.0.278
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,44 @@ name = "retailtwin"
 | 
			
		|||
authors = [{name = "Guillem Borrell", email = "borrell.guillem@bcg.com"}]
 | 
			
		||||
readme = "README.md"
 | 
			
		||||
dynamic = ["version", "description"]
 | 
			
		||||
dependencies = [
 | 
			
		||||
  "duckdb",
 | 
			
		||||
  "pydantic",
 | 
			
		||||
  "typer",
 | 
			
		||||
  "pyyaml",
 | 
			
		||||
  "pydantic-settings",
 | 
			
		||||
  "polars",
 | 
			
		||||
  "pyarrow",
 | 
			
		||||
  "sqlalchemy[asyncio] > 2.0.13",
 | 
			
		||||
  "adbc-driver-postgresql",
 | 
			
		||||
  "adbc-driver-sqlite",
 | 
			
		||||
  "prompt_toolkit",
 | 
			
		||||
  "asyncpg",
 | 
			
		||||
  "pydantic-settings"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
[project.urls]
 | 
			
		||||
Home = "https://github.com/Borrell-Guillem_bcgprod/retailtwin"
 | 
			
		||||
 | 
			
		||||
[project.optional-dependencies]
 | 
			
		||||
doc = [
 | 
			
		||||
    "mkdocs-material",
 | 
			
		||||
    "pymdown-extensions",
 | 
			
		||||
    "mkdocstrings[python-legacy]>=0.18",
 | 
			
		||||
    "mkdocs-gen-files",
 | 
			
		||||
    "markdown-include",
 | 
			
		||||
    "mkdocs-with-pdf",
 | 
			
		||||
    "mkdocs-literate-nav"]
 | 
			
		||||
 | 
			
		||||
dev = [
 | 
			
		||||
    "black",
 | 
			
		||||
    "ruff",
 | 
			
		||||
    "pre-commit"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[project.scripts]
 | 
			
		||||
retailtwin = "retailtwin.cli.data:app"
 | 
			
		||||
stock = "retailtwin.cli.stock:app"
 | 
			
		||||
pos = "retailtwin.cli.pos.main:app"
 | 
			
		||||
tasks = "retailtwin.cli.tasks.main:app"
 | 
			
		||||
							
								
								
									
										0
									
								
								src/retailtwin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										30
									
								
								src/retailtwin/api/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/retailtwin/api/README.md
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
# Super simple API-based stock terminal
 | 
			
		||||
 | 
			
		||||
Run the backend with
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
uvicorn dengfun.retail.api.main:app
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Use Caddy to reverse proxy and the following Caddyfile. Paths are static so you have to run caddy from the root of the package
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
:80 {
 | 
			
		||||
    handle_path /api/v1/* {
 | 
			
		||||
        reverse_proxy localhost:8000
 | 
			
		||||
    }
 | 
			
		||||
    file_server {
 | 
			
		||||
        root src/dengfun/retail/api/static
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
If you execute
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
caddy run -c Caddyfile
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
You should be able to browse the application at http://127.0.0.1, and reach the api docs at http://127.0.0.1/api/v1/docs
 | 
			
		||||
 | 
			
		||||
Caddy can be installed in Windows with Chocolatey
 | 
			
		||||
							
								
								
									
										0
									
								
								src/retailtwin/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								src/retailtwin/api/composition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/api/composition.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										138
									
								
								src/retailtwin/api/db.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/retailtwin/api/db.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,138 @@
 | 
			
		|||
import os
 | 
			
		||||
from operator import itemgetter
 | 
			
		||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
 | 
			
		||||
from sqlalchemy import select, desc
 | 
			
		||||
from dengfun.retail.views import get_inventory_view, get_stores_view
 | 
			
		||||
from dengfun.retail.api.settings import Settings
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def get_session():
 | 
			
		||||
    try:
 | 
			
		||||
        engine = create_async_engine(Settings().db_uri.unicode_string())
 | 
			
		||||
        yield async_sessionmaker(engine, expire_on_commit=False)
 | 
			
		||||
    except:  # noqa
 | 
			
		||||
        await engine.dispose()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def detail_button(batch: int):
 | 
			
		||||
    return "".join(
 | 
			
		||||
        [
 | 
			
		||||
            f'<a href="detail.html?batch={batch}">'
 | 
			
		||||
            f'<button class="btn btn-secondary" type="button">🔍</button>'
 | 
			
		||||
            f"</a>"
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def get_inventory(
 | 
			
		||||
    store: int,
 | 
			
		||||
    page: int,
 | 
			
		||||
    pagesize: int,
 | 
			
		||||
    sortby: str,
 | 
			
		||||
    root_path: str,
 | 
			
		||||
    async_session: async_sessionmaker[AsyncSession] = None,
 | 
			
		||||
) -> str:
 | 
			
		||||
    """Get inventory for a given location
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        store (int): Location id
 | 
			
		||||
        page (int): Page number for pagination
 | 
			
		||||
        pagesize (int): Page size for pagination
 | 
			
		||||
        sortby (str): Column name used for sorting
 | 
			
		||||
        root_path (str): Root path of the API
 | 
			
		||||
        async_session (async_sessionmaker[AsyncSession], optional):
 | 
			
		||||
            SQLAlchemy async session. Defaults to None.
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        str: HTML table rows with the content
 | 
			
		||||
    """
 | 
			
		||||
    output = list()
 | 
			
		||||
    inventory = get_inventory_view()
 | 
			
		||||
 | 
			
		||||
    # Selects the column from which to sort by, and if asc or desc
 | 
			
		||||
    col_reversed = sortby.startswith("-")
 | 
			
		||||
    col_selector = itemgetter(sortby.strip("-"))
 | 
			
		||||
 | 
			
		||||
    async with async_session() as session:
 | 
			
		||||
        stmt = (
 | 
			
		||||
            select(
 | 
			
		||||
                inventory.c[
 | 
			
		||||
                    "upc",
 | 
			
		||||
                    "batch",
 | 
			
		||||
                    "name",
 | 
			
		||||
                    "package",
 | 
			
		||||
                    "received",
 | 
			
		||||
                    "best_until",
 | 
			
		||||
                    "quantity",
 | 
			
		||||
                ]
 | 
			
		||||
            )
 | 
			
		||||
            .where(inventory.c.location == store)
 | 
			
		||||
            .limit(pagesize)
 | 
			
		||||
            .offset(pagesize * page)
 | 
			
		||||
            .order_by(
 | 
			
		||||
                desc(col_selector(inventory.c))
 | 
			
		||||
                if col_reversed
 | 
			
		||||
                else col_selector(inventory.c)
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        result = await session.execute(stmt)
 | 
			
		||||
 | 
			
		||||
        query_params = " ".join(
 | 
			
		||||
            [
 | 
			
		||||
                f'js:{{page: "{page + 1}",'
 | 
			
		||||
                f'pagesize: "{pagesize}",'
 | 
			
		||||
                f'sortby: "{sortby}",'
 | 
			
		||||
                'store: document.getElementById("store").value}'
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Turn the results in a sequence of HTML table rows
 | 
			
		||||
        for i, record in enumerate(result.fetchall()):
 | 
			
		||||
            line = ""
 | 
			
		||||
            cells = (
 | 
			
		||||
                " ".join(f"<td>{it}</td>" for it in record)
 | 
			
		||||
                + f" <td>{detail_button(record[1])}</td>"
 | 
			
		||||
            )
 | 
			
		||||
            if i == (pagesize - 1):  # Handle last row for infinite scrolling
 | 
			
		||||
                line = " ".join(
 | 
			
		||||
                    [
 | 
			
		||||
                        f'<tr hx-get="{root_path}/stock"',
 | 
			
		||||
                        f"hx-vals='{query_params}'",
 | 
			
		||||
                        f'hx-trigger="revealed" hx-swap="afterend">{cells}</tr>',
 | 
			
		||||
                    ]
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
                line = f"<tr>{cells}</tr>"
 | 
			
		||||
 | 
			
		||||
            output.append(line)
 | 
			
		||||
 | 
			
		||||
    return os.linesep.join(output)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def get_stores(
 | 
			
		||||
    root_path: str,
 | 
			
		||||
    async_session: async_sessionmaker[AsyncSession] = None,
 | 
			
		||||
):
 | 
			
		||||
    # Build store options
 | 
			
		||||
    store_rows = ['<option selected value="1">Choose store</option>']
 | 
			
		||||
    stores = get_stores_view()
 | 
			
		||||
    async with async_session() as session:
 | 
			
		||||
        stmt = select(stores)
 | 
			
		||||
        result = await session.execute(stmt)
 | 
			
		||||
        for record in result.fetchall():
 | 
			
		||||
            store_rows.append(f'<option value="{record[0]}">{record[1]}</option>')
 | 
			
		||||
 | 
			
		||||
    # Wrap with the select tag
 | 
			
		||||
    query_params = (
 | 
			
		||||
        'js:{page: "0", '
 | 
			
		||||
        'pagesize: "20", '
 | 
			
		||||
        'sortby: "upc", '
 | 
			
		||||
        'store: document.getElementById("store").value}'
 | 
			
		||||
    )
 | 
			
		||||
    return (
 | 
			
		||||
        f'<select class="form-select" id="store"'
 | 
			
		||||
        f'hx-get="{root_path}/stock" '
 | 
			
		||||
        f"hx-vals='{query_params}' "
 | 
			
		||||
        f'hx-target="#results" '
 | 
			
		||||
        f">{os.linesep.join(store_rows)}</>"
 | 
			
		||||
    )
 | 
			
		||||
							
								
								
									
										43
									
								
								src/retailtwin/api/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/retailtwin/api/main.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
from fastapi import FastAPI, Request, Depends
 | 
			
		||||
from fastapi.responses import HTMLResponse
 | 
			
		||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
 | 
			
		||||
from dengfun.retail.api.db import get_inventory, get_session, get_stores
 | 
			
		||||
 | 
			
		||||
app = FastAPI(root_path="/api/v1")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.get("/test")
 | 
			
		||||
async def test(request: Request):
 | 
			
		||||
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.get("/stock", response_class=HTMLResponse)
 | 
			
		||||
async def stock(
 | 
			
		||||
    request: Request,
 | 
			
		||||
    store: int = 1,
 | 
			
		||||
    page: int = 0,
 | 
			
		||||
    pagesize: int = 20,
 | 
			
		||||
    sortby: str = "upc",
 | 
			
		||||
    async_session: async_sessionmaker[AsyncSession] = Depends(get_session),
 | 
			
		||||
) -> str:
 | 
			
		||||
    body = await get_inventory(
 | 
			
		||||
        store=store,
 | 
			
		||||
        page=page,
 | 
			
		||||
        pagesize=pagesize,
 | 
			
		||||
        sortby=sortby,
 | 
			
		||||
        root_path=request.scope.get("root_path"),
 | 
			
		||||
        async_session=async_session,
 | 
			
		||||
    )
 | 
			
		||||
    return HTMLResponse(body, status_code=200)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.get("/stores", response_class=HTMLResponse)
 | 
			
		||||
async def stores(
 | 
			
		||||
    request: Request,
 | 
			
		||||
    async_session: async_sessionmaker[AsyncSession] = Depends(get_session),
 | 
			
		||||
):
 | 
			
		||||
    body = await get_stores(
 | 
			
		||||
        root_path=request.scope.get("root_path"),
 | 
			
		||||
        async_session=async_session,
 | 
			
		||||
    )
 | 
			
		||||
    return HTMLResponse(body, status_code=200)
 | 
			
		||||
							
								
								
									
										7
									
								
								src/retailtwin/api/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/retailtwin/api/settings.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
from pydantic import PostgresDsn
 | 
			
		||||
from pydantic_settings import BaseSettings, SettingsConfigDict
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Settings(BaseSettings):
 | 
			
		||||
    db_uri: PostgresDsn
 | 
			
		||||
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
 | 
			
		||||
							
								
								
									
										107
									
								
								src/retailtwin/api/static/detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/retailtwin/api/static/detail.html
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,107 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
 | 
			
		||||
    <!-- Latest compiled and minified CSS -->
 | 
			
		||||
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
 | 
			
		||||
        integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
 | 
			
		||||
    <title>Retail digital twin web terminal</title>
 | 
			
		||||
</head>
 | 
			
		||||
 | 
			
		||||
<html>
 | 
			
		||||
 | 
			
		||||
<body>
 | 
			
		||||
    <div class="container-fluid">
 | 
			
		||||
        <div hx-get="/api/v1/stores" hx-trigger="load">
 | 
			
		||||
        </div>
 | 
			
		||||
        <table class="table">
 | 
			
		||||
            <thead>
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <th>upc
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"upc", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-upc", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>batch</th>
 | 
			
		||||
                    <th>name
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"name", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-name", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>package</th>
 | 
			
		||||
                    <th>received
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"received", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-received", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>best_until
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"best_until", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-best_until", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>quantity
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"quantity", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-quantity", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>detail</th>
 | 
			
		||||
                </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
            <tbody id="results" hx-get="/api/v1/stock?page=0&pagesize=20&sortby=upc&store=1" hx-trigger="load">
 | 
			
		||||
            </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
    </div>
 | 
			
		||||
    <script src="https://code.jquery.com/jquery-3.7.0.slim.min.js"
 | 
			
		||||
        integrity="sha256-tG5mcZUtJsZvyKAxYLVXrmjKBVLd6VpVccqz/r4ypFE=" crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
 | 
			
		||||
        integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"
 | 
			
		||||
        integrity="sha384-Rx+T1VzGupg4BHQYs2gCW9It+akI2MM/mndMCy36UVfodzcJcF0GGLxZIzObiEfa"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://unpkg.com/htmx.org@1.9.4"
 | 
			
		||||
        integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
 | 
			
		||||
</body>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										117
									
								
								src/retailtwin/api/static/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/retailtwin/api/static/index.html
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,117 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
 | 
			
		||||
    <!-- Latest compiled and minified CSS -->
 | 
			
		||||
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
 | 
			
		||||
        integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
 | 
			
		||||
    <title>Retail digital twin web terminal</title>
 | 
			
		||||
</head>
 | 
			
		||||
 | 
			
		||||
<html>
 | 
			
		||||
 | 
			
		||||
<body>
 | 
			
		||||
    <div class="container-fluid">
 | 
			
		||||
        <div>
 | 
			
		||||
            <div class="p-2">
 | 
			
		||||
                <button class="btn btn-secondary">Home</button>
 | 
			
		||||
                <button class="btn btn-secondary">Query UPC</button>
 | 
			
		||||
                <button class="btn btn-secondary">Search</button>
 | 
			
		||||
                <button class="btn btn-secondary">Warehouse stock</button>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="container-fluid">
 | 
			
		||||
        <div hx-get="/api/v1/stores" hx-trigger="load">
 | 
			
		||||
        </div>
 | 
			
		||||
        <table class="table">
 | 
			
		||||
            <thead>
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <th>upc
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"upc", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-upc", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>batch</th>
 | 
			
		||||
                    <th>name
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"name", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-name", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>package</th>
 | 
			
		||||
                    <th>received
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"received", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-received", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>best_until
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"best_until", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-best_until", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>quantity
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"quantity", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-quantity", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>detail</th>
 | 
			
		||||
                </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
            <tbody id="results" hx-get="/api/v1/stock?page=0&pagesize=20&sortby=upc&store=1" hx-trigger="load">
 | 
			
		||||
            </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
    </div>
 | 
			
		||||
    <script src="https://code.jquery.com/jquery-3.7.0.slim.min.js"
 | 
			
		||||
        integrity="sha256-tG5mcZUtJsZvyKAxYLVXrmjKBVLd6VpVccqz/r4ypFE=" crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
 | 
			
		||||
        integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"
 | 
			
		||||
        integrity="sha384-Rx+T1VzGupg4BHQYs2gCW9It+akI2MM/mndMCy36UVfodzcJcF0GGLxZIzObiEfa"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://unpkg.com/htmx.org@1.9.4"
 | 
			
		||||
        integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
 | 
			
		||||
</body>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										107
									
								
								src/retailtwin/api/static/search.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/retailtwin/api/static/search.html
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,107 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
 | 
			
		||||
    <!-- Latest compiled and minified CSS -->
 | 
			
		||||
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet"
 | 
			
		||||
        integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
 | 
			
		||||
    <title>Retail digital twin web terminal</title>
 | 
			
		||||
</head>
 | 
			
		||||
 | 
			
		||||
<html>
 | 
			
		||||
 | 
			
		||||
<body>
 | 
			
		||||
    <div class="container-fluid">
 | 
			
		||||
        <div hx-get="/api/v1/stores" hx-trigger="load">
 | 
			
		||||
        </div>
 | 
			
		||||
        <table class="table">
 | 
			
		||||
            <thead>
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <th>upc
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"upc", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-upc", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>batch</th>
 | 
			
		||||
                    <th>name
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"name", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-name", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>package</th>
 | 
			
		||||
                    <th>received
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"received", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-received", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>best_until
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"best_until", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-best_until", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>quantity
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"quantity", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▲
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button hx-get="/api/v1/stock"
 | 
			
		||||
                            hx-vals='js:{page: "0", pagesize: "20", sortby:"-quantity", store:document.getElementById("store").value}'
 | 
			
		||||
                            hx-trigger="click" hx-target="#results">
 | 
			
		||||
                            ▼
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </th>
 | 
			
		||||
                    <th>actions</th>
 | 
			
		||||
                </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
            <tbody id="results" hx-get="/api/v1/stock?page=0&pagesize=20&sortby=upc&store=1" hx-trigger="load">
 | 
			
		||||
            </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
    </div>
 | 
			
		||||
    <script src="https://code.jquery.com/jquery-3.7.0.slim.min.js"
 | 
			
		||||
        integrity="sha256-tG5mcZUtJsZvyKAxYLVXrmjKBVLd6VpVccqz/r4ypFE=" crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
 | 
			
		||||
        integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"
 | 
			
		||||
        integrity="sha384-Rx+T1VzGupg4BHQYs2gCW9It+akI2MM/mndMCy36UVfodzcJcF0GGLxZIzObiEfa"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
    <script src="https://unpkg.com/htmx.org@1.9.4"
 | 
			
		||||
        integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
 | 
			
		||||
        crossorigin="anonymous"></script>
 | 
			
		||||
 | 
			
		||||
</body>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										269
									
								
								src/retailtwin/bootstrap.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								src/retailtwin/bootstrap.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,269 @@
 | 
			
		|||
import json
 | 
			
		||||
import string
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
from itertools import repeat
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from random import choice, choices, randint
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
import polars as pl
 | 
			
		||||
from sqlalchemy import select
 | 
			
		||||
from sqlalchemy.orm import Session
 | 
			
		||||
 | 
			
		||||
import dengfun
 | 
			
		||||
from dengfun.retail.utils import db_uri_from_session
 | 
			
		||||
from dengfun.retail.models import (
 | 
			
		||||
    Discount,
 | 
			
		||||
    Item,
 | 
			
		||||
    ItemBatch,
 | 
			
		||||
    ItemOnShelf,
 | 
			
		||||
    Location,
 | 
			
		||||
    LocationType,
 | 
			
		||||
    Provider,
 | 
			
		||||
    TaskOwner,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# Some configuration parameters.
 | 
			
		||||
PACKAGE_ROOT = Path(dengfun.__file__).parent / "retail"
 | 
			
		||||
PRODUCT_LIST_FILE = "data/products.csv"
 | 
			
		||||
DISCOUNT_LIST_FILE = "data/discounts.csv"
 | 
			
		||||
RANDOM_PEOPLE_FILE = "data/random_people.csv"
 | 
			
		||||
NUM_LOCATIONS = 100
 | 
			
		||||
LOCATION_TYPES = ["store", "warehouse"]
 | 
			
		||||
LOCATION_TYPES_WEIGHTS = [10, 1]
 | 
			
		||||
NUM_CUSTOMERS = 1_000_000
 | 
			
		||||
CUSTOMER_BATCH_SIZE = 10_000
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Some utility functions
 | 
			
		||||
def read_products() -> pl.DataFrame:
 | 
			
		||||
    """Load and sanitize the dummy product list stored in a file as part of the package
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        pl.DataFrame: Polars dataframe with the products
 | 
			
		||||
    """
 | 
			
		||||
    return (
 | 
			
		||||
        pl.read_csv((PACKAGE_ROOT / PRODUCT_LIST_FILE).resolve())
 | 
			
		||||
        .with_columns(
 | 
			
		||||
            product=pl.col("product").str.strip(" "),
 | 
			
		||||
            package=pl.col(" package").str.strip(" "),
 | 
			
		||||
            price=pl.col(" price").str.strip(" ").str.strip("$"),
 | 
			
		||||
            provider=pl.col(" provider").str.strip(" "),
 | 
			
		||||
        )
 | 
			
		||||
        .select(
 | 
			
		||||
            [pl.col("product"), pl.col("package"), pl.col("provider"), pl.col("price")]
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_discounts() -> pl.DataFrame:
 | 
			
		||||
    """Load the discount list stored in a file as part of the package
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        pl.DataFrame: _description_
 | 
			
		||||
    """
 | 
			
		||||
    return pl.read_csv((PACKAGE_ROOT / DISCOUNT_LIST_FILE).resolve())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_people() -> pl.DataFrame:
 | 
			
		||||
    """Load the dummy customer list stored in a file as part of the package
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        pl.DataFrame: _description_
 | 
			
		||||
    """
 | 
			
		||||
    return pl.read_csv((PACKAGE_ROOT / RANDOM_PEOPLE_FILE).resolve())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bootstrap_discounts(session: Session):
 | 
			
		||||
    """Load the discount table into the database
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        session (Session): SQLAlchemy ORM session
 | 
			
		||||
    """
 | 
			
		||||
    for discount in read_discounts().iter_rows():
 | 
			
		||||
        session.add(Discount(name=discount[0], definition=json.loads(discount[1])))
 | 
			
		||||
 | 
			
		||||
    session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bootstrap_providers(session: Session):
 | 
			
		||||
    """Load the providers table with data from the product list. Only the name changes,
 | 
			
		||||
    and other provider information is filled with the same data or mocked.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        session (Session): SQLAlchemy ORM session
 | 
			
		||||
    """
 | 
			
		||||
    ascii_upper = [s.upper() for s in string.ascii_letters]
 | 
			
		||||
    for provider in read_products().select(pl.col("provider").unique()).to_series():
 | 
			
		||||
        session.add(
 | 
			
		||||
            Provider(
 | 
			
		||||
                name=provider,
 | 
			
		||||
                address="Fake address street, number XX",
 | 
			
		||||
                phone="+1 555 555 55 55",
 | 
			
		||||
                vat=f"{randint(10000000,99999999)}{choice(ascii_upper)}",
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bootstrap_items(session: Session):
 | 
			
		||||
    """Load data into the Items table from the product list
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        session (Session): SQLAlchemy ORM sessoin
 | 
			
		||||
    """
 | 
			
		||||
    for data in read_products().select(pl.all()).iter_rows():
 | 
			
		||||
        volume = randint(2, 10)
 | 
			
		||||
        provider = session.scalar(select(Provider).where(Provider.name == data[2])).id
 | 
			
		||||
        session.add(
 | 
			
		||||
            Item(
 | 
			
		||||
                name=data[0],
 | 
			
		||||
                upc=randint(0, 999999999999),
 | 
			
		||||
                package=data[1],
 | 
			
		||||
                current=True,
 | 
			
		||||
                provider=provider,
 | 
			
		||||
                volume_unpacked=volume,
 | 
			
		||||
                volume_packed=volume - 1,
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
    session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bootstrap_locations(session: Session):
 | 
			
		||||
    """Create NUM_LOCATIONS of LOCATION_TYPES, with LOCATION_TYPES_WEIGHTS providing
 | 
			
		||||
    the proportion of each location. If location is of type warehouse, capacity is
 | 
			
		||||
    around 10 times bigger.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        session (Session): SQLAlchemy ORM session
 | 
			
		||||
    """
 | 
			
		||||
    for i, ltype in enumerate(LOCATION_TYPES):
 | 
			
		||||
        session.add(LocationType(name=ltype, retail=(ltype == "store")))
 | 
			
		||||
 | 
			
		||||
    session.commit()
 | 
			
		||||
 | 
			
		||||
    for i in range(NUM_LOCATIONS):
 | 
			
		||||
        c = choices(LOCATION_TYPES, LOCATION_TYPES_WEIGHTS)
 | 
			
		||||
        loc = session.scalar(select(LocationType).where(LocationType.name == c[0]))
 | 
			
		||||
        session.add(
 | 
			
		||||
            Location(
 | 
			
		||||
                loctype=loc.id,
 | 
			
		||||
                name=f"Location {i} {loc.name}",
 | 
			
		||||
                capacity=randint(50_000, 100_000)
 | 
			
		||||
                * (1 + 9 * (int(loc.name == "warehouse"))),
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bootstrap_clients(session: Session):
 | 
			
		||||
    """Load data into the customers table in batches.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        session (Session): SQLAlchemy ORM session
 | 
			
		||||
    """
 | 
			
		||||
    connection_uri = db_uri_from_session(session)
 | 
			
		||||
    people = read_people()
 | 
			
		||||
    for i in range(NUM_CUSTOMERS // CUSTOMER_BATCH_SIZE):
 | 
			
		||||
        print(f"Write batch {i} of {NUM_CUSTOMERS // CUSTOMER_BATCH_SIZE}")
 | 
			
		||||
        data = (
 | 
			
		||||
            pl.DataFrame(
 | 
			
		||||
                {
 | 
			
		||||
                    "name": people.select(pl.col("name"))
 | 
			
		||||
                    .sample(CUSTOMER_BATCH_SIZE, with_replacement=True, shuffle=True)
 | 
			
		||||
                    .to_series(),
 | 
			
		||||
                    "middlename": people.select(pl.col("middlename"))
 | 
			
		||||
                    .sample(CUSTOMER_BATCH_SIZE, with_replacement=True, shuffle=True)
 | 
			
		||||
                    .to_series(),
 | 
			
		||||
                    "surname": people.select(pl.col("surname"))
 | 
			
		||||
                    .sample(CUSTOMER_BATCH_SIZE, with_replacement=True, shuffle=True)
 | 
			
		||||
                    .to_series(),
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
            .with_columns(
 | 
			
		||||
                name=pl.concat_str(
 | 
			
		||||
                    [pl.col("name"), pl.col("middlename"), pl.col("surname")],
 | 
			
		||||
                    separator=" ",
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            .select(pl.col("name"))
 | 
			
		||||
        )
 | 
			
		||||
        pl.DataFrame(
 | 
			
		||||
            {
 | 
			
		||||
                "document": [
 | 
			
		||||
                    f"{str(randint(0, 99999999)).zfill(8)}"
 | 
			
		||||
                    for _ in range(CUSTOMER_BATCH_SIZE)
 | 
			
		||||
                ],
 | 
			
		||||
                "info": [json.dumps({"name": name[0]}) for name in data.iter_rows()],
 | 
			
		||||
            },
 | 
			
		||||
        ).write_database(
 | 
			
		||||
            "customers", connection_uri, if_exists="append", engine="sqlalchemy"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bootstrap_stock(session: Session):
 | 
			
		||||
    """Load items as stock in each location. The total weight is estimated to not to
 | 
			
		||||
    stock over capacity.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        session (Session): SQLAlchemy ORM session
 | 
			
		||||
    """
 | 
			
		||||
    products = read_products()
 | 
			
		||||
    db_uri = db_uri_from_session(session)
 | 
			
		||||
    items_df = pl.read_database("select * from items", db_uri, engine="adbc")
 | 
			
		||||
    locations_df = pl.read_database("select * from locations", db_uri, engine="adbc")
 | 
			
		||||
    discounts = list(
 | 
			
		||||
        pl.read_database("select id from discounts", db_uri, engine="adbc")
 | 
			
		||||
        .select(pl.col("id"))
 | 
			
		||||
        .to_series()
 | 
			
		||||
    ) + [None]
 | 
			
		||||
    weights = [1 for _ in range(len(discounts) - 1)] + [50]
 | 
			
		||||
    average_volume_unpacked = items_df.select(pl.col("volume_unpacked").mean())[0, 0]
 | 
			
		||||
 | 
			
		||||
    for location in locations_df.iter_rows():
 | 
			
		||||
        print(f"Stocking location {location[0]} from {len(locations_df)}")
 | 
			
		||||
        stock_quantity = int(location[3] / average_volume_unpacked / len(products))
 | 
			
		||||
        with_prices = items_df.join(
 | 
			
		||||
            products,
 | 
			
		||||
            left_on=["name", "package"],
 | 
			
		||||
            right_on=["product", "package"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Create the batch too
 | 
			
		||||
        for lot_id, unit_price, sku in zip(
 | 
			
		||||
            repeat(str(uuid4())[:23]),
 | 
			
		||||
            with_prices.select([pl.col("price")]).to_series(),
 | 
			
		||||
            with_prices.select([pl.col("sku")]).to_series(),
 | 
			
		||||
        ):
 | 
			
		||||
            batch = ItemBatch(
 | 
			
		||||
                sku=sku,
 | 
			
		||||
                lot=lot_id,
 | 
			
		||||
                order=None,
 | 
			
		||||
                received=datetime.now(),
 | 
			
		||||
                unit_cost=unit_price,  # TODO: There's no margin for the moment
 | 
			
		||||
                price=unit_price,
 | 
			
		||||
                best_until=datetime.now() + timedelta(days=30),
 | 
			
		||||
                quantity=stock_quantity,
 | 
			
		||||
            )
 | 
			
		||||
            session.add(batch)
 | 
			
		||||
            session.commit()
 | 
			
		||||
 | 
			
		||||
            random_discount = choices(discounts, weights, k=1)[0]
 | 
			
		||||
            on_shelf = ItemOnShelf(
 | 
			
		||||
                batch=batch.id,
 | 
			
		||||
                discount=random_discount if random_discount else None,
 | 
			
		||||
                quantity=stock_quantity,
 | 
			
		||||
                location=location[0],
 | 
			
		||||
            )
 | 
			
		||||
            session.add(on_shelf)
 | 
			
		||||
            session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bootstrap_taskowners(session: Session):
 | 
			
		||||
    for name in ["stocker", "cashier", "manager", "warehouse"]:
 | 
			
		||||
        taskowner = TaskOwner(name=name)
 | 
			
		||||
        session.add(taskowner)
 | 
			
		||||
 | 
			
		||||
    session.commit()
 | 
			
		||||
							
								
								
									
										0
									
								
								src/retailtwin/cli/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/cli/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										68
									
								
								src/retailtwin/cli/data.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/retailtwin/cli/data.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,68 @@
 | 
			
		|||
import typer
 | 
			
		||||
from sqlalchemy import create_engine
 | 
			
		||||
from sqlalchemy.orm import Session
 | 
			
		||||
 | 
			
		||||
from dengfun.retail.bootstrap import (
 | 
			
		||||
    bootstrap_clients,
 | 
			
		||||
    bootstrap_discounts,
 | 
			
		||||
    bootstrap_items,
 | 
			
		||||
    bootstrap_locations,
 | 
			
		||||
    bootstrap_providers,
 | 
			
		||||
    bootstrap_stock,
 | 
			
		||||
    bootstrap_taskowners,
 | 
			
		||||
)
 | 
			
		||||
from dengfun.retail.models import Base
 | 
			
		||||
from dengfun.retail.sql.sync import funcandproc
 | 
			
		||||
 | 
			
		||||
app = typer.Typer()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.command()
 | 
			
		||||
def init(db_uri: str):
 | 
			
		||||
    """Persist the schema on the database
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): SQLAlchemy db uri
 | 
			
		||||
    """
 | 
			
		||||
    engine = create_engine(db_uri, echo=True)
 | 
			
		||||
    Base.metadata.create_all(engine)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.command()
 | 
			
		||||
def sync(db_uri: str):
 | 
			
		||||
    """Sync the functions, procedures, triggers and views
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): SQLAlchemy db uri
 | 
			
		||||
    """
 | 
			
		||||
    funcandproc(db_uri)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.command()
 | 
			
		||||
def bootstrap(db_uri: str):
 | 
			
		||||
    """Populate the databse with data. Only execute on an empty database
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): Sqlalchemy db uri
 | 
			
		||||
    """
 | 
			
		||||
    engine = create_engine(db_uri)
 | 
			
		||||
 | 
			
		||||
    with Session(engine) as session:
 | 
			
		||||
        print("Discounts...")
 | 
			
		||||
        bootstrap_discounts(session)
 | 
			
		||||
        print("Providers...")
 | 
			
		||||
        bootstrap_providers(session)
 | 
			
		||||
        print("Locations...")
 | 
			
		||||
        bootstrap_locations(session)
 | 
			
		||||
        print("Items...")
 | 
			
		||||
        bootstrap_items(session)
 | 
			
		||||
        print("Clients...")
 | 
			
		||||
        bootstrap_clients(session)
 | 
			
		||||
        print("Stockage...")
 | 
			
		||||
        bootstrap_stock(session)
 | 
			
		||||
        print("TaskOwners")
 | 
			
		||||
        bootstrap_taskowners(session)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    app()
 | 
			
		||||
							
								
								
									
										109
									
								
								src/retailtwin/cli/db.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/retailtwin/cli/db.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,109 @@
 | 
			
		|||
"""
 | 
			
		||||
Several utilities to fetch data from the database
 | 
			
		||||
"""
 | 
			
		||||
import polars as pl
 | 
			
		||||
from rich.table import Table
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fetch_from_db(db_uri: str, location: int) -> Tuple[pl.DataFrame]:
 | 
			
		||||
    """Fetcn basic information about inventory from the database
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): SQLAlchemy db URI
 | 
			
		||||
        location (int): Location ID for the store or warehouse
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        Tuple[pl.DataFrame]: Two polars dataframe continig all items,
 | 
			
		||||
            and the local stock respectively
 | 
			
		||||
    """
 | 
			
		||||
    items = (
 | 
			
		||||
        query_items(db_uri)
 | 
			
		||||
        .select(pl.all())
 | 
			
		||||
        .select([pl.col("upc"), pl.col("name"), pl.col("package")])
 | 
			
		||||
        .with_columns(pl.col("upc").cast(str))
 | 
			
		||||
    )
 | 
			
		||||
    local_stock = query_local_batches(db_uri, location).with_columns(
 | 
			
		||||
        pl.col("upc").cast(str),
 | 
			
		||||
        pl.col("batch").cast(str),
 | 
			
		||||
        pl.col("quantity").cast(str),
 | 
			
		||||
        pl.col("received").cast(str),
 | 
			
		||||
        pl.col("best_until").cast(str),
 | 
			
		||||
    )
 | 
			
		||||
    return items, local_stock
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def df_to_table(df: pl.DataFrame, title="Items") -> Table:
 | 
			
		||||
    """Process a Polars dataframe and create a Rich table from it
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        df (pl.DataFrame): The polars dataframe
 | 
			
		||||
        title (str, optional): Title for the displayed table. Defaults to "Items".
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        Table: The Rich table to be displayed
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    table = Table(title=title)
 | 
			
		||||
 | 
			
		||||
    for column in df.columns:
 | 
			
		||||
        table.add_column(str(column))
 | 
			
		||||
 | 
			
		||||
    for row in df.iter_rows():
 | 
			
		||||
        table.add_row(*row)
 | 
			
		||||
 | 
			
		||||
    return table
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def query_items(db_uri: str) -> pl.DataFrame:
 | 
			
		||||
    """Query all available items from the database that can be kept in stock
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): SQLAlchemy database URI
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        pl.DataFrame: Polars dataframe with the items
 | 
			
		||||
    """
 | 
			
		||||
    return pl.read_database(
 | 
			
		||||
        "select * from items where current = true", db_uri, engine="adbc"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def query_local_batches(db_uri: str, location: int) -> pl.DataFrame:
 | 
			
		||||
    """Query all batches stored in location
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): SQLAlchemy database URI
 | 
			
		||||
        location (int): Id of the current location
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        pl.DataFrame: Polars Dataframe with the batches in stock
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    return pl.read_database(
 | 
			
		||||
        f"select * from inventory where location = {location}",
 | 
			
		||||
        db_uri,
 | 
			
		||||
        engine="adbc",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def query_warehouse_stock(db_uri: str, item: int) -> pl.DataFrame:
 | 
			
		||||
    """Returns stock available for an item in all warehouses
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): SQLAlchemy database URI
 | 
			
		||||
        item (int): Id of the item
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        pl.DataFrame: Polars Dataframe with the stock stock
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    return pl.read_database(
 | 
			
		||||
        f"select * from warehouse_stock where upc = {item}",
 | 
			
		||||
        db_uri,
 | 
			
		||||
        engine="adbc",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def sync_pos_schema():
 | 
			
		||||
    pass
 | 
			
		||||
							
								
								
									
										0
									
								
								src/retailtwin/cli/pos/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/cli/pos/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										217
									
								
								src/retailtwin/cli/pos/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								src/retailtwin/cli/pos/main.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,217 @@
 | 
			
		|||
"""
 | 
			
		||||
Terminal that mimics what a Point of Sale may operate like.
 | 
			
		||||
"""
 | 
			
		||||
import typer
 | 
			
		||||
import polars as pl
 | 
			
		||||
from dengfun.retail.cli.db import query_local_batches
 | 
			
		||||
from dengfun.retail.cli.pos.models import Base, Sync, Direction, Cart, Item
 | 
			
		||||
from dengfun.retail.utils import db_uri_from_session
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
from sqlalchemy import create_engine, select
 | 
			
		||||
from sqlalchemy.orm import Session
 | 
			
		||||
from prompt_toolkit import PromptSession
 | 
			
		||||
from prompt_toolkit.completion import WordCompleter
 | 
			
		||||
 | 
			
		||||
# from prompt_toolkit.shortcuts import clear
 | 
			
		||||
from rich.console import Console
 | 
			
		||||
from rich.markdown import Markdown
 | 
			
		||||
 | 
			
		||||
console = Console()
 | 
			
		||||
 | 
			
		||||
HELP = """
 | 
			
		||||
# Retail twin POS
 | 
			
		||||
 | 
			
		||||
This is a simple terminal that simulates a dumb PoS with a single-line screen.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
CACHE_FILE_URL = "sqlite:///pos.db"
 | 
			
		||||
 | 
			
		||||
CONNECT_ERROR_MESSAGE = """
 | 
			
		||||
# ERROR
 | 
			
		||||
 | 
			
		||||
Could not sync to the central database.
 | 
			
		||||
 | 
			
		||||
The terminal will try to operate without a connection.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def sync(session: Session, db_remote_uri: str, location: int):
 | 
			
		||||
    db_local_uri = db_uri_from_session(session)
 | 
			
		||||
 | 
			
		||||
    # Fetch data from the remote database
 | 
			
		||||
    try:
 | 
			
		||||
        inventory = query_local_batches(db_remote_uri, location)
 | 
			
		||||
    except:  # noqa: E722
 | 
			
		||||
        console.print(Markdown(CONNECT_ERROR_MESSAGE))
 | 
			
		||||
        return
 | 
			
		||||
    # The price and the discount is fixed by the latest received batch
 | 
			
		||||
    items = (
 | 
			
		||||
        inventory.select(
 | 
			
		||||
            [
 | 
			
		||||
                pl.col("upc"),
 | 
			
		||||
                pl.col("price"),
 | 
			
		||||
                pl.col("discount_definition").alias("discount"),
 | 
			
		||||
                pl.col("received"),
 | 
			
		||||
                pl.col("received").max().over("upc").alias("max_received"),
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        .filter(pl.col("received") == pl.col("max_received"))
 | 
			
		||||
        .select([pl.col("upc"), pl.col("price"), pl.col("discount")])
 | 
			
		||||
        .with_columns(pl.col("price").str.strip().cast(pl.Float32) * 100)
 | 
			
		||||
        .with_columns(pl.col("price").cast(pl.Int32))
 | 
			
		||||
    )
 | 
			
		||||
    items.write_database(
 | 
			
		||||
        "items", db_local_uri, if_exists="replace", engine="sqlalchemy"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Sync all customers
 | 
			
		||||
    (
 | 
			
		||||
        pl.read_database("select * from customers", db_remote_uri, engine="adbc")
 | 
			
		||||
        .select([pl.col("id"), pl.col("document")])
 | 
			
		||||
        .write_database(
 | 
			
		||||
            "customers", db_local_uri, if_exists="replace", engine="sqlalchemy"
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Add last sync
 | 
			
		||||
    sync = Sync(direction=Direction.pull)
 | 
			
		||||
    session.add(sync)
 | 
			
		||||
    session.commit()
 | 
			
		||||
 | 
			
		||||
    # Sync unsynced carts
 | 
			
		||||
    unsynced_carts = pl.read_database(
 | 
			
		||||
        "select * from carts where synced = false and total_amount is not null",
 | 
			
		||||
        db_local_uri,
 | 
			
		||||
        engine="adbc",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if not unsynced_carts.is_empty():
 | 
			
		||||
        (
 | 
			
		||||
            unsynced_carts.select(
 | 
			
		||||
                [pl.col("checkout_dt").alias("checkout"), pl.col("customer")]
 | 
			
		||||
            )
 | 
			
		||||
            .with_columns(location=location)
 | 
			
		||||
            .write_database(
 | 
			
		||||
                "carts", db_remote_uri, if_exists="append", engine="sqlalchemy"
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Query for items to sync
 | 
			
		||||
        query = """
 | 
			
		||||
        select
 | 
			
		||||
          c.id as cart,
 | 
			
		||||
          i.upc as upc,
 | 
			
		||||
          i.quantity
 | 
			
		||||
        from
 | 
			
		||||
          itemsoncart i
 | 
			
		||||
        join
 | 
			
		||||
          carts c
 | 
			
		||||
        on
 | 
			
		||||
          i.cart = c.id
 | 
			
		||||
        where
 | 
			
		||||
          c.synced = false"""
 | 
			
		||||
 | 
			
		||||
        pl.read_database(query, db_local_uri, engine="adbc").write_database(
 | 
			
		||||
            "itemsoncart", db_remote_uri, engine="sqlalchemy"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Set synced to cart at the end of the process
 | 
			
		||||
        for cart in session.scalars(
 | 
			
		||||
            select(Cart).filter(Cart.synced == False)  # noqa: E712
 | 
			
		||||
        ).all():
 | 
			
		||||
            cart.synced = True
 | 
			
		||||
 | 
			
		||||
        session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def load_items(session: Session):
 | 
			
		||||
    return [str(it) for it in session.scalars(select(Item.upc)).all()]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def handle_command(command: str, cart_id: int, item_id: int, **kwargs):
 | 
			
		||||
    print(command, cart_id, item_id)
 | 
			
		||||
    session = kwargs["session"]
 | 
			
		||||
 | 
			
		||||
    if command == "n":  # Start a new cart
 | 
			
		||||
        cart = Cart(checkin_dt=datetime.now(), synced=False)
 | 
			
		||||
        session.add(cart)
 | 
			
		||||
        session.commit()
 | 
			
		||||
 | 
			
		||||
        return cart.id, "", ""
 | 
			
		||||
 | 
			
		||||
    # Handle adding a new item
 | 
			
		||||
    elif cart_id is not None:
 | 
			
		||||
        # Check if the upc is in the database
 | 
			
		||||
        if session.scalar(select(Item).filter(Item.upc == int(command))).one_or_none():
 | 
			
		||||
            return cart_id, item_id, ""
 | 
			
		||||
        else:
 | 
			
		||||
            return cart_id, "", "ERROR: Code not found"
 | 
			
		||||
 | 
			
		||||
    else:
 | 
			
		||||
        cart_id, item_id if item_id else "", ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main(db_uri: str, location: int):
 | 
			
		||||
    console.print("Fetching data...")
 | 
			
		||||
 | 
			
		||||
    # Create the schema of the local database
 | 
			
		||||
    local_engine = create_engine(CACHE_FILE_URL)
 | 
			
		||||
    Base.metadata.create_all(local_engine)
 | 
			
		||||
 | 
			
		||||
    with Session(local_engine) as session:
 | 
			
		||||
        sync(session, db_uri, location)
 | 
			
		||||
 | 
			
		||||
        # Default completer with barcodes
 | 
			
		||||
        completer = WordCompleter(load_items(session))
 | 
			
		||||
 | 
			
		||||
        # Update the local copy of the data.
 | 
			
		||||
        console.print(Markdown(HELP))
 | 
			
		||||
        term = PromptSession()
 | 
			
		||||
 | 
			
		||||
        # State of the pos
 | 
			
		||||
        cart_id = ""
 | 
			
		||||
        item_id = ""
 | 
			
		||||
        error = ""
 | 
			
		||||
 | 
			
		||||
        while True:
 | 
			
		||||
            if cart_id and item_id:
 | 
			
		||||
                if error:
 | 
			
		||||
                    prompt = f"[{item_id}# quantity] !{error}!>"
 | 
			
		||||
                else:
 | 
			
		||||
                    prompt = f"[{item_id}# quantity]>"
 | 
			
		||||
            elif cart_id and not item_id:
 | 
			
		||||
                prompt = f"[{cart_id}# code]> "
 | 
			
		||||
            else:
 | 
			
		||||
                prompt = "#> "
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                text = term.prompt(prompt, completer=completer)
 | 
			
		||||
            except KeyboardInterrupt:
 | 
			
		||||
                continue
 | 
			
		||||
            except EOFError:
 | 
			
		||||
                break
 | 
			
		||||
            else:
 | 
			
		||||
                if text in ["x", "q"]:
 | 
			
		||||
                    break
 | 
			
		||||
                elif text == "h":
 | 
			
		||||
                    console.print(Markdown(HELP))
 | 
			
		||||
                elif text == "r":
 | 
			
		||||
                    console.print("Refreshing...")
 | 
			
		||||
                    sync(session, db_uri, location)
 | 
			
		||||
                    completer = WordCompleter(load_items(session))
 | 
			
		||||
                else:
 | 
			
		||||
                    cart_id, item_id, error = handle_command(
 | 
			
		||||
                        text,
 | 
			
		||||
                        cart_id,
 | 
			
		||||
                        item_id,
 | 
			
		||||
                        location=location,
 | 
			
		||||
                        completer=completer,
 | 
			
		||||
                        db_remote_uri=db_uri,
 | 
			
		||||
                        session=session,
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
    print("GoodBye!")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
app = typer.run(main)
 | 
			
		||||
							
								
								
									
										61
									
								
								src/retailtwin/cli/pos/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/retailtwin/cli/pos/models.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
from enum import Enum
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from typing import Dict
 | 
			
		||||
from sqlalchemy import ForeignKey, Numeric, JSON
 | 
			
		||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
 | 
			
		||||
 | 
			
		||||
__all__ = ["Direction", "PaymentType", "Base", "Sync", "Cart", "Item", "ItemOnCart"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Direction(Enum):
 | 
			
		||||
    push = 0
 | 
			
		||||
    pull = 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PaymentType(Enum):
 | 
			
		||||
    cash = 0
 | 
			
		||||
    card = 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Base(DeclarativeBase):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Sync(Base):
 | 
			
		||||
    __tablename__ = "syncs"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    direction: Mapped[Direction]
 | 
			
		||||
    sync_dt: Mapped[datetime] = mapped_column(default=datetime.now())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Item(Base):
 | 
			
		||||
    __tablename__ = "items"
 | 
			
		||||
    upc: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    unitprice: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=False)
 | 
			
		||||
    discount: Mapped[Dict[str, Dict[str, int]]] = mapped_column(JSON, nullable=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Customer(Base):
 | 
			
		||||
    __tablename__ = "customers"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    document: Mapped[str] = mapped_column(nullable=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Cart(Base):
 | 
			
		||||
    __tablename__ = "carts"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    checkin_dt: Mapped[datetime] = mapped_column(default=datetime.now())
 | 
			
		||||
    checkout_dt: Mapped[datetime] = mapped_column(nullable=True)
 | 
			
		||||
    total_amount: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=True)
 | 
			
		||||
    payment_type: Mapped[PaymentType] = mapped_column(nullable=True)
 | 
			
		||||
    customer: Mapped[int] = mapped_column(ForeignKey("customers.id"), nullable=True)
 | 
			
		||||
    synced: Mapped[bool]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ItemOnCart(Base):
 | 
			
		||||
    __tablename__ = "itemsoncart"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    cart: Mapped[int] = mapped_column(ForeignKey("carts.id"))
 | 
			
		||||
    upc: Mapped[int] = mapped_column(ForeignKey("items.upc"))
 | 
			
		||||
    quantity: Mapped[int]
 | 
			
		||||
							
								
								
									
										166
									
								
								src/retailtwin/cli/stock.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/retailtwin/cli/stock.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,166 @@
 | 
			
		|||
# ruff: noqa: E501
 | 
			
		||||
import typer
 | 
			
		||||
import polars as pl
 | 
			
		||||
from sqlalchemy import create_engine, text as query
 | 
			
		||||
from dengfun.retail.cli.db import query_warehouse_stock, df_to_table, fetch_from_db
 | 
			
		||||
from prompt_toolkit import PromptSession
 | 
			
		||||
from prompt_toolkit.completion import WordCompleter
 | 
			
		||||
from prompt_toolkit.shortcuts import clear
 | 
			
		||||
from rich.console import Console
 | 
			
		||||
from rich.markdown import Markdown
 | 
			
		||||
 | 
			
		||||
console = Console()
 | 
			
		||||
 | 
			
		||||
HELP = """
 | 
			
		||||
# Retail twin stock management CLI
 | 
			
		||||
 | 
			
		||||
This is a simple terminal to manage stock. Enter a single-letter command followed by <Enter>. The available commands are:
 | 
			
		||||
 | 
			
		||||
* **l**: Lists all the current items stocked in any location.
 | 
			
		||||
* **s**: Enters search mode. Search an item by name.
 | 
			
		||||
* **q**: Store query mode. Queries the stock of an item by UPC in the current location.
 | 
			
		||||
* **w**: Warehouse query mode. Queries the stock of an item by UPC in all warehouses. Requires connection to the database.
 | 
			
		||||
* **c**: Cancel mode. Retires a batch giving a UPC. Requires connection to the database.
 | 
			
		||||
* **b**: Batch mode. Requests a given quantity from an item to the warehouse. Requires connection to the database.
 | 
			
		||||
* **r**: Refresh data from the stock database.
 | 
			
		||||
* **h**: Print this help message.
 | 
			
		||||
* **x**: Exit this terminal.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def handle_command(command: str, **kwargs):
 | 
			
		||||
    """_summary_
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        command (str): Single letter command
 | 
			
		||||
    """
 | 
			
		||||
    items = kwargs["items"]
 | 
			
		||||
    session = kwargs["session"]
 | 
			
		||||
    item_completer = kwargs["completer"]
 | 
			
		||||
    location = kwargs["location"]
 | 
			
		||||
    local_stock = kwargs["local_stock"]
 | 
			
		||||
    db_uri = kwargs["db_uri"]
 | 
			
		||||
 | 
			
		||||
    if command == "l":
 | 
			
		||||
        console.print(df_to_table(items))
 | 
			
		||||
    elif command == "s":
 | 
			
		||||
        # Command opens a new prompt
 | 
			
		||||
        completer = WordCompleter(
 | 
			
		||||
            items.select(pl.col("name")).to_series().str.to_lowercase().to_list()
 | 
			
		||||
        )
 | 
			
		||||
        text = session.prompt("s> ", completer=completer)
 | 
			
		||||
        console.print(
 | 
			
		||||
            df_to_table(
 | 
			
		||||
                items.select(pl.all()).filter(
 | 
			
		||||
                    pl.col("name").str.to_lowercase().str.contains(text)
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
    elif command == "q":  # Query stock in current location
 | 
			
		||||
        upc = session.prompt("q> ", completer=item_completer)
 | 
			
		||||
        df = local_stock.select(
 | 
			
		||||
            [
 | 
			
		||||
                pl.col("upc"),
 | 
			
		||||
                pl.col("name"),
 | 
			
		||||
                pl.col("package"),
 | 
			
		||||
                pl.col("price"),
 | 
			
		||||
                pl.col("best_until"),
 | 
			
		||||
                pl.col("quantity"),
 | 
			
		||||
            ]
 | 
			
		||||
        ).filter(pl.col("upc") == upc)
 | 
			
		||||
        console.print(
 | 
			
		||||
            df_to_table(
 | 
			
		||||
                df.with_columns(pl.col("price").cast(str)),
 | 
			
		||||
                title=f"Item {upc} on location {location}",
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    elif command == "w":  # Query stock in warehouses
 | 
			
		||||
        text = session.prompt("w> ", completer=item_completer)
 | 
			
		||||
        console.print(
 | 
			
		||||
            df_to_table(
 | 
			
		||||
                query_warehouse_stock(db_uri, text).with_columns(
 | 
			
		||||
                    pl.col("received").cast(str),
 | 
			
		||||
                    pl.col("best_until").cast(str),
 | 
			
		||||
                    pl.col("upc").cast(str),
 | 
			
		||||
                    pl.col("quantity").cast(str),
 | 
			
		||||
                ),
 | 
			
		||||
                title=f"Warehouse stock for item {text}",
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    elif command == "c":  # Retire a batch from the shelves.
 | 
			
		||||
        batches = local_stock.select(pl.col("batch")).to_series().to_list()
 | 
			
		||||
        batchid = session.prompt("c [enter batch id]> ")
 | 
			
		||||
 | 
			
		||||
        if batchid in batches:
 | 
			
		||||
            console.print(
 | 
			
		||||
                df_to_table(
 | 
			
		||||
                    local_stock.select(pl.all()).filter(pl.col("batch") == batchid),
 | 
			
		||||
                    title=f"Batch {batchid} on location {location}",
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            text = session.prompt(f"c [Remove batch {batchid}? (Y/N)]> ")
 | 
			
		||||
            if text.lower() == "y":
 | 
			
		||||
                engine = create_engine(db_uri)
 | 
			
		||||
                conn = engine.connect()
 | 
			
		||||
                conn.execute(
 | 
			
		||||
                    query(f"select retire_batch_from_shelves({batchid}, {location})")
 | 
			
		||||
                )
 | 
			
		||||
                conn.commit()
 | 
			
		||||
            else:
 | 
			
		||||
                console.print("Aborted")
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            console.print(f"Error, batch {batchid} doesn't exist")
 | 
			
		||||
    else:
 | 
			
		||||
        console.print(f"Command {command} not supported")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main(db_uri: str, location: int):
 | 
			
		||||
    # Fetch all data necessary from the database
 | 
			
		||||
    console.print("Fetching data...")
 | 
			
		||||
    items, local_stock = fetch_from_db(db_uri, location)
 | 
			
		||||
 | 
			
		||||
    clear()
 | 
			
		||||
    console.print(Markdown(HELP))
 | 
			
		||||
 | 
			
		||||
    # Default completer with barcodes
 | 
			
		||||
    completer = WordCompleter(
 | 
			
		||||
        items.select(pl.col("upc")).to_series().cast(str).to_list()
 | 
			
		||||
    )
 | 
			
		||||
    session = PromptSession()
 | 
			
		||||
 | 
			
		||||
    while True:
 | 
			
		||||
        try:
 | 
			
		||||
            text = session.prompt(
 | 
			
		||||
                "#> ",
 | 
			
		||||
            )
 | 
			
		||||
        except KeyboardInterrupt:
 | 
			
		||||
            continue
 | 
			
		||||
        except EOFError:
 | 
			
		||||
            break
 | 
			
		||||
        else:
 | 
			
		||||
            if text == "x":
 | 
			
		||||
                break
 | 
			
		||||
            elif text == "h":
 | 
			
		||||
                console.print(Markdown(HELP))
 | 
			
		||||
            elif text == "r":
 | 
			
		||||
                console.print("Refreshing...")
 | 
			
		||||
                items, local_stock = fetch_from_db(db_uri, location)
 | 
			
		||||
            else:
 | 
			
		||||
                handle_command(
 | 
			
		||||
                    text,
 | 
			
		||||
                    items=items,
 | 
			
		||||
                    location=location,
 | 
			
		||||
                    session=session,
 | 
			
		||||
                    completer=completer,
 | 
			
		||||
                    local_stock=local_stock,
 | 
			
		||||
                    db_uri=db_uri,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
    print("GoodBye!")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
app = typer.run(main)
 | 
			
		||||
							
								
								
									
										0
									
								
								src/retailtwin/cli/tasks/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/cli/tasks/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								src/retailtwin/cli/tasks/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/cli/tasks/main.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								src/retailtwin/cli/tasks/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/cli/tasks/models.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										11
									
								
								src/retailtwin/data/discounts.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/retailtwin/data/discounts.csv
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
name,definition
 | 
			
		||||
2x1,{}
 | 
			
		||||
3x2,{}
 | 
			
		||||
percent_discount_10,{"discount": 10}
 | 
			
		||||
percent_discount_15,{"discount": 15}
 | 
			
		||||
percent_discount_20,{"discount": 20}
 | 
			
		||||
percent_discount_25,{"discount": 25}
 | 
			
		||||
percent_discount_30,{"discount": 30}
 | 
			
		||||
percent_discount_50,{"discount": 50}
 | 
			
		||||
second_unit_half,{}
 | 
			
		||||
second_unit_same_provider_half,{}
 | 
			
		||||
| 
		
		
			 Can't render this file because it contains an unexpected character in line 4 and column 22. 
		
	 | 
							
								
								
									
										451
									
								
								src/retailtwin/data/products.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										451
									
								
								src/retailtwin/data/products.csv
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,451 @@
 | 
			
		|||
product, package, price, provider
 | 
			
		||||
Whole grain bread, 1 loaf, $3.50, Healthy Grains
 | 
			
		||||
Pure spring water, 24 pack, $4.99, Mountain Springs
 | 
			
		||||
Organic eggs, dozen, $4.20, Happy Chicken
 | 
			
		||||
Italian spaghetti, 500g, $1.20, Mamma Italia
 | 
			
		||||
Corn flakes, 500g, $2.99, Golden Harvest
 | 
			
		||||
Habanero hot sauce, 150ml, $6.95, Fire Blast
 | 
			
		||||
Canned tuna, 180g, $1.75, Ocean's Finest
 | 
			
		||||
Full cream yogurt, 1kg, $2.75, Creamy Dreams
 | 
			
		||||
Fruit jam, 340g, $3.50, Sweet Orchard
 | 
			
		||||
Olive oil, 1 liter, $8.00, Mediterranean Gold
 | 
			
		||||
Banana chips, 200g, $2.69, Tropical Crunch
 | 
			
		||||
Almond milk, 1 liter, $3.20, Nutty Delights
 | 
			
		||||
Peanut butter, 500g, $3.89, Nutty Spread
 | 
			
		||||
Smoked salmon, 300g, $8.99, Fresh Waters
 | 
			
		||||
White wine, 750ml, $10.99, Vineyard Bliss
 | 
			
		||||
Whole wheat flour, 1kg, $2.15, Mill Power
 | 
			
		||||
Raisin granola, 500g, $3.79, Morning Fuel
 | 
			
		||||
Instant coffee, 200g, $4.99, Sunrise Roast
 | 
			
		||||
Cheddar cheese, 450g, $5.75, Cheesy Valley
 | 
			
		||||
Organic baby carrots, 450g, $2.70, Bunny Love
 | 
			
		||||
Wasabi peas, 200g, $2.30, Atomic Snacks
 | 
			
		||||
Jasmine rice, 2kg, $4.80, Oriental Gourmet
 | 
			
		||||
Gluten-free breadsticks, 200g, $3.00, Healthy Crunch
 | 
			
		||||
Lime soda, 2 liters, $1.89, Zest Fizz
 | 
			
		||||
Oatmeal cookies, 400g, $3.99, Grandma's Love
 | 
			
		||||
Quinoa cereal, 500g, $3.80, Natural Glow
 | 
			
		||||
Cooking spray, 200ml, $2.90, Kitchen Ease
 | 
			
		||||
Blueberry jam, 340g, $3.00, Berry Bliss
 | 
			
		||||
Refined coconut oil, 500ml, $4.50, Tropical magic
 | 
			
		||||
Cranberry juice, 1 liter, $3.49, Ruby Splash
 | 
			
		||||
Honey Nuts Cereal, 16 oz box, $3.60, BeeGood
 | 
			
		||||
Tomato Ketchup, plastic bottle, $1.80, Reddrop
 | 
			
		||||
Whole Grain Bread, plastic wrapped, $2.65, DeliLoaf
 | 
			
		||||
Coffee Beans, 1lb bag, $9.95, Queen's Brew
 | 
			
		||||
Citrus Hand Soap, 16 oz bottle, $2.75, CitrusSplash
 | 
			
		||||
Almond Butter, glass jar, $7.99, Nutti Delight
 | 
			
		||||
Organic Pasta, 1kg packet, $3.50, Pastaroma
 | 
			
		||||
Frozen Pizza, 14", $8.40, Pizzani
 | 
			
		||||
Extra Virgin Olive Oil, 500ml bottle, $6.60, Oliva Bella
 | 
			
		||||
Cherry Cola Soda, 12 pack cans, $5.39, BubblFizz
 | 
			
		||||
Spinach Tortilla Wraps, pack of 10, $2.35, DreamWraps
 | 
			
		||||
Jasmine Rice, 1kg bag, $4.90, Oriental Delights
 | 
			
		||||
Natural Peanut, large bag, $4.79, NutLand
 | 
			
		||||
Blueberry Fruit Bars, box of 6, $3.99, Berrylicious
 | 
			
		||||
Popcorn Kernels, 1lb bag, $2.20, Pop-Go
 | 
			
		||||
White Vinegar, 1 litre bottle, $1.90, CrispClear
 | 
			
		||||
Island Blend Coffee, 1lb bag, $11.80, Paradise Grind
 | 
			
		||||
Raspberry Jam, 12 oz jar, $3.20, Berry Habit
 | 
			
		||||
Dishwashing Liquid, 32 oz bottle, $2.75, SparkleClean
 | 
			
		||||
Classic Mayonnaise, 16 oz jar, $3.99, BestoMayo
 | 
			
		||||
Quinoa Flakes, 270g box, $4.20, QuinoaQuench
 | 
			
		||||
Tea Bags, box of 20, $2.89, DreamSip
 | 
			
		||||
Cream Crackers, 400g box, $1.70, Snappy
 | 
			
		||||
Bourbon Biscuits, pack of 12, $3.45, SweetBite
 | 
			
		||||
Avocado Oil, 250ml bottle, $7.20, GreenSqueeze
 | 
			
		||||
Thai Curry Paste, 14 oz jar, $4.30, ThaiSpice
 | 
			
		||||
Toilet Paper, 6-roll pack, $4.99, SoftCushion
 | 
			
		||||
Spaghetti Sauce, 24 oz jar, $2.79, Itali Aroma
 | 
			
		||||
Natural Juice, 64 oz bottle, $4.45, RainBow Splash
 | 
			
		||||
Strawberry Yogurt, 6 oz tub, $1.59, Creamy Spoon
 | 
			
		||||
Toilet Paper, 12 pack, $7.50, Cloud Soft
 | 
			
		||||
Energy Drink, 16 fl oz can, $2.85, Thunderbolts
 | 
			
		||||
Orange Juice, 64 fl oz, $3.50, Juicy Morning
 | 
			
		||||
Dog Food, 15 lb bag, $22.75, Paws'n'Tails
 | 
			
		||||
Toothpaste, 5.5 oz tube, $3.95, BrilliantSmiles
 | 
			
		||||
Breakfast Cereal, 12 oz box, $3.65, Morning Sunrise
 | 
			
		||||
Canned Beans, 15 oz can, $1.35, Trusty Beans
 | 
			
		||||
Ground Coffee, 1 lb, $7.50, Bright Morning Brew
 | 
			
		||||
Hand Soap, 12.5 fl oz pump bottle, $2.95, Foam'n'Fresh
 | 
			
		||||
Shampoo, 16 fl oz bottle, $5.75, Lustrous Locks
 | 
			
		||||
Olive Oil, 16.9 fl oz bottle, $8.95, SunGold Medley
 | 
			
		||||
Sugar, 5 lb bag, $2.95, Sweet Crystals
 | 
			
		||||
Chips, 11 oz packet, $3.50, Crunch Supreme
 | 
			
		||||
Laundry Detergent, 100 fl oz bottle, $9.50, Clean Vibrance
 | 
			
		||||
Bread, loaves, $2.25, Grain Bliss Bakery
 | 
			
		||||
Pasta Sauce, 24 oz jar, $3.15, Bella Nona's
 | 
			
		||||
Baby Wipes, 72 wipes pack, $3.25, CuddlePure
 | 
			
		||||
Frozen Pizza, 22 oz box, $5.95, TasteFest Pizzeria
 | 
			
		||||
Yogurt, 6 oz cup, $0.85, CreamHill Dairy
 | 
			
		||||
Instant Noodles, 3 oz packet, $0.35, Super Bowl
 | 
			
		||||
BBQ Sauce, 18 oz bottle, $3.75, Smoky Journey
 | 
			
		||||
Flour, 5 lb bag, $2.50, Home Baker's Friend
 | 
			
		||||
Soda, 12 pack cans, $4.95, Fizz and Pop
 | 
			
		||||
Vegetable Stock, 32 fl oz carton, $2.75, RichGarden Flavor
 | 
			
		||||
Quinoa, 16 oz bag, $4.95, Noble Grains
 | 
			
		||||
Protein Bar, 1.7 oz bar, $1.50, Power Fuel
 | 
			
		||||
Honey, 8 oz jar, $6.50, Busy Bees Natural
 | 
			
		||||
Aluminum Foil, 100 sq ft roll, $4.25, Silver Sheen
 | 
			
		||||
Mustard, 14 oz bottle, $1.95, Zesty Expressions
 | 
			
		||||
Ice Cream, 1.5 qt, $5.75, Frosty Delights
 | 
			
		||||
Granola bars, 5 pack, $4.10, Crunchy Delight
 | 
			
		||||
Frozen Pizza, 450g, $5.30, Pizza Fantastica
 | 
			
		||||
Rice Cakes, 10 units, $1.85, Crispy Joy
 | 
			
		||||
Cereal, 1kg, $3.59, Morning Whisk
 | 
			
		||||
Oatmeal, 500g, $2.75, FarmFresh Foods
 | 
			
		||||
Pasta sauce, 500ml, $3.90, Bella Cucina
 | 
			
		||||
Fruit yogurt, 150g, $0.70, Dairy Pearl
 | 
			
		||||
Potato Chips, 200g, $1.80, CrunchCraze Snacks
 | 
			
		||||
Canned Tuna, 150g, $1.50, Ocean's Bounty
 | 
			
		||||
Laundry Detergent, 1L, $6.99, SwiftClean
 | 
			
		||||
Organic Honey, 450g, $6.95, Bee Sweet
 | 
			
		||||
Olive Oil, 1L, $8.10, Mediterranean Magic
 | 
			
		||||
Protein Shake, 330ml, $2.50, Muscle Boost
 | 
			
		||||
Multi-vitamin juice, 1L, $3.90, Vital Sip
 | 
			
		||||
Soda, 2L, $1.99, FizzPop
 | 
			
		||||
Cheese Slices, 200g, $3.25, Cheesy Delights
 | 
			
		||||
Instant Noodles, 80g pack, $0.65, Noodle Express
 | 
			
		||||
Shaving Cream, 200ml, $4.30, SmoothMen
 | 
			
		||||
Body Wash, 400ml, $5.10, Fresh Waves
 | 
			
		||||
Toothpaste, 100g, $2.70, SparkleSmile
 | 
			
		||||
Whole Grain Bread, 500g, $2.50, Baker's Choice
 | 
			
		||||
Chocolate spread, 400g, $4.20, ChocoDream
 | 
			
		||||
Baby feeding bottles, 2 pack, $10.99, BabyCaring
 | 
			
		||||
Dog food, 2kg, $14.50, PetLove
 | 
			
		||||
Almond Milk, 1L, $3.10, NutriNatural
 | 
			
		||||
Coffee Beans, 250g, $6.30, Brew Bliss
 | 
			
		||||
Fruit Preserves, 450g, $3.55, Sweet Mama
 | 
			
		||||
Ice Cream, 2L, $7.99, Creamy Glaze
 | 
			
		||||
Baby Diapers, 50 pack, $22.50, ComfyKids
 | 
			
		||||
Solar-Powered Flashlight, 1 piece, $15.99, EcoShine
 | 
			
		||||
Wholegrain Bread, 500 grams, $4.99, Earthy Crunch
 | 
			
		||||
Organic Peanut Butter, 340 grams jar, $5.49, Nutty's Best
 | 
			
		||||
Chunky Tomato Sauce, 700 ml glass jar, $3.39, Bella Italia
 | 
			
		||||
Extra Virgin Olive Oil, 1 liter bottle, $9.99, Pure & Simple
 | 
			
		||||
Organic Brown Rice, 1 kilogram bag, $4.79, Grain Goodness
 | 
			
		||||
Greek Style Yogurt, 500 gram plastic tub, $3.49, Dairy's Pride
 | 
			
		||||
Gourmet Dark Chocolate, 200 gram bar, $3.99, Choconilla
 | 
			
		||||
Sparkling Natural Mineral Water, 1 liter bottle, $1.99, Bubbling Oasis
 | 
			
		||||
Frozen Blueberries, 500 gram bag, $4.89, Frosty Berries
 | 
			
		||||
Granulated Sugar, 1 kilogram bag, $2.39, Sweet Essentials
 | 
			
		||||
Smoked Salmon, 200 gram pack, $8.49, Ocean Delicacy
 | 
			
		||||
Shredded Mozzarella Cheese, 200 gram bag, $3.19, Cheesy Wonders
 | 
			
		||||
Cold Pressed Almond Oil, 500 ml bottle, $14.49, Nudges of Nature
 | 
			
		||||
Multi-grain bread, 1 lb bag, $2.75, Grain Masters
 | 
			
		||||
Canned corn, 15 oz can, $1.45, Kernel's Best
 | 
			
		||||
Organic chicken, 1lb bag vacuum-packed, $6.50, Free Roam Farms
 | 
			
		||||
Tomato Sauce, 24 oz jar, $3.00, Mama's Homemade
 | 
			
		||||
Broccoli florets, 1 lb bag, $2.50, Green Heaven Farms
 | 
			
		||||
Whole wheat pasta, 16 oz box, $2.80, Pasta Delight
 | 
			
		||||
Strawberry jam, 16 oz jar, $3.75, Sweet Berry
 | 
			
		||||
Natural peanut butter, 18 oz jar, $4.00, Nutty Delish
 | 
			
		||||
Toothpaste, 8 oz tube, $3.50, Fresh Minty
 | 
			
		||||
Bathroom tissue, 12 rolls, $10.00, Soft&Silky
 | 
			
		||||
Olive oil, 750 ml bottle, $7.50, Greek Gold
 | 
			
		||||
Almond milk, 64 fl oz carton, $3.50, Nutrich
 | 
			
		||||
Canned tuna, 5 oz can, $1.75, Ocean Fresh
 | 
			
		||||
Brown rice, 2 lb bag, $3.00, Healthy Grains
 | 
			
		||||
Granola bars, box of 6, $4.25, Crunchy Munchy
 | 
			
		||||
Apple cider, 64 fl oz bottle, $3.50, Gold Orchard
 | 
			
		||||
Organic eggs, dozen, $4.00, Farm Fresh
 | 
			
		||||
Shredded mozzarella, 8 oz bag, $2.75, Cheese Haven
 | 
			
		||||
Cereal, 14 oz box, $3.50, Breakfast Bites
 | 
			
		||||
Frozen pizza, 22 oz box, $5.00, Pizzeria Delights
 | 
			
		||||
Cheddar cheese, 8 oz pack, $3.80, Diary Delicacies
 | 
			
		||||
Vanilla yogurt, 32 oz tub, $3.00, Yogurt Bliss
 | 
			
		||||
Cranberry juice, 64 fl oz bottle, $3.75, Berry Fresh
 | 
			
		||||
Mild salsa, 16 oz jar, $2.50, Fiesta Flavors
 | 
			
		||||
Ground coffee, 12 oz bag, $6.00, Brew Bonanza
 | 
			
		||||
Green tea bags, box of 20, $4.00, Zen Harmony
 | 
			
		||||
White wine, 750 ml bottle, $15.00, Vineyard's Finest
 | 
			
		||||
Skinless chicken breast, 1 lb bag vacuum-packed, $6.00, Featherlite Farms
 | 
			
		||||
Strawberries, 1 lb box, $3.50, Fresh and Dewy
 | 
			
		||||
Apple pie, 24 oz box, $5.00, Granny's Bake Shop
 | 
			
		||||
Green Tea Bags, box of 20, $4.19, Golden Leaf
 | 
			
		||||
Gluten-Free Pasta, 500 gram box, $3.89, Happy Harvest
 | 
			
		||||
Cake Mix, 360 gram box, $3.39, Baking Bliss
 | 
			
		||||
Premium Dog Food, 2 kilogram bag, $8.99, Bow-wow Bites
 | 
			
		||||
Raw Honey, 450 gram jar, $6.49, Bee's Bounty
 | 
			
		||||
Breakfast Cereal, 700 gram box, $3.99, Sunrise Crunch
 | 
			
		||||
Organic Coconut Milk, 400 ml can, $2.29, Coco's Charm
 | 
			
		||||
French Roast Coffee, 250 gram pack, $5.99, Morning Bliss
 | 
			
		||||
Crunchy Peanut Brittle, 200 gram pack, $3.69, Candy Lovin'
 | 
			
		||||
Roasted Hazelnut Spread, 400 gram jar, $4.99, Nutty Spread
 | 
			
		||||
Baby Wipes, 50 count pack, $2.99, Baby's Care
 | 
			
		||||
Instant Oatmeal, 1 kilogram bag, $3.49, Oaty Delight
 | 
			
		||||
Premium Ice Cream, 500 ml tub, $4.89, Dreamy Scoops
 | 
			
		||||
Chicken Broth, 1 liter tetra pack, $2.89, Country Kitchen
 | 
			
		||||
Fair Trade Bananas, 1 kilogram, $2.49, Plantain Planet
 | 
			
		||||
Organic Eggs, Box of 12, $4.99, Healthy Yolks
 | 
			
		||||
Barbecue Sauce, 500 ml bottle, $3.99, Grill Master
 | 
			
		||||
Salted Pretzels, 300g bag, $1.99, Pretzel Kingdom
 | 
			
		||||
Orchard Apple Juice, 1 liter, $3.50, Apple Core Naturals
 | 
			
		||||
Whole Wheat Bread, 1 loaf, $2.80, Wheat Wonder
 | 
			
		||||
Peach Yoghurt, 500g pot, $1.70, Naturals Yoghurt Co.
 | 
			
		||||
Organic Strawberry Jam, 450g jar, $4.35,  Berry's Best
 | 
			
		||||
Mexican Style Salsa, 250g jar, $2.50, Southwest Spices
 | 
			
		||||
Cheesy Pita Chips, 230g bag, $1.75, Pita Pirates
 | 
			
		||||
Frozen Pepperoni Pizza, 16 inch, $5.50, Pizzeria Pronto
 | 
			
		||||
Clarified Butter Spread, 355g tub, $5.20, Golden Glow
 | 
			
		||||
Lemon Lime Soda, 2 liters, $1.99, Citrus Burst
 | 
			
		||||
Herb and Garlic Olive Oil, 500ml container, $3.80, Olive Oasis
 | 
			
		||||
BBQ Rib-flavored Potato Chips, 150g bag, $2.00, Crispy Canyon
 | 
			
		||||
All-Purpose Cleaner, 32 fl oz, $3.99, Germ Go Away
 | 
			
		||||
Double Chocolate Cookies, 350g box, $3.50, Sweet Temptations
 | 
			
		||||
Oatmeal Cereal, 750g box, $4.00, Sunshine Grains
 | 
			
		||||
Raspberry Iced Tea, 1.5 liter, $2.50, Summer Quench
 | 
			
		||||
Smoked Tuna in Can, 185g, $3.20, SeaFlavors
 | 
			
		||||
Almond Protein Bar, 75g pack, $2.10, Nourish and Energize
 | 
			
		||||
Vanilla Ice Cream, 2 liter carton, $4.99, Polar Temptations
 | 
			
		||||
Light Soy Sauce, 200ml bottle, $2.55, Eastern Essence
 | 
			
		||||
Spiced Pumpkin Soup, 400g can, $3.75, Easy Eats
 | 
			
		||||
Bacon Cheeseburger Frozen Entree, 350g box, $6.99, MealBox
 | 
			
		||||
Pistachio Nuts, 250g pouch, $5.99, Nutty Nature
 | 
			
		||||
Ginger Beer, 330ml bottle, $2.30, Zest & Spice
 | 
			
		||||
Cherry Blossom Room Spray, 300ml canister, $3.65, Home Comforts
 | 
			
		||||
Honey Roasted Peanut Butter, 340g jar, $3.89, Nutty Delight
 | 
			
		||||
White Basmati Rice, 1kg bag, $3.55, Eastern Grains
 | 
			
		||||
Rainbow Pasta, 500g bag, $2.25, Wiggly Noodles
 | 
			
		||||
Sea Salt and Caramel Popcorn, 200g bag, $1.79, PopBurst
 | 
			
		||||
All-Natural Cat Food, 1.5kg bag, $14.99, Kitty Pride
 | 
			
		||||
Organic tomato sauce, 24 oz jar, $6.35, Farm Fresh
 | 
			
		||||
Chocolate chip cereal, 15.5 oz box, $4.99, Morning Delights
 | 
			
		||||
Green tea bags, 20 pack, $3.65, Tranquil Leaves
 | 
			
		||||
Garlic herb bread, 1 loaf, $3.50, Rustic Bakery
 | 
			
		||||
Oatmeal raisin cookies, 12 count bag, $4.85, Gluten Free Galore
 | 
			
		||||
Extra virgin olive oil, 1 liter, $12.75, Olive Grove Industries
 | 
			
		||||
Dark chocolate almond bar, 100 grams, $2.75, Chocolate Euphoria
 | 
			
		||||
Natural almond butter, 16 oz jar, $7.99, Nutty Spreadables
 | 
			
		||||
Lemon verbena soap, bar, $4.85, Fresh Fields
 | 
			
		||||
Balsamic vinegar, 500 ml bottle, $7.35, Vine Magic
 | 
			
		||||
Organic frozen berries, 16 oz bag, $5.49, Nature's Sweetness
 | 
			
		||||
Gluten-free pasta, 500 grams package, $3.29, Grain Perfect
 | 
			
		||||
Spicy curry sauce, 14 oz jar, $4.49, Taste of India
 | 
			
		||||
Vanilla almond milk, 1 litre, $3.99, Silk Delicacies
 | 
			
		||||
Honey roasted peanuts, 16 oz can, $3.95, Crunchy Essentials
 | 
			
		||||
Cucumber melon lotion, 12 fl oz, $7.99, Fresh Skin
 | 
			
		||||
Organic brown rice, 2 lb bag, $4.99, Healthy Harvest
 | 
			
		||||
Apple cinnamon granola, 500 grams box, $5.99, Sunrise Cereals
 | 
			
		||||
Canned black beans, 15 oz can, $1.99, Bountiful Pantry
 | 
			
		||||
Quinoa chips, 150 grams bag, $2.79, Snack Revolution
 | 
			
		||||
Aloe vera shampoo, 16 fl oz bottle, $8.99, Pure Tresses
 | 
			
		||||
Stainless steel cleaner, 14 oz can, $5.75, Sparkle Shine
 | 
			
		||||
Pure maple syrup, 350 ml bottle, $14.99, Maple Haven
 | 
			
		||||
Mint dental floss, 50 m box, $2.79, Smile Bright
 | 
			
		||||
Unsalted mixed nuts, 16 oz jar, $6.85, Nutty Fixes
 | 
			
		||||
Lavender hand sanitizer, 2 oz bottle, $1.49, Germ Guardian
 | 
			
		||||
Soy-based vegan cheese, 200 grams package, $5.25, Veganed Cheese
 | 
			
		||||
Brown sugar glazed donuts, 6 count box, $4.49, Glaze Heaven
 | 
			
		||||
Basil pesto, 8 oz jar, $6.49, Flavors of Italy
 | 
			
		||||
Kale and spinach chips, 85 grams bag, $3.85, Power Green Crunchies
 | 
			
		||||
Diet Pasta, 500g box, $3.00, Slim Noodles
 | 
			
		||||
Acai Berry Juice, 1 liter, $4.99, Jungle Vitality
 | 
			
		||||
Vanilla Cashew Butter, 450g jar, $5.75, Nutty Naturals
 | 
			
		||||
Eco-friendly Toilet Paper, 12 roll pack, $7.99, Green Soft
 | 
			
		||||
Non-alcoholic Whisky, 700ml bottle, $25, Spiritless Spirits
 | 
			
		||||
Vegan Pork Sausages, 500g pack, $6.99, Plant Farm
 | 
			
		||||
Sugar-Free Chocolate Chip Cookies, 200g box, $4.50, Sweetless Snacks
 | 
			
		||||
Organic Coconut Oil, 500ml jar, $8.99, Pure Islands
 | 
			
		||||
Healthy Ramen Noodles, 400g pack, $2.20, Shinshi Ramen
 | 
			
		||||
Matcha Green Tea Dust, 100g box, $15.99, Orient Essence
 | 
			
		||||
Whole Grain Microwavable Rice, 500g pouch, $2.50, Quick Grains
 | 
			
		||||
Thai Curry Sauce, 200g bottle, $3.99, Taste of Thailand
 | 
			
		||||
Gluten-Free Bread Loaf, 400g pack, $4.70, Wheatless Wonders
 | 
			
		||||
Bioactive Honey, 300g jar, $10.99, Nature's Elite
 | 
			
		||||
Vegetable Protein Powder, 1kg pouch, $15.00, V-Protein
 | 
			
		||||
Low-Carb Ice cream, 500ml tub, $5.50, Guiltless Pleasures
 | 
			
		||||
Organic Chia Seeds, 500g bag, $6.50, Chia City
 | 
			
		||||
Smoked Vegan Cheese, 200g pack, $7.00, Plant Delights
 | 
			
		||||
Dairy-Free Butter, 250g tub, $3.99, Butter Bliss
 | 
			
		||||
Frozen Quinoa Mix, 400g bag, $2.99, Quick Health
 | 
			
		||||
Plant-Based Burger Patties, 450g box, $7.50, Green Butcher
 | 
			
		||||
Biotin Shampoo, 250ml bottle, $8.99, Healthy Hair
 | 
			
		||||
Soy Wax Candles, pack of 6, $11.00, Gentle Glow
 | 
			
		||||
Fit Soda, 6 cans pack, $4.99, Fresh Fizz
 | 
			
		||||
Sugar-Free Gum, 32 pieces pack, $2.50, Fresh Breeze
 | 
			
		||||
Air-Fried Popcorn, 300g bag, $3.99, Puffy Snacks
 | 
			
		||||
Anti-Aging Cream, 50g box, $25.00, Ageless Beauty
 | 
			
		||||
Activated Charcoal Toothpaste, 75ml, $7.00, Dazzle Smile
 | 
			
		||||
DIY Cake Mix, 600g box, $3.00, Bake Joy
 | 
			
		||||
Pet-friendly Bug Spray, 200ml bottle, $9.99, Bug Busters
 | 
			
		||||
Organic tomato sauce, 500g jar, $4.30, Tomatoes Forever
 | 
			
		||||
Whole grain bread, plastic bag, $2.75, WholeFood Delights
 | 
			
		||||
Organic eggs, dozen pack, $4.50, Local Farm Fresh
 | 
			
		||||
Salmon filet, vacuum pack, $8.30, Seascape Delicacy
 | 
			
		||||
Vegan lasagna, 800g tray, $6.50, Veggie Magic
 | 
			
		||||
Deluxe Mixed Nuts, 300g tin, $9.89, Nutty Goodness
 | 
			
		||||
Apple juice, 2 liters, $1.99, Apple a Day
 | 
			
		||||
Creamy peanut butter, 1 kilogram jar, $4.50, Peanut Nutopia
 | 
			
		||||
Fresh coffee beans, 500g package, $5.99, Moon Roast
 | 
			
		||||
Natural honey, 450g jar, $8.30, Nature's Gold
 | 
			
		||||
Strawberry jam, 500g jar, $3.99, Berry Nice
 | 
			
		||||
Whole grain pasta, 750g bag, $2.20, Italian Pasta King
 | 
			
		||||
Granola breakfast cereals, 600g box, $4.75, Breakfast Sunrise
 | 
			
		||||
Red Wine, 750ml bottle, $6.75, Summer Vinery
 | 
			
		||||
Bourbon whiskey, 700ml bottle, $27.99, Old Kentucky Treasure
 | 
			
		||||
Vegetarian hotdogs, pack of 8, $3.99, Plant Power
 | 
			
		||||
Gluten-free pizza, 350g box, $5.50, Bella Gluten-Free
 | 
			
		||||
Dairy-free chocolate, 100g bar, $1.80, Vegan Choco Dreams
 | 
			
		||||
Sparkling water, 1 liter, $0.99, Fizz Harmony
 | 
			
		||||
Vegetable stock cubes, pack of 6, $1.55, Veggie Secret
 | 
			
		||||
Coconut milk, 1 liter, $3.20, Tropical Bliss
 | 
			
		||||
Canned chickpeas, 400g tin, $0.85, Ideal Ingredient
 | 
			
		||||
Soya sauce, 150ml bottle, $3.50, Eastern Traditions
 | 
			
		||||
Canned pineapple, 567g tin, $1.35, Tropical Delight
 | 
			
		||||
Balsamic vinegar, 250ml bottle, $4.25, Olive Leaf
 | 
			
		||||
Baked beans, 415g tin, $0.95, Wholesome Meal
 | 
			
		||||
Premium dog food, 2kg bag, $9.95, Happy Hound
 | 
			
		||||
Organic olive oil, 500ml bottle, $7.50, Italian Olive Breeze
 | 
			
		||||
Popcorn kernels, 500g bag, $1.60, Popcorn Fiesta
 | 
			
		||||
Entire wheat flour, 5kg sack, $6.50, Healthy Harvest
 | 
			
		||||
Apple cider vinegar, 500 ml, $3.50, Golden Apple Inc.
 | 
			
		||||
Chocolate cookies, 250g package, $5.25, Choco Delight Co.
 | 
			
		||||
Goat cheese, 200g package, $6.90, Alpine Farms
 | 
			
		||||
Organic honey, 450g jar, $7.95, Bee Happy Inc.
 | 
			
		||||
Ice cream sandwich, pack of 6, $4.25, Cool Bites Co.
 | 
			
		||||
Pasta sauce, 700g jar, $3.80, Mama's Kitchen
 | 
			
		||||
Quinoa grains, 500g bag, $4.20, Nature's Bounty
 | 
			
		||||
Green tea bags, pack of 50, $5.60, Zen Moments Inc.
 | 
			
		||||
Whole wheat bread, 500g loaf, $2.95, Baker's Gold
 | 
			
		||||
Almond milk, 1 liter, $2.70, Nutty Goodness Inc.
 | 
			
		||||
Peanut butter, 500g jar, $3.70, Nutty Spread Co.
 | 
			
		||||
Instant coffee, 200g jar, $4.45, Espresso Express
 | 
			
		||||
Coconut water, 1 liter, $2.80, Tropical Bliss
 | 
			
		||||
Gluten-free pasta, 500g pack, $3.60, Pure Eats Inc.
 | 
			
		||||
Granola cereal, 700g box, $6.20, Crunchy Mornings Co.
 | 
			
		||||
Organic apple juice, 1 liter, $4.00, Fruity Sip Inc.
 | 
			
		||||
Vegetable broth, 1 liter carton, $2.95, Veggie Delights
 | 
			
		||||
Garlic bread, pack of 2, $3.40, Artisan Bakeries
 | 
			
		||||
Caramel popcorn, 200g bag, $3.80, Poppy's Popcorn
 | 
			
		||||
Chia seeds, 300g package, $5.45, Healthy Living
 | 
			
		||||
Dark chocolate bar, 100g, $2.30, Cocoa Luxe Co.
 | 
			
		||||
Vanilla soy milk, 1 liter, $3.10, Soy-n-Joy Inc.
 | 
			
		||||
Dehydrated mango strips, 200g bag, $6.50, Tropical Snacks
 | 
			
		||||
Crispy wheat crackers, 400g box, $4.00, Snack & Happy
 | 
			
		||||
Spicy salsa sauce, 350g jar, $3.75, Fiesty Flavors Inc.
 | 
			
		||||
Black olive tapenade, 200g jar, $5.20, Mediterranean Olives Co.
 | 
			
		||||
Cherry-flavored yogurt, pack of 4, $3.95, Happy Spoon Inc.
 | 
			
		||||
Gluten free almond cookies, 300g box, $5.60, Gluten Free Galore
 | 
			
		||||
Artichoke hearts in brine, 400g jar, $4.30, Garden's Treasure
 | 
			
		||||
GINGER-INFUSED WATER, 1 liter, $3.20, Zing by Nature
 | 
			
		||||
Multigrain Bread, 680g Bag, $3.20, Baker's Select
 | 
			
		||||
Organic Apples, 1lb Bag, $2.99, Eden Orchard
 | 
			
		||||
Soy Milk, 1.89 liters, $3.99, Soya Goodness
 | 
			
		||||
Roasted Almonds, 250g Packet, $5.50, Nutty Planet
 | 
			
		||||
Quinoa Pasta, 500g Packet, $3.70, Vital Vittles
 | 
			
		||||
Tomato Ketchup, 450g Bottle, $2.95, Flavor Blast
 | 
			
		||||
Sweet corn Soup, 500ml Can, $1.79, Campbell Fresh
 | 
			
		||||
Parmesan Cheese, 8 oz Pack, $4.70, Dairy Delight
 | 
			
		||||
Sparkling Water, 1.25 liters, $1.95, Aquatic Waves
 | 
			
		||||
Tamari Sauce, 300ml Bottle, $3.49, Taste of Japan
 | 
			
		||||
Jalapeno Salsa, 450g Jar, $4.15, Fiesta Flavors
 | 
			
		||||
Wheat Cereal, 15.5 oz Box, $3.70, Morning Munchie
 | 
			
		||||
Fruit Bars, 10 Bars Box, $4.20, Nutriloops
 | 
			
		||||
Whole Grain Crackers, 250g Box, $3.15, Davis Mill
 | 
			
		||||
Organic Tea bags, 25 count Box, $5.99, Tealicious
 | 
			
		||||
Veggie Chips, 5 oz Bag, $2.95, Crispy Curls
 | 
			
		||||
Apple Juice, 2 liters, $2.99, Eden Orchard
 | 
			
		||||
Chia Seeds, 1lbs Bag, $5.50, Nature's Bounty
 | 
			
		||||
Maple Syrup, 500ml Bottle, $6.99, Sweet Forest
 | 
			
		||||
Coconut Oil, 500ml Jar, $7.99, Coco Heaven
 | 
			
		||||
Dark Chocolate Bar, 3.5 oz Packet, $2.75, ChocoTreat
 | 
			
		||||
Pure Honey, 500g Jar, $7.50, Bee's Gold
 | 
			
		||||
Green Olive Spread, 180g Jar, $4.99, Taste of Mediterranean
 | 
			
		||||
Marinated Artichokes, 1lb Jar, $5.99, Bella Italia
 | 
			
		||||
Walnut Butter, 10 oz Jar, $6.49, Nutty Delight
 | 
			
		||||
Pumpkin Spice Coffee, 12 oz Bag, $7.99, Brew Morning
 | 
			
		||||
Low-Fat Yogurt, 1lbs Tub, $2.69, Dairy Good
 | 
			
		||||
Gluten-Free Cookies, 5.3 oz Box, $4.15, GlutenNoMore
 | 
			
		||||
Frozen Veggie Pizza, 405g Box, $6.89, Trattoria Prima
 | 
			
		||||
Lemon Soda, 2 liters, $2.10, Fizzy Drops
 | 
			
		||||
Crunchy chocolate cookies, 250 grams, $3.79, Coco Delights
 | 
			
		||||
Organic apple cider, 1 liter, $4.55, Orchard Bliss
 | 
			
		||||
Roasted almond butter, 400 grams, $6.10, Almo Naturals
 | 
			
		||||
Gluten-free pasta, 500 grams, $2.75, Gluten-Free Goodies
 | 
			
		||||
Low-sodium canned tomatoes, 794 grams, $1.50, Health Plus
 | 
			
		||||
Premium black coffee beans, 500 grams, $7.75, Brew Master
 | 
			
		||||
Red velvet ice cream, 1 liter, $5.49, Cream Heaven
 | 
			
		||||
Green tea bags, pack of 25, $4.29, Zen Infusions
 | 
			
		||||
Honey oat cereal, 450 grams, $3.99, Sunny Mornings
 | 
			
		||||
Fresh corn tortillas, pack of 10, $1.15, Fiesta Time
 | 
			
		||||
Extra virgin olive oil, 500 ml, $6.40, Oliva Pura
 | 
			
		||||
Dried figs snack pack, 200 grams, $3.75, Nature's Treat
 | 
			
		||||
Toasted sesame seeds, 300 grams, $3.25, Oriental Accents
 | 
			
		||||
Garlic salt seasoning, 120 grams, $2.65, Kitchen Staples
 | 
			
		||||
Cinnamon roll mix, 400 grams, $3.79, Baker's Joy
 | 
			
		||||
Roasted seaweed sheets, pack of 25, $4.99, Sea Harvest
 | 
			
		||||
Organic protein bars, pack of 6, $7.50, Protein Power
 | 
			
		||||
Vegetable stock cubes, pack of 12, $2.00, Soup Supreme
 | 
			
		||||
Low-fat Greek yogurt, 500 grams, $3.90, Greek Delight
 | 
			
		||||
Brown sugar, 500 grams, $1.65, Sweet Harmony
 | 
			
		||||
Shredded coconut, 300 grams, $2.80, Tropical Essence
 | 
			
		||||
Quinoa and oat granola, 400 grams, $5.50, Healthy Start
 | 
			
		||||
Apple cider vinegar, 500 ml, $2.65, Pure Benefits
 | 
			
		||||
Ghee clarified butter, 500 grams, $8.25, Golden Glow
 | 
			
		||||
Frozen mixed berries, 500 grams, $4.00, Fruit Symphony
 | 
			
		||||
Whole wheat bread, 400 grams, $2.45, Wholesome Loaf
 | 
			
		||||
Ranch dressing, 350 ml, $3.15, Salad Sensations
 | 
			
		||||
Sparkling water, 1.5 liters, $1.40, Crystal Flow
 | 
			
		||||
Dark chocolate bar, 100 grams, $2.85, Choco Dream
 | 
			
		||||
Purple grape juice, 1 liter, $3.60, Vineyard Fresh
 | 
			
		||||
Oatmeal Cereal, 500 grams box, $3.50, Oats Supreme
 | 
			
		||||
Banana Yogurt, 200 grams, $1.25, Banana Bungalow
 | 
			
		||||
Freshly Baked Bread, 500 grams, $2.10, BakeMaster
 | 
			
		||||
Spaghetti Pasta, 1 kilogram, $1.99, Pasta Paradiso
 | 
			
		||||
Whole wheat Flour, 2 kilograms, $3.75, Whole Wonder
 | 
			
		||||
Extra Virgin Olive Oil, 1 liter bottle, $7.59, Oil Royale
 | 
			
		||||
Low-Fat Cheese, 250 grams, $3.99, Cheese Cloud
 | 
			
		||||
Almond Butter, 400 grams jar, $6.99, Almond Appeal
 | 
			
		||||
Raspberry Jam, 300 grams jar, $2.25, Berry Dream
 | 
			
		||||
Green Tea Bags, 20 pack, $3.50, Tea Travels
 | 
			
		||||
Natural Mineral Water, 1 liter, $0.95, Aqua Pure
 | 
			
		||||
Red Table Wine, 750 ml, $9.14, Vine Veneration
 | 
			
		||||
Organic Brown Eggs, dozen, $3.50, Orchid Organics
 | 
			
		||||
Choco-Chip Cookies, 450 grams, $4.99, Crunchy Comfort
 | 
			
		||||
Salted Pretzels, 200 grams bag, $1.70, Snack Sensation
 | 
			
		||||
Quinoa Crackers, 300 grams, $2.95, Earth Eats
 | 
			
		||||
Spicy Tomato Sauce, 500 grams, $3.39, Spice Surprise
 | 
			
		||||
Orchard Apple Juice, 1 liter, $4.00, Apple Ambrosia
 | 
			
		||||
Sunflower Honey, 250 grams, $3.79, Sunny Fields
 | 
			
		||||
Root Salad Mix, 500 grams, $2.50, Veggie Victory
 | 
			
		||||
Coconut Milk, 250 ml, $1.50, Coconut Crown
 | 
			
		||||
Vanilla Ice Cream, 1 liter, $4.50, Vanilla Valley
 | 
			
		||||
Dark Roast Coffee, 500 grams, $9.79, Morning Majesty
 | 
			
		||||
Premium Dog Food, 3 kilogram, $15.99, Royal Bark
 | 
			
		||||
Aluminium Foil, 20 meters, $1.70, Shiny Shield
 | 
			
		||||
Crisp Pineapple Slices, 800 grams can, $2.99, Tropical Thrill
 | 
			
		||||
Rosemary Plant Herbs, 20 grams, $2.29, Herb Highness
 | 
			
		||||
Unsalted Pistachios, 500 grams, $8.50, Nature's Nut
 | 
			
		||||
Full Cream Yogurt, 500 grams, $3.29, Spoonful of Silk
 | 
			
		||||
Low-Carb Protein Bars, box of 6, $9.99, Fitness Fuel
 | 
			
		||||
Wholegrain Cereal, boxed, $4.00, Grainy Goodness
 | 
			
		||||
Organic Pasta, bag, $3.10, Green Earth Produce
 | 
			
		||||
Unsalted Butter, wrapped, $1.99, Creamy Classics
 | 
			
		||||
Blueberry Jam, jar, $4.50, Berry Delight
 | 
			
		||||
Dark Chocolate Bar, wrapped, $2.30, ChocoLux
 | 
			
		||||
Garlic Bread, bag, $1.50, Baked Best
 | 
			
		||||
Coconut Water, 1 liter, $3.20, Tropical Drops
 | 
			
		||||
Raspberry Yoghurt, tub, $2.75, Smooth Fridge
 | 
			
		||||
Almond Milk, carton, $2.48, NutriNuts
 | 
			
		||||
Microwave Popcorn, bag, $1.80, Poppable Joy
 | 
			
		||||
Olive Oil, 500 ml, $6.99, Golden Harvest
 | 
			
		||||
Sesame Crackers, box, $3.30, Snackers Galore
 | 
			
		||||
Apple juice, 1 liter, $3.10, Juicy Burst
 | 
			
		||||
Vegetable Chips, bag, $2.50, Crunchy Nature
 | 
			
		||||
Gluten-free Pancake Mix, N/A, $4.80, Good Belly
 | 
			
		||||
BBQ Sauce, bottle, $2.99, Southern Flavor
 | 
			
		||||
Strawberry Cheesecake Ice Cream, tub, $5.00, Sundae Bliss
 | 
			
		||||
Pack of Hotdogs, sealed, $4.25, Grill Masters
 | 
			
		||||
Pack of Blue Cheese, plastic wrap, $3.95, Cheese Gallery
 | 
			
		||||
Shrimp, Frozen Bag, $6.45, Sea Treasure
 | 
			
		||||
Sriracha Mayonnaise, jar, $3.00, Spicy Moments
 | 
			
		||||
Passionfruit Soda, can, $1.10, Fizzed Out
 | 
			
		||||
Low-fat Cottage Cheese, tub, $2.75, HealthSure
 | 
			
		||||
Alcohol Wipes, pack, $1.70, CleanQuick
 | 
			
		||||
Disposable Razors, pack, $3.80, Smooth Shave
 | 
			
		||||
Multi-grain Bread, bag, $2.80, Baked Delite
 | 
			
		||||
Roasted Cashew Nuts, bag, $3.50, Nutty Crunch
 | 
			
		||||
Toothpaste, 100g tube, $2.25, Sparkling Smile
 | 
			
		||||
Aluminum Foil, roll, $2.00, Silver Sheen
 | 
			
		||||
Dish Soap, bottle, $2.99, CleanDish
 | 
			
		||||
| 
		
		
			 Can't render this file because it contains an unexpected character in line 39 and column 17. 
		
	 | 
							
								
								
									
										1001
									
								
								src/retailtwin/data/random_people.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1001
									
								
								src/retailtwin/data/random_people.csv
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										160
									
								
								src/retailtwin/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/retailtwin/models.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,160 @@
 | 
			
		|||
from datetime import datetime
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from typing import Dict
 | 
			
		||||
 | 
			
		||||
from sqlalchemy import JSON, BigInteger, ForeignKey, Numeric, String
 | 
			
		||||
from sqlalchemy.ext.asyncio import AsyncAttrs
 | 
			
		||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
 | 
			
		||||
 | 
			
		||||
__all__ = [
 | 
			
		||||
    "Base",
 | 
			
		||||
    "Item",
 | 
			
		||||
    "Provider",
 | 
			
		||||
    "Order",
 | 
			
		||||
    "LocationType",
 | 
			
		||||
    "Location",
 | 
			
		||||
    "ItemBatch",
 | 
			
		||||
    "Discount",
 | 
			
		||||
    "Customer",
 | 
			
		||||
    "Cart",
 | 
			
		||||
    "ItemOnShelf",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Base(AsyncAttrs, DeclarativeBase):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Provider(Base):
 | 
			
		||||
    __tablename__ = "providers"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    name: Mapped[str]
 | 
			
		||||
    address: Mapped[str]
 | 
			
		||||
    phone: Mapped[str]
 | 
			
		||||
    vat: Mapped[str]
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        return f"<Provider ({self.name}) at {hex(id(self))}>"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Item(Base):
 | 
			
		||||
    __tablename__ = "items"
 | 
			
		||||
    sku: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    upc: Mapped[int] = mapped_column(BigInteger, nullable=False)
 | 
			
		||||
    provider: Mapped[int] = mapped_column(ForeignKey("providers.id"))
 | 
			
		||||
    name: Mapped[str] = mapped_column(nullable=False)
 | 
			
		||||
    package: Mapped[str] = mapped_column(unique=False)
 | 
			
		||||
    current: Mapped[bool] = mapped_column(
 | 
			
		||||
        comment="True if the item can be still requested to a provider, "
 | 
			
		||||
        "False if it has been discontinued"
 | 
			
		||||
    )
 | 
			
		||||
    # unit_weight: Mapped[int] = mapped_column(
 | 
			
		||||
    #    comment="Unit weight in grams not including additional packaging"
 | 
			
		||||
    # ) To be added as exercise
 | 
			
		||||
    volume_unpacked: Mapped[int] = mapped_column(
 | 
			
		||||
        comment="Volume of the item unpacked in cubic decimeters"
 | 
			
		||||
    )
 | 
			
		||||
    volume_packed: Mapped[int] = mapped_column(
 | 
			
		||||
        comment="Volume of each unit item when packaged in cubic decimeters"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        return f"<Item {self.name} at {hex(id(self))}>"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Order(Base):
 | 
			
		||||
    __tablename__ = "orders"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    sku: Mapped[int] = mapped_column(ForeignKey("items.sku"))
 | 
			
		||||
    placed: Mapped[datetime]
 | 
			
		||||
    qty: Mapped[int] = mapped_column(nullable=False)
 | 
			
		||||
    provider: Mapped[int] = mapped_column(ForeignKey("providers.id"))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LocationType(Base):
 | 
			
		||||
    __tablename__ = "locationtypes"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    name: Mapped[str]
 | 
			
		||||
    retail: Mapped[bool]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Location(Base):
 | 
			
		||||
    __tablename__ = "locations"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    loctype: Mapped[int] = mapped_column(ForeignKey("locationtypes.id"))
 | 
			
		||||
    name: Mapped[str]
 | 
			
		||||
    capacity: Mapped[int]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ItemBatch(Base):
 | 
			
		||||
    __tablename__ = "batches"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    lot: Mapped[str] = mapped_column(String(128), nullable=False)
 | 
			
		||||
    order: Mapped[str] = mapped_column(ForeignKey("orders.id"), nullable=True)
 | 
			
		||||
    received: Mapped[datetime] = mapped_column(nullable=True)
 | 
			
		||||
    unit_cost: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=False)
 | 
			
		||||
    price: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=False)
 | 
			
		||||
    best_until: Mapped[datetime]
 | 
			
		||||
    quantity: Mapped[int]
 | 
			
		||||
    sku: Mapped[id] = mapped_column(ForeignKey("items.sku"))
 | 
			
		||||
    item: Mapped[Item] = relationship()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Discount(Base):
 | 
			
		||||
    __tablename__ = "discounts"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    name: Mapped[str] = mapped_column(unique=True, nullable=False)
 | 
			
		||||
    definition: Mapped[Dict[str, Dict[str, int]]] = mapped_column(JSON, nullable=True)
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        return f"Type: {self.name} at {id(self)}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TaskOwner(Base):
 | 
			
		||||
    __tablename__ = "taskowners"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    name: Mapped[str] = mapped_column(unique=True, nullable=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Task(Base):
 | 
			
		||||
    __tablename__ = "tasks"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    taskowner: Mapped[int] = mapped_column(ForeignKey("taskowners.id"), nullable=False)
 | 
			
		||||
    location: Mapped[int] = mapped_column(ForeignKey("locations.id"), nullable=False)
 | 
			
		||||
    sku: Mapped[int] = mapped_column(ForeignKey("items.sku"), nullable=False)
 | 
			
		||||
    description: Mapped[str]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Customer(Base):
 | 
			
		||||
    __tablename__ = "customers"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    document: Mapped[str] = mapped_column(nullable=False)
 | 
			
		||||
    info: Mapped[Dict[str, str]] = mapped_column(JSON, nullable=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Cart(Base):
 | 
			
		||||
    __tablename__ = "carts"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    checkout: Mapped[datetime] = mapped_column(nullable=True)
 | 
			
		||||
    location: Mapped[int] = mapped_column(ForeignKey("locations.id"), nullable=False)
 | 
			
		||||
    customer: Mapped[int] = mapped_column(ForeignKey("customers.id"), nullable=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ItemOnCart(Base):
 | 
			
		||||
    __tablename__ = "itemsoncart"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    cart: Mapped[int] = mapped_column(ForeignKey("carts.id"), nullable=False)
 | 
			
		||||
    upc: Mapped[int] = mapped_column(nullable=False)  # Not FK to avoid sync issues
 | 
			
		||||
    quantity: Mapped[int] = mapped_column(nullable=True)
 | 
			
		||||
    unitprice: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=True)
 | 
			
		||||
    discount: Mapped[Decimal] = mapped_column(Numeric(9, 2), nullable=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ItemOnShelf(Base):
 | 
			
		||||
    __tablename__ = "itemsonshelf"
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True)
 | 
			
		||||
    batch: Mapped[int] = mapped_column(ForeignKey("batches.id"), nullable=True)
 | 
			
		||||
    discount: Mapped[int] = mapped_column(ForeignKey("discounts.id"), nullable=True)
 | 
			
		||||
    quantity: Mapped[int] = mapped_column(nullable=False)
 | 
			
		||||
    location: Mapped[int] = mapped_column(ForeignKey("locations.id"), nullable=False)
 | 
			
		||||
    batches: Mapped[ItemBatch] = relationship()
 | 
			
		||||
							
								
								
									
										44
									
								
								src/retailtwin/simulation.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/retailtwin/simulation.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
from datetime import datetime
 | 
			
		||||
from random import choice
 | 
			
		||||
 | 
			
		||||
import polars as pl
 | 
			
		||||
from scipy.stats.distributions import lognorm
 | 
			
		||||
from sqlalchemy.orm import Session
 | 
			
		||||
 | 
			
		||||
from dengfun.retail.models import Cart
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cart(session: Session) -> Cart:
 | 
			
		||||
    db_uri = str(session.get_bind().url.render_as_string(hide_password=False))
 | 
			
		||||
 | 
			
		||||
    customer = pl.read_database("select random_customer()", db_uri, engine="adbc")[0, 0]
 | 
			
		||||
    location = pl.read_database("select random_location()", db_uri, engine="adbc")[0, 0]
 | 
			
		||||
    cart = Cart(customer=customer, location=location, checkout=datetime.now())
 | 
			
		||||
    session.add(cart)
 | 
			
		||||
    session.commit()
 | 
			
		||||
 | 
			
		||||
    # Budget can be a small or a large expenditure
 | 
			
		||||
    budget = choice([10 * lognorm.rvs(0.25), 100 * lognorm.rvs(0.25)])
 | 
			
		||||
    items = pl.read_database("select * from items", db_uri, engine="adbc")
 | 
			
		||||
    spent = 0
 | 
			
		||||
    while spent > budget:
 | 
			
		||||
        quantity = choice([1, 2, 3, 4])
 | 
			
		||||
        item = items.sample(1, shuffle=True)
 | 
			
		||||
        price = pl.read_database(
 | 
			
		||||
            "select price_on_location({id}, {qty})", db_uri, engine="adbc"
 | 
			
		||||
        )[0, 0]
 | 
			
		||||
        session.execute(
 | 
			
		||||
            f"add_item_to_cart({cart.id}, {item.sku}, {quantity}, {location})"
 | 
			
		||||
        )
 | 
			
		||||
        session.commit()
 | 
			
		||||
        spent += price
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    from sqlalchemy import create_engine
 | 
			
		||||
    from sqlalchemy.orm import Session
 | 
			
		||||
 | 
			
		||||
    with Session(
 | 
			
		||||
        create_engine("postgresql://postgres:postgres@localhost/retail", echo=True)
 | 
			
		||||
    ) as session:
 | 
			
		||||
        cart(session)
 | 
			
		||||
							
								
								
									
										0
									
								
								src/retailtwin/sql/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/retailtwin/sql/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										9
									
								
								src/retailtwin/sql/add_item_to_cart.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/retailtwin/sql/add_item_to_cart.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
create or replace procedure add_item_to_cart(cart integer, sku integer, quantity integer)
 | 
			
		||||
language sql
 | 
			
		||||
as
 | 
			
		||||
$$
 | 
			
		||||
INSERT INTO itemsoncart
 | 
			
		||||
("cart", "sku", "quantity")
 | 
			
		||||
values
 | 
			
		||||
($1, $2, $3);
 | 
			
		||||
$$;
 | 
			
		||||
							
								
								
									
										6
									
								
								src/retailtwin/sql/checkout_cart.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/retailtwin/sql/checkout_cart.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
create or replace procedure checkout_cart(cart integer)
 | 
			
		||||
language sql
 | 
			
		||||
as
 | 
			
		||||
$$
 | 
			
		||||
 | 
			
		||||
$$;
 | 
			
		||||
							
								
								
									
										15
									
								
								src/retailtwin/sql/fetch_from_shelf.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/retailtwin/sql/fetch_from_shelf.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
create or replace function fetch_from_shelf(sku integer, loc integer)
 | 
			
		||||
  returns integer
 | 
			
		||||
language sql as
 | 
			
		||||
$$
 | 
			
		||||
  select
 | 
			
		||||
    min(itemsonshelf.id) as id
 | 
			
		||||
  from
 | 
			
		||||
    "batches"
 | 
			
		||||
  join
 | 
			
		||||
    itemsonshelf
 | 
			
		||||
  on
 | 
			
		||||
    "batches".id = itemsonshelf.batch
 | 
			
		||||
  where
 | 
			
		||||
    itemsonshelf.location = $2 and "batches".sku = $1;
 | 
			
		||||
$$;
 | 
			
		||||
							
								
								
									
										14
									
								
								src/retailtwin/sql/price_on_location.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/retailtwin/sql/price_on_location.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
create or replace function price_on_location(sku integer, loc integer)
 | 
			
		||||
  returns Numeric(9,2)
 | 
			
		||||
language sql as
 | 
			
		||||
$$
 | 
			
		||||
  select max(price) as unit_price  -- Maximum price for all batches.
 | 
			
		||||
  from                             -- Only discounts can reduce
 | 
			
		||||
    "batches"                      -- the price of an item on the cart
 | 
			
		||||
  join
 | 
			
		||||
    itemsonshelf
 | 
			
		||||
  on
 | 
			
		||||
    "batches".id = itemsonshelf.batch
 | 
			
		||||
  where
 | 
			
		||||
    itemsonshelf.location = $2 and "batches".sku = $1
 | 
			
		||||
$$;
 | 
			
		||||
							
								
								
									
										27
									
								
								src/retailtwin/sql/random_available_on_location.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/retailtwin/sql/random_available_on_location.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
create or replace function random_available_on_location(loc integer)
 | 
			
		||||
  returns integer
 | 
			
		||||
language sql as
 | 
			
		||||
$$
 | 
			
		||||
  with valid as
 | 
			
		||||
  (
 | 
			
		||||
    select
 | 
			
		||||
        batch
 | 
			
		||||
      from
 | 
			
		||||
        itemsonshelf
 | 
			
		||||
      where
 | 
			
		||||
        itemsonshelf.location = $1 and itemsonshelf.quantity > 0
 | 
			
		||||
      order by
 | 
			
		||||
        random()
 | 
			
		||||
      limit 1
 | 
			
		||||
  )
 | 
			
		||||
  select
 | 
			
		||||
    "batches".sku as sku
 | 
			
		||||
  from
 | 
			
		||||
    valid
 | 
			
		||||
  join
 | 
			
		||||
    "batches"
 | 
			
		||||
  on
 | 
			
		||||
    "batches".id = valid.batch
 | 
			
		||||
  limit
 | 
			
		||||
    1;
 | 
			
		||||
$$;
 | 
			
		||||
							
								
								
									
										38
									
								
								src/retailtwin/sql/random_cart_on_location.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/retailtwin/sql/random_cart_on_location.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
create or replace function random_cart_on_location(loc integer, lim integer)
 | 
			
		||||
  returns table(sku integer, unit_price numeric(9,2), item_id integer, discount integer)
 | 
			
		||||
language sql as
 | 
			
		||||
$$
 | 
			
		||||
  with
 | 
			
		||||
    items
 | 
			
		||||
  as
 | 
			
		||||
    (
 | 
			
		||||
      select
 | 
			
		||||
        id,
 | 
			
		||||
        random_available_on_location($1) as sku
 | 
			
		||||
      from
 | 
			
		||||
        generate_series(0, $2) id
 | 
			
		||||
    ),
 | 
			
		||||
  stock as
 | 
			
		||||
    (
 | 
			
		||||
    select
 | 
			
		||||
      sku,
 | 
			
		||||
      price_on_location(sku, $1) as unit_price,
 | 
			
		||||
      fetch_from_shelf(sku, $1) as item_id
 | 
			
		||||
    from
 | 
			
		||||
      items
 | 
			
		||||
    limit
 | 
			
		||||
      $2
 | 
			
		||||
    )
 | 
			
		||||
  select
 | 
			
		||||
    sku,
 | 
			
		||||
    unit_price,
 | 
			
		||||
    item_id,
 | 
			
		||||
    discount
 | 
			
		||||
  from
 | 
			
		||||
    stock
 | 
			
		||||
  join
 | 
			
		||||
    itemsonshelf
 | 
			
		||||
  on
 | 
			
		||||
    stock.item_id = itemsonshelf.id
 | 
			
		||||
    ;
 | 
			
		||||
$$;
 | 
			
		||||
							
								
								
									
										22
									
								
								src/retailtwin/sql/retire_batch_from_shelves.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/retailtwin/sql/retire_batch_from_shelves.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
CREATE OR REPLACE FUNCTION retire_batch_from_shelves(batch_ integer, location_ integer)
 | 
			
		||||
RETURNS void
 | 
			
		||||
LANGUAGE plpgsql
 | 
			
		||||
AS $$
 | 
			
		||||
DECLARE
 | 
			
		||||
  taskowner integer := id from taskowners where "name" = 'stocker';
 | 
			
		||||
  sku integer := sku from itemsonshelf s join "batches" b on s.batch = b.id where b.id = batch_;
 | 
			
		||||
BEGIN
 | 
			
		||||
UPDATE
 | 
			
		||||
  itemsonshelf
 | 
			
		||||
SET
 | 
			
		||||
  quantity = 0
 | 
			
		||||
WHERE
 | 
			
		||||
  batch = batch_
 | 
			
		||||
  AND "location" = location_;
 | 
			
		||||
INSERT INTO
 | 
			
		||||
  tasks
 | 
			
		||||
  ("location", sku, taskowner, "description")
 | 
			
		||||
VALUES
 | 
			
		||||
  (location_, sku, taskowner, format('Retire batch %s', batch_));
 | 
			
		||||
END;
 | 
			
		||||
$$;
 | 
			
		||||
							
								
								
									
										7
									
								
								src/retailtwin/sql/select_random_customer.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/retailtwin/sql/select_random_customer.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
create or replace function random_customer()
 | 
			
		||||
  returns integer
 | 
			
		||||
as $$
 | 
			
		||||
  SELECT id
 | 
			
		||||
  FROM customers TABLESAMPLE SYSTEM(1)
 | 
			
		||||
  LIMIT 1;
 | 
			
		||||
$$ language sql;
 | 
			
		||||
							
								
								
									
										8
									
								
								src/retailtwin/sql/select_random_location.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/retailtwin/sql/select_random_location.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
create or replace function random_location()
 | 
			
		||||
  returns integer
 | 
			
		||||
as $$
 | 
			
		||||
  SELECT id
 | 
			
		||||
  FROM locations
 | 
			
		||||
  ORDER BY random()
 | 
			
		||||
  LIMIT 1;
 | 
			
		||||
$$ language sql;
 | 
			
		||||
							
								
								
									
										16
									
								
								src/retailtwin/sql/stock_on_location.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/retailtwin/sql/stock_on_location.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
create or replace function stock_on_location(sku integer, loc integer)
 | 
			
		||||
  returns integer
 | 
			
		||||
language sql as
 | 
			
		||||
$$
 | 
			
		||||
  select
 | 
			
		||||
    -- Sum quantities from all the batches available in location
 | 
			
		||||
    sum(itemsonshelf.quantity)
 | 
			
		||||
  from
 | 
			
		||||
    itemsonshelf
 | 
			
		||||
  join
 | 
			
		||||
    "batches"
 | 
			
		||||
  on
 | 
			
		||||
    "batches".id = itemsonshelf.batch
 | 
			
		||||
  where
 | 
			
		||||
    "batches".sku = $1 and itemsonshelf.location = $2
 | 
			
		||||
$$
 | 
			
		||||
							
								
								
									
										37
									
								
								src/retailtwin/sql/sync.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/retailtwin/sql/sync.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
from pathlib import Path
 | 
			
		||||
from sqlalchemy import create_engine, text
 | 
			
		||||
from sqlalchemy.orm import Session
 | 
			
		||||
import dengfun
 | 
			
		||||
 | 
			
		||||
PACKAGE_ROOT = Path(dengfun.__file__).parent / "retail"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def funcandproc(db_uri: str):
 | 
			
		||||
    """Write functions and procedures into the given Postgresql database
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        db_uri (str): _description_
 | 
			
		||||
    """
 | 
			
		||||
    engine = create_engine(db_uri, echo=True)
 | 
			
		||||
 | 
			
		||||
    with Session(engine) as session:
 | 
			
		||||
        # Functions can be redefined in postgresql
 | 
			
		||||
        for predicate in (PACKAGE_ROOT / "sql").glob("*.sql"):
 | 
			
		||||
            print(f"Syncing {predicate}")
 | 
			
		||||
            with predicate.open() as sql:
 | 
			
		||||
                session.execute(text(sql.read()))
 | 
			
		||||
                session.commit()
 | 
			
		||||
 | 
			
		||||
        # Views have to be recreated if schema changes
 | 
			
		||||
        for predicate in (PACKAGE_ROOT / "sql" / "views").glob("*.sql"):
 | 
			
		||||
            print(f"Syncing view in {predicate}")
 | 
			
		||||
            # Get view name from the file name
 | 
			
		||||
            view_name = predicate.stem.removesuffix(".sql")
 | 
			
		||||
            with predicate.open() as sql:
 | 
			
		||||
                # First remove the view
 | 
			
		||||
                session.execute(text(f"drop view {view_name}"))
 | 
			
		||||
                session.commit()
 | 
			
		||||
 | 
			
		||||
                # And sync it
 | 
			
		||||
                session.execute(text(sql.read()))
 | 
			
		||||
                session.commit()
 | 
			
		||||
							
								
								
									
										3
									
								
								src/retailtwin/sql/views/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/retailtwin/sql/views/README.md
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
This folder hosts view definitions.
 | 
			
		||||
 | 
			
		||||
The name of the file must correspond to the name of the view, since it's used to create or update the definition
 | 
			
		||||
							
								
								
									
										18
									
								
								src/retailtwin/sql/views/inventory.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/retailtwin/sql/views/inventory.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
create or replace view inventory as (
 | 
			
		||||
		select it.upc as upc,
 | 
			
		||||
			b.id as batch,
 | 
			
		||||
			it.name as name,
 | 
			
		||||
			it.package as package,
 | 
			
		||||
			to_char(b.price, '999.99') as unitprice,
 | 
			
		||||
			-- It's fine to encode numerics as strings
 | 
			
		||||
			date(b.received at time zone 'UTC') as received,
 | 
			
		||||
			date(b.best_until at time zone 'UTC') as best_until,
 | 
			
		||||
			i.quantity as quantity,
 | 
			
		||||
			i."location" as "location",
 | 
			
		||||
			d.definition::text as discount_definition
 | 
			
		||||
		from itemsonshelf i
 | 
			
		||||
			join batches b on i.batch = b.id
 | 
			
		||||
			join items it on it.sku = b.sku
 | 
			
		||||
			left join discounts d on i.discount = d.id
 | 
			
		||||
		where it.current = true
 | 
			
		||||
	)
 | 
			
		||||
							
								
								
									
										13
									
								
								src/retailtwin/sql/views/stores.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/retailtwin/sql/views/stores.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
create or replace
 | 
			
		||||
view stores as (
 | 
			
		||||
select
 | 
			
		||||
	l.id as id,
 | 
			
		||||
	l."name" as "name"
 | 
			
		||||
from
 | 
			
		||||
	locations l
 | 
			
		||||
join locationtypes lt
 | 
			
		||||
on
 | 
			
		||||
	l.loctype = lt.id
 | 
			
		||||
where
 | 
			
		||||
	lt."name" = 'store'
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										16
									
								
								src/retailtwin/sql/views/warehouse_stock.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/retailtwin/sql/views/warehouse_stock.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
create or replace view warehouse_stock as (
 | 
			
		||||
        select it.upc as upc,
 | 
			
		||||
            l.name as warehouse,
 | 
			
		||||
            it.name as name,
 | 
			
		||||
            it.package as package,
 | 
			
		||||
            date(b.received) as received,
 | 
			
		||||
            date(b.best_until) as best_until,
 | 
			
		||||
            i.quantity as quantity
 | 
			
		||||
        from itemsonshelf i
 | 
			
		||||
            join batches b on i.batch = b.id
 | 
			
		||||
            join items it on it.sku = b.sku
 | 
			
		||||
            join locations l on l.id = i."location"
 | 
			
		||||
            join locationtypes l2 on l.loctype = l2.id
 | 
			
		||||
        where l2.name = 'warehouse'
 | 
			
		||||
            and it.current = true
 | 
			
		||||
    )
 | 
			
		||||
							
								
								
									
										5
									
								
								src/retailtwin/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/retailtwin/utils.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
from sqlalchemy.orm import Session
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def db_uri_from_session(session: Session) -> str:
 | 
			
		||||
    return str(session.get_bind().url.render_as_string(hide_password=False))
 | 
			
		||||
							
								
								
									
										36
									
								
								src/retailtwin/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/retailtwin/views.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
"""
 | 
			
		||||
This module contains a set of functions that provide SQLAlchemy table interfaces
 | 
			
		||||
to existing views in the database. Make sure these views have been synced after
 | 
			
		||||
table creation.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from dengfun.retail.models import Base
 | 
			
		||||
from functools import lru_cache
 | 
			
		||||
from sqlalchemy import Table, Column, Date, Integer, String, JSON
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Decorate with a cache to prevent sqlalchemy to create the table definition
 | 
			
		||||
# after multiple function calls. This is not an issue in production because
 | 
			
		||||
# a whole new isntance will be created by the worker generated by gunicorn,
 | 
			
		||||
# but this prevents errors during development. This is a way to create a
 | 
			
		||||
# pythonic singleton.
 | 
			
		||||
@lru_cache
 | 
			
		||||
def get_inventory_view() -> Table:
 | 
			
		||||
    return Table(
 | 
			
		||||
        "inventory",
 | 
			
		||||
        Base.metadata,
 | 
			
		||||
        Column("upc", Integer),
 | 
			
		||||
        Column("batch", Integer),
 | 
			
		||||
        Column("name", String),
 | 
			
		||||
        Column("package", String),
 | 
			
		||||
        Column("received", Date),
 | 
			
		||||
        Column("best_until", Date),
 | 
			
		||||
        Column("quantity", Integer),
 | 
			
		||||
        Column("location", Integer),
 | 
			
		||||
        Column("discount_definition", JSON),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@lru_cache
 | 
			
		||||
def get_stores_view() -> Table:
 | 
			
		||||
    return Table("stores", Base.metadata, Column("id", Integer), Column("name", String))
 | 
			
		||||
		Loading…
	
		Reference in a new issue