Calculates the lowest of prices for products in the last 30 days based on selling price and promotions.
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| data | ||
| src/pricelabel | ||
| tests | ||
| .dockerignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Gall and Gall – Price Label Service
Microservice that computes and exposes the lowest price of an article in the last 30 days, based on the regular price data and the promotion data exported from the legacy system.
Endpoints
| Endpoint | Description |
|---|---|
GET /lowest-price/{ArtikelNummer}?asof=YYYY-MM-DD |
Lowest price for one article in the 30 days up to and including asof (defaults to today) |
GET /lowest-prices?asof=YYYY-MM-DD |
Same, for all articles |
GET /health |
Liveness check |
asof is optional. The 30-day window is [asof-29d, asof] (both endpoints
inclusive).
Examples
curl 'http://localhost:8050/lowest-price/219916?asof=2023-06-18'
# {"artikelnummer":"219916","prijs":11.59,"asof":"2023-06-18"}
curl 'http://localhost:8050/lowest-prices'
How the price is computed
- All regular price rows (
data/verkoopprijzen.csv) and all promo rows (data/promoties.csv) are loaded at startup. For promos the discounted price (VoorPrijs) is used. - A price is active in the window if its validity interval overlaps the
30-day window. An interval is
IngangsDatumPrijs–EindDatumPrijsfor regular prices,StartDatumPromotie–EindDatumPromotiefor promos.null(open-ended) dates are treated as unbounded. - The answer is the minimum over all active regular and promo prices.
- If no price is active,
404is returned (single article) ornull(bulk endpoint).
Project layout
pyproject.toml # project metadata + deps (fastapi, uvicorn)
src/pricelabel/
repo.py # CSV loading
service.py # 30-day window / lowest-price logic
app.py # FastAPI app
data/ # legacy CSV exports
Dockerfile # multi-stage build (uv installs the project)
docker-compose.yml
Run locally
git clone https://git.stellarvalley.com/niels/price.git
cd price
docker compose up -d --build
curl http://localhost:8050/health
The API is on port 8050, bound to 0.0.0.0.
Without Docker
curl -Ls https://astral.sh/uv/install.sh | sh # maybe logout and back in to enable $PATH to uv
git clone https://git.stellarvalley.com/niels/price.git
cd price
uv sync # installs the app + dev deps (pytest, httpx) into .venv
uv run uvicorn pricelabel.app:app --host 0.0.0.0 --port 8050
Running the tests (no Docker needed)
uv run pytest -q
The tests load the real sample data from data/ and cover the CSV
loading (repo), the 30-day window logic (service), and the HTTP API
(single/bulk endpoints, 404, asof handling, health check).
Deployment
The service is stateless: data files are read once at startup and nothing is persisted, so any number of identical containers can run.
- Container image: build the multi-stage
Dockerfile(theuvbuild stage installs the project into an isolatedsite-packages, the runtime stage only ships Python, the installed package and the data files). Push it to your registry (Harbor/ECR/GHCR). - Orchestration: one
Deployment/Servicein Kubernetes (or an ECS task on Fargate) with 2+ replicas behind a load balancer, rolling updates, and readiness/liveness probes hitting/health. - Data delivery: the legacy system currently delivers the CSVs into
this folder. In production we would mount the same feed (SFTP/MinIO/S3)
as a volume and have the app re-load on change or on schedule, or point
PRICES_CSV/PROMOTIES_CSVat the published path. Because data loading is isolated inrepo.py, moving to an object store or a database later only touches that one module. - Scaling path: if the data set grows large, the same code works as-is (files are small); the in-memory model could then be swapped for SQLite and an indexed query per article/date, again without touching the API.
- Secrets/Ops: no credentials are needed today; enable structured access logs in front of uvicorn, and keep the image on a pinned Python base for reproducible builds.
Known simplifications
- Promo rows are accepted regardless of
Status(the sample data contains a single, consistently-used code40); filter by status is a one-liner inrepo.pyif needed. - The price is reported "as of now" using the container's system clock;
set the
asofquery parameter to evaluate historical windows. ArtikelDatum(launch date) is kept in the source data but is not needed for the 30-day window logic.