Calculates the lowest of prices for products in the last 30 days based on selling price and promotions.
Find a file
Niels de Water 07a35380c1
All checks were successful
Tests / test (push) Successful in 6s
u
2026-09-12 11:44:22 +02:00
.forgejo/workflows added workflow 2026-09-12 11:31:56 +02:00
data init 2026-09-12 11:28:50 +02:00
src/pricelabel init 2026-09-12 11:28:50 +02:00
tests init 2026-09-12 11:28:50 +02:00
.dockerignore init 2026-09-12 11:28:50 +02:00
docker-compose.yml init 2026-09-12 11:28:50 +02:00
Dockerfile init 2026-09-12 11:28:50 +02:00
pyproject.toml init 2026-09-12 11:28:50 +02:00
README.md u 2026-09-12 11:44:22 +02:00
uv.lock init 2026-09-12 11:28:50 +02:00

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

  1. 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.
  2. A price is active in the window if its validity interval overlaps the 30-day window. An interval is IngangsDatumPrijsEindDatumPrijs for regular prices, StartDatumPromotieEindDatumPromotie for promos. null (open-ended) dates are treated as unbounded.
  3. The answer is the minimum over all active regular and promo prices.
  4. If no price is active, 404 is returned (single article) or null (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.

  1. Container image: build the multi-stage Dockerfile (the uv build stage installs the project into an isolated site-packages, the runtime stage only ships Python, the installed package and the data files). Push it to your registry (Harbor/ECR/GHCR).
  2. Orchestration: one Deployment/Service in Kubernetes (or an ECS task on Fargate) with 2+ replicas behind a load balancer, rolling updates, and readiness/liveness probes hitting /health.
  3. 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_CSV at the published path. Because data loading is isolated in repo.py, moving to an object store or a database later only touches that one module.
  4. 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.
  5. 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 code 40); filter by status is a one-liner in repo.py if needed.
  • The price is reported "as of now" using the container's system clock; set the asof query parameter to evaluate historical windows.
  • ArtikelDatum (launch date) is kept in the source data but is not needed for the 30-day window logic.