A small operations application for registering IT equipment and allocating the best available set to an employee from a role-based equipment policy.
Requirements: Docker with Docker Compose.
docker compose up --buildOpen http://localhost:8088. The first startup builds both applications, runs Flyway migrations, and inserts sample inventory. PostgreSQL data is kept in the inventory-postgres volume, so the Flyway seed migration runs only once for that database.
To stop the application:
docker compose downTo reset the database and reload seed data:
docker compose down --volumes
docker compose up --buildbackend: Kotlin, Spring Boot 4.1, Spring Data JPA, PostgreSQL, Flyway, Gradlefrontend: Angular 22 standalone components, served by nginx- nginx sends
/api/*to the backend; the browser never needs a separate backend URL or CORS configuration - Flyway owns the schema and seed data; Hibernate validates the schema rather than generating it
The API uses uppercase enum values (MAIN_COMPUTER, AVAILABLE, and so on). The UI translates them into readable labels.
An allocation request is a shopping list for one employee, such as “one laptop with condition at least 0.80, preferably Apple, plus two monitors.” The allocator chooses a complete set of different available devices for that list.
flowchart LR
R[Employee request] --> S[Policy slots]
S --> F[Remove devices that fail hard rules]
F --> C[Compare valid complete sets]
C --> A[Reserve the best set]
C --> X[No complete set]
X --> E[Explain the shortage]
In practice:
- Remove devices with the wrong type or a condition below the slot’s minimum.
- Compare the remaining combinations as one group, so one slot cannot steal the only valid device from another slot.
- Prefer, in order: matching brand, newer purchase date, higher condition, then lower id.
- Reserve the winning set in one transaction, or store a clear failure reason if no complete set exists.
Choosing the whole set matters. With two monitor slots—one requiring condition ≥ 0.85 and one preferring LG—the allocator can give the 0.90 LG monitor to the strict slot and the 0.80 Dell monitor to the other slot. A one-at-a-time choice could make the request fail unnecessarily.
Devices move through AVAILABLE → RESERVED → ASSIGNED. Cancelling a reservation releases all
reserved devices; returning a confirmed allocation releases only the individual device that was
returned. The allocation record remains as history.
Technical implementation details
A policy is a list of slots. Each slot names an equipment type, optionally a minimum condition score, optionally a preferred brand, and whether newer equipment is preferred:
1 laptop with condition >= 0.8, prefer Apple; 2 monitors
The service must pick one distinct available item per slot such that every hard constraint holds and the soft preferences are satisfied as well as possible across the whole set -- or, if no such set exists, fail with a reason an operator can act on.
Three things make this harder than it looks.
- Hard constraints are structural, not expensive. A pairing that violates type or minimum condition must be impossible, not merely penalised. Penalties fail the moment soft preferences accumulate past them.
- Items compete across slots. Two monitor slots share one monitor pool. Deciding one slot in isolation can consume the only item another slot could have used.
- Distinctness is implicit. "2 monitors" means two different physical monitors.
Allocation builds a bipartite flow graph:
source --(1)--> slot_i --(1, cost = -score)--> item_j --(1)--> sink
- One node per policy slot, one per available item of a requested type.
- An edge from a slot to an item exists only if the item meets the slot's hard
constraints (
meetsHardConstraintsinAllocationMatcher.kt). Ineligible pairs are absent from the graph, which is what makes hard constraints structural. - Every edge has capacity 1. The unit capacity on
item -> sinkis what enforces distinctness: an item can carry flow to at most one slot. - The slot-to-item edge cost is the negated preference score, so minimising cost maximises preference.
A successive-shortest-augmenting-path min-cost max-flow solver then pushes one unit of flow per slot. Max-flow settles feasibility (can every slot be filled?), min-cost settles optimality (of all complete assignments, which scores highest?). One pass settles both. The shortest-path step is SPFA (queue-based Bellman-Ford): costs are negative and residual edges flip sign, so plain Dijkstra would need potentials.
Consider the case the brief calls out -- two monitor slots and three available monitors:
| Monitor | Brand | Condition |
|---|---|---|
| M1 | LG | 0.90 |
| M2 | Dell | 0.80 |
| M3 | Dell | 0.70 |
Policy: slot A = monitor, minimum condition 0.85. slot B = monitor, prefer LG.
Greedy fills slot B first, because that is the slot with a preference to satisfy. The only LG is M1, so B takes M1. Slot A now needs a monitor at 0.85 or better, and only M2 (0.80) and M3 (0.70) remain. The request fails, even though a valid allocation exists: M1 to slot A (0.90 clears its minimum) and M2 to slot B (no LG available, but the slot has no hard constraint). Two items allocated, zero constraints violated.
Greedy fails whichever order it uses, because the problem is structural: a greedy choice
for one slot can consume the only item another slot could have used, and greedy has no way
to undo that choice. Min-cost max-flow does not have this problem: augmenting paths can push
flow back along residual edges, so an early assignment is revisited automatically when a
later slot needs that item more. The solver either returns the globally highest-scoring
complete assignment or proves none exists. AllocationScoringTest pins this exact instance and the M1-to-A, M2-to-B assignment;
AllocationShortfallsTest pins that the diagnosis stays silent on it.
Soft preferences are combined into one edge score, but as strict tiers, not a blend. The documented order is brand > recency > condition > id, and the implementation guarantees it: each tier is a multiplicative unit chosen so that
unit(tier) > maximum combined contribution of every tier below it
| Tier | Signal | Range | Unit |
|---|---|---|---|
| 1 | Preferred brand matches | 0 or 1 | 10^16 |
| 2 | Recency, if preferRecent (100 000 - age in days) |
0 - 100 000 | 10^10 |
| 3 | Condition score in hundredths | 0 - 100 | 10^7 |
| 4 | Id tie-break (10^7 - id) | 0 - 9 999 999 | 1 |
So a brand match (10^16) beats any recency advantage (at most 10^15), one day of recency
(10^10) beats any condition advantage (at most 10^9), and one hundredth of condition (10^7)
beats any id advantage (at most 10^7 - 1). No accumulation of lower-priority signals can
outvote a higher-priority one. AllocationMatcher.tierInvariantsHold() states the rule and
AllocationScoringTest asserts it, alongside one test per tier boundary that sets the
higher tier against the maximum swing of everything below it.
The units are also bounded from above. SPFA sums edge costs along a residual path that can
cross every slot the API allows (50), so 50 x the maximum edge score must stay inside
Long. It does, with a margin of about 16x, and a test pins that too.
Two consequences:
- Recency is sharp. An item purchased one day later beats one in far better condition,
as long as both clear the slot's hard minimum. That is the literal meaning of "prefer
newer equipment". If months or model year fit the business better, change the
ageDaysline inAllocationMatcher.score(). - The tie-break covers ids below ten million. Beyond that every item ties at zero and order falls back to candidate order, which the repository already fixes as ascending id.
When the solver cannot fill every slot, the request is stored as FAILED with a
failureReason the UI shows verbatim. The reason is exact.
The hard constraints are only type and minimum condition, so within a type every slot's candidate set is a threshold set -- all items at or above the slot's minimum -- and threshold sets are nested: a tighter slot's candidates are a subset of a looser slot's. For nested sets, Hall's marriage condition collapses to one check per distinct threshold:
the number of slots demanding at least that minimum must not exceed the number of items meeting it
Types never share candidates, so the checks are independent across types. AllocationShortfalls
walks each type's thresholds from tightest to loosest, accumulating demand while supply
widens, and reports every threshold where demand exceeds supply. The solver stays the authority
on feasibility; the diagnosis only explains. A randomised test confirms they agree on every
instance: the diagnosis finds a shortfall if and only if the matcher returns no assignment.
The earlier implementation counted compatible items per type at the loosest minimum and so missed competition between slots of the same type. Two monitor slots, one needing 0.90 and one needing anything, against stock at 0.50 and 0.60: two monitors, two wanted, "no shortage" -- yet the 0.90 slot has nothing. The new diagnosis says so:
No valid equipment set is available (monitor: 1 slot needs condition >= 0.90, 0 available).
And for the competing-slots case with two slots at 0.85 against stock at 0.90, 0.80, 0.70:
No valid equipment set is available (monitor: 2 slots need condition >= 0.85, 1 available).
failure_reason is TEXT (migration V3), so even a 50-slot policy failing on every
threshold is explained in full rather than truncated to fit a column.
Both the matcher and the diagnosis decide eligibility through one predicate,
PolicySlotRequest.accepts(item). That is what keeps them in agreement: a new hard
constraint added there reaches both, and a randomised test asserts they still agree. The
service normalises each slot once (normalised() trims the brand) and hands the same values
to the matcher, the diagnosis and the persisted policy, and @Digits(1, 2) on
minimumCondition keeps requests at the column's NUMERIC(3,2) scale -- so the threshold
the matcher enforces is the one Postgres stores and the failure reason reports.
For S slots, E eligible equipment items, V = S + E + 2 graph vertices, and
A <= S x E compatibility edges, the implementation's conservative worst-case bound is
O(S x V x A) time and O(V + A) space. The SPFA pass is usually far faster than that
bound; see the measured numbers below. The failure diagnosis is O(S log S + S x E) and
runs only on the failure path.
| Approach | Optimal? | Complexity | Why not this |
|---|---|---|---|
| Per-slot greedy | No | O(S x E) |
Fails the competing-slots case above |
| Backtracking / exhaustive search | Yes | Exponential | Fine for 2 slots, unusable at the 50-slot API limit |
| Hungarian algorithm | Yes | O(n^3) on a padded square matrix |
Equivalent result, but pads to max(S, E)^2 -- wasteful when E >> S, which is the normal case here |
| Min-cost max-flow (chosen) | Yes | O(S x V x A) worst case |
Scales with actual edges, not padded matrix size |
The price is more code than a sorted greedy pass. For very large inventories it could be improved with Johnson potentials plus Dijkstra instead of SPFA, more aggressive pre-filtering before graph construction, or an external optimisation solver. The current approach is deterministic, easy to test, and appropriate for an operations inventory where requests contain relatively few slots.
Measured with ./gradlew :backend:benchmark (AllocationMatcherBenchmark): a seeded catalogue of 5 000 equipment items, 50 warmup and 300 measured iterations per scenario, OpenJDK 21.0.9 in the gradle:9.2-jdk21 container on a 12-core machine. Candidate counts differ per scenario because only items of a requested type are eligible.
| Scenario | Candidates | p50 | p90 | p99 | max |
|---|---|---|---|---|---|
| 1 laptop, condition >= 0.8, prefer Apple | 1 211 | 0.124 ms | 0.231 ms | 0.565 ms | 3.129 ms |
| 1 laptop + 2 monitors | 2 514 | 0.474 ms | 1.494 ms | 1.629 ms | 2.770 ms |
| Full desk setup (4 slots) | 5 000 | 0.666 ms | 0.971 ms | 2.440 ms | 3.034 ms |
| Contended: 10 monitors, tight minimums | 1 303 | 0.511 ms | 2.244 ms | 3.404 ms | 4.198 ms |
| Worst case: 50 slots (API maximum) | 5 000 | 31.764 ms | 34.221 ms | 41.502 ms | 80.231 ms |
| Unsatisfiable (minimum above every item) | 1 211 | 0.073 ms | 0.085 ms | 0.120 ms | 0.208 ms |
What the numbers say:
- Realistic policies finish in under 2 ms at p99. The brief's one-laptop-plus-two-monitors policy runs at 0.47 ms p50 against 2 514 candidates.
- Cost scales with slot count, not catalogue size. Each slot needs one augmenting path, so 50 slots cost roughly 50x the 1-slot case though both scan the same catalogue. Graph construction is linear in candidates.
- Failure is the fastest path. An unsatisfiable policy returns in 0.07 ms: the first augmentation finds no path and the solver exits at once.
- Matching is not the bottleneck. Even the pathological 50-slot request stays under 42 ms at p99 (the 80 ms max is a single outlier, consistent with a GC pause).
AllocationService.createholds a pessimistic row lock for the whole window, so lock contention will dominate under concurrency long before the algorithm does. If 50-slot policies become common, replace SPFA with Johnson potentials plus Dijkstra.
The benchmark is a wall-clock harness, not JMH. It characterises the curve and catches regressions; microsecond precision would need JMH with blackholes and forked JVMs.
Creation runs in one transaction and pessimistically locks all available equipment rows of the requested types before matching. The chosen items become RESERVED atomically, preventing concurrent requests from receiving the same item. Confirm changes them to ASSIGNED; cancel returns them to AVAILABLE; returning a confirmed device releases that device alone. Allocation-item rows remain as history after each transition.
| Method | Path | Purpose |
|---|---|---|
POST |
/equipments |
Register equipment |
GET |
/equipments?state=&type= |
List and filter equipment |
GET |
/equipments/{id} |
Get one equipment record |
POST |
/allocations |
Create and immediately attempt an allocation |
GET |
/allocations |
List requests |
GET |
/allocations/{id} |
Get policy, state, and allocated items |
POST |
/allocations/{id}/confirm |
Assign reserved items |
POST |
/allocations/{id}/cancel |
Release reserved items |
POST |
/allocations/{id}/items/{equipmentId}/return |
Return one assigned device |
Example request:
{
"employeeId": "EMP-1042",
"policy": [
{
"type": "MAIN_COMPUTER",
"minimumCondition": 0.8,
"preferredBrand": "Apple",
"preferRecent": true
},
{ "type": "MONITOR" },
{ "type": "MONITOR" }
]
}Theme values live as CSS custom properties at the top of frontend/src/styles.css; every rule reads from them, so a rebrand is a change to that one block.
| Token | Value |
|---|---|
--color-primary / --color-accent |
#FF6C1B |
--color-on-primary |
#171614 |
--color-secondary |
#252E38 |
--color-text |
#171614 |
--color-link |
#1F6FB5 |
--radius |
0px |
--font-heading / --font-body |
Overused Grotesk, Arial, sans-serif |
--font-mono |
IBM Plex Mono |
--size-h1 / --size-h2 / --size-body |
56px / 20px / 18px |
--space-1 … --space-10 |
4px base unit |
Two deliberate deviations from the supplied brand sheet, both for WCAG AA:
- Labels on
#FF6C1Bare#171614, not white. White on the brand orange measures 2.83:1, below even the 3:1 floor for UI components. Near-black gives 6.38:1 and leaves the brand colour itself untouched. - Links are
#1F6FB5, not#3898EC. The brand blue is 3.06:1 on white; the darkened variant is 5.25:1.
Fonts load from CDNs with the brand sheet's own Arial fallback, so a blocked or slow CDN degrades the page rather than breaking it. For production, self-host both families under frontend/public/fonts/ and swap the <link> tags in index.html for local @font-face rules.
Both stacks have static analysis wired up.
# Kotlin — style and code smells
./gradlew :backend:ktlintCheck :backend:detekt
./gradlew :backend:ktlintFormat # autofix
# Angular / TypeScript — includes template accessibility rules
cd frontend && npx eslint srceslint.config.js runs typed linting (projectService) so rules needing the type checker work, and enables angular-eslint's template accessibility ruleset alongside prefer-signals and prefer-inject to keep the codebase signal-first and zoneless.
detekt's tool classpath is pinned to Kotlin 2.0.21 in backend/build.gradle.kts: detekt 1.23.x refuses to run against the project's 2.2.21 compiler, and detekt 2.x is still alpha. detekt analyses on an isolated classpath, so this does not affect how the project compiles. Revisit once detekt 2.x is stable.
Backend tests through the Gradle wrapper:
./gradlew :backend:test # unit tests (benchmarks excluded)
./gradlew :backend:benchmark # allocation latency benchmarkFrontend development (Node 24.15+, which the Angular 22 CLI requires):
cd frontend
npm ci
npm startThe Angular development proxy expects the backend at localhost:8080. The production Compose setup exposes only the combined UI/API entry point at port 8088.