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 }