package recruitmatch import "github.com/shopspring/decimal" var hundred = decimal.NewFromInt(100) // pct returns num over den as a percentage rounded to two places. // // A zero or negative denominator yields exactly zero rather than NaN or a panic. That is the zero data // organization case, which is the first thing every new client sees, so it is the default path and not // an edge case. // // The operand order matches internal/modules/commandcenter.pct deliberately. Dividing first and // multiplying second is equivalent to multiplying first at this precision: with // decimal.DivisionPrecision at 16, an exhaustive sweep of every num and den pair for den from 1 to 400 // produced zero divergence after Round(2). Keeping one formula across both dashboards means the two // screens can never disagree about the same ratio. func pct(num, den int64) decimal.Decimal { if den <= 0 { return decimal.Zero } return decimal.NewFromInt(num).Div(decimal.NewFromInt(den)).Mul(hundred).Round(2) } // funnelStages is the fixed, ordered pipeline. It is a package level definition rather than a literal // inside buildFunnel so the key set is a single source of truth for the tests and the frontend. var funnelStages = []struct{ key, label string }{ {"identified", "Identified"}, {"screened", "Screened"}, {"submitted", "Submitted"}, {"approved", "Approved"}, } // buildFunnel renders the pipeline overview from four counts of DISTINCT people. // // The caller must pass counts drawn from the same live candidate set, which service_queries guarantees // by deriving every stage from rm_cand. Under that guarantee the sequence is non increasing, so no // stage can exceed 100 percent and the funnel never renders an impossible width. // // buildFunnel does not enforce monotonicity, it relies on it. Enforcing here by clamping would hide a // query bug behind a plausible looking chart, which is worse than rendering the anomaly. The invariant // is asserted in the tests and in the integration test against a real database. func buildFunnel(identified, screened, submitted, approved int64) []FunnelStage { counts := []int64{identified, screened, submitted, approved} out := make([]FunnelStage, 0, len(funnelStages)) for i, s := range funnelStages { out = append(out, FunnelStage{ Key: s.key, Label: s.label, Count: counts[i], Pct: pct(counts[i], identified).StringFixed(2), }) } return out } // buildDecisions totals slate membership decisions and the approve rate over decided members only. // Pending members are excluded from the rate denominator because an undecided member is not evidence // either way; including them would make the rate drift purely from review backlog. func buildDecisions(approved, rejected, pending int64) Decisions { decided := approved + rejected return Decisions{ Approved: approved, Rejected: rejected, Pending: pending, Total: decided + pending, ApproveRate: pct(approved, decided).StringFixed(2), } } // notTracked is the fixed set of panels the product shows as explicitly untracked, with the reason each // one is blank. Keeping it in one place means the UI never has to guess and never invents a number. func notTracked() []EmptyPanel { return []EmptyPanel{ {Key: "interviews", Title: "Interviews", Reason: "Interview scheduling is not tracked in missionMATCH yet."}, {Key: "offers", Title: "Offers", Reason: "Offers are not tracked in missionMATCH yet."}, {Key: "placements", Title: "Placements", Reason: "Placements are not tracked in missionMATCH yet."}, {Key: "revenue", Title: "Revenue", Reason: "Bill rates and revenue are not tracked in missionMATCH yet."}, {Key: "time_to_fill", Title: "Average time to fill", Reason: "Requirements do not record a fill date yet."}, {Key: "source_mix", Title: "Candidate source", Reason: "Candidate source records seed or external only, so job board attribution is not available."}, } }