BI Feed
Vuoro exchanges data with a business-intelligence warehouse in two directions:
- Outbound — a read-only HTTP feed (
/api/v1/bi/*) that the warehouse pulls hours, staff cost and sentiment data from. - Inbound — a daily job that reads demand forecasts and labour suggestions produced by the warehouse. These drive demand-driven planning and AI roster drafting.
Both are configured for a single tenant per deployment through environment variables.
Outbound Feed
Configuration and authentication
| Variable | Purpose |
|---|---|
BI_API_TOKEN | Static bearer token (at least 16 characters) the warehouse presents |
BI_API_TENANT_ID | UUID of the tenant whose data is exposed |
The /bi/* routes are only registered when both variables are set. Otherwise the routes return 404. Requests must send Authorization: Bearer <BI_API_TOKEN>. The token is compared in constant time, and a missing or wrong token returns 401. The feed does not use user sessions, and the standard per-IP rate limit applies.
Common behaviour
fromandtoare inclusive Helsinki calendar dates (YYYY-MM-DD). The range can be at most 366 days.- Days are bucketed by the
Europe/Helsinkidate of the worklog's actual start (or the shift's planned start). A worklog that crosses midnight is attributed entirely to its start day. - Hour values are decimal strings with 4 fractional digits (for example
"7.5000"). Euro values are decimal strings with 2 fractional digits. - Employees are identified by internal UUID only. The payload never contains names or email addresses.
- Open worklogs (no clock-out yet) and discarded worklogs are excluded.
GET /api/v1/bi/daily-hours
Aggregated paid hours, with one row per (date, locationId, departmentId).
Query parameters: from, to, and optionally locationId and departmentId.
Each row has date, locationId, locationName, departmentId, departmentName, paidHours, headcount and byStatus (hours per worklog status: draft, approved, locked, exported). Only worked-time sources (clock, manual) count. Sick leave is excluded.
GET /api/v1/bi/employee-day
Employee-day grain, with one row per (date, locationId, departmentId, employeeId). The response is { rows, nextUpdatedSince }.
Query parameters: from, to, and optionally locationId, departmentId and updatedSince (an ISO 8601 cursor).
| Field | Meaning |
|---|---|
actualHours | Hours from worklogs, all sources |
bySource | actualHours broken down by worklog source: clock, manual, sick_leave. The consumer decides what counts as worked time versus absence and should fail loudly on unknown keys. |
scheduledHours | Hours from published shifts, independent of actuals. This distinguishes a sick day from a no-show. Scheduled-only days appear with actualHours: "0.0000". |
rawHourlyRate | Effective base hourly rate for that work date, resolved with the same override → wage table → fallback chain that payroll uses. It is null when it cannot be resolved; the row is still emitted. |
status | The least-advanced status among the contributing worklogs, or null for scheduled-only rows |
premiumAmount | Total euros of TES premiums on the row's worked worklogs |
premiumsByType | { hours, amount } for each premium type: SUN100, EVE, NIGHT, V100, X50, AP50, TS50. Every key is always present. |
allowanceAmount | Euro total of custom and shift allowances on the row's worklogs (the amounts payroll pays as custom allowances). "0.00" when there are none. |
About premiums:
- They are calculated when the feed is read, using the same shared code as the payroll engine, so they reconcile with payroll per employee, location and premium type.
- Only
clockandmanualworklogs that are not sick leave and not on a cancelled shift contribute. Draft worklogs are included. - Period-level items have no single work date and are not included: overtime classification (REG/ADD/OT50/OT100), equalization, sick pay, AP leave compensation, deductions, union dues, training and cancellation compensation.
Incremental pulls. Pass the previous response's nextUpdatedSince as updatedSince. Only keys whose worklogs, shifts, worklog adjustments or allowances changed after the cursor are returned. Each returned row is recomputed from live data: a worklog that was discarded, edited or moved re-emits its old key with updated (possibly zero) hours instead of disappearing. Consecutive pulls overlap by a short safety window, and every row is a full-state record, so upsert on the row key.
Wage-table and public-holiday edits do not advance the cursor. Run a periodic full refresh (no updatedSince) to pick those up.
GET /api/v1/bi/shift-pulse
Post-shift sentiment ratings, with one row per pulse (one per clock-out worklog). The response is { rows, nextUpdatedSince }.
Query parameters: from, to (shift dates), and optionally locationId and updatedSince.
Each row has worklogId, employeeId, locationId, shiftDate, rating (1–5), source (kiosk or web) and createdAt. The optional free-text comment is not exposed. Pulses cannot be changed after they are created, so updatedSince is a simple forward cursor on createdAt. Rows are idempotent by worklogId.
Inbound Demand Ingest
Vuoro reads full-state JSON snapshots from a Google Cloud Storage bucket, using a read-only service account:
| Object | Content |
|---|---|
feeds/demand/latest.json | Demand forecast in 30-minute slots, per location |
feeds/labor/latest.json | Daily labour suggestions, per location |
feeds/actuals/latest.json | Realised demand actuals |
| Variable | Purpose |
|---|---|
BI_INGEST_GCS_BUCKET | Bucket name |
GOOGLE_APPLICATION_CREDENTIALS | Path to the service-account credentials file |
BI_INGEST_TENANT_ID | Tenant the ingested data belongs to |
The ingest job is only scheduled when all three are set. It runs daily at 06:00 Europe/Helsinki as a BullMQ repeatable job, retries downloads with exponential backoff, and fails loudly on a missing or unreadable object instead of treating it as "no demand".
Planners read the ingested data through authenticated endpoints (Supervisor and above, RLS-scoped):
GET /api/v1/bi-demand/forecast?locationId=&from=&to=returns the 30-minute forecast rows.GET /api/v1/bi-demand/labor?locationId=&from=&to=returns the daily labour-suggestion rows.
These read endpoints are always registered, even when the ingest itself is not configured.
AI roster drafting is controlled separately (ROSTER_DRAFT_ENABLED plus the LLM API key and model variables) and is off by default.