diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..8b162db
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index b771a71..a908c12 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
\ No newline at end of file
diff --git a/src/retailtwin/__init__.py b/src/retailtwin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/retailtwin/api/README.md b/src/retailtwin/api/README.md
new file mode 100644
index 0000000..ab0dfca
--- /dev/null
+++ b/src/retailtwin/api/README.md
@@ -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
\ No newline at end of file
diff --git a/src/retailtwin/api/__init__.py b/src/retailtwin/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/retailtwin/api/composition.py b/src/retailtwin/api/composition.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/retailtwin/api/db.py b/src/retailtwin/api/db.py
new file mode 100644
index 0000000..fcfd473
--- /dev/null
+++ b/src/retailtwin/api/db.py
@@ -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''
+ f''
+ f""
+ ]
+ )
+
+
+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"
{it} | " for it in record)
+ + f" {detail_button(record[1])} | "
+ )
+ if i == (pagesize - 1): # Handle last row for infinite scrolling
+ line = " ".join(
+ [
+ f'{cells}
',
+ ]
+ )
+ else:
+ line = f"{cells}
"
+
+ 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 = ['']
+ 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'')
+
+ # Wrap with the select tag
+ query_params = (
+ 'js:{page: "0", '
+ 'pagesize: "20", '
+ 'sortby: "upc", '
+ 'store: document.getElementById("store").value}'
+ )
+ return (
+ f'