package tools import ( "context" "encoding/json" "fmt" "time" "github.com/krow/krow-backend/go-api/internal/repo" ) // The workforce tools: attendance, overtime and shift coverage. // // Ports of `workforce.attendance`, `workforce.overtime` and // `workforce.coverage`, which between them are named by eighteen of the shipped // skills — the densest cluster in the registry. // // All three read shift_records, and all three go through authorize(), so a // talent caller sees their own shifts and an operator sees the tenant's. The // aggregate is computed after the predicate, never before: "the workforce // averaged 4% late" computed over rows a caller may not read is a leak even // though no row is printed. // // shift_status is `present | late | absent | no_show` — there is no // "completed", "scheduled" or "cancelled" member. A status literal that is not // in the enum matches no rows and raises nothing, so a wrong guess here reads // as a quiet zero rather than an error. The values below are the enum's own. type periodInput struct { Period string `json:"period"` Limit int `json:"limit"` } func (p periodInput) limitOr(n int) int { if p.Limit <= 0 { return n } if p.Limit > 100 { return 100 } return p.Limit } // decodePeriod reads the shared period/limit arguments. func decodePeriod(inputs json.RawMessage) (periodInput, time.Time, time.Time, *Result) { var in periodInput if len(inputs) > 0 { if err := json.Unmarshal(inputs, &in); err != nil { r := Failf(CodeInvalidInput, "the arguments were not valid JSON") return in, time.Time{}, time.Time{}, &r } } from, to, err := windowFor(in.Period, time.Now()) if err != nil { r := Failf(CodeInvalidInput, "%s", err.Error()) return in, time.Time{}, time.Time{}, &r } return in, from, to, nil } // periodSchema is the shared argument shape. One definition so three tools // cannot describe the same argument three slightly different ways — the model // reads these as documentation, and inconsistent documentation is worse than // terse documentation. func periodSchema(limitHelp string) map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "period": map[string]any{ "type": "string", "enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"}, "description": "The window to read. Omit for all recorded history. " + "Windows are computed from the current date; do not pass a date.", }, "limit": map[string]any{ "type": "integer", "minimum": 1, "maximum": 100, "description": limitHelp, }, }, "additionalProperties": false, } } /* ── Attendance ─────────────────────────────────────────────────────────── */ // WorkforceAttendance reports shift attendance: completion, lateness, no-shows. func WorkforceAttendance(db repo.Querier) Tool { return Tool{ Name: "workforce_attendance", Description: "Read shift attendance: how many shifts were scheduled, completed, " + "missed or started late, the average minutes late, and the workers with the " + "weakest attendance. Use for questions about reliability, no-shows, lateness " + "and whether shifts are being worked.", InputSchema: periodSchema("How many workers to list, worst attendance first. Defaults to 10."), Effect: EffectRead, MaxResultBytes: DefaultMaxResultBytes, Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result { q, denied := authorize(tc, "shift-records") if denied != nil { return *denied } in, from, to, bad := decodePeriod(inputs) if bad != nil { return *bad } if !from.IsZero() { q.gte("shift_date", from) q.lt("shift_date", to) } var ( total, completed, missed, noShow, late int64 avgLate *float64 ) err := db.QueryRow(ctx, ` SELECT count(*), count(*) FILTER (WHERE status IN ('present', 'late')), count(*) FILTER (WHERE status IN ('no_show', 'absent')), count(*) FILTER (WHERE status = 'no_show'), count(*) FILTER (WHERE minutes_late > 0), avg(minutes_late) FILTER (WHERE minutes_late > 0) FROM shift_records WHERE `+q.clause(), q.args..., ).Scan(&total, &completed, &missed, &noShow, &late, &avgLate) if err != nil { return Failf(CodeFailed, "the shift records could not be read") } workers, err := weakestAttendance(ctx, db, q, in.limitOr(10)) if err != nil { return Failf(CodeFailed, "the shift records could not be read") } data := map[string]any{ "period": periodOrAll(in.Period), "shiftsScheduled": total, "shiftsWorked": completed, "shiftsMissed": missed, "noShows": noShow, "lateStarts": late, "workers": workers, } if avgLate != nil { data["averageMinutesLateWhenLate"] = int(*avgLate + 0.5) } if total > 0 { data["completionRatePercent"] = int(float64(completed)/float64(total)*100 + 0.5) } else { data["note"] = "No shifts match that. This is a real answer, not a failure to look." } return OK(data) }, } } type workerAttendance struct { Worker string `json:"worker"` Shifts int64 `json:"shifts"` Missed int64 `json:"missed"` NoShows int64 `json:"noShows"` Late int64 `json:"lateStarts"` Reliable int `json:"reliabilityPercent"` AvgMinute int `json:"averageMinutesLate,omitempty"` } func weakestAttendance(ctx context.Context, db repo.Querier, q *query, limit int) ([]workerAttendance, error) { args := append(append([]any{}, q.args...), limit) rows, err := db.Query(ctx, ` SELECT worker_name, count(*), count(*) FILTER (WHERE status IN ('no_show', 'absent')), count(*) FILTER (WHERE status = 'no_show'), count(*) FILTER (WHERE minutes_late > 0), coalesce(avg(minutes_late) FILTER (WHERE minutes_late > 0), 0) FROM shift_records WHERE `+q.clause()+` GROUP BY worker_name ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC, count(*) FILTER (WHERE minutes_late > 0) DESC, worker_name ASC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return nil, err } defer rows.Close() var out []workerAttendance for rows.Next() { var w workerAttendance var avg float64 if err := rows.Scan(&w.Worker, &w.Shifts, &w.Missed, &w.NoShows, &w.Late, &avg); err != nil { return nil, err } w.AvgMinute = int(avg + 0.5) if w.Shifts > 0 { w.Reliable = int(float64(w.Shifts-w.Missed)/float64(w.Shifts)*100 + 0.5) } out = append(out, w) } return out, rows.Err() } /* ── Overtime ───────────────────────────────────────────────────────────── */ // WorkforceOvertime reports overtime hours and who is accruing them. func WorkforceOvertime(db repo.Querier) Tool { return Tool{ Name: "workforce_overtime", Description: "Read overtime: total overtime hours, how many shifts ran over, and " + "the workers accruing the most. Use for questions about overtime cost, who is " + "working beyond their scheduled hours, and whether overtime is concentrated.", InputSchema: periodSchema("How many workers to list, most overtime first. Defaults to 10."), Effect: EffectRead, MaxResultBytes: DefaultMaxResultBytes, Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result { q, denied := authorize(tc, "shift-records") if denied != nil { return *denied } in, from, to, bad := decodePeriod(inputs) if bad != nil { return *bad } if !from.IsZero() { q.gte("shift_date", from) q.lt("shift_date", to) } var ( shiftsWithOT int64 totalOT float64 totalActual float64 ) err := db.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE overtime_hours > 0), coalesce(sum(overtime_hours), 0), coalesce(sum(actual_hours), 0) FROM shift_records WHERE `+q.clause(), q.args..., ).Scan(&shiftsWithOT, &totalOT, &totalActual) if err != nil { return Failf(CodeFailed, "the shift records could not be read") } args := append(append([]any{}, q.args...), in.limitOr(10)) rows, err := db.Query(ctx, ` SELECT worker_name, coalesce(sum(overtime_hours), 0), count(*) FILTER (WHERE overtime_hours > 0) FROM shift_records WHERE `+q.clause()+` GROUP BY worker_name HAVING sum(overtime_hours) > 0 ORDER BY sum(overtime_hours) DESC, worker_name ASC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return Failf(CodeFailed, "the shift records could not be read") } defer rows.Close() type worker struct { Worker string `json:"worker"` Hours float64 `json:"overtimeHours"` Shifts int64 `json:"shiftsWithOvertime"` } var workers []worker for rows.Next() { var w worker if err := rows.Scan(&w.Worker, &w.Hours, &w.Shifts); err != nil { return Failf(CodeFailed, "the shift records could not be read") } workers = append(workers, w) } if err := rows.Err(); err != nil { return Failf(CodeFailed, "the shift records could not be read") } data := map[string]any{ "period": periodOrAll(in.Period), "totalOvertimeHours": round1(totalOT), "shiftsWithOvertime": shiftsWithOT, "workers": workers, } if totalActual > 0 { data["overtimeSharePercent"] = int(totalOT/totalActual*100 + 0.5) } if totalOT == 0 { data["note"] = "No overtime was recorded in that window. This is a real answer, not a failure to look." } return OK(data) }, } } /* ── Coverage ───────────────────────────────────────────────────────────── */ // WorkforceCoverage reports whether shifts are covered and what is unfilled. func WorkforceCoverage(db repo.Querier) Tool { return Tool{ Name: "workforce_coverage", Description: "Read shift coverage: how many shifts are scheduled, cancelled or " + "unworked, and which roles have the most uncovered shifts. Use for questions " + "about gaps in the rota, roles that are hard to staff, and what is at risk of " + "going unworked.", InputSchema: periodSchema("How many roles to list, most uncovered first. Defaults to 10."), Effect: EffectRead, MaxResultBytes: DefaultMaxResultBytes, Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result { q, denied := authorize(tc, "shift-records") if denied != nil { return *denied } in, from, to, bad := decodePeriod(inputs) if bad != nil { return *bad } if !from.IsZero() { q.gte("shift_date", from) q.lt("shift_date", to) } args := append(append([]any{}, q.args...), in.limitOr(10)) rows, err := db.Query(ctx, ` SELECT coalesce(nullif(role, ''), 'unspecified'), count(*), count(*) FILTER (WHERE status IN ('no_show', 'absent')), count(*) FILTER (WHERE status = 'late') FROM shift_records WHERE `+q.clause()+` GROUP BY 1 ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC, 1 ASC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return Failf(CodeFailed, "the shift records could not be read") } defer rows.Close() type roleCoverage struct { Role string `json:"role"` Shifts int64 `json:"shifts"` Uncovered int64 `json:"uncovered"` Late int64 `json:"lateStarts"` Covered int `json:"coveragePercent"` } var ( roles []roleCoverage allShifts, allUncovered, sch int64 ) for rows.Next() { var r roleCoverage if err := rows.Scan(&r.Role, &r.Shifts, &r.Uncovered, &r.Late); err != nil { return Failf(CodeFailed, "the shift records could not be read") } if r.Shifts > 0 { r.Covered = int(float64(r.Shifts-r.Uncovered)/float64(r.Shifts)*100 + 0.5) } allShifts += r.Shifts allUncovered += r.Uncovered sch += r.Late roles = append(roles, r) } if err := rows.Err(); err != nil { return Failf(CodeFailed, "the shift records could not be read") } data := map[string]any{ "period": periodOrAll(in.Period), "shifts": allShifts, "uncovered": allUncovered, "lateStarts": sch, "roles": roles, } if allShifts > 0 { data["coveragePercent"] = int(float64(allShifts-allUncovered)/float64(allShifts)*100 + 0.5) } else { data["note"] = "No shifts match that. This is a real answer, not a failure to look." } return OK(data) }, } } func round1(f float64) float64 { return float64(int(f*10+0.5)) / 10 }