Skip to main content

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​

VariablePurpose
BI_API_TOKENStatic bearer token (at least 16 characters) the warehouse presents
BI_API_TENANT_IDUUID 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​

  • from and to are inclusive Helsinki calendar dates (YYYY-MM-DD). The range can be at most 366 days.
  • Days are bucketed by the Europe/Helsinki date 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).

FieldMeaning
actualHoursHours from worklogs, all sources
bySourceactualHours 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.
scheduledHoursHours from published shifts, independent of actuals. This distinguishes a sick day from a no-show. Scheduled-only days appear with actualHours: "0.0000".
rawHourlyRateEffective 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.
statusThe least-advanced status among the contributing worklogs, or null for scheduled-only rows
premiumAmountTotal 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.
allowanceAmountEuro 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 clock and manual worklogs 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:

ObjectContent
feeds/demand/latest.jsonDemand forecast in 30-minute slots, per location
feeds/labor/latest.jsonDaily labour suggestions, per location
feeds/actuals/latest.jsonRealised demand actuals
VariablePurpose
BI_INGEST_GCS_BUCKETBucket name
GOOGLE_APPLICATION_CREDENTIALSPath to the service-account credentials file
BI_INGEST_TENANT_IDTenant 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.