test(explore): #1500 regression fixtures for budget allocation (CG-6)
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>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Regression fixture for GitHub issue #1500 / epic CG-1 — relevance-proportional
|
||||
* explore budget allocation.
|
||||
*
|
||||
* The reporter's repo is a Go service whose GENERATED FKIT CRUD layer sits beside
|
||||
* the hand-written use-case that does the real work. Asking an architecture
|
||||
* question that doesn't name the exact use-case ("how does payroll cycle create
|
||||
* and calculate payslips?") spends the explore envelope on the generated CRUD,
|
||||
* because the generated layer name-collides on every term in the question while
|
||||
* the hand-written workflow is one big file that gets clipped.
|
||||
*
|
||||
* `__tests__/fixtures/payroll-go/` reproduces that shape permanently. This suite
|
||||
* is in two halves:
|
||||
*
|
||||
* 1. **Fixture shape** — green today. These pin the properties the fixture must
|
||||
* keep for the gate below to mean anything: the generated/hand-written split
|
||||
* (including the ordinary-named generated files only a CONTENT header betrays,
|
||||
* which is the #1500 case), the deliberate name collisions, and the
|
||||
* runPayrollCycleAll → BuildPayslip → Upsert chain resolving end-to-end. If
|
||||
* the fixture rots, these fail first and say so.
|
||||
*
|
||||
* 2. **Budget allocation** — the gate, written with `it.fails` because it
|
||||
* DOCUMENTS A BUG THAT IS STILL OPEN. Vitest passes an `it.fails` test only
|
||||
* while its body throws, so the suite is green today and goes RED the moment
|
||||
* allocation is fixed (CG-10 scoring + CG-12 proportional bytes).
|
||||
* **When it goes red, delete the `.fails` — do not delete the test.**
|
||||
*
|
||||
* The same assertions run outside vitest, against the built dist and with the
|
||||
* full CG-4 per-file diagnostic, via `node scripts/agent-eval/probe-allocation.mjs`
|
||||
* (declared in `scripts/agent-eval/allocation-fixtures.json`).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import CodeGraph from '../src/index';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
|
||||
import { isGeneratedFile, hasGeneratedHeader } from '../src/extraction/generated-detection';
|
||||
|
||||
const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
|
||||
|
||||
/** The question a newcomer asks — names none of the symbols that answer it. */
|
||||
const QUERY = 'how does payroll cycle create and calculate payslips?';
|
||||
|
||||
/** The hand-written workflow: what the query is actually about. */
|
||||
const ANSWER_PREFIXES = [
|
||||
'internal/usecase/',
|
||||
'internal/store/',
|
||||
'internal/transport/',
|
||||
'internal/domain/',
|
||||
'cmd/',
|
||||
];
|
||||
/** The generated CRUD/DTO layer: what wins the envelope today. */
|
||||
const GENERATED_PREFIX = 'internal/gen/';
|
||||
|
||||
const startsWithAny = (p: string, prefixes: string[]) => prefixes.some((x) => p.startsWith(x));
|
||||
|
||||
describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', () => {
|
||||
let testDir: string;
|
||||
let cg: CodeGraph;
|
||||
let handler: ToolHandler;
|
||||
let response: string;
|
||||
/** Delivered source bytes per file, attributed from the final response. */
|
||||
let bytes: Map<string, number>;
|
||||
|
||||
beforeAll(async () => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1500-'));
|
||||
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
|
||||
// A stray index in the checked-in tree would be copied in and reused.
|
||||
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
|
||||
|
||||
cg = CodeGraph.initSync(testDir);
|
||||
await cg.indexAll();
|
||||
handler = new ToolHandler(cg);
|
||||
|
||||
const result = await handler.execute('codegraph_explore', { query: QUERY });
|
||||
response = result.content?.[0]?.text ?? '';
|
||||
bytes = attributeSourceBytes(response);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (cg) cg.destroy();
|
||||
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── 1. Fixture shape ──────────────────────────────────────────────────────
|
||||
|
||||
describe('fixture shape', () => {
|
||||
it('indexes as a Go project with both layers present', () => {
|
||||
const files = cg.getFiles().map((f) => f.path);
|
||||
expect(files.filter((p) => p.endsWith('.go')).length).toBeGreaterThanOrEqual(15);
|
||||
expect(files.some((p) => p.startsWith(GENERATED_PREFIX))).toBe(true);
|
||||
expect(files.some((p) => p.startsWith('internal/usecase/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags every generated file and no hand-written one', () => {
|
||||
for (const file of cg.getFiles()) {
|
||||
expect(file.generated, `${file.path} generated flag`).toBe(
|
||||
file.path.startsWith(GENERATED_PREFIX),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries generated files that ONLY a content header betrays — the #1500 case', () => {
|
||||
// Half the generated tree has ordinary names (`payslip.go`, `store.go`).
|
||||
// Path-only detection misses them; the CG-5 content check is what catches
|
||||
// them. Without these the fixture would be a .pb.go fixture, not a #1500 one.
|
||||
const contentOnly = [
|
||||
'internal/gen/fkit/payroll/payslip.go',
|
||||
'internal/gen/fkit/payroll/payroll_cycle.go',
|
||||
'internal/gen/fkit/payroll/store.go',
|
||||
'internal/gen/fkit/payroll/calculate.go',
|
||||
'internal/gen/fkit/payroll/dto.go',
|
||||
'internal/gen/fkit/employee/employee.go',
|
||||
'internal/gen/fkit/timesheet/timesheet.go',
|
||||
];
|
||||
for (const rel of contentOnly) {
|
||||
const source = fs.readFileSync(path.join(testDir, rel), 'utf-8');
|
||||
expect(isGeneratedFile(rel), `${rel} must NOT be detectable by path`).toBe(false);
|
||||
expect(hasGeneratedHeader(source), `${rel} must be detectable by header`).toBe(true);
|
||||
expect(cg.getFile(rel)?.generated, `${rel} indexed flag`).toBe(true);
|
||||
}
|
||||
// …beside the conventional path-detectable ones, so both channels are covered.
|
||||
expect(isGeneratedFile('internal/gen/payrollpb/payroll.pb.go')).toBe(true);
|
||||
});
|
||||
|
||||
it('collides the generated layer with the hand-written one by name', () => {
|
||||
// A naive scorer sees two BuildPayslips and two Upserts and has no reason
|
||||
// to prefer the one that implements the business rule.
|
||||
for (const name of ['BuildPayslip', 'Upsert', 'Store']) {
|
||||
const files = new Set(cg.getNodesByName(name).map((n) => n.filePath));
|
||||
expect([...files].some((p) => p.startsWith(GENERATED_PREFIX)), `${name} generated`).toBe(true);
|
||||
expect([...files].some((p) => !p.startsWith(GENERATED_PREFIX)), `${name} hand-written`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the hand-written workflow chain end-to-end in the graph', () => {
|
||||
const calleesOf = (name: string, file: string) => {
|
||||
const node = cg.getNodesByName(name).find((n) => n.filePath === file);
|
||||
expect(node, `${name} in ${file}`).toBeTruthy();
|
||||
return cg
|
||||
.getOutgoingEdges(node!.id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => cg.getNode(e.target))
|
||||
.filter((n): n is NonNullable<typeof n> => !!n);
|
||||
};
|
||||
|
||||
// handler → use-case
|
||||
expect(
|
||||
calleesOf('RunCycle', 'internal/transport/httpapi/payroll_handler.go')
|
||||
.some((n) => n.name === 'RunCycle' && n.filePath === 'internal/usecase/payroll/cycle.go'),
|
||||
).toBe(true);
|
||||
|
||||
// use-case → the workflow
|
||||
expect(
|
||||
calleesOf('RunCycle', 'internal/usecase/payroll/cycle.go')
|
||||
.some((n) => n.name === 'runPayrollCycleAll'),
|
||||
).toBe(true);
|
||||
|
||||
// the workflow → build + persist
|
||||
const workflow = calleesOf('runPayrollCycleAll', 'internal/usecase/payroll/cycle.go');
|
||||
expect(
|
||||
workflow.some((n) => n.name === 'BuildPayslip' && n.filePath === 'internal/usecase/payroll/payslip_builder.go'),
|
||||
'runPayrollCycleAll must reach the hand-written BuildPayslip',
|
||||
).toBe(true);
|
||||
expect(workflow.some((n) => n.name === 'Upsert'), 'runPayrollCycleAll must reach an Upsert').toBe(true);
|
||||
});
|
||||
|
||||
it('routes an HTTP entry point into the workflow', () => {
|
||||
const router = cg.getNodesInFile('internal/transport/httpapi/router.go');
|
||||
expect(router.some((n) => n.kind === 'route' || n.name === 'NewRouter')).toBe(true);
|
||||
});
|
||||
|
||||
it('sizes the two layers so the size-driven render split actually bites', () => {
|
||||
// The mechanism the epic is about: a small file ships WHOLE, a large one
|
||||
// falls through to clipped clusters. The workflow file must stay above the
|
||||
// whole-file window and the generated files below it, or the fixture stops
|
||||
// reproducing anything.
|
||||
const lines = (rel: string) => fs.readFileSync(path.join(testDir, rel), 'utf-8').split('\n').length;
|
||||
expect(lines('internal/usecase/payroll/cycle.go')).toBeGreaterThan(220);
|
||||
for (const rel of ['internal/gen/fkit/payroll/payslip.go', 'internal/gen/fkit/payroll/payroll_cycle.go']) {
|
||||
expect(lines(rel)).toBeLessThan(220);
|
||||
}
|
||||
});
|
||||
|
||||
it('answers the query at all', () => {
|
||||
expect(response.length).toBeGreaterThan(1000);
|
||||
expect(bytes.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. Budget allocation — the open bug ───────────────────────────────────
|
||||
|
||||
describe('budget allocation', () => {
|
||||
const share = (predicate: (p: string) => boolean) => {
|
||||
let total = 0;
|
||||
for (const [file, n] of bytes) if (predicate(file)) total += n;
|
||||
return total / response.length;
|
||||
};
|
||||
const answerShare = () => share((p) => startsWithAny(p, ANSWER_PREFIXES));
|
||||
const generatedShare = () => share((p) => p.startsWith(GENERATED_PREFIX));
|
||||
|
||||
/**
|
||||
* BASELINE 2026-08-03 (very-tiny tier, 13,000-char budget): 23,020 chars
|
||||
* allocated against it, cut to 16,011 by the 19,500 hard ceiling. The
|
||||
* generated CRUD delivers 57.4%; the hand-written layer delivers 25.6%, all
|
||||
* of it domain types. `cycle.go` is allocated the single largest slice
|
||||
* (7,052 chars, 30.6%) and delivers ZERO — the ceiling drops its whole
|
||||
* section — so runPayrollCycleAll, the hand-written BuildPayslip and the
|
||||
* real Upsert never reach the agent at all.
|
||||
*
|
||||
* Each `it.fails` below passes ONLY while that is still true.
|
||||
* ⚠ When one goes red, the bug is fixed: remove `.fails`, keep the test.
|
||||
*/
|
||||
it.fails('CG-12 GATE: concentrates the envelope on the hand-written workflow', () => {
|
||||
expect(answerShare()).toBeGreaterThanOrEqual(0.55);
|
||||
});
|
||||
|
||||
it.fails('CG-12 GATE: does not spend the envelope on the generated CRUD', () => {
|
||||
expect(generatedShare()).toBeLessThanOrEqual(0.25);
|
||||
});
|
||||
|
||||
it.fails('CG-12 GATE: delivers the workflow file it allocated the most bytes to', () => {
|
||||
expect(bytes.get('internal/usecase/payroll/cycle.go') ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.fails('CG-12 GATE: delivers the calculation the question asks about', () => {
|
||||
expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.fails('CG-12 GATE: puts the hand-written chain in the response, not its generated twin', () => {
|
||||
// Bare `BuildPayslip`/`Upsert` also match the generated collisions — these
|
||||
// needles are unique to the hand-written chain.
|
||||
expect(response).toContain('runPayrollCycleAll');
|
||||
expect(response).toContain('func (s *Service) BuildPayslip');
|
||||
expect(response).toContain('s.store.Upsert(ctx, slip)');
|
||||
});
|
||||
|
||||
it('records the shape of the failure so a regression is legible', () => {
|
||||
// Not a gate — an assertion-free-ish snapshot of WHY the gates above fail,
|
||||
// so a future change that shifts the numbers shows up in the diff rather
|
||||
// than silently flipping an it.fails.
|
||||
const generated = generatedShare();
|
||||
const answer = answerShare();
|
||||
const workflow = bytes.get('internal/usecase/payroll/cycle.go') ?? 0;
|
||||
expect({
|
||||
generatedWinsEnvelope: generated > answer,
|
||||
workflowFileDeliversNothing: workflow === 0,
|
||||
}).toEqual({
|
||||
generatedWinsEnvelope: true,
|
||||
workflowFileDeliversNothing: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
# payroll-go — the #1500 regression fixture
|
||||
|
||||
A synthetic Go service reproducing the repo shape from [issue #1500](https://github.com/colbymchenry/codegraph/issues/1500):
|
||||
**generated CRUD sitting beside the hand-written use-case that does the real work.**
|
||||
|
||||
This tree is a fixture, not a program. It never compiles or runs — it exists to be
|
||||
indexed. Keep it valid, idiomatic Go anyway: the extractor's output is the whole point.
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
cmd/payrolld/main.go wires the service
|
||||
internal/transport/httpapi/ HTTP entry point → use-case
|
||||
internal/usecase/payroll/ ← THE ANSWER. Hand-written workflow:
|
||||
cycle.go runPayrollCycleAll (227 lines)
|
||||
payslip_builder.go BuildPayslip — the actual pay calculation
|
||||
prorate.go
|
||||
internal/domain/payroll/payslip.go hand-written domain types
|
||||
internal/store/payslipstore/store.go the real Upsert
|
||||
internal/platform/clock/clock.go
|
||||
|
||||
internal/gen/fkit/payroll/ ← THE NOISE. Generated CRUD, ORDINARY names:
|
||||
payslip.go CreatePayslip, GetPayslip, UpdatePayslip, a second BuildPayslip
|
||||
payroll_cycle.go CreatePayrollCycle, PayrollCycleCreateRequest, …
|
||||
store.go a second Upsert
|
||||
calculate.go CalculatePayrollCycleTotals, CalculatePayslipNet, …
|
||||
dto.go
|
||||
internal/gen/fkit/employee/, timesheet/ more generated CRUD
|
||||
internal/gen/payrollpb/*.pb.go generated, detectable by PATH
|
||||
```
|
||||
|
||||
The chain the fixture is built around is `runPayrollCycleAll` → `BuildPayslip` → `Upsert`,
|
||||
entered from `POST /v1/payroll/cycles/{cycleID}/run`.
|
||||
|
||||
## The three properties that make it a regression fixture
|
||||
|
||||
1. **Generated files that only a CONTENT header betrays.** The `internal/gen/fkit/**`
|
||||
files have ordinary names (`payslip.go`, `store.go`) and carry
|
||||
`// Code generated by fkit v3.11.0. DO NOT EDIT.`. Path-only detection misses every
|
||||
one of them — that is the #1500 case, and why CG-5 added the content check. The
|
||||
`payrollpb/*.pb.go` files cover the path-detectable channel beside them.
|
||||
|
||||
2. **Deliberate name collisions.** `BuildPayslip`, `Upsert` and `Store` each exist twice,
|
||||
once generated and once hand-written. The generated layer also name-collides on every
|
||||
term of the question below — `CreatePayslip`, `PayrollCycleCreateRequest`,
|
||||
`CalculatePayrollCycleTotals` — so a scorer that rewards incidental name matches
|
||||
surfaces the CRUD path.
|
||||
|
||||
3. **A size split that drives the render mode.** `cycle.go` is deliberately over the
|
||||
whole-file window (227 lines) so it falls through to clipped clusters; the generated
|
||||
files are deliberately under it so they ship whole. Allocation follows file size, not
|
||||
relevance. `__tests__/explore-allocation-1500.test.ts` pins both sides of that split —
|
||||
if you edit these files, keep it.
|
||||
|
||||
## The assertion
|
||||
|
||||
Query: **"how does payroll cycle create and calculate payslips?"** — an architecture
|
||||
question that names none of the symbols that answer it. The budget should concentrate on
|
||||
the hand-written workflow. As of 2026-08-03 it does not:
|
||||
|
||||
| | allocated | delivered |
|
||||
|---|---|---|
|
||||
| hand-written | 48.4% | **25.6%** (all of it domain types) |
|
||||
| generated CRUD | 39.9% | **57.4%** |
|
||||
|
||||
`cycle.go` is allocated the single largest slice (7,052 chars, 30.6%) and delivers
|
||||
**zero** — the hard ceiling drops its whole section. `payslip_builder.go` (rank #8) never
|
||||
renders at all. `runPayrollCycleAll`, the hand-written `BuildPayslip` and the real
|
||||
`Upsert` never reach the agent.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
node scripts/agent-eval/probe-allocation.mjs payroll-go # exits 1 today, by design
|
||||
npx vitest run __tests__/explore-allocation-1500.test.ts # green today, by design
|
||||
```
|
||||
|
||||
The probe reports per-file budget share against `scripts/agent-eval/allocation-fixtures.json`.
|
||||
The vitest suite pins the fixture's shape and holds the allocation assertion as `it.fails` —
|
||||
green while the bug is open, red the moment it is fixed. See
|
||||
`docs/design/explore-budget-allocation.md`.
|
||||
|
||||
## Known finding: the chain's `Upsert` edge resolves to the generated store
|
||||
|
||||
`runPayrollCycleAll` calls `s.store.Upsert(ctx, slip)`, where `s.store` is a
|
||||
`*payslipstore.Store`. The graph resolves that edge to `internal/gen/fkit/payroll/store.go`
|
||||
— the **generated** `Store.Upsert` — not the hand-written one. Same-name method resolution
|
||||
across two packages that both define `Store.Upsert` picks the wrong receiver.
|
||||
|
||||
This is a resolution defect, not a budget one, and it is left unfixed on purpose: it is
|
||||
upstream of the allocation bug (a wrong edge pulls the generated store into the subgraph
|
||||
and inflates its score), so it belongs with the scoring work in CG-10 rather than here.
|
||||
The test asserts only that the workflow reaches *an* `Upsert`, so tightening the resolver
|
||||
later will not break the fixture.
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/example/payroll-svc/internal/platform/clock"
|
||||
"github.com/example/payroll-svc/internal/store/payslipstore"
|
||||
"github.com/example/payroll-svc/internal/transport/httpapi"
|
||||
"github.com/example/payroll-svc/internal/usecase/payroll"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
|
||||
store := payslipstore.New()
|
||||
svc := payroll.NewService(store, clock.System{})
|
||||
router := httpapi.NewRouter(httpapi.NewPayrollHandler(svc))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("payrolld listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("payrolld: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/example/payroll-svc
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,159 @@
|
||||
package payroll
|
||||
|
||||
import "time"
|
||||
|
||||
// CycleStatus is the lifecycle state of a payroll cycle.
|
||||
type CycleStatus string
|
||||
|
||||
const (
|
||||
CycleOpen CycleStatus = "open"
|
||||
CycleClosed CycleStatus = "closed"
|
||||
)
|
||||
|
||||
// ContractKind distinguishes the two pay models this service supports.
|
||||
type ContractKind string
|
||||
|
||||
const (
|
||||
ContractSalaried ContractKind = "salaried"
|
||||
ContractHourly ContractKind = "hourly"
|
||||
)
|
||||
|
||||
// LineKind separates the two halves of a payslip.
|
||||
type LineKind string
|
||||
|
||||
const (
|
||||
LineEarning LineKind = "earning"
|
||||
LineDeduction LineKind = "deduction"
|
||||
)
|
||||
|
||||
// Cycle is one payroll period.
|
||||
type Cycle struct {
|
||||
ID string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Status CycleStatus
|
||||
ClosedAt time.Time
|
||||
ReopenReason string
|
||||
}
|
||||
|
||||
// Line is a single earning or deduction on a payslip.
|
||||
type Line struct {
|
||||
Code string
|
||||
Kind LineKind
|
||||
AmountCents int64
|
||||
}
|
||||
|
||||
// Payslip is what a cycle produces for one employee.
|
||||
type Payslip struct {
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Currency string
|
||||
PeriodFrom time.Time
|
||||
PeriodTo time.Time
|
||||
Lines []Line
|
||||
GrossCents int64
|
||||
DeductionCents int64
|
||||
NetCents int64
|
||||
Underwater bool
|
||||
RunAt time.Time
|
||||
RunReason string
|
||||
}
|
||||
|
||||
// Timesheet is the approved unit count backing an hourly payslip.
|
||||
type Timesheet struct {
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Approved bool
|
||||
Units int
|
||||
}
|
||||
|
||||
// Allowance is a recurring earning attached to a contract.
|
||||
type Allowance struct {
|
||||
Code string
|
||||
AmountCents int64
|
||||
Prorated bool
|
||||
}
|
||||
|
||||
// Contract holds the pay terms for one employee.
|
||||
type Contract struct {
|
||||
Kind ContractKind
|
||||
Currency string
|
||||
RateCents int64
|
||||
PeriodRateCents int64
|
||||
OvertimeThresholdUnits int
|
||||
OvertimeMultiplier float64
|
||||
Allowances []Allowance
|
||||
StartsOn time.Time
|
||||
EndsOn time.Time
|
||||
}
|
||||
|
||||
// OverlapsWindow reports whether the contract is live at any point in the window.
|
||||
func (c Contract) OverlapsWindow(from, to time.Time) bool {
|
||||
if !c.StartsOn.IsZero() && c.StartsOn.After(to) {
|
||||
return false
|
||||
}
|
||||
if !c.EndsOn.IsZero() && c.EndsOn.Before(from) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// PeriodUnits is the contractual unit count for a window, used when a salaried
|
||||
// employee has no approved timesheet.
|
||||
func (c Contract) PeriodUnits(from, to time.Time) int {
|
||||
if to.Before(from) {
|
||||
return 0
|
||||
}
|
||||
days := int(to.Sub(from).Hours()/24) + 1
|
||||
return days * 8
|
||||
}
|
||||
|
||||
// Deduction is a fixed or proportional subtraction from gross.
|
||||
type Deduction struct {
|
||||
Code string
|
||||
FixedCents int64
|
||||
RateBasisPoints int
|
||||
}
|
||||
|
||||
// AmountFor resolves a deduction against a gross amount.
|
||||
func (d Deduction) AmountFor(grossCents int64) int64 {
|
||||
if d.FixedCents > 0 {
|
||||
return d.FixedCents
|
||||
}
|
||||
return grossCents * int64(d.RateBasisPoints) / 10000
|
||||
}
|
||||
|
||||
// TaxBand is one slice of a progressive tax schedule.
|
||||
type TaxBand struct {
|
||||
UpToCents int64
|
||||
RateBasisPoints int
|
||||
}
|
||||
|
||||
// Leave is an absence window.
|
||||
type Leave struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
Unpaid bool
|
||||
}
|
||||
|
||||
// Employee is the payroll view of a person.
|
||||
type Employee struct {
|
||||
ID string
|
||||
Contract Contract
|
||||
Deductions []Deduction
|
||||
TaxBands []TaxBand
|
||||
Leave []Leave
|
||||
}
|
||||
|
||||
// UnpaidLeaveCoversWindow reports whether unpaid leave swallows the whole window.
|
||||
func (e Employee) UnpaidLeaveCoversWindow(from, to time.Time) bool {
|
||||
for _, l := range e.Leave {
|
||||
if !l.Unpaid {
|
||||
continue
|
||||
}
|
||||
if !l.From.After(from) && !l.To.Before(to) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/employee/contract.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/employee
|
||||
|
||||
package employee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContractRow is the generated row type for table contract.
|
||||
type ContractRow struct {
|
||||
ID string
|
||||
EmployeeID string
|
||||
Kind string
|
||||
Currency string
|
||||
RateCents int64
|
||||
PeriodRateCents int64
|
||||
StartsOn time.Time
|
||||
EndsOn time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ContractCreateRequest is the generated create payload for table contract.
|
||||
type ContractCreateRequest struct {
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Kind string `json:"kind"`
|
||||
Currency string `json:"currency"`
|
||||
RateCents int64 `json:"rateCents"`
|
||||
PeriodRateCents int64 `json:"periodRateCents"`
|
||||
StartsOn time.Time `json:"startsOn"`
|
||||
}
|
||||
|
||||
// CreateContract inserts one contract row.
|
||||
func CreateContract(ctx context.Context, db *sql.DB, req ContractCreateRequest) (ContractRow, error) {
|
||||
const q = `INSERT INTO contract (employee_id, kind, currency, rate_cents, period_rate_cents, starts_on)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`
|
||||
return scanContract(db.QueryRowContext(ctx, q, req.EmployeeID, req.Kind, req.Currency,
|
||||
req.RateCents, req.PeriodRateCents, req.StartsOn))
|
||||
}
|
||||
|
||||
// GetContract selects one contract row by primary key.
|
||||
func GetContract(ctx context.Context, db *sql.DB, id string) (ContractRow, error) {
|
||||
const q = `SELECT * FROM contract WHERE id = $1`
|
||||
return scanContract(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// DeleteContract removes one contract row.
|
||||
func DeleteContract(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM contract WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListContractsForEmployee selects contract rows for one employee.
|
||||
func ListContractsForEmployee(ctx context.Context, db *sql.DB, employeeID string) ([]ContractRow, error) {
|
||||
const q = `SELECT * FROM contract WHERE employee_id = $1 ORDER BY starts_on DESC`
|
||||
rows, err := db.QueryContext(ctx, q, employeeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ContractRow
|
||||
for rows.Next() {
|
||||
var r ContractRow
|
||||
if err := rows.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents,
|
||||
&r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanContract(row *sql.Row) (ContractRow, error) {
|
||||
var r ContractRow
|
||||
err := row.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents,
|
||||
&r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/employee/employee.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/employee
|
||||
|
||||
package employee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmployeeRow is the generated row type for table employee.
|
||||
type EmployeeRow struct {
|
||||
ID string
|
||||
Email string
|
||||
FullName string
|
||||
Status string
|
||||
HiredOn time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// EmployeeCreateRequest is the generated create payload for table employee.
|
||||
type EmployeeCreateRequest struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// EmployeeUpdateRequest is the generated update payload for table employee.
|
||||
type EmployeeUpdateRequest struct {
|
||||
FullName *string `json:"fullName,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// CreateEmployee inserts one employee row.
|
||||
func CreateEmployee(ctx context.Context, db *sql.DB, req EmployeeCreateRequest) (EmployeeRow, error) {
|
||||
const q = `INSERT INTO employee (email, full_name, status) VALUES ($1, $2, $3) RETURNING *`
|
||||
return scanEmployee(db.QueryRowContext(ctx, q, req.Email, req.FullName, req.Status))
|
||||
}
|
||||
|
||||
// GetEmployee selects one employee row by primary key.
|
||||
func GetEmployee(ctx context.Context, db *sql.DB, id string) (EmployeeRow, error) {
|
||||
const q = `SELECT * FROM employee WHERE id = $1`
|
||||
return scanEmployee(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// UpdateEmployee patches one employee row.
|
||||
func UpdateEmployee(ctx context.Context, db *sql.DB, id string, req EmployeeUpdateRequest) (EmployeeRow, error) {
|
||||
const q = `UPDATE employee SET full_name = COALESCE($2, full_name), status = COALESCE($3, status),
|
||||
updated_at = now() WHERE id = $1 RETURNING *`
|
||||
return scanEmployee(db.QueryRowContext(ctx, q, id, req.FullName, req.Status))
|
||||
}
|
||||
|
||||
// DeleteEmployee removes one employee row.
|
||||
func DeleteEmployee(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM employee WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListEmployeesByStatus selects employee rows in one status.
|
||||
func ListEmployeesByStatus(ctx context.Context, db *sql.DB, status string) ([]EmployeeRow, error) {
|
||||
const q = `SELECT * FROM employee WHERE status = $1 ORDER BY full_name`
|
||||
rows, err := db.QueryContext(ctx, q, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmployeeRow
|
||||
for rows.Next() {
|
||||
var r EmployeeRow
|
||||
if err := rows.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanEmployee(row *sql.Row) (EmployeeRow, error) {
|
||||
var r EmployeeRow
|
||||
err := row.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll/aggregates.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// PayrollCycleTotals is the generated aggregate row for a payroll cycle.
|
||||
type PayrollCycleTotals struct {
|
||||
CycleID string
|
||||
Payslips int64
|
||||
GrossCents int64
|
||||
DeductionCents int64
|
||||
NetCents int64
|
||||
}
|
||||
|
||||
// CalculatePayrollCycleTotals runs the generated SUM aggregate over the
|
||||
// payslip rows of one cycle. It totals what is already stored; it does not
|
||||
// calculate any payslip.
|
||||
func CalculatePayrollCycleTotals(ctx context.Context, db *sql.DB, cycleID string) (PayrollCycleTotals, error) {
|
||||
const q = `SELECT count(*), COALESCE(sum(gross_cents), 0), COALESCE(sum(deduction_cents), 0),
|
||||
COALESCE(sum(net_cents), 0)
|
||||
FROM payslip WHERE cycle_id = $1`
|
||||
var t PayrollCycleTotals
|
||||
t.CycleID = cycleID
|
||||
err := db.QueryRowContext(ctx, q, cycleID).Scan(&t.Payslips, &t.GrossCents, &t.DeductionCents, &t.NetCents)
|
||||
return t, err
|
||||
}
|
||||
|
||||
// CalculatePayslipNet recomputes net from the stored gross and deduction
|
||||
// columns of one row. Pure column arithmetic — no pay rules.
|
||||
func CalculatePayslipNet(row PayslipRow) int64 {
|
||||
return row.GrossCents - row.DeductionCents
|
||||
}
|
||||
|
||||
// CalculatePayrollCycleAverage averages the stored net over a cycle.
|
||||
func CalculatePayrollCycleAverage(ctx context.Context, db *sql.DB, cycleID string) (int64, error) {
|
||||
totals, err := CalculatePayrollCycleTotals(ctx, db, cycleID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if totals.Payslips == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return totals.NetCents / totals.Payslips, nil
|
||||
}
|
||||
|
||||
// CalculateEmployeeYearToDate sums an employee's stored payslips for a year.
|
||||
func CalculateEmployeeYearToDate(ctx context.Context, db *sql.DB, employeeID string, year int) (int64, error) {
|
||||
const q = `SELECT COALESCE(sum(net_cents), 0) FROM payslip
|
||||
WHERE employee_id = $1 AND extract(year from period_from) = $2`
|
||||
var n int64
|
||||
err := db.QueryRowContext(ctx, q, employeeID, year).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import "time"
|
||||
|
||||
// PayslipDTO is the generated wire representation of a payslip row.
|
||||
type PayslipDTO struct {
|
||||
ID string `json:"id"`
|
||||
CycleID string `json:"cycleId"`
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Currency string `json:"currency"`
|
||||
PeriodFrom time.Time `json:"periodFrom"`
|
||||
PeriodTo time.Time `json:"periodTo"`
|
||||
GrossCents int64 `json:"grossCents"`
|
||||
DeductionCents int64 `json:"deductionCents"`
|
||||
NetCents int64 `json:"netCents"`
|
||||
}
|
||||
|
||||
// PayrollCycleDTO is the generated wire representation of a payroll_cycle row.
|
||||
type PayrollCycleDTO struct {
|
||||
ID string `json:"id"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PayslipListDTO is the generated list envelope for payslip rows.
|
||||
type PayslipListDTO struct {
|
||||
Items []PayslipDTO `json:"items"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// PayslipToDTO converts a payslip row to its wire form.
|
||||
func PayslipToDTO(r PayslipRow) PayslipDTO {
|
||||
return PayslipDTO{
|
||||
ID: r.ID,
|
||||
CycleID: r.CycleID,
|
||||
EmployeeID: r.EmployeeID,
|
||||
Currency: r.Currency,
|
||||
PeriodFrom: r.PeriodFrom,
|
||||
PeriodTo: r.PeriodTo,
|
||||
GrossCents: r.GrossCents,
|
||||
DeductionCents: r.DeductionCents,
|
||||
NetCents: r.NetCents,
|
||||
}
|
||||
}
|
||||
|
||||
// PayslipFromDTO converts a wire payslip back to a row.
|
||||
func PayslipFromDTO(d PayslipDTO) PayslipRow {
|
||||
return PayslipRow{
|
||||
ID: d.ID,
|
||||
CycleID: d.CycleID,
|
||||
EmployeeID: d.EmployeeID,
|
||||
Currency: d.Currency,
|
||||
PeriodFrom: d.PeriodFrom,
|
||||
PeriodTo: d.PeriodTo,
|
||||
GrossCents: d.GrossCents,
|
||||
DeductionCents: d.DeductionCents,
|
||||
NetCents: d.NetCents,
|
||||
}
|
||||
}
|
||||
|
||||
// PayrollCycleToDTO converts a payroll_cycle row to its wire form.
|
||||
func PayrollCycleToDTO(r PayrollCycleRow) PayrollCycleDTO {
|
||||
return PayrollCycleDTO{ID: r.ID, Start: r.Start, End: r.End, Status: r.Status}
|
||||
}
|
||||
|
||||
// PayslipsToListDTO wraps payslip rows in the generated list envelope.
|
||||
func PayslipsToListDTO(rows []PayslipRow, total int64) PayslipListDTO {
|
||||
items := make([]PayslipDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, PayslipToDTO(r))
|
||||
}
|
||||
return PayslipListDTO{Items: items, Total: total}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll/payroll_cycle.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PayrollCycleRow is the generated row type for table payroll_cycle.
|
||||
type PayrollCycleRow struct {
|
||||
ID string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Status string
|
||||
ClosedAt time.Time
|
||||
ReopenReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PayrollCycleCreateRequest is the generated create payload for payroll_cycle.
|
||||
type PayrollCycleCreateRequest struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PayrollCycleUpdateRequest is the generated update payload for payroll_cycle.
|
||||
type PayrollCycleUpdateRequest struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
ReopenReason *string `json:"reopenReason,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePayrollCycle inserts one payroll_cycle row.
|
||||
func CreatePayrollCycle(ctx context.Context, db *sql.DB, req PayrollCycleCreateRequest) (PayrollCycleRow, error) {
|
||||
const q = `INSERT INTO payroll_cycle (start_on, end_on, status) VALUES ($1, $2, $3) RETURNING *`
|
||||
return scanPayrollCycle(db.QueryRowContext(ctx, q, req.Start, req.End, req.Status))
|
||||
}
|
||||
|
||||
// GetPayrollCycle selects one payroll_cycle row by primary key.
|
||||
func GetPayrollCycle(ctx context.Context, db *sql.DB, id string) (PayrollCycleRow, error) {
|
||||
const q = `SELECT * FROM payroll_cycle WHERE id = $1`
|
||||
return scanPayrollCycle(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// UpdatePayrollCycle patches one payroll_cycle row.
|
||||
func UpdatePayrollCycle(ctx context.Context, db *sql.DB, id string, req PayrollCycleUpdateRequest) (PayrollCycleRow, error) {
|
||||
const q = `UPDATE payroll_cycle SET status = COALESCE($2, status),
|
||||
reopen_reason = COALESCE($3, reopen_reason), updated_at = now()
|
||||
WHERE id = $1 RETURNING *`
|
||||
return scanPayrollCycle(db.QueryRowContext(ctx, q, id, req.Status, req.ReopenReason))
|
||||
}
|
||||
|
||||
// DeletePayrollCycle removes one payroll_cycle row.
|
||||
func DeletePayrollCycle(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM payroll_cycle WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPayrollCycles selects every payroll_cycle row.
|
||||
func ListPayrollCycles(ctx context.Context, db *sql.DB) ([]PayrollCycleRow, error) {
|
||||
const q = `SELECT * FROM payroll_cycle ORDER BY start_on DESC`
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PayrollCycleRow
|
||||
for rows.Next() {
|
||||
var r PayrollCycleRow
|
||||
if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListPayrollCyclesByStatus selects payroll_cycle rows in one status.
|
||||
func ListPayrollCyclesByStatus(ctx context.Context, db *sql.DB, status string) ([]PayrollCycleRow, error) {
|
||||
const q = `SELECT * FROM payroll_cycle WHERE status = $1 ORDER BY start_on DESC`
|
||||
rows, err := db.QueryContext(ctx, q, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PayrollCycleRow
|
||||
for rows.Next() {
|
||||
var r PayrollCycleRow
|
||||
if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BuildPayrollCycle maps a create request onto a row.
|
||||
func BuildPayrollCycle(req PayrollCycleCreateRequest) PayrollCycleRow {
|
||||
return PayrollCycleRow{Start: req.Start, End: req.End, Status: req.Status}
|
||||
}
|
||||
|
||||
func scanPayrollCycle(row *sql.Row) (PayrollCycleRow, error) {
|
||||
var r PayrollCycleRow
|
||||
err := row.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
|
||||
&r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll/payslip.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PayslipRow is the generated row type for table payslip.
|
||||
type PayslipRow struct {
|
||||
ID string
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Currency string
|
||||
PeriodFrom time.Time
|
||||
PeriodTo time.Time
|
||||
GrossCents int64
|
||||
DeductionCents int64
|
||||
NetCents int64
|
||||
Underwater bool
|
||||
RunAt time.Time
|
||||
RunReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PayslipCreateRequest is the generated create payload for table payslip.
|
||||
type PayslipCreateRequest struct {
|
||||
CycleID string `json:"cycleId"`
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Currency string `json:"currency"`
|
||||
GrossCents int64 `json:"grossCents"`
|
||||
DeductionCents int64 `json:"deductionCents"`
|
||||
NetCents int64 `json:"netCents"`
|
||||
}
|
||||
|
||||
// PayslipUpdateRequest is the generated update payload for table payslip.
|
||||
type PayslipUpdateRequest struct {
|
||||
GrossCents *int64 `json:"grossCents,omitempty"`
|
||||
DeductionCents *int64 `json:"deductionCents,omitempty"`
|
||||
NetCents *int64 `json:"netCents,omitempty"`
|
||||
RunReason *string `json:"runReason,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePayslip inserts one payslip row.
|
||||
func CreatePayslip(ctx context.Context, db *sql.DB, req PayslipCreateRequest) (PayslipRow, error) {
|
||||
const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`
|
||||
row := db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Currency, req.GrossCents, req.DeductionCents, req.NetCents)
|
||||
return scanPayslip(row)
|
||||
}
|
||||
|
||||
// GetPayslip selects one payslip row by primary key.
|
||||
func GetPayslip(ctx context.Context, db *sql.DB, id string) (PayslipRow, error) {
|
||||
const q = `SELECT * FROM payslip WHERE id = $1`
|
||||
return scanPayslip(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// UpdatePayslip patches one payslip row.
|
||||
func UpdatePayslip(ctx context.Context, db *sql.DB, id string, req PayslipUpdateRequest) (PayslipRow, error) {
|
||||
const q = `UPDATE payslip SET gross_cents = COALESCE($2, gross_cents),
|
||||
deduction_cents = COALESCE($3, deduction_cents),
|
||||
net_cents = COALESCE($4, net_cents),
|
||||
run_reason = COALESCE($5, run_reason),
|
||||
updated_at = now() WHERE id = $1 RETURNING *`
|
||||
return scanPayslip(db.QueryRowContext(ctx, q, id, req.GrossCents, req.DeductionCents, req.NetCents, req.RunReason))
|
||||
}
|
||||
|
||||
// DeletePayslip removes one payslip row.
|
||||
func DeletePayslip(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM payslip WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPayslipsByCycle selects every payslip row for a cycle.
|
||||
func ListPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]PayslipRow, error) {
|
||||
const q = `SELECT * FROM payslip WHERE cycle_id = $1 ORDER BY employee_id`
|
||||
rows, err := db.QueryContext(ctx, q, cycleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PayslipRow
|
||||
for rows.Next() {
|
||||
var r PayslipRow
|
||||
if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo,
|
||||
&r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountPayslipsByCycle counts payslip rows for a cycle.
|
||||
func CountPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) (int64, error) {
|
||||
const q = `SELECT count(*) FROM payslip WHERE cycle_id = $1`
|
||||
var n int64
|
||||
err := db.QueryRowContext(ctx, q, cycleID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// BuildPayslip maps a create request onto a row. Field copy only — the
|
||||
// generator has no knowledge of pay rules.
|
||||
func BuildPayslip(req PayslipCreateRequest) PayslipRow {
|
||||
return PayslipRow{
|
||||
CycleID: req.CycleID,
|
||||
EmployeeID: req.EmployeeID,
|
||||
Currency: req.Currency,
|
||||
GrossCents: req.GrossCents,
|
||||
DeductionCents: req.DeductionCents,
|
||||
NetCents: req.NetCents,
|
||||
}
|
||||
}
|
||||
|
||||
func scanPayslip(row *sql.Row) (PayslipRow, error) {
|
||||
var r PayslipRow
|
||||
err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo,
|
||||
&r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason,
|
||||
&r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Store is the generated repository over every payroll table.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore returns a generated store bound to db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// Upsert writes one payslip row, keyed by (cycle_id, employee_id).
|
||||
func (s *Store) Upsert(ctx context.Context, row PayslipRow) (PayslipRow, error) {
|
||||
const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (cycle_id, employee_id) DO UPDATE SET
|
||||
gross_cents = EXCLUDED.gross_cents,
|
||||
deduction_cents = EXCLUDED.deduction_cents,
|
||||
net_cents = EXCLUDED.net_cents,
|
||||
updated_at = now()
|
||||
RETURNING *`
|
||||
return scanPayslip(s.db.QueryRowContext(ctx, q, row.CycleID, row.EmployeeID, row.Currency,
|
||||
row.GrossCents, row.DeductionCents, row.NetCents))
|
||||
}
|
||||
|
||||
// UpsertPayrollCycle writes one payroll_cycle row, keyed by id.
|
||||
func (s *Store) UpsertPayrollCycle(ctx context.Context, row PayrollCycleRow) (PayrollCycleRow, error) {
|
||||
const q = `INSERT INTO payroll_cycle (id, start_on, end_on, status)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, updated_at = now()
|
||||
RETURNING *`
|
||||
return scanPayrollCycle(s.db.QueryRowContext(ctx, q, row.ID, row.Start, row.End, row.Status))
|
||||
}
|
||||
|
||||
// CreatePayslip inserts one payslip row through the store.
|
||||
func (s *Store) CreatePayslip(ctx context.Context, req PayslipCreateRequest) (PayslipRow, error) {
|
||||
return CreatePayslip(ctx, s.db, req)
|
||||
}
|
||||
|
||||
// GetPayslip reads one payslip row through the store.
|
||||
func (s *Store) GetPayslip(ctx context.Context, id string) (PayslipRow, error) {
|
||||
return GetPayslip(ctx, s.db, id)
|
||||
}
|
||||
|
||||
// UpdatePayslip patches one payslip row through the store.
|
||||
func (s *Store) UpdatePayslip(ctx context.Context, id string, req PayslipUpdateRequest) (PayslipRow, error) {
|
||||
return UpdatePayslip(ctx, s.db, id, req)
|
||||
}
|
||||
|
||||
// DeletePayslip removes one payslip row through the store.
|
||||
func (s *Store) DeletePayslip(ctx context.Context, id string) error {
|
||||
return DeletePayslip(ctx, s.db, id)
|
||||
}
|
||||
|
||||
// ListPayslipsByCycle lists payslip rows for a cycle through the store.
|
||||
func (s *Store) ListPayslipsByCycle(ctx context.Context, cycleID string) ([]PayslipRow, error) {
|
||||
return ListPayslipsByCycle(ctx, s.db, cycleID)
|
||||
}
|
||||
|
||||
// CreatePayrollCycle inserts one payroll_cycle row through the store.
|
||||
func (s *Store) CreatePayrollCycle(ctx context.Context, req PayrollCycleCreateRequest) (PayrollCycleRow, error) {
|
||||
return CreatePayrollCycle(ctx, s.db, req)
|
||||
}
|
||||
|
||||
// GetPayrollCycle reads one payroll_cycle row through the store.
|
||||
func (s *Store) GetPayrollCycle(ctx context.Context, id string) (PayrollCycleRow, error) {
|
||||
return GetPayrollCycle(ctx, s.db, id)
|
||||
}
|
||||
|
||||
// ListPayrollCycles lists payroll_cycle rows through the store.
|
||||
func (s *Store) ListPayrollCycles(ctx context.Context) ([]PayrollCycleRow, error) {
|
||||
return ListPayrollCycles(ctx, s.db)
|
||||
}
|
||||
|
||||
// Tx runs fn inside a transaction.
|
||||
func (s *Store) Tx(ctx context.Context, fn func(*Store) error) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(s); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/timesheet/timesheet.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/timesheet
|
||||
|
||||
package timesheet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimesheetRow is the generated row type for table timesheet.
|
||||
type TimesheetRow struct {
|
||||
ID string
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Units int
|
||||
Approved bool
|
||||
ApprovedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TimesheetCreateRequest is the generated create payload for table timesheet.
|
||||
type TimesheetCreateRequest struct {
|
||||
CycleID string `json:"cycleId"`
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Units int `json:"units"`
|
||||
}
|
||||
|
||||
// CreateTimesheet inserts one timesheet row.
|
||||
func CreateTimesheet(ctx context.Context, db *sql.DB, req TimesheetCreateRequest) (TimesheetRow, error) {
|
||||
const q = `INSERT INTO timesheet (cycle_id, employee_id, units) VALUES ($1, $2, $3) RETURNING *`
|
||||
return scanTimesheet(db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Units))
|
||||
}
|
||||
|
||||
// GetTimesheet selects one timesheet row by primary key.
|
||||
func GetTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) {
|
||||
const q = `SELECT * FROM timesheet WHERE id = $1`
|
||||
return scanTimesheet(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// ApproveTimesheet flips the approved column on one timesheet row.
|
||||
func ApproveTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) {
|
||||
const q = `UPDATE timesheet SET approved = true, approved_at = now() WHERE id = $1 RETURNING *`
|
||||
return scanTimesheet(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// ListTimesheetsByCycle selects timesheet rows for one cycle.
|
||||
func ListTimesheetsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]TimesheetRow, error) {
|
||||
const q = `SELECT * FROM timesheet WHERE cycle_id = $1 ORDER BY employee_id`
|
||||
rows, err := db.QueryContext(ctx, q, cycleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TimesheetRow
|
||||
for rows.Next() {
|
||||
var r TimesheetRow
|
||||
if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved,
|
||||
&r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanTimesheet(row *sql.Row) (TimesheetRow, error) {
|
||||
var r TimesheetRow
|
||||
err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved,
|
||||
&r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.27.1
|
||||
// source: payroll/v1/payroll.proto
|
||||
|
||||
package payrollpb
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// RunPayrollCycleRequest is the generated request message.
|
||||
type RunPayrollCycleRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CycleId string `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
|
||||
DryRun bool `protobuf:"varint,2,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"`
|
||||
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) GetCycleId() string {
|
||||
if x != nil {
|
||||
return x.CycleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) GetDryRun() bool {
|
||||
if x != nil {
|
||||
return x.DryRun
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) Reset() { *x = RunPayrollCycleRequest{} }
|
||||
func (x *RunPayrollCycleRequest) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
// RunPayrollCycleResponse is the generated response message.
|
||||
type RunPayrollCycleResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CycleId string `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
|
||||
Payslips []*Payslip `protobuf:"bytes,2,rep,name=payslips,proto3" json:"payslips,omitempty"`
|
||||
GrossCents int64 `protobuf:"varint,3,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"`
|
||||
NetCents int64 `protobuf:"varint,4,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleResponse) GetPayslips() []*Payslip {
|
||||
if x != nil {
|
||||
return x.Payslips
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleResponse) Reset() { *x = RunPayrollCycleResponse{} }
|
||||
func (x *RunPayrollCycleResponse) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
// Payslip is the generated payslip message.
|
||||
type Payslip struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
CycleId string `protobuf:"bytes,2,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
|
||||
EmployeeId string `protobuf:"bytes,3,opt,name=employee_id,json=employeeId,proto3" json:"employee_id,omitempty"`
|
||||
GrossCents int64 `protobuf:"varint,4,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"`
|
||||
DeductionCents int64 `protobuf:"varint,5,opt,name=deduction_cents,json=deductionCents,proto3" json:"deduction_cents,omitempty"`
|
||||
NetCents int64 `protobuf:"varint,6,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"`
|
||||
PeriodFrom *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=period_from,json=periodFrom,proto3" json:"period_from,omitempty"`
|
||||
PeriodTo *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=period_to,json=periodTo,proto3" json:"period_to,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Payslip) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Payslip) GetNetCents() int64 {
|
||||
if x != nil {
|
||||
return x.NetCents
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Payslip) Reset() { *x = Payslip{} }
|
||||
func (x *Payslip) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
// PayrollCycle is the generated cycle message.
|
||||
type PayrollCycle struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Start *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=start,proto3" json:"start,omitempty"`
|
||||
End *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=end,proto3" json:"end,omitempty"`
|
||||
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PayrollCycle) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PayrollCycle) Reset() { *x = PayrollCycle{} }
|
||||
func (x *PayrollCycle) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
var file_payroll_v1_payroll_proto_rawDesc = []byte{
|
||||
0x0a, 0x18, 0x70, 0x61, 0x79, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x61, 0x79,
|
||||
0x72, 0x6f, 0x6c, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x70, 0x61, 0x79, 0x72,
|
||||
}
|
||||
|
||||
var file_payroll_v1_payroll_proto_goTypes = []any{
|
||||
(*RunPayrollCycleRequest)(nil),
|
||||
(*RunPayrollCycleResponse)(nil),
|
||||
(*Payslip)(nil),
|
||||
(*PayrollCycle)(nil),
|
||||
}
|
||||
|
||||
var File_payroll_v1_payroll_proto protoreflect.FileDescriptor
|
||||
@@ -0,0 +1,104 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.4.0
|
||||
// - protoc v5.27.1
|
||||
// source: payroll/v1/payroll.proto
|
||||
|
||||
package payrollpb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
PayrollService_RunPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/RunPayrollCycle"
|
||||
PayrollService_GetPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/GetPayrollCycle"
|
||||
PayrollService_ListPayslips_FullMethodName = "/payroll.v1.PayrollService/ListPayslips"
|
||||
)
|
||||
|
||||
// PayrollServiceClient is the generated client API for PayrollService.
|
||||
type PayrollServiceClient interface {
|
||||
RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error)
|
||||
GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error)
|
||||
ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error)
|
||||
}
|
||||
|
||||
type payrollServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
// NewPayrollServiceClient returns a generated client.
|
||||
func NewPayrollServiceClient(cc grpc.ClientConnInterface) PayrollServiceClient {
|
||||
return &payrollServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *payrollServiceClient) RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) {
|
||||
out := new(RunPayrollCycleResponse)
|
||||
err := c.cc.Invoke(ctx, PayrollService_RunPayrollCycle_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *payrollServiceClient) GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error) {
|
||||
out := new(PayrollCycle)
|
||||
err := c.cc.Invoke(ctx, PayrollService_GetPayrollCycle_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *payrollServiceClient) ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) {
|
||||
out := new(RunPayrollCycleResponse)
|
||||
err := c.cc.Invoke(ctx, PayrollService_ListPayslips_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PayrollServiceServer is the generated server API for PayrollService.
|
||||
type PayrollServiceServer interface {
|
||||
RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error)
|
||||
GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error)
|
||||
ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error)
|
||||
mustEmbedUnimplementedPayrollServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedPayrollServiceServer must be embedded for forward compatibility.
|
||||
type UnimplementedPayrollServiceServer struct{}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) mustEmbedUnimplementedPayrollServiceServer() {}
|
||||
|
||||
// RegisterPayrollServiceServer registers the generated service.
|
||||
func RegisterPayrollServiceServer(s grpc.ServiceRegistrar, srv PayrollServiceServer) {
|
||||
s.RegisterService(&PayrollService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
// PayrollService_ServiceDesc is the generated service descriptor.
|
||||
var PayrollService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "payroll.v1.PayrollService",
|
||||
HandlerType: (*PayrollServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{MethodName: "RunPayrollCycle"},
|
||||
{MethodName: "GetPayrollCycle"},
|
||||
{MethodName: "ListPayslips"},
|
||||
},
|
||||
Metadata: "payroll/v1/payroll.proto",
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package clock
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock is the time seam so a payroll run is reproducible in tests.
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// System is the production clock.
|
||||
type System struct{}
|
||||
|
||||
func (System) Now() time.Time { return time.Now().UTC() }
|
||||
|
||||
// Fixed is a frozen clock.
|
||||
type Fixed struct{ At time.Time }
|
||||
|
||||
func (f Fixed) Now() time.Time { return f.At }
|
||||
@@ -0,0 +1,119 @@
|
||||
package payslipstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// Store is the hand-written persistence seam the use-case layer writes through.
|
||||
// It is deliberately narrow: the generated fkit store can address every table,
|
||||
// this one only exposes the operations a payroll cycle needs.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
payslips map[string]payroll.Payslip
|
||||
cycles map[string]payroll.Cycle
|
||||
employees map[string][]payroll.Employee
|
||||
sheets map[string]payroll.Timesheet
|
||||
}
|
||||
|
||||
func New() *Store {
|
||||
return &Store{
|
||||
payslips: map[string]payroll.Payslip{},
|
||||
cycles: map[string]payroll.Cycle{},
|
||||
employees: map[string][]payroll.Employee{},
|
||||
sheets: map[string]payroll.Timesheet{},
|
||||
}
|
||||
}
|
||||
|
||||
func key(cycleID, employeeID string) string { return cycleID + "/" + employeeID }
|
||||
|
||||
// Upsert writes a payslip, replacing any prior slip for the same
|
||||
// (cycle, employee). A re-run of a cycle must not duplicate rows, so this is
|
||||
// an upsert rather than an insert.
|
||||
func (s *Store) Upsert(ctx context.Context, slip payroll.Payslip) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if slip.CycleID == "" || slip.EmployeeID == "" {
|
||||
return fmt.Errorf("payslip missing cycle or employee id")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.payslips[key(slip.CycleID, slip.EmployeeID)] = slip
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByCycle returns every payslip a cycle produced.
|
||||
func (s *Store) ListByCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]payroll.Payslip, 0, len(s.payslips))
|
||||
for _, slip := range s.payslips {
|
||||
if slip.CycleID == cycleID {
|
||||
out = append(out, slip)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return payroll.Cycle{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cycle, ok := s.cycles[cycleID]
|
||||
if !ok {
|
||||
return payroll.Cycle{}, fmt.Errorf("cycle %s not found", cycleID)
|
||||
}
|
||||
return cycle, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveCycle(ctx context.Context, cycle payroll.Cycle) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cycles[cycle.ID] = cycle
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) EmployeesForCycle(ctx context.Context, cycleID string) ([]payroll.Employee, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.employees[cycleID], nil
|
||||
}
|
||||
|
||||
func (s *Store) Timesheet(ctx context.Context, cycleID, employeeID string) (payroll.Timesheet, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return payroll.Timesheet{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
ts, ok := s.sheets[key(cycleID, employeeID)]
|
||||
if !ok {
|
||||
return payroll.Timesheet{}, fmt.Errorf("no timesheet for %s in %s", employeeID, cycleID)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// Seed loads fixture data; the real service reads from Postgres.
|
||||
func (s *Store) Seed(cycle payroll.Cycle, employees []payroll.Employee, sheets []payroll.Timesheet) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cycles[cycle.ID] = cycle
|
||||
s.employees[cycle.ID] = employees
|
||||
for _, ts := range sheets {
|
||||
s.sheets[key(ts.CycleID, ts.EmployeeID)] = ts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/example/payroll-svc/internal/usecase/payroll"
|
||||
)
|
||||
|
||||
// PayrollHandler is the HTTP entry point into the payroll use-case layer.
|
||||
type PayrollHandler struct {
|
||||
svc *payroll.Service
|
||||
}
|
||||
|
||||
func NewPayrollHandler(svc *payroll.Service) *PayrollHandler {
|
||||
return &PayrollHandler{svc: svc}
|
||||
}
|
||||
|
||||
type runCycleRequest struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type runCycleResponse struct {
|
||||
CycleID string `json:"cycleId"`
|
||||
Payslips int `json:"payslips"`
|
||||
GrossCents int64 `json:"grossCents"`
|
||||
NetCents int64 `json:"netCents"`
|
||||
}
|
||||
|
||||
// RunCycle kicks off a payroll cycle: it hands the cycle id to the use-case
|
||||
// layer, which builds and persists a payslip per active employee.
|
||||
func (h *PayrollHandler) RunCycle(w http.ResponseWriter, r *http.Request) {
|
||||
cycleID := r.PathValue("cycleID")
|
||||
if cycleID == "" {
|
||||
httpError(w, http.StatusBadRequest, "cycleID is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req runCycleRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "malformed body")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := h.svc.RunCycle(r.Context(), cycleID, payroll.RunOptions{
|
||||
DryRun: req.DryRun,
|
||||
Reason: req.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, payroll.ErrCycleClosed) {
|
||||
httpError(w, http.StatusConflict, "cycle already closed")
|
||||
return
|
||||
}
|
||||
httpError(w, http.StatusInternalServerError, "run failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, runCycleResponse{
|
||||
CycleID: result.CycleID,
|
||||
Payslips: len(result.Payslips),
|
||||
GrossCents: result.TotalGrossCents,
|
||||
NetCents: result.TotalNetCents,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PayrollHandler) GetCycle(w http.ResponseWriter, r *http.Request) {
|
||||
cycle, err := h.svc.Cycle(r.Context(), r.PathValue("cycleID"))
|
||||
if err != nil {
|
||||
httpError(w, http.StatusNotFound, "no such cycle")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cycle)
|
||||
}
|
||||
|
||||
func (h *PayrollHandler) ListPayslips(w http.ResponseWriter, r *http.Request) {
|
||||
slips, err := h.svc.PayslipsForCycle(r.Context(), r.PathValue("cycleID"))
|
||||
if err != nil {
|
||||
httpError(w, http.StatusNotFound, "no such cycle")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, slips)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func httpError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
// NewRouter wires the HTTP surface. The payroll cycle endpoint is the only
|
||||
// entry point into the hand-written use-case layer.
|
||||
func NewRouter(h *PayrollHandler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/payroll/cycles/{cycleID}/run", h.RunCycle)
|
||||
mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}", h.GetCycle)
|
||||
mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}/payslips", h.ListPayslips)
|
||||
mux.HandleFunc("GET /healthz", health)
|
||||
return mux
|
||||
}
|
||||
|
||||
func health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// BuildPayslip turns one employee's contract and timesheet into a complete
|
||||
// payslip for the cycle: base pay, overtime, allowances, then deductions and
|
||||
// tax, in that order. Every amount is in integer cents; nothing here rounds
|
||||
// until the final net, so a cent never disappears between two lines.
|
||||
//
|
||||
// This is the calculation the generated CRUD layer does NOT do — fkit's
|
||||
// BuildPayslip only copies fields between a DTO and a row.
|
||||
func (s *Service) BuildPayslip(
|
||||
ctx context.Context,
|
||||
cycle payroll.Cycle,
|
||||
employee payroll.Employee,
|
||||
timesheet payroll.Timesheet,
|
||||
) (payroll.Payslip, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return payroll.Payslip{}, err
|
||||
}
|
||||
if timesheet.EmployeeID != "" && timesheet.EmployeeID != employee.ID {
|
||||
return payroll.Payslip{}, fmt.Errorf("timesheet/employee mismatch: %s vs %s", timesheet.EmployeeID, employee.ID)
|
||||
}
|
||||
|
||||
slip := payroll.Payslip{
|
||||
CycleID: cycle.ID,
|
||||
EmployeeID: employee.ID,
|
||||
Currency: employee.Contract.Currency,
|
||||
PeriodFrom: cycle.Start,
|
||||
PeriodTo: cycle.End,
|
||||
}
|
||||
|
||||
base := s.basePayCents(employee, cycle, timesheet)
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "BASE", Kind: payroll.LineEarning, AmountCents: base,
|
||||
})
|
||||
|
||||
if overtime := s.overtimeCents(employee, timesheet); overtime > 0 {
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "OT", Kind: payroll.LineEarning, AmountCents: overtime,
|
||||
})
|
||||
}
|
||||
|
||||
for _, allowance := range employee.Contract.Allowances {
|
||||
amount := prorateAllowance(allowance, cycle, employee)
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: allowance.Code, Kind: payroll.LineEarning, AmountCents: amount,
|
||||
})
|
||||
}
|
||||
|
||||
slip.GrossCents = sumKind(slip.Lines, payroll.LineEarning)
|
||||
|
||||
for _, d := range employee.Deductions {
|
||||
amount := d.AmountFor(slip.GrossCents)
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: d.Code, Kind: payroll.LineDeduction, AmountCents: amount,
|
||||
})
|
||||
}
|
||||
|
||||
tax, err := s.taxCents(employee, slip.GrossCents)
|
||||
if err != nil {
|
||||
return payroll.Payslip{}, fmt.Errorf("tax for %s: %w", employee.ID, err)
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "TAX", Kind: payroll.LineDeduction, AmountCents: tax,
|
||||
})
|
||||
|
||||
slip.DeductionCents = sumKind(slip.Lines, payroll.LineDeduction)
|
||||
slip.NetCents = slip.GrossCents - slip.DeductionCents
|
||||
if slip.NetCents < 0 {
|
||||
slip.NetCents = 0
|
||||
slip.Underwater = true
|
||||
}
|
||||
|
||||
return slip, nil
|
||||
}
|
||||
|
||||
// basePayCents is the contractual pay for the period: salaried staff get the
|
||||
// period rate prorated across their contract window, hourly staff get rate ×
|
||||
// approved units.
|
||||
func (s *Service) basePayCents(e payroll.Employee, cycle payroll.Cycle, ts payroll.Timesheet) int64 {
|
||||
switch e.Contract.Kind {
|
||||
case payroll.ContractSalaried:
|
||||
full := e.Contract.PeriodRateCents
|
||||
return prorateSalary(full, e.Contract, cycle)
|
||||
case payroll.ContractHourly:
|
||||
return e.Contract.RateCents * int64(ts.Units)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// overtimeCents pays approved units above the contractual threshold at the
|
||||
// contract's overtime multiplier.
|
||||
func (s *Service) overtimeCents(e payroll.Employee, ts payroll.Timesheet) int64 {
|
||||
if e.Contract.Kind != payroll.ContractHourly {
|
||||
return 0
|
||||
}
|
||||
threshold := e.Contract.OvertimeThresholdUnits
|
||||
if threshold <= 0 || ts.Units <= threshold {
|
||||
return 0
|
||||
}
|
||||
extra := int64(ts.Units - threshold)
|
||||
return int64(float64(e.Contract.RateCents) * e.Contract.OvertimeMultiplier * float64(extra))
|
||||
}
|
||||
|
||||
// taxCents applies the employee's tax band schedule to the gross.
|
||||
func (s *Service) taxCents(e payroll.Employee, gross int64) (int64, error) {
|
||||
if len(e.TaxBands) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var tax int64
|
||||
remaining := gross
|
||||
for _, band := range e.TaxBands {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if band.RateBasisPoints < 0 || band.RateBasisPoints > 10000 {
|
||||
return 0, fmt.Errorf("invalid band rate %d", band.RateBasisPoints)
|
||||
}
|
||||
slice := remaining
|
||||
if band.UpToCents > 0 && slice > band.UpToCents {
|
||||
slice = band.UpToCents
|
||||
}
|
||||
tax += slice * int64(band.RateBasisPoints) / 10000
|
||||
remaining -= slice
|
||||
}
|
||||
return tax, nil
|
||||
}
|
||||
|
||||
func sumKind(lines []payroll.Line, kind payroll.LineKind) int64 {
|
||||
var total int64
|
||||
for _, l := range lines {
|
||||
if l.Kind == kind {
|
||||
total += l.AmountCents
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// prorateSalary scales a full period rate down when the contract covers only
|
||||
// part of the cycle window (a mid-period joiner or leaver).
|
||||
func prorateSalary(fullCents int64, contract payroll.Contract, cycle payroll.Cycle) int64 {
|
||||
window := calendarDays(cycle.Start, cycle.End)
|
||||
if window <= 0 {
|
||||
return 0
|
||||
}
|
||||
covered := calendarDays(laterOf(cycle.Start, contract.StartsOn), earlierOf(cycle.End, contract.EndsOn))
|
||||
if covered >= window {
|
||||
return fullCents
|
||||
}
|
||||
if covered <= 0 {
|
||||
return 0
|
||||
}
|
||||
return fullCents * int64(covered) / int64(window)
|
||||
}
|
||||
|
||||
// prorateAllowance applies the same window rule to a recurring allowance.
|
||||
func prorateAllowance(a payroll.Allowance, cycle payroll.Cycle, e payroll.Employee) int64 {
|
||||
if !a.Prorated {
|
||||
return a.AmountCents
|
||||
}
|
||||
return prorateSalary(a.AmountCents, e.Contract, cycle)
|
||||
}
|
||||
|
||||
func calendarDays(from, to time.Time) int {
|
||||
if to.Before(from) {
|
||||
return 0
|
||||
}
|
||||
return int(to.Sub(from).Hours()/24) + 1
|
||||
}
|
||||
|
||||
func laterOf(a, b time.Time) time.Time {
|
||||
if b.IsZero() || a.After(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func earlierOf(a, b time.Time) time.Time {
|
||||
if b.IsZero() || a.Before(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -92,6 +92,84 @@ This is the gap the rest of the epic closes: relevance-proportional allocation w
|
||||
relative cliff (CG-12), on top of scoring that stops rewarding incidental name collisions
|
||||
(CG-10).
|
||||
|
||||
## The regression fixtures (CG-6)
|
||||
|
||||
Two fixtures pin the failure mode so it can never silently return. Both **fail today** —
|
||||
that is what they are for. They become the pass gate for CG-10 + CG-12.
|
||||
|
||||
They are declared in `scripts/agent-eval/allocation-fixtures.json` and run by
|
||||
`scripts/agent-eval/probe-allocation.mjs`, which drives the CG-4 diagnostic through a JSONL
|
||||
sidecar (so it measures the shipping allocator, not a re-derivation), groups the rendered
|
||||
files into `answer` vs `incidental`, and checks declared share thresholds. Needs a current
|
||||
`npm run build`; exits 1 while any assertion fails.
|
||||
|
||||
```bash
|
||||
node scripts/agent-eval/probe-allocation.mjs # both
|
||||
node scripts/agent-eval/probe-allocation.mjs payroll-go # one
|
||||
node scripts/agent-eval/probe-allocation.mjs --json # machine-readable
|
||||
```
|
||||
|
||||
### 1. `payroll-go` — the reporter's shape
|
||||
|
||||
`__tests__/fixtures/payroll-go/` is a synthetic Go service: generated FKIT CRUD beside a
|
||||
hand-written payroll use-case, entered from an HTTP route. Full description in that
|
||||
directory's README. The essentials:
|
||||
|
||||
- Generated files with **ordinary names** carrying `// Code generated ... DO NOT EDIT.` —
|
||||
invisible to path-only detection, which is what makes this a #1500 fixture rather than a
|
||||
`.pb.go` one — beside `payrollpb/*.pb.go` covering the path-detectable channel.
|
||||
- Deliberate collisions: `BuildPayslip`, `Upsert` and `Store` each exist twice, generated
|
||||
and hand-written, and the generated layer name-collides on every query term.
|
||||
- `cycle.go` (227 lines) sits above the whole-file window so it clips; the generated files
|
||||
sit below it so they ship whole.
|
||||
|
||||
Query — an architecture question naming none of the answering symbols: *"how does payroll
|
||||
cycle create and calculate payslips?"*
|
||||
|
||||
| | allocated | delivered |
|
||||
|---|---|---|
|
||||
| hand-written | 48.4% | **25.6%** (all of it domain types) |
|
||||
| generated CRUD | 39.9% | **57.4%** |
|
||||
|
||||
`cycle.go` is allocated the single largest slice (7,052 chars, 30.6%) and delivers **zero**
|
||||
— the 19,500 hard ceiling drops its whole section. `payslip_builder.go` (rank #8) never
|
||||
renders. So `runPayrollCycleAll`, the hand-written `BuildPayslip` and the real `Upsert`
|
||||
never reach the agent, and every byte that did arrive describes either CRUD or types.
|
||||
|
||||
This fixture is hermetic: the probe copies the tree to a temp dir and re-indexes per run,
|
||||
so two runs on one build are byte-identical (verified). `__tests__/explore-allocation-1500.test.ts`
|
||||
runs the same assertions in vitest — the fixture-shape half green, the allocation half as
|
||||
`it.fails` so the suite stays green while the bug is open and goes **red when it is fixed**.
|
||||
|
||||
**Finding, deliberately left unfixed:** `runPayrollCycleAll` calls `s.store.Upsert` on a
|
||||
`*payslipstore.Store`, but the graph resolves that edge to the **generated**
|
||||
`internal/gen/fkit/payroll/store.go` `Store.Upsert`. Same-name method resolution across two
|
||||
packages that both define `Store.Upsert` picks the wrong receiver. It is upstream of the
|
||||
allocation bug — a wrong edge pulls the generated store into the subgraph and inflates its
|
||||
score — so it belongs with CG-10's scoring work, not with the fixture.
|
||||
|
||||
### 2. `self-query` — the same bug with no generated code in sight
|
||||
|
||||
The baseline above, promoted to a fixture: this repo, *"how does explore allocate its output
|
||||
budget across files"*. `scripts/agent-eval/*.mjs` mention `explore` and `BUDGET`
|
||||
incidentally — they are eval harnesses, not the allocator — and they are small enough to
|
||||
ship whole, while `src/mcp/tools.ts` is large enough to be clipped.
|
||||
|
||||
At 493 indexed files (small tier): the script corpus takes **71.8%** of the delivered
|
||||
envelope (79.4% allocated) against `tools.ts`'s **18.5%**, despite `tools.ts` scoring 46 vs
|
||||
10, carrying 2.3× the graph mass and 3× the distinct term hits.
|
||||
|
||||
This fixture reads the **live** index of this repo, so unlike `payroll-go` its exact numbers
|
||||
move as the repo changes. Its assertions are relative for that reason (answer group vs
|
||||
incidental group, largest delivered file), never fixed percentages. Two things to know:
|
||||
|
||||
- The `<500`-file tier boundary is close. This repo indexes 493 files including the new
|
||||
fixture; crossing 500 flips `maxOutputChars` 18,000 → 24,000, `maxFiles` 5 → 8 and
|
||||
`maxCharsPerFile` 3,800 → 6,500, which moves every number in the table above. Re-baseline
|
||||
after the crossing rather than treating the drift as a regression.
|
||||
- Adding the `payroll-go` fixture itself moved the count 472 → 493. Its Go files match none
|
||||
of this query's terms, so they change the tier arithmetic and nothing else.
|
||||
|
||||
### Reproducing
|
||||
|
||||
The query explores this repo, so **uncommitted edits to `src/mcp/tools.ts` change the
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"$comment": [
|
||||
"Regression fixtures for GitHub issue #1500 / epic CG-1 — relevance-proportional",
|
||||
"explore budget allocation. Run them with `node scripts/agent-eval/probe-allocation.mjs`",
|
||||
"against a built dist/. BOTH FIXTURES FAIL TODAY: that is the point — they document",
|
||||
"the bug and become the pass gate for the allocation change (CG-10 + CG-12).",
|
||||
"",
|
||||
"`groups` partitions the files explore rendered into `answer` (what the query is",
|
||||
"actually about) and `incidental` (what wins the envelope today on name collisions).",
|
||||
"Assertions are on the DELIVERED envelope unless suffixed `Allocated`; delivered is",
|
||||
"what the agent got, allocated is what the render loop chose before the hard ceiling.",
|
||||
"Shares are fractions of the whole response, meta-text included, so they never sum to 1."
|
||||
],
|
||||
"fixtures": [
|
||||
{
|
||||
"id": "payroll-go",
|
||||
"title": "#1500 — generated Go CRUD beside a hand-written payroll workflow",
|
||||
"kind": "fixture",
|
||||
"path": "__tests__/fixtures/payroll-go",
|
||||
"query": "how does payroll cycle create and calculate payslips?",
|
||||
"rationale": [
|
||||
"The reporter's repo shape: a Go service whose generated FKIT CRUD layer sits",
|
||||
"beside the hand-written use-case that does the real work. The query deliberately",
|
||||
"does NOT name runPayrollCycleAll / BuildPayslip / Upsert — an architecture",
|
||||
"question phrased the way a newcomer would phrase it. The generated layer",
|
||||
"name-collides on every query term (CreatePayslip, PayrollCycleCreateRequest,",
|
||||
"CalculatePayrollCycleTotals, a second BuildPayslip, a second Upsert), so a",
|
||||
"scorer that rewards incidental name matches surfaces the CRUD path.",
|
||||
"Half the generated files carry ORDINARY names and are only detectable by their",
|
||||
"`// Code generated ... DO NOT EDIT.` header (CG-5), which is what makes this",
|
||||
"the #1500 case rather than a .pb.go case."
|
||||
],
|
||||
"groups": {
|
||||
"answer": [
|
||||
"internal/usecase/**",
|
||||
"internal/store/**",
|
||||
"internal/transport/**",
|
||||
"internal/domain/**",
|
||||
"cmd/**"
|
||||
],
|
||||
"incidental": ["internal/gen/**"]
|
||||
},
|
||||
"assert": {
|
||||
"answerShareAtLeast": 0.55,
|
||||
"incidentalShareAtMost": 0.25,
|
||||
"topFileGroup": "answer",
|
||||
"mustDeliverBytes": [
|
||||
"internal/usecase/payroll/cycle.go",
|
||||
"internal/usecase/payroll/payslip_builder.go"
|
||||
],
|
||||
"$mustContainComment": "Needles are chosen to match the HAND-WRITTEN chain only — a bare `BuildPayslip`/`Upsert` also matches the generated collisions, which is the whole point of the fixture.",
|
||||
"mustContain": [
|
||||
"runPayrollCycleAll",
|
||||
"func (s *Service) BuildPayslip",
|
||||
"s.store.Upsert(ctx, slip)"
|
||||
]
|
||||
},
|
||||
"baseline": {
|
||||
"measuredOn": "2026-08-03",
|
||||
"note": "19 files → very-tiny tier (13,000 budget); 23,020 chars allocated against it, cut to 16,011 by the 19,500 hard ceiling.",
|
||||
"delivered": {
|
||||
"internal/gen/fkit/payroll/payslip.go": 0.307,
|
||||
"internal/gen/fkit/payroll/payroll_cycle.go": 0.266,
|
||||
"internal/domain/payroll/payslip.go": 0.256,
|
||||
"internal/usecase/payroll/cycle.go": 0.0
|
||||
},
|
||||
"verdict": "The workflow file is allocated the single largest slice (30.6%) and delivers ZERO — the hard ceiling drops its whole section. Generated CRUD takes 57.4% of what the agent actually receives; runPayrollCycleAll, BuildPayslip and the real Upsert never reach the response."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "self-query",
|
||||
"title": "This repo — incidental `explore`/`BUDGET` matches in the agent-eval scripts",
|
||||
"kind": "self",
|
||||
"path": ".",
|
||||
"query": "how does explore allocate its output budget across files",
|
||||
"rationale": [
|
||||
"The same failure mode with no generated code in sight. `scripts/agent-eval/*.mjs`",
|
||||
"mention `explore` and `BUDGET` incidentally — they are eval harnesses, not the",
|
||||
"allocator — and they are small enough to ship WHOLE, while src/mcp/tools.ts (which",
|
||||
"carries getExploreOutputBudget and the render loop, and scores 4x higher on every",
|
||||
"signal) is large enough to be clipped at maxCharsPerFile. Allocation follows file",
|
||||
"size, not relevance.",
|
||||
"",
|
||||
"Unlike payroll-go this fixture reads THIS repo's live index, so its exact numbers",
|
||||
"move as the repo changes (indexed file count crossing 500 flips the budget tier).",
|
||||
"The assertions are therefore relative — answer-vs-incidental, not fixed percentages."
|
||||
],
|
||||
"groups": {
|
||||
"answer": ["src/mcp/**"],
|
||||
"incidental": ["scripts/**"]
|
||||
},
|
||||
"assert": {
|
||||
"answerShareAtLeast": 0.5,
|
||||
"incidentalShareAtMost": 0.25,
|
||||
"topFileGroup": "answer",
|
||||
"mustDeliverBytes": ["src/mcp/tools.ts"]
|
||||
},
|
||||
"baseline": {
|
||||
"measuredOn": "2026-08-03",
|
||||
"note": "493 files → small tier (18,000 budget); 27,518 chars allocated against it, cut to 19,749 by the 25,000 hard ceiling.",
|
||||
"delivered": {
|
||||
"scripts/agent-eval/offload-eval-hook.mjs": 0.25,
|
||||
"scripts/agent-eval/offload-eval-metrics.mjs": 0.236,
|
||||
"scripts/agent-eval/parse-session.mjs": 0.232,
|
||||
"src/mcp/tools.ts": 0.185,
|
||||
"scripts/agent-eval/offload-eval-cost.mjs": 0.0
|
||||
},
|
||||
"verdict": "The script corpus takes 71.8% of the delivered envelope (79.4% of what was allocated) against tools.ts's 18.5%, despite tools.ts scoring 46 vs 10, carrying 2.3x the graph mass and 3x the distinct term hits."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Deterministic per-file budget-share probe for `codegraph_explore` (CG-6).
|
||||
*
|
||||
* `probe-explore.mjs` prints what explore returned. This prints how the response
|
||||
* was DIVIDED — which files won the byte envelope and in what proportion — and
|
||||
* checks that division against a declared expectation. It is the regression gate
|
||||
* for GitHub issue #1500 / epic CG-1: an architecture question that doesn't name
|
||||
* the exact use-case must concentrate the budget on the code that answers it, not
|
||||
* on a generated CRUD layer (or an eval script) that merely name-collides.
|
||||
*
|
||||
* The numbers come from the CG-4 diagnostic (`CODEGRAPH_EXPLORE_DEBUG`), read back
|
||||
* from a JSONL sidecar, so the probe measures the shipping allocator rather than
|
||||
* re-deriving shares from the markdown.
|
||||
*
|
||||
* Fixtures are declared in `allocation-fixtures.json`. A `kind: "fixture"` entry is
|
||||
* hermetic — the fixture tree is copied to a fresh temp dir and indexed per run, so
|
||||
* two runs on one build give identical numbers. A `kind: "self"` entry reads this
|
||||
* repo's live index and therefore moves as the repo changes; its assertions are
|
||||
* relative for that reason.
|
||||
*
|
||||
* Usage (needs a current `npm run build`):
|
||||
* node scripts/agent-eval/probe-allocation.mjs # every fixture
|
||||
* node scripts/agent-eval/probe-allocation.mjs payroll-go # one fixture
|
||||
* node scripts/agent-eval/probe-allocation.mjs --json # machine-readable
|
||||
* node scripts/agent-eval/probe-allocation.mjs --keep # keep the temp index
|
||||
*
|
||||
* Exit code: 0 if every assertion holds, 1 if any fails, 2 on a setup error.
|
||||
* BOTH FIXTURES ARE EXPECTED TO FAIL until CG-10/CG-12 land — that failure is the
|
||||
* documented bug. Use --expect-fail to invert the exit code while it is the state
|
||||
* of the world (0 = still broken, 1 = fixed, go flip the gate).
|
||||
*/
|
||||
import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(HERE, '../..');
|
||||
const SPEC_PATH = join(HERE, 'allocation-fixtures.json');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
||||
const wanted = argv.filter((a) => !a.startsWith('--'));
|
||||
const asJson = flags.has('--json');
|
||||
const keepTemp = flags.has('--keep');
|
||||
const expectFail = flags.has('--expect-fail');
|
||||
|
||||
const say = (line = '') => { if (!asJson) console.log(line); };
|
||||
const pct = (f) => `${(f * 100).toFixed(1)}%`;
|
||||
const num = (n) => Math.round(n).toLocaleString('en-US');
|
||||
|
||||
/** Load the built dist — the probe measures the shipping allocator, not src. */
|
||||
async function loadDist() {
|
||||
const distIndex = join(REPO_ROOT, 'dist/index.js');
|
||||
if (!existsSync(distIndex)) {
|
||||
console.error('dist/ not built — run `npm run build` first.');
|
||||
process.exit(2);
|
||||
}
|
||||
const idx = await import(pathToFileURL(distIndex).href);
|
||||
const tools = await import(pathToFileURL(join(REPO_ROOT, 'dist/mcp/tools.js')).href);
|
||||
// esModuleInterop: dynamic import of CJS yields { default: module.exports, ...named }
|
||||
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
|
||||
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
|
||||
if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') {
|
||||
console.error('could not resolve CodeGraph/ToolHandler from dist/');
|
||||
process.exit(2);
|
||||
}
|
||||
return { CodeGraph, ToolHandler };
|
||||
}
|
||||
|
||||
/** `internal/gen/**` → /^internal\/gen\/.*$/ . Supports `**`, `*` and literals. */
|
||||
function globToRegExp(glob) {
|
||||
// Park `**` on a sentinel no path can contain, so the `*` pass cannot eat it.
|
||||
// Written as an escape, not a literal byte — a raw NUL makes git treat this
|
||||
// whole script as binary, which costs every future diff of it.
|
||||
const DOUBLE_STAR = '\u0000';
|
||||
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
const body = escaped
|
||||
.replace(/\*\*/g, DOUBLE_STAR)
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replaceAll(DOUBLE_STAR, '.*');
|
||||
return new RegExp(`^${body}$`);
|
||||
}
|
||||
|
||||
const groupOf = (path, groups) => {
|
||||
for (const [name, globs] of Object.entries(groups)) {
|
||||
if (globs.some((g) => globToRegExp(g).test(path))) return name;
|
||||
}
|
||||
return 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* Run one explore call with the diagnostic pointed at a sidecar, and return the
|
||||
* report plus the response text.
|
||||
*/
|
||||
async function runExplore({ CodeGraph, ToolHandler }, repoPath, query, sidecar) {
|
||||
const cg = CodeGraph.openSync(repoPath);
|
||||
const prior = process.env.CODEGRAPH_EXPLORE_DEBUG;
|
||||
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
|
||||
try {
|
||||
const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
|
||||
const text = res.content?.[0]?.text ?? '';
|
||||
const lines = readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
|
||||
if (lines.length === 0) throw new Error('diagnostic produced no report');
|
||||
return { report: JSON.parse(lines[lines.length - 1]), text };
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
|
||||
else process.env.CODEGRAPH_EXPLORE_DEBUG = prior;
|
||||
try { cg.close?.(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy a fixture tree to a fresh temp dir and index it — hermetic per run. */
|
||||
async function materializeFixture({ CodeGraph }, fixturePath) {
|
||||
const src = resolve(REPO_ROOT, fixturePath);
|
||||
if (!existsSync(src)) throw new Error(`fixture tree not found: ${src}`);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cg-alloc-'));
|
||||
cpSync(src, dir, { recursive: true });
|
||||
// A stray index inside the checked-in tree would be copied in and reused.
|
||||
rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
|
||||
const cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
cg.close?.();
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Evaluate one fixture's assertions against its report. Returns check rows. */
|
||||
function evaluate(fixture, report, text) {
|
||||
const { groups, assert: want } = fixture;
|
||||
const delivered = new Map();
|
||||
const allocated = new Map();
|
||||
for (const f of report.files) {
|
||||
const g = groupOf(f.path, groups);
|
||||
delivered.set(g, (delivered.get(g) ?? 0) + f.share);
|
||||
allocated.set(g, (allocated.get(g) ?? 0) + f.allocatedShare);
|
||||
}
|
||||
const share = (g) => delivered.get(g) ?? 0;
|
||||
const top = report.files
|
||||
.filter((f) => f.finalChars > 0)
|
||||
.sort((a, b) => b.finalChars - a.finalChars)[0];
|
||||
|
||||
const checks = [];
|
||||
const add = (name, pass, detail) => checks.push({ name, pass, detail });
|
||||
|
||||
if (want.answerShareAtLeast !== undefined) {
|
||||
add(
|
||||
`answer group takes >= ${pct(want.answerShareAtLeast)} of the envelope`,
|
||||
share('answer') >= want.answerShareAtLeast,
|
||||
`answer ${pct(share('answer'))} delivered (${pct(allocated.get('answer') ?? 0)} allocated)`,
|
||||
);
|
||||
}
|
||||
if (want.incidentalShareAtMost !== undefined) {
|
||||
add(
|
||||
`incidental group takes <= ${pct(want.incidentalShareAtMost)} of the envelope`,
|
||||
share('incidental') <= want.incidentalShareAtMost,
|
||||
`incidental ${pct(share('incidental'))} delivered (${pct(allocated.get('incidental') ?? 0)} allocated)`,
|
||||
);
|
||||
}
|
||||
if (want.topFileGroup) {
|
||||
const actual = top ? groupOf(top.path, groups) : '(nothing delivered)';
|
||||
add(
|
||||
`largest delivered file is in "${want.topFileGroup}"`,
|
||||
actual === want.topFileGroup,
|
||||
top ? `${top.path} (${pct(top.share)}, group "${actual}")` : 'no file delivered any source',
|
||||
);
|
||||
}
|
||||
for (const path of want.mustDeliverBytes ?? []) {
|
||||
const rec = report.files.find((f) => f.path === path);
|
||||
add(
|
||||
`${path} delivers source`,
|
||||
!!rec && rec.finalChars > 0,
|
||||
rec
|
||||
? `${num(rec.finalChars)} delivered of ${num(rec.emittedChars)} allocated` +
|
||||
(rec.finalChars === 0 && rec.emittedChars > 0 ? ' — hard ceiling dropped the whole section' : '') +
|
||||
(rec.emittedChars === 0 ? ` — never rendered (${rec.skipped ?? 'not reached'}, rank #${rec.rank})` : '')
|
||||
: 'not among the ranked candidates',
|
||||
);
|
||||
}
|
||||
for (const needle of want.mustContain ?? []) {
|
||||
add(`response contains "${needle}"`, text.includes(needle), text.includes(needle) ? 'present' : 'absent');
|
||||
}
|
||||
return { checks, delivered, allocated, top };
|
||||
}
|
||||
|
||||
function printReport(fixture, report, evaluated) {
|
||||
const { checks, delivered, allocated } = evaluated;
|
||||
const env = report.envelope;
|
||||
say('');
|
||||
say(`── ${fixture.id} — ${fixture.title}`);
|
||||
say(` query "${report.query}"`);
|
||||
say(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
|
||||
say(
|
||||
` envelope ${num(env.chars)} delivered · ${num(env.allocatedChars)} allocated` +
|
||||
` of ${num(report.budget.maxOutputChars)} budget (hard ceiling ${num(report.budget.hardCeiling)})` +
|
||||
`${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
|
||||
);
|
||||
say('');
|
||||
say(' group alloc% deliv%');
|
||||
for (const g of ['answer', 'incidental', 'other']) {
|
||||
if (!delivered.has(g) && !allocated.has(g)) continue;
|
||||
say(` ${g.padEnd(12)} ${pct(allocated.get(g) ?? 0).padStart(6)} ${pct(delivered.get(g) ?? 0).padStart(6)}`);
|
||||
}
|
||||
say('');
|
||||
say(' # alloc% deliv% bytes score graph hits gen render file');
|
||||
for (const f of report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0)) {
|
||||
say(
|
||||
' ' + String(f.rank).padStart(2) + ' ' +
|
||||
pct(f.allocatedShare).padStart(6) + ' ' +
|
||||
pct(f.share).padStart(6) + ' ' +
|
||||
num(f.emittedChars).padStart(7) + ' ' +
|
||||
String(f.score).padStart(5) + ' ' +
|
||||
f.graphScore.toFixed(5).padStart(7) + ' ' +
|
||||
String(f.termHits).padStart(4) + ' ' +
|
||||
(f.generated ? ' ✓ ' : ' ') + ' ' +
|
||||
((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
|
||||
f.path,
|
||||
);
|
||||
}
|
||||
say('');
|
||||
for (const c of checks) say(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const spec = JSON.parse(readFileSync(SPEC_PATH, 'utf-8'));
|
||||
const fixtures = spec.fixtures.filter((f) => wanted.length === 0 || wanted.includes(f.id));
|
||||
if (fixtures.length === 0) {
|
||||
console.error(`no fixture matched ${JSON.stringify(wanted)}; known: ${spec.fixtures.map((f) => f.id).join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const dist = await loadDist();
|
||||
const sidecarDir = mkdtempSync(join(tmpdir(), 'cg-alloc-diag-'));
|
||||
const results = [];
|
||||
const temps = [];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
let repoPath;
|
||||
if (fixture.kind === 'fixture') {
|
||||
repoPath = await materializeFixture(dist, fixture.path);
|
||||
temps.push(repoPath);
|
||||
} else {
|
||||
repoPath = resolve(REPO_ROOT, fixture.path);
|
||||
if (!existsSync(join(repoPath, '.codegraph'))) {
|
||||
console.error(`${fixture.id}: ${repoPath} has no .codegraph index — run \`codegraph init\` there first.`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const sidecar = join(sidecarDir, `${fixture.id}.jsonl`);
|
||||
mkdirSync(dirname(sidecar), { recursive: true });
|
||||
const { report, text } = await runExplore(dist, repoPath, fixture.query, sidecar);
|
||||
const evaluated = evaluate(fixture, report, text);
|
||||
printReport(fixture, report, evaluated);
|
||||
results.push({
|
||||
id: fixture.id,
|
||||
kind: fixture.kind,
|
||||
query: fixture.query,
|
||||
passed: evaluated.checks.every((c) => c.pass),
|
||||
checks: evaluated.checks,
|
||||
shares: {
|
||||
delivered: Object.fromEntries(evaluated.delivered),
|
||||
allocated: Object.fromEntries(evaluated.allocated),
|
||||
},
|
||||
envelope: report.envelope,
|
||||
files: report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0),
|
||||
});
|
||||
}
|
||||
|
||||
if (!keepTemp) {
|
||||
for (const dir of temps) rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(sidecarDir, { recursive: true, force: true });
|
||||
} else {
|
||||
say('');
|
||||
say(` kept: ${[...temps, sidecarDir].join(' ')}`);
|
||||
}
|
||||
|
||||
const allPassed = results.every((r) => r.passed);
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ passed: allPassed, fixtures: results }, null, 2));
|
||||
} else {
|
||||
say('');
|
||||
for (const r of results) say(`${r.passed ? 'PASS' : 'FAIL'} ${r.id}`);
|
||||
if (!allPassed) {
|
||||
say('');
|
||||
say('Failures here are the DOCUMENTED #1500 bug — the budget goes to files that merely');
|
||||
say('name-collide with the query. They become the pass gate once CG-10/CG-12 land.');
|
||||
}
|
||||
}
|
||||
process.exit(expectFail ? (allPassed ? 1 : 0) : (allPassed ? 0 : 1));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack ?? String(err));
|
||||
process.exit(2);
|
||||
});
|
||||
Reference in New Issue
Block a user