Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/pipeline_stage.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/recruiting/pipeline_stage.go |
| Original SHA-256 | 381bf598e4a2c1f6d23f1a5c302a9f6512ee43a4b32c282c42498aa0f48578df |
| Git blob SHA | b73fd0c19bdb18dfc7bd885a584932777704eef4 |
| Reading-copy SHA-256 | 381bf598e4a2c1f6d23f1a5c302a9f6512ee43a4b32c282c42498aa0f48578df |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/recruiting/pipeline_stage.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/modules/recruiting/pipeline_stage.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓215 lines · 8.7 KB
package recruiting
import (
"time"
"github.com/shopspring/decimal"
)
// The candidate pipeline vocabulary, mirrored from migration 0048 and pinned against it by test.
//
// A deliberate difference from the requisition lifecycle in requisition_status.go: there is no closed
// transition graph here, and inventing one would be wrong rather than rigorous.
//
// A requisition status is a state machine because the states are mutually exclusive facts about one
// order. A pipeline stage is a progress marker on a person, and recruiters legitimately skip and reverse
// it: a known candidate goes straight from sourced to submitted, a client asks for a second screen after
// an interview, a rejected candidate is revived when the requisition reopens. A graph that forbade those
// would be a graph recruiters route around, and the work would stop being recorded at all.
//
// What IS enforced is the set of invariants that are true regardless of path, and every one of them is
// enforced by the database rather than here, because only the database can enforce them without a race:
//
// 1. from_stage must equal the application's actual stage (rm_apply_stage_event)
// 2. an event cannot predate the current stage change (rm_apply_stage_event)
// 3. stage is writable only through an event (rm_guard_application_stage)
// 4. the high water mark never decreases (rm_guard_application_stage)
// 5. a terminal stage carries a coded reason (rm_apply_stage_event + CHECK)
// 6. placements never exceed the requisition's openings (rm_apply_stage_event, under a row lock)
//
// This file holds only what is genuinely pure: the ladder, the ordering, and the disposition vocabulary.
const (
stageSourced = "sourced"
stageScreened = "screened"
stageSubmitted = "submitted"
stageInterviewing = "interviewing"
stageOffered = "offered"
stagePlaced = "placed"
stageRejected = "rejected"
stageWithdrawn = "withdrawn"
)
// stageOrd mirrors rm_stage_ord in migration 0048. Terminal outcomes are negative because a rejection is
// an outcome, not a rung, and must never sort as though it were further along than an offer.
var stageOrd = map[string]int{
stageSourced: 1, stageScreened: 2, stageSubmitted: 3,
stageInterviewing: 4, stageOffered: 5, stagePlaced: 6,
stageRejected: -1, stageWithdrawn: -2,
}
// ladder is the funnel, in order. Only these six are rungs.
var ladder = []string{stageSourced, stageScreened, stageSubmitted, stageInterviewing, stageOffered, stagePlaced}
var stageLabels = map[string]string{
stageSourced: "Sourced", stageScreened: "Screened", stageSubmitted: "Submitted",
stageInterviewing: "Interviewing", stageOffered: "Offered", stagePlaced: "Placed",
stageRejected: "Rejected", stageWithdrawn: "Withdrawn",
}
func isStage(s string) bool { _, ok := stageOrd[s]; return ok }
func isTerminalStage(s string) bool { return s == stageRejected || s == stageWithdrawn }
// dispositionCodes is the closed vocabulary from migration 0054.
//
// Closed on purpose, and this is a compliance requirement rather than tidiness. A four fifths analysis
// under NYC Local Law 144 or an OFCCP audit groups rejections by reason, and prose cannot be grouped.
// Free text here would make the adverse impact report this product exists to provide impossible to
// compute from its own data.
//
// The map value records who made the decision, which is the distinction the report actually needs:
// an employer rejection and a candidate withdrawal are not the same event and must not be pooled.
var dispositionCodes = map[string]string{
"not_qualified": stageRejected,
"clearance_gap": stageRejected,
"rate_mismatch": stageRejected,
"location_mismatch": stageRejected,
"client_declined": stageRejected,
"interview_performance": stageRejected,
"offer_declined_by_client": stageRejected,
"position_cancelled": stageRejected,
"duplicate": stageRejected,
"candidate_withdrew": stageWithdrawn,
"candidate_unresponsive": stageWithdrawn,
"accepted_other_offer": stageWithdrawn,
"compensation": stageWithdrawn,
"other": "", // permitted for either, because a real reason sometimes fits neither list
}
// validDisposition reports whether a code may be used with a terminal stage. A code belonging to the
// other outcome is refused: recording "candidate withdrew" as an employer rejection would overstate the
// employer's rejection rate in exactly the report that rate matters for.
func validDisposition(code, stage string) error {
owner, ok := dispositionCodes[code]
if !ok {
return &ValidationError{Field: "disposition_code", Message: "is not a permitted reason"}
}
if owner != "" && owner != stage {
return &ValidationError{
Field: "disposition_code",
Message: "that reason belongs to a " + owner + " outcome, not a " + stage + " one",
}
}
return nil
}
// funnelFrom builds the stage funnel from high water marks.
//
// The count at rung k is the number of applications whose furthest_stage_ord is at least k. That
// quantity is non increasing in k by construction, because it counts a superset at every lower rung, so
// the funnel cannot render a later stage larger than an earlier one. Counting applications CURRENTLY at
// each stage would not have that property: a placed candidate is no longer at submitted, and the chart
// would contradict itself.
//
// marks is one entry per application, already filtered to the tenant by the caller's query.
func funnelFrom(marks []int, total int) []FunnelStage {
out := make([]FunnelStage, 0, len(ladder))
for i, name := range ladder {
k := i + 1
n := 0
for _, m := range marks {
if m >= k {
n++
}
}
out = append(out, FunnelStage{
Stage: name,
Label: stageLabels[name],
Count: n,
Pct: pctOf(n, total),
})
}
return out
}
// pctOf renders a share to two places using exact decimal, never float64, so a reported percentage is
// reproducible and the compliance figures do not depend on binary rounding.
func pctOf(n, total int) string {
if total <= 0 {
return "0.00"
}
return decimal.NewFromInt(int64(n)).
Mul(decimal.NewFromInt(100)).
Div(decimal.NewFromInt(int64(total))).
StringFixed(2)
}
// conversionFrom computes stage to stage conversion across the ladder.
//
// Each rate is the share of applications reaching rung k that went on to reach rung k+1. Because the
// underlying counts are monotone, every rate is in [0, 100] with no clamping, which is what makes these
// numbers safe to publish: a conversion above 100 percent would be arithmetically impossible rather than
// merely hidden.
func conversionFrom(f []FunnelStage) []Conversion {
out := make([]Conversion, 0, len(f)-1)
for i := 0; i+1 < len(f); i++ {
out = append(out, Conversion{
From: f[i].Stage, To: f[i+1].Stage,
FromLabel: f[i].Label, ToLabel: f[i+1].Label,
Rate: pctOf(f[i+1].Count, f[i].Count),
})
}
return out
}
// stageAge is how long an application has sat where it is. Exact decimal seconds for the reasons set out
// in migration 0052: a day is not always 86400 seconds, and truncating short intervals loses them.
func stageAge(changedAt time.Time, now time.Time) decimal.Decimal {
return secondsBetween(changedAt, now)
}
// dispositionLabel renders a code for a human. Kept beside the vocabulary so a new code cannot be added
// without a label, which is how enum values end up shown to users as raw snake case.
func dispositionLabel(code string) string {
switch code {
case "not_qualified":
return "Did not meet the requirements"
case "clearance_gap":
return "Clearance does not meet the requirement"
case "rate_mismatch":
return "Rate outside the approved range"
case "location_mismatch":
return "Cannot work the required location"
case "client_declined":
return "Client passed after submittal"
case "interview_performance":
return "Interview outcome"
case "offer_declined_by_client":
return "Client withdrew the offer"
case "position_cancelled":
return "The job order closed"
case "duplicate":
return "Duplicate application"
case "candidate_withdrew":
return "Candidate withdrew"
case "candidate_unresponsive":
return "Candidate went unresponsive"
case "accepted_other_offer":
return "Accepted another offer"
case "compensation":
return "Compensation expectations"
case "other":
return "Other"
}
return code
}
// ladderView is the stage vocabulary for the browser, so the board cannot invent a stage the database
// would refuse.
func ladderView() []FunnelStage {
out := make([]FunnelStage, 0, len(ladder)+2)
for _, s := range append(append([]string{}, ladder...), stageRejected, stageWithdrawn) {
out = append(out, FunnelStage{Stage: s, Label: stageLabels[s]})
}
return out
}