384 lines
13 KiB
Go
384 lines
13 KiB
Go
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
|
|
}
|