package staffing import ( "context" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" ) // GenerateGap produces the ranked recommendations to close one named gap (POST // /recommendations/gap/{requirementID}). It scores the eligible bench against the requirement, reads // current commitment so a committed candidate is a reassign rather than a duplicate assign, classifies // deterministically, narrates behind the faithfulness gate, and persists a fresh run that supersedes the // prior pending gap rows for this requirement. A requirement that is missing, deleted, or backed by a non // open opportunity returns ErrNotFound (404). func (s *Service) GenerateGap(ctx context.Context, requirementID uuid.UUID, req GapRequest) (RunResponse, error) { if s.db == nil { return RunResponse{}, ErrNoDatabase } orgID, err := s.orgID(ctx) if err != nil { return RunResponse{}, err } floor, adj, verr := parseOverrides(req.StaffingFloor, req.AdjacencyThreshold) if verr != nil { return RunResponse{}, verr } cfg := s.cfg.WithOverrides(floor, adj) asOf := s.clock() var ( recs []Recommendation resByCand map[string]engine.MatchResult valueSource string ) // Phase A (read): load, score, classify. No writes, so a long read never blocks a writer. err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) rv, e := q.GetRequirementWithValue(ctx, requirementID) if e != nil { if e == pgx.ErrNoRows { return ErrNotFound } return e } if rv.DeletedAt != nil { return ErrNotFound } if rv.OpportunityID != nil && rv.OpportunityStatus != nil && *rv.OpportunityStatus != "open" { return ErrNotFound // a closed opportunity is not a real open gap } er, e := matching.LoadEngineRequirement(ctx, q, requirementID) if e != nil { return e } openRows, e := q.ListOpenRequirementsWithValue(ctx, dbgen.ListOpenRequirementsWithValueParams{Limit: openRequirementCap, Offset: 0}) if e != nil { return e } priorities, vs := buildPriorities(cfg, openRows) valueSource = vs gapPriority, ok := priorities[requirementID.String()] if !ok { // a standalone requirement may not be in the open list; price it directly v := decimal.Zero if rv.OpportunityValue != nil { v = *rv.OpportunityValue } gapPriority = PriorityMultiplier(v, cfg.ValueReference, cfg.PriorityMin, cfg.PriorityMax) priorities[requirementID.String()] = gapPriority } commitRows, e := q.ListCommitments(ctx) if e != nil { return e } commit := buildCommitments(commitRows) cands, e := q.ListCandidates(ctx, dbgen.ListCandidatesParams{Limit: benchScanLimit, Offset: 0}) if e != nil { return e } bench, rbc, e := s.scoreBench(ctx, tx, er, gapPriority, cands, commit, priorities, true, cfg, asOf) if e != nil { return e } resByCand = rbc openSlots := int(rv.Quantity) - commit.countByReq[requirementID.String()] if openSlots < 0 { openSlots = 0 } recs = ClassifyForGap(cfg, GapContext{RequirementID: requirementID.String(), OpenSlots: openSlots, Priority: gapPriority}, bench) return nil }) if err != nil { return RunResponse{}, err } // Phase B (no tx): narrate each recommendation behind the faithfulness gate. enriched := s.enrich(ctx, recs) // Every gap recommendation is against this one requirement, so key the frozen results by that pair. resByPair := make(map[string]engine.MatchResult, len(resByCand)) for cid, res := range resByCand { resByPair[pairKey(cid, requirementID.String())] = res } // Phase C (write): persist the run, the frozen evaluations, and the recommendations atomically. scope := requirementID var run dbgen.RecommendationRun var rows []dbgen.StaffingRecommendation err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { // Serialize gap runs for THIS requirement so a concurrent run (a double click or a retry) cannot // leave two live pending sets; runs for different requirements still proceed in parallel. if e := acquireAdvisoryLock(ctx, tx, advisoryKey(ViewGap, orgID, requirementID.String())); e != nil { return e } var e error run, rows, e = s.persistRun(ctx, tx, orgID, ViewGap, &scope, runParams(cfg, valueSource), "staffing.gap", enriched, resByPair, func(ctx context.Context, q *dbgen.Queries) error { return q.SupersedePendingGap(ctx, requirementID) }) return e }) if err != nil { return RunResponse{}, err } return RunResponse{Run: runView(run), Recommendations: recommendationViews(rows)}, nil }