agent build
This commit is contained in:
383
go-api/internal/tools/workforce.go
Normal file
383
go-api/internal/tools/workforce.go
Normal file
@@ -0,0 +1,383 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user