--- a/src/primary/modules/capture-dashboard/capture-live/dto/capture-live.dto.ts +++ b/src/primary/modules/capture-dashboard/capture-live/dto/capture-live.dto.ts @@ -26,11 +26,7 @@ @ApiProperty({ description: 'The entire pill sentence including the number and unit. There is no variance formula.' }) varianceLabel: string; } -/** - * Every field is seeded. The banner looks computed — 46% is close to 88/192 — but - * nothing here is derived, and the only arithmetic the frontend does is - * locationsTotal minus locationsComplete. - */ +/** The Live tab's header: the day line, the progress bar and the totals printed under it. */ export class CaptureLiveBannerDto { @ApiProperty() eyebrow: string; @@ -38,6 +34,12 @@ @ApiProperty({ description: 'Pre-formatted. Do not re-derive from the project daysElapsed/daysTotal.' }) dayLine: string; + @ApiProperty({ description: 'Departments whose every room is complete.' }) + departmentsComplete: number; + + @ApiProperty({ description: 'Departments holding at least one of the project’s rooms.' }) + departmentsTotal: number; + @ApiProperty() locationsComplete: number; @@ -56,6 +58,15 @@ @ApiPropertyOptional({ description: 'Consumables runs only: distinct products counted across every room, each once however many rooms stock it.' }) skuTotal?: number; + @ApiPropertyOptional({ description: 'Consumables runs only: audits whose count matched, summed over the team as its Audit tiles show them.' }) + auditsPassed?: number; + + @ApiPropertyOptional({ description: 'Consumables runs only: audits done, summed over the team.' }) + auditsDone?: number; + + @ApiPropertyOptional({ description: 'Consumables runs only: audits still owed. Every person on the team owes the same number, and one person’s extra audits do not cover another’s.' }) + auditsPending?: number; + @ApiProperty({ description: 'Rooms done out of all rooms, 0–100 — the same count as locationsComplete / locationsTotal.' }) completePct: number; @@ -63,7 +74,7 @@ forecast: CaptureLiveForecastDto; @ApiPropertyOptional({ - description: 'False while the run is under the forecast floors; the tab hides the projected-finish block rather than showing an unearned date.', + description: 'False while the run is under the forecast floors; the behind-estimate red flag waits for it rather than judging an unearned date.', }) forecastReady?: boolean; } --- a/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts +++ b/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts @@ -104,6 +104,9 @@ /** Banner copy. Fixed labels rather than data, but they belong with the panel that prints them. */ const BANNER_EYEBROW = 'Live capture in progress'; +/** Audits every person on a consumables team owes; the banner's pending figure counts down from it. */ +const AUDITS_PER_CAPTURER = 5; + /** A capture the team could not confirm or tag — the quality signal the clean-capture rate counts against. */ const FLAGGED_STICKER = '"InventorySticker"."unable_to_confirm" OR "InventorySticker"."is_missing_asset_tag" OR "InventorySticker"."is_missing_biomed_tag"'; @@ -112,6 +115,8 @@ interface BannerRow { days_total: number; days_elapsed: number; + departments_total: number; + departments_complete: number; locations_total: number; locations_complete: number; captured: number; @@ -170,6 +175,16 @@ insight_type: CaptureInsightTypeEnum; account_type: string | null; team_median: string | null; + audits_passed: number; + audits_done: number; + audits_pending: number; +} + +/** The team's audits, summed as its Audit tiles show them. */ +interface TeamAudits { + auditsPassed: number; + auditsDone: number; + auditsPending: number; } interface NthWorkingDayRow { @@ -401,7 +416,7 @@ const consumables = isConsumableRun(project.captureDomain); const pace = resolvePaceSettings(project.paceConfig); const [banner, groups, team, stale] = await Promise.all([ - this.buildBanner(project.id, consumables, pace), + this.buildBanner(project.id, consumables), this.listLocationGroups(project.id, consumables, { pageNumber: 0, pageSize: LIVE_FACILITY_PAGE_SIZE }), this.buildTeam(project.id, consumables, pace), this.staleRooms(project.id, consumables), @@ -410,7 +425,8 @@ const redFlags = await this.buildRedFlags(project.id, consumables, banner, stale, team.rows, pace); return { - banner, + // The audit totals come off the team rows, so the banner and the Audit tiles cannot disagree. + banner: consumables ? { ...banner, ...team.audits } : banner, // The tab renders `locationGroups`; this keeps the flat field the DTO declares, taken from the // rows already fetched rather than paying for a second scan of the same rooms. locations: groups.groups.flatMap((group) => group.rows), @@ -491,7 +507,7 @@ // ── Banner ──────────────────────────────────────────────────────────────── - private async buildBanner(projectId: number, consumables: boolean, pace: PaceSettings): Promise { + private async buildBanner(projectId: number, consumables: boolean): Promise { const replacements = { projectId, tz: this.projectTimezone(), denovoExcluded: DENOVO_EXCLUDED }; // Read off the rooms' own groups, so the banner totals and the room chips cannot disagree. const shelfCte = consumables ? `, ${CONSUMABLE_GROUP_CTE}` : ''; @@ -527,6 +543,14 @@ (SELECT MAX(day) FROM working)::text AS window_end, (SELECT avg_start_hours FROM day_shape) AS avg_start_hours, (SELECT avg_active_hours FROM day_shape) AS avg_active_hours, + (SELECT COUNT(DISTINCT l.department_id) FROM inventory_locations l + WHERE ${projectRoomsSql()} AND l.department_id IS NOT NULL)::int AS departments_total, + (SELECT COUNT(*) FROM ( + SELECT l.department_id FROM inventory_locations l + WHERE ${projectRoomsSql()} AND l.department_id IS NOT NULL + GROUP BY l.department_id + HAVING BOOL_AND(COALESCE(l.completed, FALSE)) + ) done)::int AS departments_complete, (SELECT COUNT(*) FROM inventory_locations l WHERE ${projectRoomsSql()})::int AS locations_total, (SELECT COUNT(*) FROM inventory_locations l @@ -539,7 +563,7 @@ WHERE capture_project_id = :projectId AND deleted_at IS NULL)::int AS expected${shelfTotalsSql}`, { type: QueryTypes.SELECT, replacements }, ), - this.onSiteToday(projectId, consumables, pace.dayEndMinutes * 60), + this.onSiteToday(projectId, consumables), ]); const row = window[0]; @@ -553,6 +577,8 @@ return { eyebrow: BANNER_EYEBROW, dayLine: `Day ${daysElapsed} of ${daysTotal} · ${onSite} capturer${onSite === 1 ? '' : 's'} on-site`, + departmentsComplete: Number(row?.departments_complete) || 0, + departmentsTotal: Number(row?.departments_total) || 0, locationsComplete, locationsTotal, assetsCaptured, @@ -579,17 +605,28 @@ return [String(Number(projectId)), db.sequelize.escape(this.projectTimezone())]; } - /** Distinct project capturers whose last capture today is within the day-end limit, clones excluded. */ - private async onSiteToday(projectId: number, consumables: boolean, dayEndSecs: number): Promise { + /** + * Everyone rostered on today's visits, plus anyone who captured today without being rostered for it. + * The day-end limit marks a quiet capturer Finished on their card; it does not take them off site. + */ + private async onSiteToday(projectId: number, consumables: boolean): Promise { const [row] = await db.sequelize.query<{ n: number }>( - `SELECT COUNT(DISTINCT k.created_by)::int AS n - FROM inventory_stickers k - JOIN inventory_locations l ON l.id = k.location_id - WHERE ${projectRoomsSql()} AND k.deleted_at IS NULL AND k.clone_id IS NULL AND ${projectCapturesSql()} - AND ${stickerScopeSql(consumables)} - AND (k.created_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date - AND k.created_at >= now() - make_interval(secs => :dayEndSecs)`, - { type: QueryTypes.SELECT, replacements: { projectId, tz: this.projectTimezone(), dayEndSecs } }, + `SELECT COUNT(*)::int AS n + FROM ( + SELECT m.user_id + FROM capture_visit_members m + JOIN capture_visits v ON v.id = m.capture_visit_id AND v.deleted_at IS NULL + WHERE v.capture_project_id = :projectId AND m.deleted_at IS NULL + AND m.work_date = (now() AT TIME ZONE :tz)::date + UNION + SELECT k.created_by + FROM inventory_stickers k + JOIN inventory_locations l ON l.id = k.location_id + WHERE ${projectRoomsSql()} AND k.deleted_at IS NULL AND k.clone_id IS NULL AND ${projectCapturesSql()} + AND ${stickerScopeSql(consumables)} + AND (k.created_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date + ) today`, + { type: QueryTypes.SELECT, replacements: { projectId, tz: this.projectTimezone() } }, ); return row?.n ?? 0; } @@ -1099,9 +1136,10 @@ * 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. */ - private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null }> { + private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null; audits: TeamAudits }> { const replacements = { projectId, + auditsPerCapturer: AUDITS_PER_CAPTURER, tz: this.projectTimezone(), ...paceReplacements(pace), minLineGaps: pace.minLineGaps, @@ -1157,8 +1195,16 @@ ), 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 + 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 + 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_shown, m.audit_base, m.clean_rate, med.team_median, + ta.audits_passed, ta.audits_done, ta.audits_pending, CASE WHEN m.clean_rate < :qualityFloor THEN :qualityWarn WHEN m.captures_per_minute IS NULL OR med.team_median IS NULL THEN :noPace @@ -1168,12 +1214,19 @@ END AS insight_type FROM measured m CROSS JOIN med + CROSS JOIN team_audit ta ORDER BY m.captures_per_minute DESC NULLS LAST, m.name ASC`, { type: QueryTypes.SELECT, replacements }, ); + const first = rows[0]; return { - median: rows[0]?.team_median == null ? null : round1(Number(rows[0].team_median)), + median: first?.team_median == null ? null : round1(Number(first.team_median)), + audits: { + auditsPassed: Number(first?.audits_passed) || 0, + auditsDone: Number(first?.audits_done) || 0, + auditsPending: Number(first?.audits_pending) || 0, + }, rows: rows.map((row) => ({ id: String(row.user_id), name: row.name?.trim() || `User ${row.user_id}`,