--- a/src/primary/modules/capture-dashboard/capture-live/capture-live-pace.service.ts +++ b/src/primary/modules/capture-dashboard/capture-live/capture-live-pace.service.ts @@ -86,7 +86,7 @@ const iso = (col: string) => `to_char(${col} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`; const [row] = await db.sequelize.query( - `WITH ${paceCte(consumables, true)}, + `WITH ${paceCte(consumables, { oneUser: true })}, pick AS ( SELECT COALESCE((SELECT day FROM scoped_day WHERE day = CAST(:day AS date)), (SELECT MAX(day) FROM scoped_day)) AS day ), --- a/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts +++ b/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts @@ -8,7 +8,7 @@ import { DenovoInventoryStatusEnum, ScopeType } from 'src/shared/utils/enum'; import { readableWhere, resolveCaptureScope } from '../access/capture-scope'; import { CaptureColorKeyEnum, CaptureInsightTypeEnum, CaptureLocationStatusEnum, CaptureSeverityEnum } from '../dto/capture-dashboard.enum'; -import { CONSUMABLE_GROUP_CTE, CONSUMABLE_ROOM_CTE, CONSUMABLE_TEAM_AUDIT_CTE } from './consumable-scope.sql'; +import { CONSUMABLE_CAPTURER_SKU_CTE, CONSUMABLE_GROUP_CTE, CONSUMABLE_ROOM_CTE, consumableTeamAuditCte } from './consumable-scope.sql'; import { paceCte, paceReplacements, PaceSettings, resolvePaceSettings } from './pace.sql'; import { projectCapturesSql, projectFacilitiesSql, projectRoomsSql, projectStartSql, projectTeamSql, stickerScopeSql } from './project-rooms.sql'; import { @@ -173,6 +173,7 @@ audit_shown: number; audit_base: number; clean_rate: string; + project_audit_failed: number; insight_type: CaptureInsightTypeEnum; account_type: string | null; team_median: string | null; @@ -181,13 +182,20 @@ audits_pending: number; } -/** The team's audits, summed as its Audit tiles show them. */ +/** The team's audits to date, summed person by person. */ interface TeamAudits { auditsPassed: number; auditsDone: number; auditsPending: number; } +/** A person's failed audits to date — what the quality red flag reads, whatever today holds. */ +interface TeamQuality { + id: string; + name: string; + auditFailed: number; +} + interface NthWorkingDayRow { day: string | null; } @@ -332,18 +340,24 @@ /** * What the team tile's audit rate is measured over: required fields on an asset run, the room audits - * of the capturer's products on a consumables one. Both hand back `total` and `needs_review` per capturer. + * of the capturer's products on a consumables one. Both hand back `total` and `needs_review` per + * capturer, twice: `audited` for the project to date and `audited_today` for today's work. */ private auditedCte(consumables: boolean): string { - return consumables ? CONSUMABLE_TEAM_AUDIT_CTE : this.assetAuditCte(consumables); + return consumables + ? `${CONSUMABLE_CAPTURER_SKU_CTE}, ${consumableTeamAuditCte('audited')}, ${consumableTeamAuditCte('audited_today', true)}` + : `${this.assetAuditCte(consumables, 'audited')}, ${this.assetAuditCte(consumables, 'audited_today', true)}`; } /** - * The Audit cell's figures, `audit_shown` over `audit_base`: audited over captured on an asset run, - * SKUs whose audit matched the system count over the SKUs audited on a consumables one. + * Today's Audit cell, `audit_shown` over `audit_base`: audited over captured on an asset run, SKUs + * whose audit matched the system count over the SKUs audited on a consumables one. Plus the passes to + * date the banner totals read — a consumables figure, so an asset run carries 0. */ private auditFiguresSql(consumables: boolean): string { - return consumables ? 'COALESCE(au.passed, 0)::int AS audit_shown, COALESCE(au.total, 0)::int AS audit_base' : 'COALESCE(au.total, 0)::int AS audit_shown, COALESCE(ps.total, 0)::int AS audit_base'; + return consumables + ? 'COALESCE(aut.passed, 0)::int AS audit_shown, COALESCE(aut.total, 0)::int AS audit_base, COALESCE(au.passed, 0)::int AS project_audit_passed' + : 'COALESCE(aut.total, 0)::int AS audit_shown, COALESCE(ps.today_count, 0)::int AS audit_base, 0 AS project_audit_passed'; } /** @@ -353,11 +367,11 @@ * An asset with an inventory row is read from its columns, a standalone processed sticker from its * `extracted_data`. A known model answers manufacturer, model and model number at once, and * declaring a tag missing answers that tag. Consumables and items no longer on the floor are out of - * the population rather than counted against it. + * the population rather than counted against it. Written as CTE `name`; `today` keeps it to today's captures. */ - private assetAuditCte(consumables: boolean): string { + private assetAuditCte(consumables: boolean, name: string, today = false): string { return ` - audited AS ( + ${name} AS ( SELECT k.created_by AS user_id, COUNT(*)::int AS total, COUNT(*) FILTER ( @@ -389,7 +403,7 @@ COALESCE((CASE WHEN pa.id IS NOT NULL THEN pa.additional_details ->> 'biomedTagEnabled' ELSE a.additional_details ->> 'biomedTagEnabled' END)::boolean, false) AS biomed_on ) tag WHERE ${projectRoomsSql()} AND k.deleted_at IS NULL AND ${projectCapturesSql()} - AND ${stickerScopeSql(consumables)} + AND ${stickerScopeSql(consumables)}${today ? ' AND (k.created_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date' : ''} AND ( (k.inventory_id IS NOT NULL AND i.id IS NOT NULL AND i.discontinue_date IS NULL @@ -423,7 +437,7 @@ this.staleRooms(project.id, consumables), ]); // Reads the banner's forecast, so it runs once that has resolved. - const redFlags = await this.buildRedFlags(project.id, consumables, banner, stale, team.rows, pace); + const redFlags = await this.buildRedFlags(project.id, consumables, banner, stale, team.quality, pace); return { // The audit totals come off the team rows, so the banner and the Audit tiles cannot disagree. @@ -1045,14 +1059,7 @@ * Each one is a condition the data actually shows — inaccessible rooms nobody revisited, a * capturer whose clean rate slipped, rooms left mid-capture, and a run tracking behind its window. */ - private async buildRedFlags( - projectId: number, - consumables: boolean, - banner: CaptureLiveBannerDto, - stale: StaleRoomRow[], - team: CaptureTeamPerfRowDto[], - pace: PaceSettings, - ): Promise { + private async buildRedFlags(projectId: number, consumables: boolean, banner: CaptureLiveBannerDto, stale: StaleRoomRow[], quality: TeamQuality[], pace: PaceSettings): Promise { const flags: CaptureRedFlagDto[] = []; // Rooms the team could not enter, and how many of those still have nothing captured. The @@ -1091,9 +1098,8 @@ }); } - // The team rows already carry each person's clean-capture rate; flagging off them costs no - // second pass over the stickers, and the two panels cannot disagree on the number. - const failing = team.filter((row) => row.auditFailed >= pace.maxFailedAudits); + // Failed audits to date, off the team query: a quality concern outlasts the day its audits failed. + const failing = quality.filter((row) => row.auditFailed >= pace.maxFailedAudits); for (const person of failing.sort((a, b) => b.auditFailed - a.auditFailed)) { flags.push({ id: `quality-${person.id}`, @@ -1132,11 +1138,12 @@ /** * One row per person who captured on this project or was rostered onto one of its visits. * - * Pace covers today when they scanned today, the project to date otherwise. `capturesPerMinute` is - * captures per active minute, null until there is enough to measure. The team median spans the rows - * with a pace and is what each verdict is judged against. + * Every figure on a row covers today, so a new day starts blank: pace, hours on site and the Audit + * cell. `capturesPerMinute` is captures per active minute, null until today holds enough to measure. + * The team median spans today's paces and is what each verdict is judged against. `audits` and + * `quality` are the project to date, for the banner totals and the red flags. */ - private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null; audits: TeamAudits }> { + private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null; audits: TeamAudits; quality: TeamQuality[] }> { const replacements = { projectId, auditsPerCapturer: AUDITS_PER_CAPTURER, @@ -1155,7 +1162,7 @@ }; const rows = await db.sequelize.query( - `WITH ${paceCte(consumables)}, ${this.auditedCte(consumables)}, + `WITH ${paceCte(consumables, { todayOnly: true })}, ${this.auditedCte(consumables)}, project_stickers AS ( SELECT k.created_by AS user_id, COUNT(*)::int AS total, @@ -1182,29 +1189,32 @@ ROUND(COALESCE(sc.active_hours, 0), 1)::numeric AS active_hours, COALESCE(ps.today_count, 0)::int AS today_count, ROUND(COALESCE(sc.hours, 0), 1)::numeric AS hours_on_site, - COALESCE(au.total, 0)::int AS audit_total, - COALESCE(au.needs_review, 0)::int AS audit_failed, + COALESCE(aut.total, 0)::int AS audit_total, + COALESCE(aut.needs_review, 0)::int AS audit_failed, ${this.auditFiguresSql(consumables)}, - CASE WHEN COALESCE(au.total, 0) = 0 THEN 1::numeric - ELSE 1 - (au.needs_review::numeric / au.total) END AS clean_rate + CASE WHEN COALESCE(aut.total, 0) = 0 THEN 1::numeric + ELSE 1 - (aut.needs_review::numeric / aut.total) END AS clean_rate, + COALESCE(au.total, 0)::int AS project_audit_done, + COALESCE(au.needs_review, 0)::int AS project_audit_failed FROM people p LEFT JOIN users u ON u.id = p.user_id LEFT JOIN accounts acc ON acc.id = u.account_id LEFT JOIN project_stickers ps ON ps.user_id = p.user_id LEFT JOIN audited au ON au.user_id = p.user_id + LEFT JOIN audited_today aut ON aut.user_id = p.user_id LEFT JOIN scoped sc ON sc.user_id = p.user_id ), med AS ( SELECT (PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY captures_per_minute))::numeric AS team_median FROM measured ), - -- per person, so one capturer's extra audits do not cover another's + -- to date and per person, so one capturer's extra audits do not cover another's team_audit AS ( - SELECT COALESCE(SUM(audit_shown), 0)::int AS audits_passed, - COALESCE(SUM(audit_total), 0)::int AS audits_done, - COALESCE(SUM(GREATEST(:auditsPerCapturer - audit_total, 0)), 0)::int AS audits_pending + SELECT COALESCE(SUM(project_audit_passed), 0)::int AS audits_passed, + COALESCE(SUM(project_audit_done), 0)::int AS audits_done, + COALESCE(SUM(GREATEST(:auditsPerCapturer - project_audit_done, 0)), 0)::int AS audits_pending FROM measured ) - SELECT m.user_id, m.name, m.account_type, m.captures_per_minute, m.active_hours, m.today_count, m.hours_on_site, m.audit_total, m.audit_failed, m.audit_shown, m.audit_base, m.clean_rate, med.team_median, + SELECT m.user_id, m.name, m.account_type, m.captures_per_minute, m.active_hours, m.today_count, m.hours_on_site, m.audit_total, m.audit_failed, m.audit_shown, m.audit_base, m.clean_rate, m.project_audit_failed, med.team_median, ta.audits_passed, ta.audits_done, ta.audits_pending, CASE WHEN m.audit_failed >= :maxFailedAudits THEN :qualityWarn @@ -1221,6 +1231,7 @@ ); const first = rows[0]; + const nameOf = (row: TeamRow): string => row.name?.trim() || `User ${row.user_id}`; return { median: first?.team_median == null ? null : round1(Number(first.team_median)), audits: { @@ -1228,9 +1239,10 @@ auditsDone: Number(first?.audits_done) || 0, auditsPending: Number(first?.audits_pending) || 0, }, + quality: rows.map((row) => ({ id: String(row.user_id), name: nameOf(row), auditFailed: Number(row.project_audit_failed) || 0 })), rows: rows.map((row) => ({ id: String(row.user_id), - name: row.name?.trim() || `User ${row.user_id}`, + name: nameOf(row), capturesPerMinute: row.captures_per_minute == null ? null : round1(Number(row.captures_per_minute)), activeHours: round1(Number(row.active_hours) || 0), todayCount: Number(row.today_count) || 0, --- a/src/primary/modules/capture-dashboard/capture-live/consumable-scope.sql.ts +++ b/src/primary/modules/capture-dashboard/capture-live/consumable-scope.sql.ts @@ -91,34 +91,40 @@ GROUP BY g.location_id )`; +/** The SKUs — a live product in a room, the unit an audit covers — each capturer captured into. */ +export const CONSUMABLE_CAPTURER_SKU_CTE = ` + capturer_sku AS ( + SELECT DISTINCT t.created_by AS user_id, l.id AS location_id, t.consumables_id + ${PROJECT_STOCK_FROM} + JOIN consumables c ON c.id = t.consumables_id AND c.deleted_at IS NULL + ${PROJECT_STOCK_WHERE} + )`; + /** - * The team tile's audit for a consumables run, per capturer, in SKUs — a live product in a room, the - * unit an audit covers — they captured into: `total` those audited, `passed` the ones whose latest - * audit counted exactly its system count and `needs_review` the ones that did not match. + * The team tile's audit for a consumables run, per capturer, over `capturer_sku`: `total` the SKUs + * audited, `passed` the ones whose latest audit counted exactly its system count and `needs_review` + * the ones that did not match. Written as CTE `name`; `today` counts only audits made today. * * The mobile audit screen's rule — an audit is one product in one room, the latest one is its state, * and it passes when `audited_count` equals `system_count`. `total` and `needs_review` are the shape * the asset audit hands the tile too. */ -export const CONSUMABLE_TEAM_AUDIT_CTE = ` - latest_audit AS ( +export const consumableTeamAuditCte = (name: string, today = false): string => { + const madeToday = today ? ' AND (a.audited_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date' : ''; + return ` + ${name}_latest AS ( SELECT DISTINCT ON (a.location_id, a.consumables_id) a.location_id, a.consumables_id, a.system_count, a.audited_count FROM consumable_audits a - WHERE a.account_id IN (${projectFacilitiesSql()}) AND a.deleted_at IS NULL AND a.consumables_id IS NOT NULL + WHERE a.account_id IN (${projectFacilitiesSql()}) AND a.deleted_at IS NULL AND a.consumables_id IS NOT NULL${madeToday} ORDER BY a.location_id, a.consumables_id, a.audited_at DESC ), - capturer_sku AS ( - SELECT DISTINCT t.created_by AS user_id, l.id AS location_id, t.consumables_id - ${PROJECT_STOCK_FROM} - JOIN consumables c ON c.id = t.consumables_id AND c.deleted_at IS NULL - ${PROJECT_STOCK_WHERE} - ), - audited AS ( + ${name} AS ( SELECT s.user_id, COUNT(la.consumables_id)::int AS total, COUNT(*) FILTER (WHERE la.audited_count = la.system_count)::int AS passed, COUNT(*) FILTER (WHERE la.audited_count <> la.system_count)::int AS needs_review FROM capturer_sku s - LEFT JOIN latest_audit la ON la.location_id = s.location_id AND la.consumables_id = s.consumables_id + LEFT JOIN ${name}_latest la ON la.location_id = s.location_id AND la.consumables_id = s.consumables_id GROUP BY s.user_id )`; +}; --- a/src/primary/modules/capture-dashboard/capture-live/pace.sql.ts +++ b/src/primary/modules/capture-dashboard/capture-live/pace.sql.ts @@ -65,11 +65,13 @@ * On-site runs from one usual line before the first scan to now while today is open (to the last * scan once the day has ended), capped by the planned hours. Active time drops breaks but keeps the * walk between rooms; `gap_kind` splits every gap into counting, walking and break seconds. - * The scope is today for anyone who scanned today, the project to date otherwise. `oneUser` - * narrows it to `:userId`; limits are per person, so that person's figures do not change. + * The scope is today for anyone who scanned today, the project to date otherwise; `todayOnly` keeps + * everyone to today, so a new day starts empty. `oneUser` narrows it to `:userId`; limits are per + * person, so that person's figures do not change. */ -export const paceCte = (consumables: boolean, oneUser = false): string => { +export const paceCte = (consumables: boolean, { oneUser = false, todayOnly = false }: { oneUser?: boolean; todayOnly?: boolean } = {}): string => { const today = '(now() AT TIME ZONE :tz)::date'; + const dayScope = todayOnly ? `s.day = ${today}` : `CASE WHEN EXISTS (SELECT 1 FROM worked_today w WHERE w.user_id = s.user_id) THEN s.day = ${today} ELSE TRUE END`; return ` scan AS ( SELECT k.id, k.created_by AS user_id, (k.created_at AT TIME ZONE :tz)::date AS day, k.created_at, k.location_id @@ -101,10 +103,7 @@ LEFT JOIN plan_day p ON p.user_id = s.user_id AND p.day = s.day -- a day stays open until the capturer has been silent past the day-end limit CROSS JOIN LATERAL (SELECT s.day = ${today} AND now() - s.last_scan <= make_interval(secs => :dayEndSecs) AS open) o - WHERE CASE - WHEN EXISTS (SELECT 1 FROM worked_today w WHERE w.user_id = s.user_id) THEN s.day = ${today} - ELSE TRUE - END + WHERE ${dayScope} ), gap AS ( SELECT g.user_id, g.day, g.secs, g.moved, g.at, g.prev_at, g.location_id, g.prev_location_id