Two permanent fixtures pinning the failure mode from issue #1500 — explore spending its byte envelope on files that merely name-collide with the query. BOTH FAIL TODAY, by design: they document the bug and become the pass gate for CG-10 (scoring) + CG-12 (proportional allocation). __tests__/fixtures/payroll-go/ — a synthetic Go service mirroring the reporter's shape: generated FKIT CRUD beside a hand-written payroll use-case, entered from an HTTP route. Half the generated tree carries ORDINARY names detectable only by their `// Code generated ... DO NOT EDIT.` header (the #1500 case, and end-to-end cover for CG-5); `payrollpb/*.pb.go` covers the path-detectable channel. BuildPayslip, Upsert and Store each exist twice, generated and hand-written. cycle.go sits above the whole-file window so it clips; the generated files sit below it so they ship whole. Asking "how does payroll cycle create and calculate payslips?" — naming none of the answering symbols — the generated CRUD delivers 57.4% of the envelope against the hand-written layer's 25.6%, all of the latter domain types. cycle.go is allocated the single largest slice (30.6%) and delivers ZERO: the hard ceiling drops its whole section. runPayrollCycleAll, the hand-written BuildPayslip and the real Upsert never reach the agent. The second fixture is this repo, "how does explore allocate its output budget across files", where scripts/agent-eval/*.mjs take 71.8% against tools.ts's 18.5% despite scoring 4.6x lower. It reads the live index, so its assertions are relative rather than fixed percentages. - scripts/agent-eval/probe-allocation.mjs — per-file budget-share probe, driving the CG-4 diagnostic through a JSONL sidecar so it measures the shipping allocator. Fixture entries are hermetic (copy + re-index per run, verified byte-identical across runs); exits 1 while any assertion fails. - scripts/agent-eval/allocation-fixtures.json — both fixtures declared, with the 2026-08-03 baselines. - __tests__/explore-allocation-1500.test.ts — fixture-shape assertions green today; the allocation assertions held as `it.fails` so the suite stays green while the bug is open and goes RED the moment it is fixed. Also documented and deliberately left unfixed: runPayrollCycleAll's `s.store.Upsert` edge resolves to the GENERATED Store.Upsert, not the hand-written one — same-name method resolution across two packages picks the wrong receiver. It is upstream of the allocation bug, so it belongs with CG-10's scoring work. Refs #1500 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
6.6 KiB
Go
228 lines
6.6 KiB
Go
package payroll
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/example/payroll-svc/internal/domain/payroll"
|
|
"github.com/example/payroll-svc/internal/platform/clock"
|
|
"github.com/example/payroll-svc/internal/store/payslipstore"
|
|
)
|
|
|
|
// ErrCycleClosed is returned when a cycle has already been finalized.
|
|
var ErrCycleClosed = errors.New("payroll cycle is closed")
|
|
|
|
// ErrNoEmployees is returned when a cycle resolves to an empty roster.
|
|
var ErrNoEmployees = errors.New("payroll cycle has no active employees")
|
|
|
|
// RunOptions tunes a single run of a payroll cycle.
|
|
type RunOptions struct {
|
|
// DryRun computes every payslip but persists nothing.
|
|
DryRun bool
|
|
// Reason is recorded on the audit trail for re-runs.
|
|
Reason string
|
|
// Only, when non-empty, restricts the run to these employee ids.
|
|
Only []string
|
|
}
|
|
|
|
// RunResult is the outcome of one payroll cycle run.
|
|
type RunResult struct {
|
|
CycleID string
|
|
Payslips []payroll.Payslip
|
|
TotalGrossCents int64
|
|
TotalNetCents int64
|
|
Skipped []string
|
|
FinishedAt time.Time
|
|
}
|
|
|
|
// Service is the hand-written payroll use-case layer. It owns the order of
|
|
// operations for a cycle: resolve the roster, build a payslip per employee,
|
|
// then persist. The generated CRUD layer under internal/gen has no opinion
|
|
// about any of that — it can only read and write single rows.
|
|
type Service struct {
|
|
store *payslipstore.Store
|
|
clock clock.Clock
|
|
}
|
|
|
|
func NewService(store *payslipstore.Store, c clock.Clock) *Service {
|
|
return &Service{store: store, clock: c}
|
|
}
|
|
|
|
// RunCycle is the public entry point used by the HTTP handler. It loads the
|
|
// cycle, guards its state, and delegates the actual work to runPayrollCycleAll.
|
|
func (s *Service) RunCycle(ctx context.Context, cycleID string, opts RunOptions) (RunResult, error) {
|
|
cycle, err := s.loadCycle(ctx, cycleID)
|
|
if err != nil {
|
|
return RunResult{}, err
|
|
}
|
|
if cycle.Status == payroll.CycleClosed {
|
|
return RunResult{}, ErrCycleClosed
|
|
}
|
|
|
|
roster, err := s.rosterFor(ctx, cycle, opts)
|
|
if err != nil {
|
|
return RunResult{}, err
|
|
}
|
|
if len(roster) == 0 {
|
|
return RunResult{}, ErrNoEmployees
|
|
}
|
|
|
|
return s.runPayrollCycleAll(ctx, cycle, roster, opts)
|
|
}
|
|
|
|
// runPayrollCycleAll is the heart of the cycle: for every employee on the
|
|
// roster it builds a payslip from that employee's contract and timesheet,
|
|
// then upserts the result. Ordering matters — a payslip is only persisted
|
|
// after every earning, deduction and tax line has been resolved, so a
|
|
// partially-computed slip can never reach the store.
|
|
func (s *Service) runPayrollCycleAll(
|
|
ctx context.Context,
|
|
cycle payroll.Cycle,
|
|
roster []payroll.Employee,
|
|
opts RunOptions,
|
|
) (RunResult, error) {
|
|
result := RunResult{CycleID: cycle.ID}
|
|
now := s.clock.Now()
|
|
|
|
for _, employee := range roster {
|
|
if err := ctx.Err(); err != nil {
|
|
return result, err
|
|
}
|
|
|
|
timesheet, err := s.timesheetFor(ctx, cycle, employee)
|
|
if err != nil {
|
|
result.Skipped = append(result.Skipped, employee.ID)
|
|
continue
|
|
}
|
|
|
|
slip, err := s.BuildPayslip(ctx, cycle, employee, timesheet)
|
|
if err != nil {
|
|
return result, fmt.Errorf("build payslip for %s: %w", employee.ID, err)
|
|
}
|
|
|
|
slip.RunAt = now
|
|
slip.RunReason = opts.Reason
|
|
|
|
if !opts.DryRun {
|
|
if err := s.store.Upsert(ctx, slip); err != nil {
|
|
return result, fmt.Errorf("persist payslip for %s: %w", employee.ID, err)
|
|
}
|
|
}
|
|
|
|
result.Payslips = append(result.Payslips, slip)
|
|
result.TotalGrossCents += slip.GrossCents
|
|
result.TotalNetCents += slip.NetCents
|
|
}
|
|
|
|
if !opts.DryRun {
|
|
if err := s.closeCycle(ctx, cycle, now); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
sort.Slice(result.Payslips, func(i, j int) bool {
|
|
return result.Payslips[i].EmployeeID < result.Payslips[j].EmployeeID
|
|
})
|
|
result.FinishedAt = now
|
|
return result, nil
|
|
}
|
|
|
|
// rosterFor resolves which employees this cycle pays. An employee joins the
|
|
// roster when their contract overlaps the cycle window and they are not on
|
|
// unpaid leave for the whole period.
|
|
func (s *Service) rosterFor(ctx context.Context, cycle payroll.Cycle, opts RunOptions) ([]payroll.Employee, error) {
|
|
all, err := s.store.EmployeesForCycle(ctx, cycle.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
only := map[string]bool{}
|
|
for _, id := range opts.Only {
|
|
only[id] = true
|
|
}
|
|
|
|
roster := make([]payroll.Employee, 0, len(all))
|
|
for _, e := range all {
|
|
if len(only) > 0 && !only[e.ID] {
|
|
continue
|
|
}
|
|
if !e.Contract.OverlapsWindow(cycle.Start, cycle.End) {
|
|
continue
|
|
}
|
|
if e.UnpaidLeaveCoversWindow(cycle.Start, cycle.End) {
|
|
continue
|
|
}
|
|
roster = append(roster, e)
|
|
}
|
|
|
|
sort.Slice(roster, func(i, j int) bool { return roster[i].ID < roster[j].ID })
|
|
return roster, nil
|
|
}
|
|
|
|
func (s *Service) timesheetFor(ctx context.Context, cycle payroll.Cycle, e payroll.Employee) (payroll.Timesheet, error) {
|
|
ts, err := s.store.Timesheet(ctx, cycle.ID, e.ID)
|
|
if err != nil {
|
|
return payroll.Timesheet{}, err
|
|
}
|
|
if ts.Approved {
|
|
return ts, nil
|
|
}
|
|
if e.Contract.Kind == payroll.ContractSalaried {
|
|
// Salaried staff are paid the contractual period regardless of an
|
|
// unapproved timesheet; hourly staff are skipped until approval.
|
|
return payroll.Timesheet{
|
|
CycleID: cycle.ID,
|
|
EmployeeID: e.ID,
|
|
Approved: true,
|
|
Units: e.Contract.PeriodUnits(cycle.Start, cycle.End),
|
|
}, nil
|
|
}
|
|
return payroll.Timesheet{}, fmt.Errorf("timesheet for %s not approved", e.ID)
|
|
}
|
|
|
|
func (s *Service) loadCycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
|
|
if cycleID == "" {
|
|
return payroll.Cycle{}, errors.New("empty cycle id")
|
|
}
|
|
return s.store.Cycle(ctx, cycleID)
|
|
}
|
|
|
|
func (s *Service) closeCycle(ctx context.Context, cycle payroll.Cycle, at time.Time) error {
|
|
cycle.Status = payroll.CycleClosed
|
|
cycle.ClosedAt = at
|
|
return s.store.SaveCycle(ctx, cycle)
|
|
}
|
|
|
|
// Cycle exposes a cycle for the read endpoints.
|
|
func (s *Service) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
|
|
return s.loadCycle(ctx, cycleID)
|
|
}
|
|
|
|
// PayslipsForCycle lists the payslips a completed cycle produced.
|
|
func (s *Service) PayslipsForCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
|
|
slips, err := s.store.ListByCycle(ctx, cycleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(slips, func(i, j int) bool { return slips[i].EmployeeID < slips[j].EmployeeID })
|
|
return slips, nil
|
|
}
|
|
|
|
// Reopen unwinds a closed cycle so it can be re-run after a correction.
|
|
func (s *Service) Reopen(ctx context.Context, cycleID string, reason string) error {
|
|
cycle, err := s.loadCycle(ctx, cycleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cycle.Status != payroll.CycleClosed {
|
|
return nil
|
|
}
|
|
cycle.Status = payroll.CycleOpen
|
|
cycle.ReopenReason = reason
|
|
cycle.ClosedAt = time.Time{}
|
|
return s.store.SaveCycle(ctx, cycle)
|
|
}
|