package recruiting import ( "time" "github.com/shopspring/decimal" ) // The requisition lifecycle, expressed as data rather than as a chain of if statements, so that every // one of the thirty six ordered pairs of statuses is decided in one place and provable in one test. // // A job order is not a free form field with a status label on it. It is opened, it may be paused while // the client reorganizes, it ends exactly once, and the time it spent paused is subtracted from how long // it took to fill. Every metric this product will report about recruiting speed is a function of those // three facts, so the transitions and the clocks they move are defined together here and nowhere else. const ( reqDraft = "draft" reqOpen = "open" reqOnHold = "on_hold" reqFilled = "filled" reqClosed = "closed" reqCancelled = "cancelled" ) // reqStatuses is the vocabulary. It is pinned against the CHECK constraint in migration 0047 by a test, // because a value this code accepts and the database refuses is a 500 rather than a clean rejection. var reqStatuses = map[string]bool{ reqDraft: true, reqOpen: true, reqOnHold: true, reqFilled: true, reqClosed: true, reqCancelled: true, } // reqTerminal are the statuses that end an order and therefore must carry closed_at. Kept as its own // set rather than inlined so the closed_at rule and the CHECK constraint in 0047 cannot drift apart. var reqTerminal = map[string]bool{reqFilled: true, reqClosed: true, reqCancelled: true} // reqTransitions is the legal graph. // // The shape encodes real recruiting rather than a tidy diagram. A draft has not been released to anyone, // so it cannot be held or filled, only opened or abandoned. An order that ended can be reopened, because // a placement falling through in week two is ordinary and forcing a new requisition would break the // history of the role. Cancelled is reopenable for the same reason a delete needs an undo: it is the one // transition that is usually a misclick. var reqTransitions = map[string]map[string]bool{ reqDraft: {reqOpen: true, reqCancelled: true}, reqOpen: {reqOnHold: true, reqFilled: true, reqClosed: true, reqCancelled: true}, reqOnHold: {reqOpen: true, reqFilled: true, reqClosed: true, reqCancelled: true}, reqFilled: {reqOpen: true, reqClosed: true}, reqClosed: {reqOpen: true}, reqCancelled: {reqOpen: true}, } // statusEffect is what a transition does to the three clocks. Separating the decision from the write // means the arithmetic is testable without a database, and the SQL becomes a direct transcription of a // value rather than a place where the rule is re derived. type statusEffect struct { // NoOp is a transition to the same status. It is accepted and changes nothing, which makes a repeated // request idempotent rather than an error, and records no event so the history is not padded with // entries where nothing happened. NoOp bool SetClosedAt bool ClearClosedAt bool StartHold bool SettleHold bool } // transitionFor decides a status change. // // The four clock rules are stated as biconditionals against the target status rather than enumerated per // pair. That is what makes the two database invariants hold for every legal transition by construction: // after applying the effect, closed_at is non null exactly when the target is terminal, and held_since is // non null exactly when the target is on hold. TestTransitionPreservesInvariants proves it over the whole // graph rather than trusting the argument. func transitionFor(from, to string) (statusEffect, error) { if !reqStatuses[to] { return statusEffect{}, &ValidationError{Field: "status", Message: "is not a permitted status"} } if from == to { return statusEffect{NoOp: true}, nil } if !reqTransitions[from][to] { return statusEffect{}, &ValidationError{ Field: "status", Message: "a " + from + " requisition cannot move to " + to, } } return statusEffect{ SetClosedAt: reqTerminal[to] && !reqTerminal[from], ClearClosedAt: !reqTerminal[to] && reqTerminal[from], StartHold: to == reqOnHold, // Leaving a hold settles it even when the destination is terminal. Without this, an order closed // while paused keeps an open hold bracket forever and its accrued hold time grows without bound // against a dead row, which would make its adjusted age fall below zero and keep falling. SettleHold: from == reqOnHold, }, nil } // reqAge computes how long an order has been alive and how long it was actually being worked. // // Both numbers are legitimate and the industry does not agree on which to report, so both are returned // and the caller labels them. Elapsed is the wall clock. Adjusted subtracts every period the order spent // on hold, including the hold currently in progress, which is the number that answers "how long did this // take us" without punishing a recruiter for a client's hiring freeze. // // Arithmetic is exact decimal on a nanosecond integer, never float64. Seconds rather than calendar days // because a day is not always 86400 seconds across a daylight saving boundary, and a duration expressed // in days would silently gain or lose an hour twice a year. func reqAge(openedAt time.Time, closedAt *time.Time, heldSeconds decimal.Decimal, heldSince *time.Time, now time.Time) (elapsed, adjusted decimal.Decimal) { end := now if closedAt != nil { end = *closedAt } elapsed = secondsBetween(openedAt, end) hold := heldSeconds if heldSince != nil { // A hold in progress accrues up to the same end point used for elapsed, so the two measurements // are taken from one instant and cannot disagree. hold = hold.Add(secondsBetween(*heldSince, end)) } adjusted = elapsed.Sub(hold) if adjusted.IsNegative() { // Unreachable given the invariants: hold periods are disjoint subsets of the interval from // opened_at to end, because status holds one value at a time and every bracket is settled when it // closes, so their sum cannot exceed the whole. Clamped rather than returned negative because a // negative age is never a real measurement, and TestAdjustedAgeIsNeverNegative asserts that this // branch does not fire for any legal sequence of transitions. return elapsed, decimal.Zero } return elapsed, adjusted } // secondsBetween is an exact elapsed second count. Sub returns an integer nanosecond count, so dividing // by 1e9 in decimal is lossless, whereas Seconds() would round through a float64. func secondsBetween(from, to time.Time) decimal.Decimal { return decimal.NewFromInt(to.Sub(from).Nanoseconds()).Div(decimal.NewFromInt(1e9)) } // days renders a second count as days to three places, which is the unit a recruiter reads. Rounding is // applied only here, at the display boundary, so no stored or accumulated value is ever rounded. func days(seconds decimal.Decimal) float64 { d, _ := seconds.Div(decimal.NewFromInt(86400)).Round(3).Float64() return d }