Operating notes for the KloudC RetailOps server + website repository (FastAPI + SQLite + React/Vite; one process per store; POS adapters). Load before changing anything under backend/ or frontend/ so known traps are not repeated.
Instal·la
npx skillscat add sundar6264-ux/kloudc-retailops/retailops Instal·la des del registre de SkillsCat.
KloudC RetailOps - project skill
KloudC RetailOps is a multi-store operations product (KloudC LLC): one container per
store runs the API, the website and the scheduled jobs against that store's
own SQLite database and uploads. The POS is behind an adapter
(backend/app/pos/); Toast is the first one.
Read first:
docs/MULTISTORE_M1_CONTRACT.md- the M1 interfaces (POS adapters, neutral
models, capabilities). Still in force.docs/M2_CONTRACT.md- the M2 interfaces (store settings, branding, store
pack, directory, app tenant config, gating) and each batch's "As built".
Ground rules
- No store's own data or secrets in git. No real names, addresses,
phone numbers, staff, catalogs, prices or credentials. Settings default to
KloudC RetailOps values or blank; a store's values live in its.envand its
store pack, outside git..env.examplefiles hold blank values only. - Never under another product's domain. Hosts come from settings
(PLATFORM_DOMAIN,PUBLIC_BASE_URL, ...). The name-grep test (M2
contract 9.2, inbackend/tests/) fails on the legacy origin store's
names outside its allowlist; shrink the allowlist, never grow it. - No outside calls in tests. Point outside base URLs at
http://127.0.0.1:9(discard port) so an accidental call fails fast. No
real Toast / Clover / Whapi / SMTP / FCM / Anthropic calls, ever. - Additive API changes only. Clients in the field read existing keys;
add keys and routes, never rename or remove them.
Architecture in one screen
- Backend: FastAPI, SQLAlchemy 2, Alembic, APScheduler (in-process jobs,
no OS cron).app/main.pymounts the built website (frontend/dist) at/when it exists: one process, one port. Health:GET /api/health. - DB: SQLite in WAL mode with a 5 s busy timeout (
app/db.py), so a
reader (sqlite3, Litestream, backups) never blocks the app. - Auth: opaque session tokens in
user_sessions(not JWT), cookie for
the website (HttpOnly, Secure, SameSite=Lax), bearer token for the app.
Every router is created withdependencies=[Depends(get_current_user)]. - Frontend: React + Vite + react-router-dom with
HashRouter(the
static mount has no SPA catch-all, soBrowserRouterdeep links would 404). - Deploy: the repo-root
Dockerfile(multi-stage: website build, then
Python 3.12-slim with Tesseract) anddocker-compose.yml(one store plus an
optional Litestream sidecar).alembic upgrade headruns on container start.
Gotchas (each one shipped as a real bug once)
- Python 3.9 compatibility. CI runs 3.9 and 3.12. Use
typing.Optional[X], neverX | None, and nomatch. - SQLite drops timezone info on read. Every timestamp column uses
app/db.py'sUTCDateTime(), neverDateTime(timezone=True), or values
come back naive and browsers show them hours off. Show times in the store
time zone, never with a bare.toLocaleString(). - Toast timestamps (
2026-08-23T18:28:45.000+0000, no colon in the
offset) do not parse withdatetime.fromisoformaton 3.9; usestrptime(..., "%Y-%m-%dT%H:%M:%S.%f%z"). - Toast selections nest
modifiers; they are stored as their own rows
withparent_selection_guid. "Real sold items" queries needparent_selection_guid IS NULL, or modifiers are double-counted. When a
shared field's meaning changes, grep every consumer, not just the one you
are working on. - A Toast order's
businessDate/openedDatecan be a scheduled pickup
time, not when it was paid; "sold" counts use the payment date. - Multi-value inventory fields (
supplier,barcode) are
semicolon-separated; split before validating or filtering. - The inventory parser keeps an allowlisted column subset, matched by
lowercased header name. A new column needsCOLUMN_MAP(and a migration);
it never backfills old uploads. - Toast API limits: 429s on bulk backfills (retry failed dates from
job_runs); the Purchasing API is scope-blocked; the Analytics API is a
separate product with its own credentials and has no cost/margin data. - Testing auth over plain http: the session cookie is
Secure, sohttpxwill not send it back over http (correct behaviour); use bearer
tokens orcurlcookie jars in local checks. - Custom button hover styles need
:not(:disabled)- the globalbutton:hover:not(:disabled)rule has higher specificity than a bare.class:hover, so the button flashes the accent colour otherwise. - Never use a whole-file write on a large existing source file to make
a small change; edit in place. A whole-file overwrite once dropped most
ofapi.js. - Scheduled jobs run once ~10 s after start-up (gap-aware sync
catch-up).SCHEDULER_ENABLED=false/NOTIFICATIONS_ENABLED=false
turn the jobs off for local runs and smoke tests.
Adding a feature (established pattern)
- Service function in
backend/app/services/<feature>.py(pure queries,
no request objects), router inbackend/app/routers/, registered inapp/main.py. - A migration only when a table or column is needed; the next free number,
recorded in the M2 contract first. - Gate the feature: a key in
app/features.pyand, when it needs a POS
capability or a store feature, the entries inapp/pos/gating.py. - Website: page in
frontend/src/pages/, API call insrc/api.js, route
insrc/featureRoutes.js; pure logic in a*Model.jswith a*.test.mjsnext to it (node --test). - Tests:
backend/tests/test_<feature>.py; both Python versions.
Commands
cd backend && python -m pytest -q # backend suite
cd frontend && node --test $(find src -name '*.test.mjs') && npx vite build
cd backend && alembic upgrade head # migrate the DATABASE_URL database
python scripts/parity/parity_record.py ... # parity harness, see scripts/parity/README.mdThe parity harness is the regression gate for any change that must not
alter a store's numbers: record old and new code against the same database
copy and expect DIFFERENCES: 0 (same Python version on both sides).