35 Commits

Author SHA1 Message Date
7750819d73 Merge remote-tracking branch 'origin/main' into feature/S19_multi-project
All checks were successful
Build, test and publish / verify (push) Successful in 10m1s
2026-11-10 08:53:20 +01:00
77b7159f0a S19: adopt shared mini-pc runner and landscape workflows 2026-11-10 08:53:20 +01:00
c6a2b70919 Merge pull request 'feature/S19_multi-project' (#20) from feature/S19_multi-project into main
All checks were successful
Build, test and publish / verify (push) Successful in 10m25s
Reviewed-on: #20
2026-11-08 21:31:35 +01:00
c6a495a93c S19: consume versioned engineering resources and move landscape documentation
All checks were successful
Build, test and publish / verify (push) Successful in 8m54s
2026-11-08 15:23:10 +01:00
bd898e92d2 S19 refinement
All checks were successful
Build, test and publish / verify (push) Successful in 8m15s
2026-11-08 13:39:16 +01:00
d2d4713c51 Merge pull request 'git helper tool added and improved coding-standards' (#19) from git-tools into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m46s
Reviewed-on: #19
2026-11-08 11:43:46 +01:00
8546f222fe git helper tool added and improved coding-standards
All checks were successful
Build, test and publish / verify (push) Successful in 6m57s
2026-11-08 10:25:37 +01:00
3c3ca1949c Merge pull request 'fixed the ADR licensing and gave due credit to the work of Matt Pocock' (#18) from adr-update into main
All checks were successful
Build, test and publish / verify (push) Successful in 5m34s
Reviewed-on: #18
2026-11-07 14:35:09 +01:00
7603b3339b fixed the ADR licensing and gave due credit to the work of Matt Pocock
All checks were successful
Build, test and publish / verify (push) Successful in 7m3s
2026-11-07 14:34:46 +01:00
98d53e9cb5 Merge pull request 'adr' (#17) from adr into main
Some checks failed
Build, test and publish / verify (push) Failing after 8m44s
Reviewed-on: #17
2026-11-06 14:01:59 +01:00
36f4b15516 improved the adr skill on latest session and after ai feedback. removed dependency on other skill
All checks were successful
Build, test and publish / verify (push) Successful in 7m33s
2026-11-06 13:59:56 +01:00
0f1c5dcd92 skill development and ADR sessions
All checks were successful
Build, test and publish / verify (push) Successful in 7m44s
2026-11-06 13:26:25 +01:00
6acd005f98 Merge pull request 'Fix pipeline deployment with postgres' (#16) from S16_deploy-fix into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m10s
Reviewed-on: #16
2026-11-05 16:10:17 +01:00
01506ef837 Fix pipeline deployment with postgres
All checks were successful
Build, test and publish / verify (push) Successful in 6m15s
2026-11-05 11:20:49 +01:00
7a358ae623 Merge pull request 'S16_database' (#15) from S16_database into main
All checks were successful
Build, test and publish / verify (push) Successful in 7m21s
Reviewed-on: #15
2026-11-04 22:15:25 +01:00
2d8822c1a4 S16 - some transactional hardening
All checks were successful
Build, test and publish / verify (push) Successful in 7m32s
2026-11-04 21:54:20 +01:00
694bf3fa86 S16 postgresql opslag added
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
2026-11-04 17:10:25 +01:00
f59cad115a Merge pull request 'S12_async-feedback' (#14) from S12_async-feedback into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m37s
Reviewed-on: #14
2026-11-04 08:35:22 +01:00
a3cff48a92 S12 - turned schedule status into enum and some other review comments
All checks were successful
Build, test and publish / verify (push) Successful in 7m6s
2026-11-04 08:05:39 +01:00
c241392132 S12 - Async feedback for scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
2026-11-03 23:18:44 +01:00
f1785ed174 S12 refinement result
All checks were successful
Build, test and publish / verify (push) Successful in 4m57s
2026-11-03 16:22:23 +01:00
b92fac1d1a Merge pull request 'S6: record production deployment and recovery evidence' (#13) from docs/S6-production-validation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #13
2026-11-03 16:13:52 +01:00
95430691e4 updated spec status
All checks were successful
Build, test and publish / verify (push) Successful in 6m5s
2026-11-03 16:13:38 +01:00
95aa8c6302 testfile
Some checks failed
Build, test and publish / verify (push) Has been cancelled
2026-11-03 16:12:30 +01:00
3369b9a81c Record S6 production and NAS recovery acceptance [skip ci] 2026-11-03 15:34:31 +01:00
b0d6592761 Merge pull request 'S6: manual versioned Synology deployment' (#12) from feature/S6_deployment into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m6s
Reviewed-on: #12
2026-11-03 14:51:13 +01:00
60496de8bb Record successful S6 feature build validation [skip ci] 2026-11-03 14:48:59 +01:00
525e9f9335 Implement manual versioned Synology deployments for S6
All checks were successful
Build, test and publish / verify (push) Successful in 6m34s
2026-11-03 14:16:52 +01:00
c9db528756 refinement of spec S6
All checks were successful
Build, test and publish / verify (push) Successful in 5m49s
2026-11-03 13:30:47 +01:00
b1544357bf Merge pull request 'ortools-documentation' (#11) from ortools-documentation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #11
2026-11-03 13:09:09 +01:00
3a5203eb47 some renaming of variables
All checks were successful
Build, test and publish / verify (push) Successful in 5m55s
2026-11-03 12:58:15 +01:00
a7f7730ea0 S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
2026-11-03 11:03:47 +01:00
c965f9b41d Merge pull request 'refinement2' (#10) from refinement2 into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m45s
Reviewed-on: #10
2026-11-01 21:29:04 +01:00
8fc430537b refinement S16 - database storage
All checks were successful
Build, test and publish / verify (push) Successful in 5m26s
2026-11-01 21:28:42 +01:00
fc307de64e backlog management
All checks were successful
Build, test and publish / verify (push) Successful in 5m38s
2026-11-01 20:51:27 +01:00
211 changed files with 13418 additions and 3592 deletions

View File

@@ -5,7 +5,7 @@ on:
jobs:
verify:
runs-on: agiledancers-mini-pc
runs-on: mini-pc
env:
CI: 'true'
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
@@ -19,21 +19,23 @@ jobs:
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
# container names and diagnostic artifacts, including reruns of one SHA.
run: java ci/Ci.java attempt
run: java generated/ci/Ci.java attempt
- name: Build and test both images
run: java ci/Ci.java verify
run: java generated/ci/Ci.java verify
- name: Publish verified main images
if: success() && gitea.ref == 'refs/heads/main'
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: java ci/Ci.java publish
run: java generated/ci/Ci.java publish
- name: Collect diagnostics and clean up test containers
if: always()
run: java ci/Ci.java cleanup
run: java generated/ci/Ci.java cleanup
- name: Retain diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5

View File

@@ -0,0 +1,70 @@
name: Deploy production
on:
workflow_dispatch:
inputs:
mode:
description: 'Release mode — export legacy data before the first S16 deployment'
required: true
type: choice
default: new
options:
- new
- retry
increment:
description: 'New release only: semantic version increment (first release is v1.0.0)'
required: true
type: choice
default: patch
options:
- patch
- minor
- major
version:
description: 'Retry only: existing version tag, e.g. v1.2.3; leave empty for new release'
required: false
type: string
# Gitea 1.25 ignores concurrency. The helper acquires a NAS-wide mkdir lock.
jobs:
deploy:
if: gitea.ref == 'refs/heads/main'
runs-on: mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_EVENT: ${{ gitea.event_name }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out trusted main workflow revision
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
run: java generated/ci/Ci.java attempt
- name: Release verified images — persistent PostgreSQL storage
env:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_INCREMENT: ${{ inputs.increment }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java generated/ci/Ci.java deploy
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: deployment-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/deployment/
if-no-files-found: warn
retention-days: 14

View File

@@ -0,0 +1,44 @@
name: Rollback production
on:
workflow_dispatch:
jobs:
rollback:
if: gitea.ref == 'refs/heads/main'
runs-on: mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_EVENT: ${{ gitea.event_name }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out trusted main workflow revision
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
run: java generated/ci/Ci.java attempt
- name: Restore previous successful release — requires a compatible database schema
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java generated/ci/Ci.java rollback
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: rollback-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/deployment/
if-no-files-found: warn
retention-days: 14

View File

@@ -1,22 +1,26 @@
# Project skills and specifications
# Shared skills and specifications
- Project skills are stored in `skills/`; specifications belong in `specs/`.
- For code reviews, read and follow `skills/code-review/SKILL.md`. If no
dedicated Skill tool is available, load the file directly.
- Use `grill-me` only when the user explicitly requests it, for example
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
- Before using it, read `skills/grill-me/SKILL.md` and its dependency
`skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these
files directly and follow their instructions within the available tools.
- Installing or mentioning a skill does not start an interview. Do not apply
`grilling` automatically to ordinary development requests.
- Keep the upstream skill files unchanged. Source revision and license are
recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`.
- Shared agent workflows live in [landscape skills](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/README.md).
Read the requested SKILL.md there before applying it. A sibling checkout is optional.
- For code reviews use landscape's skills/code-review/SKILL.md with docs/backend-review.md.
- Use grill-me only when explicitly requested, with its skills/grilling/SKILL.md dependency.
Installing or mentioning a skill starts no interview.
- Keep upstream skills unchanged; licenses and revision records live in landscape/skills/.
- Specifications and ADRs belong to landscape. Engineering owns pinned build resources.
# Backend changes and reviews
- Before changing backend Java, read `CODING_STANDARDS.md`.
- For reviews, use `skills/code-review/SKILL.md` together with
- For reviews, use landscape's `skills/code-review/SKILL.md` together with
`docs/backend-review.md`. Keep upstream skills unchanged.
- Obtain maintainer agreement before adding any standards waiver; record its
narrow scope and reason alongside the affected code or configuration.
# Landscape and engineering
- Read current [landscape guidance](http://192.168.2.28:3000/wanjakrah/landscape),
including applicable ADRs and specifications. This repository owns its implementation.
- Engineering resources are pinned by `engineering.lock.json`. Do not edit
`generated/`; change engineering and deliberately adopt a released version.
- Regenerate with `mvn -N -Pregenerate-engineering validate`; check with
`python3 scripts/engineering.py --check`. No sibling checkout is needed.

View File

@@ -1,255 +1,11 @@
# Backend coding standards
# Agile Dancers coding standards
This document is the authoritative policy for handwritten production and test Java
in `agiledancers-be`. Generated code and Java helpers outside that module are out
of scope. Existing frontend conventions in README.md and agiledancers-fe remain in force.
Read [the pinned engineering standards](generated/CODING_STANDARDS.md) before
changing Java. Their backend-module placeholder means `agiledancers-be` here.
Frontend conventions remain in README.md and agiledancers-fe.
## Scope and authority
The existing maintainer-approved exception for Group's eight-parameter canonical
constructor remains in `tooling/standards/waivers.tsv`. No new waiver is introduced
by S19. For reviews also read [the local companion](docs/backend-review.md).
- Cover handwritten production and test Java in `agiledancers-be`. Exclude
generated code. Dependency injection and production architecture rules apply
where relevant. Frontend and Java helpers outside the backend are out of scope.
- [Java coding guidelines](https://www.geeksforgeeks.org/java/coding-guidelines-in-java/)
supplied source material. Only the rules adopted in this local document apply;
changes to the article do not change project policy.
- The project's pinned Palantir formatter wins formatting conflicts, including
comment layout. S8 remains responsible for formatter integration.
- Do not adopt unrelated static-analysis rule bundles.
## Java conventions
- Types use UpperCamelCase; methods and variables use lowerCamelCase; constants
use UPPER_SNAKE_CASE; package names are lowercase.
- One top-level type per file; explicit member visibility; `@Override` on
overriding methods. Do not invent visibility modifiers where Java forbids them.
- Opening braces stay on the declaration/control-statement line. All `if`, `for`
and `while` bodies require braces, including enhanced for and do/while bodies.
- No wildcard imports. Static imports are allowed only in test classes.
- Comments explain intent or constraints. Document public contracts with Javadoc
where their meaning is not clear from the declaration; do not require boilerplate
documentation for every accessor.
- Block comments use the following layout, subject to Palantir formatting:
```java
/*
This is an example of a block comment.
It may have more than one line.
*/
```
- Prefer records for immutable data carriers. Classes are allowed when mutation,
framework requirements or inheritance make records unsuitable. Preserve API and
serialization behavior. Record immutability is shallow: protect mutable
components appropriately.
- Require `final` for parameters, local variables and fields wherever they can
remain assigned once, including loop variables and catch parameters where Java
permits it. Field finality must remain compatible with framework injection and
lifecycle requirements. Permit reassignment when the algorithm needs it; do not
introduce artificial holder objects to avoid reassignment. This rule does not
require final classes or methods.
- Use explicit types unless the initializer visibly identifies the type. `var` is
allowed with `new School(...)` or an explicit cast, but not for method results,
literals or other inferred expressions. For example,
`final var school = new School(...)` is allowed, while a repository result uses
`final School school = repository.find(id)`. Borderline cases require review.
- No additional member-ordering or line-width policy beyond Palantir.
## Metrics
Targets guide development. Review signals require assessment, not automatic
failure. Blocking limits fail verification. Strict inequalities matter: a value
exactly at a blocking limit passes.
| Metric | Target | Review signal | Blocking limit |
| --- | ---: | ---: | ---: |
| Cyclomatic complexity per method/constructor | ≤ 5 | > 7 | > 10 |
| Cognitive complexity per method/constructor | ≤ 10 | > 10 | > 15 |
| Method/constructor length, code lines | ≤ 20 | > 30 | > 50 |
| Parameters per method | ≤ 3 | > 4 | > 5 |
| Parameters per explicitly written constructor | ≤ 4 | > 5 | > 7 |
| Injected dependencies per class | ≤ 4 | > 5 | > 7 |
| Fan-out per class, unique used types | ≤ 10 | > 15 | Review only |
| Own superclass depth, excluding Object | ≤ 1 | > 2 | > 3 |
| Control-flow nesting | ≤ 2 | > 3 | > 4 |
| Production duplicated lines density | ≤ 1% | > 1% | > 3% |
| Class length, code lines | ≤ 200 | > 300 | > 500 |
| Declared public methods per class, excluding trivial accessors | ≤ 10 | > 15 | Review only |
### Measurement definitions
- Use the pinned PMD algorithms as authoritative for cyclomatic and cognitive
complexity. Document their exact configuration, including switch and boolean
treatment. Do not accidentally introduce a class-level aggregate complexity gate.
- Measure each method, constructor and explicitly written record constructor
independently. Lambda bodies contribute to their enclosing executable;
methods in nested/local classes are separate executables.
- Parameter limits exclude implicit record constructors and generated members.
Record component counts remain a design review concern.
- Length means physical source lines after Palantir formatting, excluding blank
and comment-only lines. Declaration and brace-only lines count; a line containing
both code and comments counts. Do not substitute statement counts or NCSS.
Measure nested types separately and exclude them from enclosing class length.
- Count distinct injected collaborators by resolved type and qualifier. Include
constructor, field, setter, inherited and implicit constructor injection.
Repeated injection of the same type and qualifier counts once; differently
qualified instances of the same type count separately.
- Fan-out counts distinct explicitly referenced resolved types, including JDK and
framework types. Exclude primitives, the class itself and generated references.
- Inheritance depth counts project-owned superclass layers; exclude Object,
interfaces and external framework ancestry.
- Nesting combines `if`, loops, `switch` and `catch`, with the first structure at
depth one. An `else if` chain stays at one level. `else`, `try` and `finally`
add no level themselves. Lambdas retain surrounding depth; methods in nested
classes start fresh.
- Public-method counts include explicitly declared public instance and static
methods; exclude constructors, inherited methods and trivial accessors. A
trivial accessor only reads or assigns its corresponding field, with no
validation, branching or other work.
- Duplication limits apply only to production code. Duplication in tests is allowed
and has no target, review signal or blocking limit.
- Detect production clones with CPD, minimum 100 tokens, without ignoring
identifier or literal differences. Density is
`100 × distinct duplicated code-line locations / all production code-line locations`.
Exclude blank/comment-only lines from numerator and denominator. Count every
occurrence, including the first; merge overlapping ranges to count each source
line once. This project metric need not equal a future Sonar metric.
## Enforcement and developer workflow
- Use explicitly pinned Java-25-compatible Checkstyle and PMD/CPD versions.
Retain ArchUnit for architecture checks and add small targeted custom checks
where standard rules cannot accurately express the agreed semantics.
- Automate every blocking metric and explicit rule where reliable checking is
available. Identify rules requiring manual review explicitly; do not claim
they are automated. Review signals must appear in reports even when builds pass.
- Run standards checks during `mvn verify`; provide documented standalone commands.
Ordinary compile/test commands retain the current S8 formatting behavior.
- Standards checks report violations without rewriting code. CI passes these
checks before image publication. Parser failures or unsupported Java constructs
fail with the affected path and actionable diagnostics, rather than being skipped.
- Add a root AGENTS.md instruction requiring agents to read CODING_STANDARDS.md
before changing backend Java. Project review instructions must explicitly cover
manual rules, review signals in changed code and approved waivers, without
repeating checks already enforced by tooling.
- Keep upstream skill files unchanged. Put project-specific code-review guidance
in a companion file referenced by project instructions and used alongside
`skills/code-review/SKILL.md`.
- Waivers must be explicit, narrowly scoped and carry a recorded reason alongside
code or configuration. Agents need maintainer agreement before introducing one.
No blanket suppressions, exclusions of difficult files or weakened thresholds
merely to make verification pass.
## Coverage
- Require at least 80% backend line coverage across handwritten production code
for the module as a whole, not separately per class or package. Compare the
unrounded percentage against 80%.
- Local `mvn verify` must reach 80% using backend unit/integration tests alone.
This intentionally tightens the original combined-tests-only requirement.
- CI additionally instruments the backend exercised by Playwright, merges that
coverage with backend unit/integration coverage and gates the combined result.
The Playwright contribution need not separately reach 80%.
- Exclude generated code only; do not broadly exclude DTOs, configuration or
difficult code. Publish line and branch coverage reports; branch coverage has
no agreed blocking percentage.
- PIT measures mutation-test effectiveness, not line coverage, and is outside
S7's required implementation. Configuring Sonar in the pipeline remains out of
scope for S14. FindBugs/SpotBugs is not an additional required check suite.
## Implemented checks and manual review
Checkstyle **12.3.1**, PMD/CPD **7.28.0**, and JaCoCo **0.8.14** are pinned.
`tooling/standards/checkstyle.xml` selects individual naming, structure, import and brace checks; no general analysis bundle is enabled.
`tooling/standards/src/main/java/Standards.java` supplies the targeted measurements.
ArchUnit remains in the backend test suite for architectural boundaries.
PMD uses Java 25, the running JDK's `jrt-fs.jar`, compiled backend classes and the
resolved test dependency classpath. Cyclomatic complexity uses PMD's default
options: boolean paths count, assertions do not. Decisions include if/loops,
catch, ternary and throw; non-default switch alternatives count individually,
including comma-separated labels. There is no aggregate class-complexity gate.
Cognitive complexity uses PMD's boolean-operation sequences, nesting, switch,
recursion and flow rules. The scoped visitors retain those algorithms while
including lambdas in their enclosing executable and excluding nested/local type
bodies, whose methods are measured independently. Compact record constructors
use the same visitors and start with cyclomatic complexity one.
Custom checks measure physical code lines from regular parser tokens, including
brace/declaration lines and mixed code/comment lines. They count multiline literal
lines while excluding physically blank lines. Class length excludes nested-type
tokens, preserving lines shared with enclosing code. Other custom measurements
implement the definitions above: nesting, resolved type fan-out, project-owned
superclass depth, public non-accessor methods, and injected collaborators.
Injection includes inherited fields/setters, explicit `@Inject` constructors,
single constructors on bean classes, records' implicit canonical constructors,
and Lombok required/all-argument constructor injection. Qualifier keys use resolved
annotation names and values, including defaults; repeat sites are deduplicated.
The CPD configuration preserves identifiers/literals, uses 100 tokens and disables
skip blocks. Its line-location union counts overlapping occurrences only once.
Checkstyle and targeted checks enforce import/brace/naming/visibility rules,
`@Override`, visible constructor/cast/new-array `var` initializer forms, final parameters, locals (including
loops/catches and lambdas) and initialized fields. PMD's three selected finality
rules additionally cover control-flow assignment and private constructor-initialized
fields. Framework/lifecycle requirements, generated Lombok setters, and external
writes to public/protected fields need review before changing field finality.
No tool can infer whether a comment explains intent, whether a public contract
needs Javadoc, whether a record is appropriate, or whether mutable components are
sufficiently protected. These are **manual rules**, as are ambiguous type inference
and architecture/DI design decisions. Review all `DESIGN` record-component entries
and `REVIEW` signals, even when a build passes, using `docs/backend-review.md`.
Reports never rewrite Java. Formatter application remains S8's separate validate
execution. Generated output under `target` and generated source directories are
outside the handwritten `src/main/java` and `src/test/java` scope. No maintained
file, DTO, configuration class or package is excluded from standards or coverage.
JaCoCo filters compiler/Lombok-generated members; no handwritten-code exclusions
are configured. Line and branch results are published, with only lines gated.
Exact metric waivers live in `tooling/standards/waivers.tsv`; each records a metric,
fully resolved executable signature (or class), exact retained value and the
maintainer agreement/reason. No wildcard waivers are allowed, and a changed value
fails again. A waived blocking result remains visible as `WAIVER`. PMD suppression
annotations/comments cannot silently waive a rule. The only approved waiver is the eight-parameter `Group` canonical constructor,
which preserves its JSON contract while defensively copying both lists. Adding a waiver always requires maintainer agreement.
## Commands and reports
Run from the root with JDK 25:
```sh
mvn -Dformat.mode=check clean verify
# Prepare standalone analysis without running tests:
mvn -pl agiledancers-be test-compile dependency:build-classpath@standards-classpath
mvn -f tooling/standards/pom.xml test exec:java
# Exact local coverage gate after a test/report build:
java tooling/coverage/Coverage.java agiledancers-be/target/site/jacoco/jacoco.xml
```
`verify` runs backend tests, builds resolved analysis inputs, generates JaCoCo
reports, runs checker regression tests and standards analysis, then gates exact
module-wide line coverage. `compile` and `test` retain existing formatting behavior
and do not run the verify-only standards checks. Standalone analysis requires its
classpath preparation; unresolved types fail with the path and preparation advice.
Full metrics and findings: `agiledancers-be/target/standards/report.txt`.
Checker regression results: `tooling/standards/target/surefire-reports`.
Coverage HTML/XML/CSV: `agiledancers-be/target/site/jacoco` (local backend tests)
and `agiledancers-be/target/site/jacoco-combined` (merged browser contribution).
The gate compares integer line counts (`5 * covered >= 4 * total`), rejecting an
empty report and percentages that would round up to 80%.
CI first passes local verify. It mounts the pinned JaCoCo agent into its disposable
backend test container, exposes the agent only on the isolated Docker network,
and uses the pinned CLI to dump `target/playwright.exec` after Playwright. A missing
or empty dump fails. The following commands merge both datasets and gate the result
before the verified-commit marker can authorize publication:
```sh
mvn -pl agiledancers-be jacoco:merge@combined jacoco:report@combined-report exec:exec@combined-coverage
```
CI retains both coverage reports, execution data, standards findings and regression
results with its usual diagnostics artifacts. The agent is mounted only for tests;
published images contain no agent. PIT and Sonar configuration remain outside S7.
See `specs/S7-validation.md` for migration evidence and retained-signal assessment.
Validation evidence: [S7](docs/validation/S7-validation.md).

View File

@@ -2,11 +2,17 @@ FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /workspace
COPY pom.xml ./
COPY .mvn .mvn
COPY generated generated
COPY tooling tooling
COPY ci ci
COPY engineering.properties engineering.properties
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
COPY agiledancers-be/src agiledancers-be/src
RUN mvn -B -ntp clean verify
# Source builds run the same PostgreSQL tests as local/CI verification.
# Supply connection configuration as BuildKit secrets, which are not retained in image layers.
RUN --mount=type=secret,id=database_url,env=QUARKUS_DATASOURCE_JDBC_URL,required=true \
--mount=type=secret,id=database_user,env=QUARKUS_DATASOURCE_USERNAME,required=true \
--mount=type=secret,id=database_password,env=QUARKUS_DATASOURCE_PASSWORD,required=true \
mvn -B -ntp clean verify
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app

121
README.md
View File

@@ -1,3 +1,18 @@
# Landscape and shared engineering
Architecture decisions, specifications and project onboarding are maintained in
[landscape](http://192.168.2.28:3000/wanjakrah/landscape). Shared agent skills are maintained in landscape/skills/. Reusable tooling,
standards and the project generator are maintained in
[engineering](http://192.168.2.28:3000/wanjakrah/engineering).
This project commits the engineering version recorded in `engineering.lock.json`
under `generated/`. Ordinary builds need no other repository checkout. Regenerate
with `mvn -N -Pregenerate-engineering validate`; CI checks the selected tag and
commit with `python3 scripts/engineering.py --check`. To upgrade, update both tag
and commit, follow engineering's UPGRADING.md, regenerate and review the diff.
Project configuration in `engineering.properties`, workflows and `deploy/` remains
application-owned. Read CODING_STANDARDS.md and AGENTS.md for project guidance.
# agiledancers
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
@@ -19,14 +34,19 @@ mvn -pl agiledancers-be quarkus:dev
Run the packaged application after building:
```sh
# Set DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD first.
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
```
Build and run a Docker container from the repository root (only Docker is required):
Build a Docker container against an isolated test database (see [connection setup](docs/persistent-storage.md)):
```sh
docker build -t agiledancers .
docker run --rm --name agiledancers -p 8080:8080 agiledancers
docker build --network=host \
--secret id=database_url,env=TEST_DATABASE_URL \
--secret id=database_user,env=TEST_DATABASE_USER \
--secret id=database_password,env=TEST_DATABASE_PASSWORD -t agiledancers .
docker run --rm --name agiledancers -p 8080:8080 \
-e DATABASE_URL -e DATABASE_USER -e DATABASE_PASSWORD agiledancers
```
The Docker build compiles the application and runs the tests in a Maven build stage,
@@ -42,7 +62,13 @@ curl --fail http://localhost:8080/q/health/ready
Quarkus configuration can be overridden with environment variables, for example
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
The REST API uses shared in-memory storage; data is lost when the process stops.
PostgreSQL stores all school data, planning state and the latest scheduling run
through JPA/Hibernate ORM. Persistence entities remain in the repository layer.
Flyway initializes and versions the schema. Production requires `DATABASE_URL`
(a JDBC PostgreSQL URL), `DATABASE_USER` and `DATABASE_PASSWORD`; missing credentials,
connection failures and migration failures prevent startup. See
[database setup and transition](docs/persistent-storage.md) before replacing an
in-memory release: export every school before stopping it.
Architecture checks run with the normal test suite, or separately with:
@@ -56,7 +82,7 @@ mvn -pl agiledancers-be -Dtest=ArchitectureTest test
| --- | --- | --- |
| `model` | Domain entities and specifications | Model |
| `service` | Application use cases and future ports | Model, repositories |
| `repository` | Current in-memory persistence | Model |
| `repository` | JPA/Hibernate entities, repositories and transaction boundaries | Model |
| `rest` | Future REST services | Services, model |
| `solver` | Future OR-Tools calculations | Services (ports), model |
@@ -83,7 +109,8 @@ the solver live in `model.scheduling`. Each group requirement combines its group
ID and appearance count in one record.
`service.port.SchedulingEngine` is the application-owned interface for solving a
scheduling problem. `SchedulingService` calls this interface; dependency injection
scheduling problem. `SchedulingRuns` calls this interface for background runs;
`SchedulingService` retains the synchronous service entry point. Dependency injection
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
types, so neither the caller nor the contract depends on OR-Tools or the concrete
school-management service. Architecture tests enforce the port's location and
@@ -154,23 +181,47 @@ dancers; deleting a school also deletes its dancers, groups and shows.
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
with unique date/type combinations. The displayed date range comes from these
dayparts. Each show stores `appearances` for every group type. Omitting counts
initializes defaults on creation or the initial daypart configuration; later
edits preserve saved counts and reject counts outside 1..N. With no dayparts,
shows remain unscheduled and saved counts are retained.
dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
dayparts; preschool groups once; showcases every daypart. See
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
coach exceptions, ordering and optimization rules.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` uses OR-Tools to
assign every current school group to its required number of distinct dayparts,
minimizing the busiest/quietest appearance gap. It replaces assignments only on
a valid, current result. The response distinguishes optimal balance from a valid
result whose optimality is unproven. Concurrent runs return `409`; changed inputs
also reject the result with `409`. Failed runs preserve existing assignments.
Scheduling runs synchronously with a 30-minute solver limit; development and
production proxies allow 31 minutes. The UI warns before edits that may invalidate
existing schedules and offers explicit scheduling again afterward.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a
run identifier and starts an ordered draft calculation in the background using
OR-Tools with a total thirty-minute time limit. Duplicate starts for the same
show return `409`; different shows may run concurrently. A feasible result is
accepted without claiming optimality. Stale results and failures retain existing
assignments and appear as distinct terminal run states.
School JSON exports use format version **2**, including dated dayparts, assignments,
and all appearance counts. Version 1 files are intentionally unsupported.
`GET .../shows/{showId}/schedule` returns the latest run (`204` if none exists).
`GET /api/scheduling/events` streams SSE JSON objects containing a `runs` snapshot
on connection and changes, or `runs: null` as a fifteen-second heartbeat. Clients
recover current state on reconnection rather than requiring event replay. The
production proxy disables buffering. The UI shows phase, elapsed time, solution
discovery and final objective scores, and reports completion in an application-wide
snackbar. Reloading recovers the latest state without replaying old notifications.
The latest run and saved assignments survive restart. Startup marks formerly
running calculations `INTERRUPTED`, preserves the previous assignments, and allows
a new run. Results and successful status commit in one database transaction.
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize`
checks every hard rule before marking a schedule final. Show responses include
`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final
status. The UI supports ordering and moving performances, and displays violations.
`GET .../coaches` lists coach registrations. `POST .../coaches` with
`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses
the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer`
removes the identity link. The dancers screen supports maintaining these links.
School JSON exports use format version **3**, including preferredDayParts, schedule status and
coach-to-dancer links. Version 2 remains importable; its schedules migrate to
drafts with default preferredDayParts. Version 1 is unsupported. Legacy `appearances` fields are
retained for file compatibility; S18 scheduling uses preferredDayParts and fixed type exceptions.
The REST `appearances` input is deprecated; use `preferredDayParts`.
Run the HTTP integration tests with:
@@ -254,11 +305,16 @@ Only successful `main` runs publish images to the Synology registry. See
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status.
Production releases and rollback use separate manual Gitea workflows. See the
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
semantic version tags, release/retry/rollback and the accepted data-loss limitation
during the one-time transition from an in-memory release, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
### Java formatting (S8)
Maven is the authoritative formatting path. The root build runs the actual
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
**8.63.4** text recipe in `tooling/format/Format.java`. The pinned
**8.63.4** text recipe in `generated/tooling/format/Format.java`. The pinned
`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
No OpenRewrite AutoFormat or additional refactoring recipes run.
@@ -303,9 +359,9 @@ format-on-save must not override Maven's result. Other editors can invoke the
standalone Maven apply command as an external tool.
Verification and measurements are recorded in
[the S8 evidence](specs/S8-formatting-evidence.md). Reproduce the isolated failure,
[the S8 evidence](docs/validation/S8-formatting-evidence.md). Reproduce the isolated failure,
UTF-8, import, cache and idempotence checks with
`python3 tooling/format/test.py`, and the sequential end-to-end build measurements
the formatter integration test in engineering (`python3 tests/format_integration.py`), and the sequential end-to-end build measurements
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
prerequisites). Results are written under `target/`.
@@ -316,4 +372,19 @@ It defines the agreed rules, exact metric limits, manual review duties, pinned
analysis tools, coverage policy and standalone commands. `mvn verify` runs standards
checks and requires at least 80% module-wide backend line coverage. CI also collects
and merges coverage from Playwright before publication. Reports are retained with
CI diagnostics. See [S7 validation](specs/S7-validation.md) for current migration status.
CI diagnostics. See [S7 validation](docs/validation/S7-validation.md) for current migration status.
Existing-school mutations require `X-School-Revision` with the revision loaded by
the editor. `GET /api/dance-schools/{id}/data` returns the school and all its editor
data from one consistent snapshot; `school.revision` identifies that snapshot.
Missing or stale revisions receive `409` with a reload instruction. The import
preview returns `existingRevision` for replacement confirmation. Creation of a
new school does not need a revision. The frontend never automatically retries a
conflicting edit with a newer revision.
Domain-to-view projection is generated by MapStruct in `rest.mapping.DomainViewMapper`.
It is a CDI bean with `unmappedTargetPolicy = ERROR`, so unmatched target fields fail
compilation. REST resources supply calculated validation results explicitly. The
entity-to-domain graph mappings remain handwritten in the repository layer.

View File

@@ -14,6 +14,11 @@
<packaging>jar</packaging>
<dependencies>
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>${mapstruct.version}</version></dependency>
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-hibernate-orm</artifactId></dependency>
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jdbc-postgresql</artifactId></dependency>
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-flyway</artifactId></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
@@ -63,6 +68,16 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths combine.children="append">
<path><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>${mapstruct.version}</version></path>
<path><groupId>org.projectlombok</groupId><artifactId>lombok-mapstruct-binding</artifactId><version>${lombok-mapstruct-binding.version}</version></path>
</annotationProcessorPaths>
<compilerArgs><arg>-Amapstruct.suppressGeneratorTimestamp=true</arg></compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>
@@ -82,20 +97,20 @@
<executable>${maven.home}/bin/mvn</executable>
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
<argument>${maven.multiModuleProjectDirectory}/tooling/standards/pom.xml</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/standards/pom.xml</argument>
<argument>test</argument><argument>exec:java</argument>
</arguments>
</configuration>
</execution>
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
</arguments></configuration>
</execution>
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
</arguments></configuration>
</execution></executions>

View File

@@ -4,6 +4,7 @@ import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@@ -13,4 +14,7 @@ import lombok.ToString;
public class Coach extends AbstractEntity {
@ToString.Include
private final String name;
@Setter
private Dancer dancer;
}

View File

@@ -20,6 +20,9 @@ public class DanceSchool extends AbstractEntity {
@Setter
private String name;
@Setter
private long revision;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();

View File

@@ -18,6 +18,12 @@ public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private Integer preferredDayParts;
@Setter
private boolean finalized;
@Setter
private Map<DanceGroupType, Integer> appearances;

View File

@@ -1,3 +1,21 @@
package nl.agilecc.agiledancers.model.scheduling;
public record GroupRequirement(long groupId, int appearances) {}
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
public record GroupRequirement(
long groupId,
int appearances,
boolean showcase,
Set<Long> dancerIds,
Set<DanceGroupStyle> styles,
double averageAge) {
public GroupRequirement {
dancerIds = Set.copyOf(dancerIds);
styles = Set.copyOf(styles);
}
public GroupRequirement(final long groupId, final int appearances) {
this(groupId, appearances, false, Set.of(), Set.of(), 0);
}
}

View File

@@ -0,0 +1,117 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Shared hard-rule validation for solver results and manually edited schedules. */
public final class ScheduleRules {
private ScheduleRules() {}
public static List<String> violations(final SchedulingInput input, final List<List<Long>> parts) {
final List<String> errors = new ArrayList<>();
if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) {
errors.add("Configure dayparts and preferredDayParts between 1 and the number of dayparts.");
}
if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) {
errors.add("Assignments must contain one list for every daypart.");
return List.copyOf(errors);
}
final Map<Long, GroupRequirement> groups = new HashMap<>();
input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g));
for (int p = 0; p < parts.size(); p++) {
validatePart(parts.get(p), groups, p, errors);
}
appearances(input, parts, errors);
attendance(input, parts, errors);
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
if (max - min > 2) {
errors.add("Daypart performance counts differ by more than two.");
}
return List.copyOf(errors);
}
private static void validatePart(
final List<Long> ids, final Map<Long, GroupRequirement> groups, final int part, final List<String> errors) {
if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) {
errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups.");
return;
}
final List<GroupRequirement> ordered = ids.stream().map(groups::get).toList();
showcases(ordered, part, errors);
gaps(ordered, part, errors);
}
private static void showcases(final List<GroupRequirement> groups, final int part, final List<String> errors) {
final long count = groups.stream().filter(GroupRequirement::showcase).count();
if (count == 0) {
return;
}
final boolean first = groups.getFirst().showcase();
final boolean last = groups.getLast().showcase();
if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) {
errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing.");
}
}
private static void gaps(final List<GroupRequirement> groups, final int part, final List<String> errors) {
final Map<Long, Integer> last = new HashMap<>();
for (int i = 0; i < groups.size(); i++) {
for (final long dancer : groups.get(i).dancerIds()) {
final Integer previous = last.put(dancer, i);
if (previous != null && i - previous < 3) {
errors.add("Daypart " + (part + 1) + ": dancer " + dancer
+ " needs two intervening performances before group "
+ groups.get(i).groupId() + ".");
}
}
}
}
private static void appearances(
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
for (final GroupRequirement group : input.groups()) {
if (parts.stream()
.filter((final List<Long> ids) -> ids.contains(group.groupId()))
.count()
!= group.appearances()) {
errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts.");
}
}
}
private static void attendance(
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
final Set<Long> exempt = new HashSet<>(input.coachDancerIds());
input.groups().stream()
.filter(GroupRequirement::showcase)
.forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds()));
final Map<Long, Set<Integer>> attendance = new HashMap<>();
for (final GroupRequirement group : input.groups()) {
recordAttendance(group, parts, attendance);
}
attendance.forEach((final Long dancer, final Set<Integer> dayparts) -> {
if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) {
errors.add(
"Dancer " + dancer + " exceeds preferredDayParts=" + input.preferredDayParts() + " dayparts.");
}
});
}
private static void recordAttendance(
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
for (int p = 0; p < parts.size(); p++) {
if (parts.get(p).contains(group.groupId())) {
for (final long dancer : group.dancerIds()) {
attendance
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
.add(p);
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingFailureKind {
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -1,9 +1,27 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.List;
import java.util.Set;
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
/**
* Immutable solver snapshot.
*
* @param coachDancerIds dancer registration IDs linked to coaches, used for participation exemptions
*/
public record SchedulingInput(
long showId,
long revision,
int dayParts,
List<GroupRequirement> groups,
int preferredDayParts,
Set<Long> coachDancerIds) {
public SchedulingInput {
groups = List.copyOf(groups);
coachDancerIds = Set.copyOf(coachDancerIds);
}
public SchedulingInput(
final long showId, final long revision, final int dayParts, final List<GroupRequirement> groups) {
this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of());
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingPhase {
PREPARING,
ATTENDANCE,
SPREAD,
VARIETY
}

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.model.scheduling;
/** Progress is observational; intermediate assignments are never published or applied. */
public record SchedulingProgress(SchedulingPhase phase, boolean feasible) {}

View File

@@ -0,0 +1,60 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.time.Instant;
import java.util.UUID;
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
public record SchedulingRun(
UUID runId,
long schoolId,
long showId,
String showName,
Instant startedAt,
Instant finishedAt,
SchedulingState state,
SchedulingProgress progress,
String message,
SchedulingSummary summary) {
public static SchedulingRun started(final long schoolId, final long showId, final String name) {
return new SchedulingRun(
UUID.randomUUID(),
schoolId,
showId,
name,
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
null,
SchedulingState.RUNNING,
new SchedulingProgress(SchedulingPhase.PREPARING, false),
"Berekening gestart.",
null);
}
public SchedulingRun progressed(final SchedulingProgress update) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
null,
state,
new SchedulingProgress(update.phase(), progress.feasible() || update.feasible()),
message,
null);
}
public SchedulingRun completed(
final SchedulingState outcome, final String explanation, final SchedulingSummary scores) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
outcome,
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
explanation,
scores);
}
}

View File

@@ -0,0 +1,13 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingState {
RUNNING,
INTERRUPTED,
OPTIMAL,
VALID,
STALE,
INVALID_INPUT,
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Scores match the solver objectives; lower values are better. */
public record SchedulingSummary(long attendanceExcess, int performanceSpread, long varietyPenalty) {
public static SchedulingSummary of(final SchedulingInput input, final SchedulingResult result) {
final List<List<Long>> parts = result.assignments();
final Map<Long, GroupRequirement> groups = new HashMap<>();
input.groups().forEach((final GroupRequirement group) -> groups.put(group.groupId(), group));
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
return new SchedulingSummary(attendance(input, parts), max - min, variety(parts, groups));
}
private static long attendance(final SchedulingInput input, final List<List<Long>> parts) {
final Set<Long> showcases = new HashSet<>();
final Map<Long, Set<Integer>> attendance = new HashMap<>();
for (final GroupRequirement group : input.groups()) {
if (group.showcase()) {
showcases.addAll(group.dancerIds());
} else {
recordAttendance(group, parts, attendance);
}
}
showcases.removeAll(input.coachDancerIds());
return showcases.stream()
.mapToLong((final Long dancer) ->
Math.max(0, attendance.getOrDefault(dancer, Set.of()).size() - input.preferredDayParts()))
.sum();
}
private static void recordAttendance(
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
for (int part = 0; part < parts.size(); part++) {
if (parts.get(part).contains(group.groupId())) {
for (final long dancer : group.dancerIds()) {
attendance
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
.add(part);
}
}
}
}
private static long variety(final List<List<Long>> parts, final Map<Long, GroupRequirement> groups) {
long penalty = 0;
for (final List<Long> part : parts) {
for (int index = 1; index < part.size(); index++) {
penalty += VarietyScore.penalty(groups.get(part.get(index - 1)), groups.get(part.get(index)));
}
}
return penalty;
}
}

View File

@@ -0,0 +1,20 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.HashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
public final class VarietyScore {
private VarietyScore() {}
/** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */
public static long penalty(final GroupRequirement a, final GroupRequirement b) {
final Set<DanceGroupStyle> union = new HashSet<>(a.styles());
union.addAll(b.styles());
final Set<DanceGroupStyle> shared = new HashSet<>(a.styles());
shared.retainAll(b.styles());
final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size();
final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5);
return Math.round(1_000_000 * style) + Math.round(1_000_000 * age);
}
}

View File

@@ -7,11 +7,19 @@ import nl.agilecc.agiledancers.model.AbstractEntity;
public abstract class AbstractRepository<T extends AbstractEntity> {
private long sequence = 0L;
private java.util.function.LongSupplier identifiers;
protected final Map<Long, T> entities = new LinkedHashMap<>();
public long nextId() {
return sequence++;
return identifiers == null ? sequence++ : identifiers.getAsLong();
}
/** Replaces detached transaction state; production identifiers come from PostgreSQL. */
public void load(final java.util.Collection<T> values, final java.util.function.LongSupplier identifiers) {
entities.clear();
values.forEach((final T entity) -> entities.put(entity.getId(), entity));
this.identifiers = identifiers;
}
public T add(final T entity) {

View File

@@ -0,0 +1,113 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.OptimisticLockException;
import jakarta.persistence.PersistenceException;
import jakarta.transaction.Status;
import jakarta.transaction.TransactionManager;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import nl.agilecc.agiledancers.repository.entity.EntityIdentifiers;
/** Owns short JTA units of work; Hibernate flushes before commit and publication follows durable commit. */
@Singleton
public class Database {
private final EntityManager entities;
private final TransactionManager manager;
private final ThreadLocal<List<Runnable>> committed = new ThreadLocal<>();
private final ThreadLocal<List<Runnable>> rolledBack = new ThreadLocal<>();
@Inject
public Database(final EntityManager entities, final TransactionManager manager) {
this.entities = entities;
this.manager = manager;
}
/**
* Runs work in a new transaction, or joins the current JTA transaction. Callback registration is supported only
* inside transactions started by this instance. Commit callbacks run in registration order after durable commit;
* their exceptions propagate to the caller without rolling back committed work or running remaining callbacks.
* Rollback callbacks run before the original failure is rethrown; a callback exception replaces that failure.
*/
public <T> T transaction(final Callable<T> work) throws Exception {
try {
if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
return work.call();
}
return outerTransaction(work);
} catch (final OptimisticLockException failure) {
throw new RevisionConflictException();
} catch (final PersistenceException
| jakarta.transaction.SystemException
| jakarta.transaction.RollbackException
| jakarta.transaction.HeuristicMixedException
| jakarta.transaction.HeuristicRollbackException
| jakarta.transaction.NotSupportedException failure) {
if (optimisticConflict(failure)) {
throw new RevisionConflictException();
}
System.getLogger(Database.class.getName())
.log(System.Logger.Level.WARNING, "JPA transaction failed", failure);
throw new StorageException(failure);
}
}
private static boolean optimisticConflict(final Throwable failure) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof OptimisticLockException) {
return true;
}
}
return false;
}
private <T> T outerTransaction(final Callable<T> work) throws Exception {
final List<Runnable> publications = new ArrayList<>();
manager.begin();
committed.set(publications);
rolledBack.set(new ArrayList<>());
final T result;
try {
result = work.call();
manager.commit();
} catch (final Exception | Error failure) {
rollback(failure);
rolledBack.get().forEach(Runnable::run);
throw failure;
} finally {
committed.remove();
rolledBack.remove();
}
publications.forEach(Runnable::run);
return result;
}
private void rollback(final Throwable failure) {
try {
if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
manager.rollback();
}
} catch (final jakarta.transaction.SystemException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
/** Registers a callback on this thread's transaction owned by this instance; see {@link #transaction(Callable)}. */
public void afterCommit(final Runnable action) {
committed.get().add(action);
}
/** Registers a rollback callback under the same ownership requirement as {@link #afterCommit(Runnable)}. */
public void afterRollback(final Runnable action) {
rolledBack.get().add(action);
}
public long nextId() {
return entities.find(EntityIdentifiers.class, 1, LockModeType.PESSIMISTIC_WRITE)
.claim();
}
}

View File

@@ -0,0 +1,62 @@
package nl.agilecc.agiledancers.repository;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.entity.CoachEntity;
import nl.agilecc.agiledancers.repository.entity.GroupEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
public class GroupMapping {
public static Map<Long, DanceGroup> groups(
final SchoolEntity stored,
final DanceSchool school,
final Map<Long, Coach> coaches,
final Map<Long, Dancer> dancers) {
final Map<Long, DanceGroup> result = new LinkedHashMap<>();
for (final GroupEntity entity : stored.getGroups()) {
final DanceGroup group = DanceGroup.builder()
.category(entity.getCategory())
.type(entity.getType())
.startAge(entity.getStartAge())
.endAge(entity.getEndAge())
.coach(
entity.getCoach() == null
? null
: coaches.get(entity.getCoach().getId().getLocalId()))
.styles(new LinkedHashSet<>(entity.getStyles()))
.build();
group.setId(entity.getId().getLocalId());
entity.getDancerIds().stream().sorted().forEach((final Long id) -> group.addDancer(dancers.get(id)));
school.addDanceGroup(group);
result.put(group.getId(), group);
}
return result;
}
public static GroupEntity group(final SchoolEntity school, final DanceGroup group) {
final GroupEntity entity = new GroupEntity();
entity.setId(new SchoolMemberId(school.getId(), group.getId()));
entity.setSchool(school);
entity.setCategory(group.getCategory());
entity.setType(group.getType());
entity.setStartAge(group.getStartAge());
entity.setEndAge(group.getEndAge());
if (group.getCoach() != null) {
entity.setCoach(school.getCoaches().stream()
.filter((final CoachEntity coach) ->
coach.getId().getLocalId() == group.getCoach().getId())
.findFirst()
.orElseThrow());
}
entity.setStyles(new LinkedHashSet<>(group.getStyles()));
group.getDancers()
.forEach((final Dancer dancer) -> entity.getDancerIds().add(dancer.getId()));
return entity;
}
}

View File

@@ -0,0 +1,70 @@
package nl.agilecc.agiledancers.repository;
import java.util.LinkedHashMap;
import java.util.Map;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.entity.CoachEntity;
import nl.agilecc.agiledancers.repository.entity.DancerEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
/** Converts people while preserving the shared dancer identity used by coach links. */
public class PeopleMapping {
public static Map<Long, Dancer> dancers(final SchoolEntity stored, final DanceSchool school) {
final Map<Long, Dancer> result = new LinkedHashMap<>();
for (final DancerEntity person : stored.getDancers()) {
final Dancer dancer = Dancer.builder()
.name(person.getName())
.birthDate(person.getBirthDate())
.build();
dancer.setId(person.getId().getLocalId());
school.addDancer(dancer);
result.put(dancer.getId(), dancer);
}
return result;
}
public static Map<Long, Coach> coaches(
final SchoolEntity stored, final DanceSchool school, final Map<Long, Dancer> dancers) {
final Map<Long, Coach> result = new LinkedHashMap<>();
for (final CoachEntity trainer : stored.getCoaches()) {
final Coach coach = Coach.builder()
.name(trainer.getName())
.dancer(
trainer.getDancer() == null
? null
: dancers.get(trainer.getDancer().getId().getLocalId()))
.build();
coach.setId(trainer.getId().getLocalId());
school.addCoach(coach);
result.put(coach.getId(), coach);
}
return result;
}
public static DancerEntity dancer(final SchoolEntity school, final Dancer dancer) {
final DancerEntity entity = new DancerEntity();
entity.setId(new SchoolMemberId(school.getId(), dancer.getId()));
entity.setSchool(school);
entity.setName(dancer.getName());
entity.setBirthDate(dancer.getBirthDate());
return entity;
}
public static CoachEntity coach(final SchoolEntity school, final Coach coach) {
final CoachEntity entity = new CoachEntity();
entity.setId(new SchoolMemberId(school.getId(), coach.getId()));
entity.setSchool(school);
entity.setName(coach.getName());
if (coach.getDancer() != null) {
entity.setDancer(school.getDancers().stream()
.filter((final DancerEntity dancer) ->
dancer.getId().getLocalId() == coach.getDancer().getId())
.findFirst()
.orElseThrow());
}
return entity;
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.repository;
/** An optimistic persistence conflict requires a new editor snapshot, never an automatic retry. */
public class RevisionConflictException extends RuntimeException {
public RevisionConflictException() {
super("Deze school is gewijzigd. Herlaad de gegevens en probeer opnieuw.");
}
}

View File

@@ -0,0 +1,78 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.repository.entity.RunEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
@Singleton
public class RunRepository {
private final EntityManager entities;
@Inject
public RunRepository(final EntityManager entities) {
this.entities = entities;
}
public List<SchedulingRun> load() {
return entities
.createQuery("from StoredRun run order by run.id.schoolId,run.id.localId", RunEntity.class)
.getResultList()
.stream()
.map(this::read)
.toList();
}
public void recover() {
load().stream()
.filter((final SchedulingRun run) -> run.state() == SchedulingState.RUNNING)
.forEach((final SchedulingRun run) -> save(run.completed(
SchedulingState.INTERRUPTED,
"Berekening onderbroken door een backendherstart. Geen nieuw resultaat toegepast; bestaande"
+ " indeling behouden. Start de berekening opnieuw.",
null)));
}
public void save(final SchedulingRun run) {
final RunEntity entity = new RunEntity();
entity.setId(new SchoolMemberId(run.schoolId(), run.showId()));
entity.setRunId(run.runId());
entity.setShowName(run.showName());
entity.setStartedAt(run.startedAt());
entity.setFinishedAt(run.finishedAt());
entity.setState(run.state());
entity.setPhase(run.progress().phase());
entity.setFeasible(run.progress().feasible());
entity.setMessage(run.message());
if (run.summary() != null) {
entity.setAttendanceExcess(run.summary().attendanceExcess());
entity.setPerformanceSpread(run.summary().performanceSpread());
entity.setVarietyPenalty(run.summary().varietyPenalty());
}
entities.merge(entity);
}
private SchedulingRun read(final RunEntity entity) {
final SchedulingSummary summary = entity.getAttendanceExcess() == null
? null
: new SchedulingSummary(
entity.getAttendanceExcess(), entity.getPerformanceSpread(), entity.getVarietyPenalty());
return new SchedulingRun(
entity.getRunId(),
entity.getId().getSchoolId(),
entity.getId().getLocalId(),
entity.getShowName(),
entity.getStartedAt(),
entity.getFinishedAt(),
entity.getState(),
new SchedulingProgress(entity.getPhase(), entity.isFeasible()),
entity.getMessage(),
summary);
}
}

View File

@@ -0,0 +1,47 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.ShowEntity;
/** Exposes detached domain snapshots, keeping Hibernate sessions and lazy collections inside the adapter. */
@Singleton
public class SchoolReader {
private final EntityManager entities;
@Inject
public SchoolReader(final EntityManager entities) {
this.entities = entities;
}
public List<DanceSchool> load() {
return entities
.createQuery("from StoredSchool school order by school.id", SchoolEntity.class)
.getResultList()
.stream()
.map(this::school)
.toList();
}
private DanceSchool school(final SchoolEntity entity) {
final DanceSchool school = DanceSchool.builder()
.key(entity.getKey())
.name(entity.getName())
.revision(entity.getRevision())
.build();
school.setId(entity.getId());
final Map<Long, Dancer> dancers = PeopleMapping.dancers(entity, school);
final Map<Long, Coach> coaches = PeopleMapping.coaches(entity, school, dancers);
final Map<Long, DanceGroup> groups = GroupMapping.groups(entity, school, coaches, dancers);
entity.getShows().forEach((final ShowEntity show) -> school.addShow(ShowMapping.show(show, groups)));
return school;
}
}

View File

@@ -0,0 +1,82 @@
package nl.agilecc.agiledancers.repository;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import java.util.List;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.repository.entity.RunEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
/** Hibernate merges the aggregate graph and handles child differences, orphan removal and optimistic versioning. */
@Singleton
public class SchoolWriter {
private final EntityManager entities;
@Inject
public SchoolWriter(final EntityManager entities) {
this.entities = entities;
}
public void checkRevision(final long schoolId, final long expected) {
final SchoolEntity school = entities.find(SchoolEntity.class, schoolId, LockModeType.OPTIMISTIC);
if (school == null || school.getRevision() != expected) {
throw new RevisionConflictException();
}
}
public void save(final DanceSchool school) {
final SchoolEntity existing = entities.find(SchoolEntity.class, school.getId());
final SchoolEntity graph = graph(school);
if (existing == null) {
entities.persist(graph);
} else {
removeDeletedRuns(school);
graph.setRevision(existing.getRevision());
graph.setChangeStamp(existing.getChangeStamp() + 1);
entities.merge(graph);
}
entities.flush();
}
private SchoolEntity graph(final DanceSchool school) {
final SchoolEntity graph = new SchoolEntity();
graph.setId(school.getId());
graph.setKey(school.getKey());
graph.setName(school.getName());
school.getDancers()
.forEach((final Dancer dancer) -> graph.getDancers().add(PeopleMapping.dancer(graph, dancer)));
school.getCoaches().forEach((final Coach coach) -> graph.getCoaches().add(PeopleMapping.coach(graph, coach)));
school.getDanceGroups()
.forEach((final DanceGroup group) -> graph.getGroups().add(GroupMapping.group(graph, group)));
school.getShows().forEach((final Show show) -> graph.getShows().add(ShowMapping.show(graph, show)));
return graph;
}
private void removeDeletedRuns(final DanceSchool school) {
final List<Long> shows = school.getShows().stream().map(Show::getId).toList();
entities
.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
.setParameter("school", school.getId())
.getResultList()
.stream()
.filter((final RunEntity run) -> !shows.contains(run.getId().getLocalId()))
.forEach(entities::remove);
}
public void delete(final long schoolId) {
entities.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
.setParameter("school", schoolId)
.getResultList()
.forEach(entities::remove);
final SchoolEntity school = entities.find(SchoolEntity.class, schoolId);
if (school != null) {
entities.remove(school);
}
}
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.repository;
import java.util.LinkedHashMap;
import java.util.Map;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.repository.entity.DayPartEntity;
import nl.agilecc.agiledancers.repository.entity.DayPartId;
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
import nl.agilecc.agiledancers.repository.entity.ShowEntity;
public class ShowMapping {
public static Show show(final ShowEntity entity, final Map<Long, DanceGroup> groups) {
final Show show = Show.builder()
.name(entity.getName())
.preferredDayParts(entity.getPreferredDayParts())
.finalized(entity.isFinalized())
.appearances(Map.copyOf(entity.getAppearances()))
.dayParts(entity.getDayParts().stream()
.map((final DayPartEntity part) -> DayPart.builder()
.date(part.getDate())
.type(part.getType())
.danceGroups(part.getGroupIds().stream()
.map(groups::get)
.toList())
.build())
.toList())
.build();
show.setId(entity.getId().getLocalId());
return show;
}
public static ShowEntity show(final SchoolEntity school, final Show show) {
final ShowEntity entity = new ShowEntity();
entity.setId(new SchoolMemberId(school.getId(), show.getId()));
entity.setSchool(school);
entity.setName(show.getName());
entity.setPreferredDayParts(show.getPreferredDayParts());
entity.setFinalized(show.isFinalized());
entity.setAppearances(new LinkedHashMap<>(show.getAppearances()));
for (int index = 0; index < show.getDayParts().size(); index++) {
entity.getDayParts().add(part(entity, index, show.getDayParts().get(index)));
}
return entity;
}
private static DayPartEntity part(final ShowEntity show, final int position, final DayPart part) {
final DayPartEntity entity = new DayPartEntity();
entity.setId(new DayPartId(show.getId().getSchoolId(), show.getId().getLocalId(), position));
entity.setShow(show);
entity.setDate(part.getDate());
entity.setType(part.getType());
part.getDanceGroups()
.forEach((final DanceGroup group) -> entity.getGroupIds().add(group.getId()));
return entity;
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.repository;
/** Storage failures never authorize an in-memory fallback. */
public class StorageException extends RuntimeException {
public StorageException(final Exception cause) {
super("Database niet beschikbaar. De wijziging is niet opgeslagen; herlaad en probeer opnieuw.", cause);
}
}

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "StoredCoach")
@Table(name = "coach")
public class CoachEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String name;
// The shared school key is read from the owning row; only the linked member ID is writable.
@ManyToOne(
fetch = FetchType.LAZY,
cascade = {CascadeType.MERGE, CascadeType.PERSIST})
@org.hibernate.annotations.JoinColumnsOrFormulas({
@org.hibernate.annotations.JoinColumnOrFormula(
column = @JoinColumn(name = "dancer_id", referencedColumnName = "id")),
@org.hibernate.annotations.JoinColumnOrFormula(
formula =
@org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
})
private DancerEntity dancer;
}

View File

@@ -0,0 +1,20 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "StoredDancer")
@Table(name = "dancer")
public class DancerEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String name;
@Column(name = "birth_date", nullable = false)
private LocalDate birthDate;
}

View File

@@ -0,0 +1,56 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinColumns;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
@Getter
@Setter
@Entity(name = "StoredDayPart")
@Table(name = "day_part")
public class DayPartEntity {
@EmbeddedId
private DayPartId id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns({
@JoinColumn(name = "school_id", referencedColumnName = "school_id", insertable = false, updatable = false),
@JoinColumn(name = "show_id", referencedColumnName = "id", insertable = false, updatable = false)
})
private ShowEntity show;
@Column(name = "day", nullable = false)
private LocalDate date;
@Enumerated(EnumType.STRING)
@Column(name = "part_type", nullable = false)
private DayPartType type;
@ElementCollection
@CollectionTable(
name = "assignment",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "show_id", referencedColumnName = "show_id"),
@JoinColumn(name = "part_position", referencedColumnName = "position")
})
@OrderColumn(name = "position")
@Column(name = "group_id", nullable = false)
private List<Long> groupIds = new ArrayList<>();
}

View File

@@ -0,0 +1,26 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Embeddable
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode
public class DayPartId implements Serializable {
@jakarta.persistence.Column(name = "school_id")
private long schoolId;
@jakarta.persistence.Column(name = "show_id")
private long showId;
@jakarta.persistence.Column(name = "position")
private int position;
}

View File

@@ -0,0 +1,24 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "EntityIdentifiers")
@Table(name = "entity_identifiers")
public class EntityIdentifiers {
@Id
private int id;
@Column(name = "next_value", nullable = false)
private long nextValue;
public long claim() {
return nextValue++;
}
}

View File

@@ -0,0 +1,72 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@Setter
@Entity(name = "StoredGroup")
@Table(name = "dance_group")
public class GroupEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String category;
@Enumerated(EnumType.STRING)
@Column(name = "group_type", nullable = false)
private DanceGroupType type;
@Column(name = "start_age", nullable = false)
private int startAge;
@Column(name = "end_age", nullable = false)
private int endAge;
// The shared school key is read from the owning row; only the linked member ID is writable.
@ManyToOne(
fetch = FetchType.LAZY,
cascade = {CascadeType.MERGE, CascadeType.PERSIST})
@org.hibernate.annotations.JoinColumnsOrFormulas({
@org.hibernate.annotations.JoinColumnOrFormula(
column = @JoinColumn(name = "coach_id", referencedColumnName = "id")),
@org.hibernate.annotations.JoinColumnOrFormula(
formula =
@org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
})
private CoachEntity coach;
@ElementCollection
@CollectionTable(
name = "group_style",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "group_id", referencedColumnName = "id")
})
@Enumerated(EnumType.STRING)
@Column(name = "style", nullable = false)
private Set<DanceGroupStyle> styles = new LinkedHashSet<>();
@ElementCollection
@CollectionTable(
name = "group_dancer",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "group_id", referencedColumnName = "id")
})
@Column(name = "dancer_id", nullable = false)
private Set<Long> dancerIds = new LinkedHashSet<>();
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
@Getter
@Setter
@Entity(name = "StoredRun")
@Table(name = "scheduling_run")
public class RunEntity {
@EmbeddedId
@AttributeOverride(name = "localId", column = @Column(name = "show_id"))
private SchoolMemberId id;
@Column(name = "run_id", nullable = false)
private UUID runId;
@Column(name = "show_name", nullable = false)
private String showName;
@Column(name = "started_at", nullable = false)
private Instant startedAt;
@Column(name = "finished_at")
private Instant finishedAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SchedulingState state;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SchedulingPhase phase;
private boolean feasible;
@Column(nullable = false)
private String message;
@Column(name = "attendance_excess")
private Long attendanceExcess;
@Column(name = "performance_spread")
private Integer performanceSpread;
@Column(name = "variety_penalty")
private Long varietyPenalty;
}

View File

@@ -0,0 +1,52 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(name = "StoredSchool")
@Table(name = "school")
public class SchoolEntity {
@Id
private long id;
@Column(name = "school_key", nullable = false)
private UUID key;
@Column(nullable = false)
private String name;
@Version
private long revision;
// Marks child-only changes so Hibernate advances the aggregate version exactly once.
@Column(name = "change_stamp", nullable = false)
private long changeStamp;
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<DancerEntity> dancers = new ArrayList<>();
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<CoachEntity> coaches = new ArrayList<>();
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<GroupEntity> groups = new ArrayList<>();
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.localId")
private List<ShowEntity> shows = new ArrayList<>();
}

View File

@@ -0,0 +1,21 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@MappedSuperclass
public abstract class SchoolMemberEntity {
@EmbeddedId
private SchoolMemberId id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id", insertable = false, updatable = false)
private SchoolEntity school;
}

View File

@@ -0,0 +1,23 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Embeddable
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode
public class SchoolMemberId implements Serializable {
@jakarta.persistence.Column(name = "school_id")
private long schoolId;
@jakarta.persistence.Column(name = "id")
private long localId;
}

View File

@@ -0,0 +1,51 @@
package nl.agilecc.agiledancers.repository.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapKeyColumn;
import jakarta.persistence.MapKeyEnumerated;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@Setter
@Entity(name = "StoredShow")
@Table(name = "show")
public class ShowEntity extends SchoolMemberEntity {
@Column(nullable = false)
private String name;
@Column(name = "preferred_day_parts", nullable = false)
private int preferredDayParts;
private boolean finalized;
@ElementCollection
@CollectionTable(
name = "show_appearance",
joinColumns = {
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
@JoinColumn(name = "show_id", referencedColumnName = "id")
})
@MapKeyEnumerated(EnumType.STRING)
@MapKeyColumn(name = "group_type")
@Column(name = "appearances", nullable = false)
private Map<DanceGroupType, Integer> appearances = new LinkedHashMap<>();
@OneToMany(mappedBy = "show", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.position")
private List<DayPartEntity> dayParts = new ArrayList<>();
}

View File

@@ -0,0 +1,52 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.CoachLinks;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/coaches")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CoachResource {
private @Inject DomainViewMapper views;
private @Inject CoachLinks links;
private @PathParam("schoolId") long schoolId;
@GET
public List<CoachView> list() {
return links.list(schoolId).stream().map(views::coach).toList();
}
@POST
public CoachView create(final @NotNull @Valid CoachRequest request) {
return views.coach(links.link(schoolId, null, request.dancerId()));
}
@PUT
@Path("/{id}")
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
return views.coach(links.link(schoolId, id, request.dancerId()));
}
@DELETE
@Path("/{id}/dancer")
public void unlink(final @PathParam("id") long id) {
links.unlink(schoolId, id);
}
}

View File

@@ -19,26 +19,30 @@ import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceGroupResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<GroupView> list() {
return service.groups(schoolId).stream().map(GroupView::of).toList();
return service.groups(schoolId).stream().map(views::group).toList();
}
@GET
@Path("/{id}")
public GroupView get(final @PathParam("id") long id) {
return GroupView.of(service.group(schoolId, id));
return views.group(service.group(schoolId, id));
}
@POST
@@ -50,14 +54,14 @@ public class DanceGroupResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(GroupView.of(entity))
.entity(views.group(entity))
.build();
}
@PUT
@Path("/{id}")
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
return GroupView.of(service.updateGroup(
return views.group(service.updateGroup(
schoolId,
id,
new GroupDetails(

View File

@@ -19,23 +19,44 @@ import java.util.List;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceSchoolResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject nl.agilecc.agiledancers.service.ShowSchedules schedules;
@GET
@Path("/{id}/data")
public nl.agilecc.agiledancers.rest.apimodel.SchoolDataView data(final @PathParam("id") long id) {
final DanceSchool school = service.school(id);
return new nl.agilecc.agiledancers.rest.apimodel.SchoolDataView(
views.school(school),
school.getDancers().stream().map(views::dancer).toList(),
school.getDanceGroups().stream().map(views::group).toList(),
school.getShows().stream()
.map((final nl.agilecc.agiledancers.model.Show show) ->
views.show(show, schedules.violations(id, show.getId())))
.toList(),
school.getCoaches().stream().map(views::coach).toList());
}
@GET
public List<SchoolView> list() {
return service.schools().stream().map(SchoolView::of).toList();
return service.schools().stream().map(views::school).toList();
}
@GET
@Path("/{id}")
public SchoolView get(final @PathParam("id") long id) {
return SchoolView.of(service.school(id));
return views.school(service.school(id));
}
@POST
@@ -44,14 +65,14 @@ public class DanceSchoolResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(SchoolView.of(entity))
.entity(views.school(entity))
.build();
}
@PUT
@Path("/{id}")
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
return SchoolView.of(service.updateSchool(id, request.name()));
return views.school(service.updateSchool(id, request.name()));
}
@DELETE

View File

@@ -19,25 +19,29 @@ import java.util.List;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DancerResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<DancerView> list() {
return service.dancers(schoolId).stream().map(DancerView::of).toList();
return service.dancers(schoolId).stream().map(views::dancer).toList();
}
@GET
@Path("/{id}")
public DancerView get(final @PathParam("id") long id) {
return DancerView.of(service.dancer(schoolId, id));
return views.dancer(service.dancer(schoolId, id));
}
@POST
@@ -46,14 +50,14 @@ public class DancerResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(DancerView.of(entity))
.entity(views.dancer(entity))
.build();
}
@PUT
@Path("/{id}")
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
return views.dancer(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
}
@DELETE

View File

@@ -13,6 +13,7 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
public Response toResponse(final ManagementException exception) {
final int status = switch (exception.getKind()) {
case NOT_FOUND -> 404;
case STORAGE_UNAVAILABLE -> 503;
case INVALID_INPUT -> 400;
case CONFLICT -> 409;
};

View File

@@ -0,0 +1,44 @@
package nl.agilecc.agiledancers.rest;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.subscription.BackPressureStrategy;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Duration;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import org.jboss.resteasy.reactive.RestStreamElementType;
@Path("/api/scheduling/events")
public class SchedulingEventsResource {
private @Inject SchedulingRuns scheduling;
@GET
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<SchedulingEventView> events() {
final Multi<SchedulingEventView> updates = Multi.createFrom()
.emitter(
(final io.smallrye.mutiny.subscription.MultiEmitter<? super SchedulingEventView> emitter) -> {
final Runnable unsubscribe = scheduling.subscribe(
(final List<SchedulingRun> runs) -> emitter.emit(new SchedulingEventView(runs)));
emitter.onTermination(unsubscribe);
},
BackPressureStrategy.LATEST);
// Heartbeats keep idle proxy connections alive without changing the current snapshot.
final Multi<SchedulingEventView> heartbeat = Multi.createFrom()
.ticks()
.every(Duration.ofSeconds(15))
.onItem()
.transform((final Long tick) -> new SchedulingEventView(null));
return Multi.createBy()
.merging()
.streams(updates, heartbeat)
.runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool());
}
}

View File

@@ -0,0 +1,50 @@
package nl.agilecc.agiledancers.rest;
import io.vertx.ext.web.RoutingContext;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchoolTransactions;
import nl.agilecc.agiledancers.service.model.SchoolChange;
@SchoolTransactionBoundary
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class SchoolRequestInterceptor {
private static final Pattern SCHOOL_PATH = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
private @Inject RoutingContext context;
private @Inject SchoolTransactions transactions;
@AroundInvoke
public Object invoke(final InvocationContext invocation) throws Exception {
final String path = context.request().path();
final String method = context.request().method().name();
final boolean mutation = !method.equals("GET") && !path.endsWith("/preview");
final Matcher matcher = SCHOOL_PATH.matcher(path);
final Long schoolId = matcher.find()
? Long.valueOf(matcher.group(1))
: number(context.request().getParam("replaceId"));
final Long revision = mutation ? number(context.request().getHeader("X-School-Revision")) : null;
return transactions.request(
new SchoolChange(schoolId, revision, mutation, mutation && !path.endsWith("/schedule")),
invocation::proceed);
}
private static Long number(final String value) {
if (value == null) {
return null;
}
try {
return Long.valueOf(value);
} catch (final NumberFormatException failure) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Ongeldige schoolversie of school-ID.");
}
}
}

View File

@@ -15,15 +15,19 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.ImportPreview;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
public class SchoolSnapshotResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject ObjectMapper mapper;
@@ -48,7 +52,7 @@ public class SchoolSnapshotResource {
@Path("/import")
@Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) {
return SchoolView.of(service.importSchool(parse(json), replaceId));
return views.school(service.importSchool(parse(json), replaceId));
}
private SchoolSnapshot parse(final String json) {

View File

@@ -0,0 +1,13 @@
package nl.agilecc.agiledancers.rest;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Bounds school HTTP operations to one durable transaction and optimistic revision check. */
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface SchoolTransactionBoundary {}

View File

@@ -18,49 +18,81 @@ import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject SchedulingService scheduling;
private @Inject SchedulingRuns scheduling;
private @Inject ShowSchedules schedules;
private @PathParam("schoolId") long schoolId;
@GET
public List<ShowView> list() {
return service.shows(schoolId).stream().map(ShowView::of).toList();
return service.shows(schoolId).stream().map(this::view).toList();
}
@GET
@Path("/{id}")
public ShowView get(final @PathParam("id") long id) {
return ShowView.of(service.show(schoolId, id));
return view(service.show(schoolId, id));
}
@POST
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
final Show entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
final Show entity = schedules.configure(schoolId, null, settings(request));
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(ShowView.of(entity))
.entity(view(entity))
.build();
}
@PUT
@Path("/{id}")
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
return view(schedules.configure(schoolId, id, settings(request)));
}
private ShowView view(final Show show) {
return views.show(show, schedules.violations(schoolId, show.getId()));
}
private static Show settings(final ShowRequest request) {
return Show.builder()
.name(request.name())
.preferredDayParts(request.preferredDayParts())
.dayParts(parts(request))
.build();
}
@PUT
@Path("/{id}/assignments")
public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) {
return view(schedules.saveDraft(schoolId, id, request.assignments()));
}
@POST
@Path("/{id}/finalize")
@Consumes(MediaType.WILDCARD)
public ShowView finalizeSchedule(final @PathParam("id") long id) {
return view(schedules.finalizeSchedule(schoolId, id));
}
private static List<DayPart> parts(final ShowRequest request) {
@@ -76,10 +108,14 @@ public class ShowResource {
@POST
@Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD)
public SchedulingView schedule(final @PathParam("id") long id) {
final SchedulingResult result = scheduling.schedule(schoolId, id);
return new SchedulingView(
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
public Response schedule(final @PathParam("id") long id) {
return Response.accepted(scheduling.start(schoolId, id)).build();
}
@GET
@Path("/{id}/schedule")
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
return scheduling.status(schoolId, id);
}
@DELETE

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
public record CoachRequest(@NotNull Long dancerId) {}

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.rest.apimodel;
public record CoachView(long id, String name, Long dancerId) {}

View File

@@ -1,10 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.Dancer;
public record DancerView(long id, String name, LocalDate birthDate) {
public static DancerView of(final Dancer dancer) {
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
}
}
public record DancerView(long id, String name, LocalDate birthDate) {}

View File

@@ -5,8 +5,6 @@ import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -22,15 +20,4 @@ public record GroupView(
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
}
public static GroupView of(final DanceGroup group) {
return new GroupView(
group.getId(),
group.getCategory(),
group.getType(),
group.getStartAge(),
group.getEndAge(),
Set.copyOf(group.getStyles()),
group.getDancers().stream().map(Dancer::getId).toList());
}
}

View File

@@ -0,0 +1,15 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record ScheduleRequest(@NotNull List<List<Long>> assignments) {
public ScheduleRequest {
assignments = assignments == null
? null
: assignments.stream()
.map((final List<Long> p) ->
p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p)))
.toList();
}
}

View File

@@ -0,0 +1,11 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
public record SchedulingEventView(List<SchedulingRun> runs) {
public SchedulingEventView {
runs = runs == null ? null : List.copyOf(runs);
}
}

View File

@@ -0,0 +1,18 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
/** One school and all editor data from the same revision and database snapshot. */
public record SchoolDataView(
SchoolView school,
List<DancerView> dancers,
List<GroupView> groups,
List<ShowView> shows,
List<CoachView> coaches) {
public SchoolDataView {
dancers = List.copyOf(dancers);
groups = List.copyOf(groups);
shows = List.copyOf(shows);
coaches = List.copyOf(coaches);
}
}

View File

@@ -3,24 +3,12 @@ package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.Show;
public record SchoolView(long id, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
public record SchoolView(
long id, long revision, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
public SchoolView {
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
}
public static SchoolView of(final DanceSchool school) {
return new SchoolView(
school.getId(),
school.getName(),
school.getDancers().stream().map(Dancer::getId).toList(),
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
school.getShows().stream().map(Show::getId).toList());
}
}

View File

@@ -13,7 +13,8 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(
@NotBlank String name,
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {
Map<DanceGroupType, Integer> appearances,
Integer preferredDayParts) {
public ShowRequest {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));

View File

@@ -5,29 +5,19 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
public record ShowView(
long id,
String name,
List<DayPartView> dayParts,
Map<DanceGroupType, Integer> appearances,
int preferredDayParts,
boolean finalized,
List<String> violations) {
public ShowView {
violations = List.copyOf(violations);
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
public static ShowView of(final Show show) {
return new ShowView(
show.getId(),
show.getName(),
show.getDayParts().stream()
.map((final DayPart p) -> new DayPartView(
p.getDate(),
p.getType(),
p.getDanceGroups().stream()
.map(DanceGroup::getId)
.toList()))
.toList(),
show.getAppearances());
}
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.rest.mapping;
import java.util.List;
import nl.agilecc.agiledancers.model.AbstractEntity;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.rest.apimodel.DayPartView;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingInputs;
import org.mapstruct.AnnotateWith;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.Named;
import org.mapstruct.ReportingPolicy;
/** Generates API projections only; domain graph identity and validation remain application-owned. */
// Mark generated implementations so coverage and architecture checks retain their handwritten-code scope.
@AnnotateWith(lombok.Generated.class)
@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface DomainViewMapper {
@Mapping(target = "dancerIds", source = "dancers")
@Mapping(target = "danceGroupIds", source = "danceGroups")
@Mapping(target = "showIds", source = "shows")
public SchoolView school(final DanceSchool school);
public DancerView dancer(final Dancer dancer);
@Mapping(target = "dancerIds", source = "dancers")
public GroupView group(final DanceGroup group);
@Mapping(target = "dancerId", source = "dancer")
public CoachView coach(final Coach coach);
@Mapping(target = "danceGroupIds", source = "danceGroups")
public DayPartView dayPart(final DayPart part);
/** Violations are calculated by the use case, never inferred by the mapper. */
@Mapping(target = "violations", source = "violations")
@Mapping(target = "preferredDayParts", source = "show", qualifiedByName = "preferredDayParts")
public ShowView show(final Show show, final List<String> violations);
public default Long id(final AbstractEntity entity) {
return entity == null ? null : entity.getId();
}
@Named("preferredDayParts")
public default int preferredDayParts(final Show show) {
return SchedulingInputs.preferred(show);
}
}

View File

@@ -0,0 +1,51 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
@Singleton
public class CoachLinks {
@Inject
private SchoolManagementService schools;
public List<Coach> list(final long schoolId) {
return List.copyOf(schools.school(schoolId).getCoaches());
}
public Coach link(final long schoolId, final Long coachId, final long dancerId) {
synchronized (schools) {
final DanceSchool school = schools.school(schoolId);
final Dancer dancer = schools.dancer(schoolId, dancerId);
final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId);
coach.setDancer(dancer);
school.getShows().forEach(schools::touch);
return coach;
}
}
private static Coach create(final DanceSchool school, final Dancer dancer) {
final Coach coach = Coach.builder().name(dancer.getName()).build();
coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1);
school.addCoach(coach);
return coach;
}
private static Coach required(final DanceSchool school, final long id) {
return school.getCoaches().stream()
.filter((final Coach c) -> c.getId() == id)
.findFirst()
.orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach"));
}
public void unlink(final long schoolId, final long id) {
synchronized (schools) {
final DanceSchool school = schools.school(schoolId);
required(school, id).setDancer(null);
school.getShows().forEach(schools::touch);
}
}
}

View File

@@ -2,6 +2,7 @@ package nl.agilecc.agiledancers.service;
public enum ManagementExceptionKind {
NOT_FOUND,
STORAGE_UNAVAILABLE,
INVALID_INPUT,
CONFLICT
}

View File

@@ -0,0 +1,17 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
/** Carries a stable calculation outcome independently of its human-readable explanation. */
public class SchedulingFailure extends ManagementException {
private final SchedulingFailureKind outcome;
public SchedulingFailure(final SchedulingFailureKind outcome, final String message) {
super(ManagementExceptionKind.INVALID_INPUT, message);
this.outcome = outcome;
}
public SchedulingFailureKind getOutcome() {
return outcome;
}
}

View File

@@ -0,0 +1,66 @@
package nl.agilecc.agiledancers.service;
import java.time.LocalDate;
import java.time.Period;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public final class SchedulingInputs {
private SchedulingInputs() {}
public static int preferred(final Show show) {
if (show.getPreferredDayParts() != null) {
return show.getPreferredDayParts();
}
return (show.getDayParts().size() == 1 ? 1 : 2);
}
public static SchedulingInput of(final DanceSchool school, final Show show, final long revision) {
final int preferredDayParts = preferred(show);
final LocalDate date = show.getDayParts().stream()
.map(DayPart::getDate)
.min(LocalDate::compareTo)
.orElse(LocalDate.now());
final List<GroupRequirement> groups = school.getDanceGroups().stream()
.filter((final DanceGroup g) -> !g.getDancers().isEmpty())
.map((final DanceGroup g) -> requirement(g, show, date))
.toList();
final Set<Long> coaches = school.getCoaches().stream()
.map(Coach::getDancer)
.filter(java.util.Objects::nonNull)
.map(Dancer::getId)
.collect(Collectors.toSet());
return new SchedulingInput(
show.getId(), revision, show.getDayParts().size(), groups, preferredDayParts, coaches);
}
private static GroupRequirement requirement(final DanceGroup group, final Show show, final LocalDate date) {
final int count = switch (group.getType()) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE -> show.getDayParts().size();
default -> preferred(show);
};
final double age = group.getDancers().stream()
.mapToInt((final Dancer d) ->
Period.between(d.getBirthDate(), date).getYears())
.average()
.orElse(0);
return new GroupRequirement(
group.getId(),
count,
group.getType() == DanceGroupType.SHOW_CASE,
group.getDancers().stream().map(Dancer::getId).collect(Collectors.toSet()),
group.getStyles(),
age);
}
}

View File

@@ -0,0 +1,54 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.concurrent.Callable;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.repository.Database;
import nl.agilecc.agiledancers.repository.RunRepository;
/** Coordinates scheduling status with the same unit of work as domain changes. */
@Singleton
public class SchedulingPersistence {
private final SchoolTransactions transactions;
private final Database database;
private final RunRepository runs;
@Inject
public SchedulingPersistence(
final SchoolTransactions transactions, final Database database, final RunRepository runs) {
this.transactions = transactions;
this.database = database;
this.runs = runs;
}
public List<SchedulingRun> recover() {
return transactions.background(() -> {
runs.recover();
return runs.load();
});
}
public List<SchedulingRun> load() {
return transactions.background(runs::load);
}
public <T> T work(final Callable<T> action) {
return transactions.background(action);
}
public void save(final SchedulingRun run, final Runnable publication) {
runs.save(run);
database.afterCommit(publication);
}
public void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) {
database.afterRollback(rollback);
save(run, launch);
}
public void result(final long schoolId) {
transactions.saveResult(schoolId);
}
}

View File

@@ -0,0 +1,240 @@
package nl.agilecc.agiledancers.service;
import io.quarkus.runtime.Startup;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
/** Owns background tasks; the school monitor orders short commits and publication, never solver computation. */
@Startup
@Singleton
public class SchedulingRuns {
private final SchoolManagementService schools;
private final SchedulingEngine engine;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map<Long, SchedulingRun> runs = new HashMap<>();
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
private @Inject SchedulingPersistence persistence;
@Inject
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
this.schools = schools;
this.engine = engine;
}
@PostConstruct
public void recover() {
persistence.recover().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
}
public SchedulingRun start(final long schoolId, final long showId) {
synchronized (schools) {
final SchedulingInput input = schools.beginScheduling(schoolId, showId);
final SchedulingRun run = SchedulingRun.started(
schoolId, showId, schools.show(schoolId, showId).getName());
final Runnable launch = () -> launch(run, input);
if (persistence == null) {
launch.run();
} else {
persistence.accepted(run, launch, () -> schools.endScheduling(showId));
}
return run;
}
}
private void launch(final SchedulingRun run, final SchedulingInput input) {
publish(run);
try {
executor.submit(() -> calculate(run, input));
} catch (final RuntimeException failure) {
finish(run.completed(
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
if (persistence == null) {
throw failure;
}
}
}
public SchedulingRun status(final long schoolId, final long showId) {
synchronized (schools) {
schools.show(schoolId, showId);
return runs.get(showId);
}
}
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
public Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
synchronized (schools) {
if (persistence != null) {
runs.clear();
persistence.load().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
}
final Object key = new Object();
listeners.put(key, listener);
listener.accept(List.copyOf(runs.values()));
return () -> unsubscribe(key);
}
}
private void unsubscribe(final Object key) {
synchronized (schools) {
listeners.remove(key);
}
}
private void publish(final SchedulingRun run) {
synchronized (schools) {
runs.put(run.showId(), run);
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
for (final Consumer<List<SchedulingRun>> listener : List.copyOf(listeners.values())) {
notifyListener(listener, snapshot);
}
}
}
private void notifyListener(final Consumer<List<SchedulingRun>> listener, final List<SchedulingRun> snapshot) {
try {
listener.accept(snapshot);
} catch (final RuntimeException disconnected) {
// A disconnected transport must not turn a committed solver outcome into a failure.
listeners.values().remove(listener);
}
}
private void progress(final SchedulingRun run, final SchedulingProgress update) {
work(() -> {
save(latest(run).progressed(update));
return null;
});
}
private void calculate(final SchedulingRun run, final SchedulingInput input) {
try {
final SchedulingResult result =
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
work(() -> {
schools.finishScheduling(run.schoolId(), input, result);
if (persistence != null) {
persistence.result(run.schoolId());
}
save(latest(run)
.completed(
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
result.optimal()
? "Optimaal conceptrooster."
: "Geldig conceptrooster; optimaliteit niet bewezen.",
summary));
return null;
});
schools.endScheduling(run.showId());
} catch (final ManagementException failure) {
finish(failed(run, failure));
} catch (final RuntimeException | LinkageError failure) {
finish(latest(run)
.completed(
SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null));
}
}
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
final SchedulingState outcome = failure instanceof final SchedulingFailure scheduling
? switch (scheduling.getOutcome()) {
case INFEASIBLE -> SchedulingState.INFEASIBLE;
case NO_SOLUTION -> SchedulingState.NO_SOLUTION;
case SOLVER_FAILED -> SchedulingState.SOLVER_FAILED;
}
: (failure.getKind() == ManagementExceptionKind.CONFLICT
? SchedulingState.STALE
: SchedulingState.INVALID_INPUT);
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
}
private SchedulingRun latest(final SchedulingRun run) {
synchronized (schools) {
return runs.get(run.showId());
}
}
private void finish(final SchedulingRun completed) {
try {
while (true) {
try {
work(() -> {
schools.show(completed.schoolId(), completed.showId());
save(completed);
return null;
});
return;
} catch (final StorageException unavailable) {
// Keep the run guard and terminal outcome until storage recovers; never publish an uncommitted
// status.
if (executor.isShutdown() || !waitForStorage()) {
return;
}
} catch (final ManagementException removed) {
if (removed.getKind() != ManagementExceptionKind.NOT_FOUND) {
throw removed;
}
return;
}
}
} finally {
schools.endScheduling(completed.showId());
}
}
private boolean waitForStorage() {
try {
Thread.sleep(1000);
return true;
} catch (final InterruptedException stopping) {
Thread.currentThread().interrupt();
return false;
}
}
private void save(final SchedulingRun run) {
if (persistence == null) {
publish(run);
} else {
persistence.save(run, () -> publish(run));
}
}
private <T> T work(final Callable<T> action) {
synchronized (schools) {
if (persistence != null) {
return persistence.work(action);
}
try {
return action.call();
} catch (final RuntimeException failure) {
throw failure;
} catch (final Exception failure) {
throw new IllegalStateException(failure);
}
}
}
@PreDestroy
public void close() {
executor.shutdownNow();
}
}

View File

@@ -18,7 +18,7 @@ import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -30,7 +30,7 @@ import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.ImportPreview;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
/** Owns the in-memory school aggregates; all writes go through this shared service. */
/** Owns detached school aggregates within the shared transaction monitor; all domain writes pass through here. */
@Singleton
public class SchoolManagementService {
private final DanceSchoolRepository schools = new DanceSchoolRepository();
@@ -41,7 +41,29 @@ public class SchoolManagementService {
private final Map<Long, Long> revisions = new HashMap<>();
private final Set<Long> running = new HashSet<>();
private void touch(final Show show) {
/** Installs a detached relational snapshot under the service's existing aggregate lock. */
public synchronized void load(final List<DanceSchool> state, final java.util.function.LongSupplier identifiers) {
revisions.clear();
schools.load(state, identifiers);
dancers.load(
state.stream()
.flatMap((final DanceSchool school) -> school.getDancers().stream())
.toList(),
identifiers);
groups.load(
state.stream()
.flatMap((final DanceSchool school) -> school.getDanceGroups().stream())
.toList(),
identifiers);
shows.load(
state.stream()
.flatMap((final DanceSchool school) -> school.getShows().stream())
.toList(),
identifiers);
}
public synchronized void touch(final Show show) {
show.setFinalized(false);
revisions.merge(show.getId(), 1L, Long::sum);
}
@@ -58,16 +80,13 @@ public class SchoolManagementService {
ManagementExceptionKind.INVALID_INPUT,
"Scheduling requires at least one school group and one daypart.");
}
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
if (SchedulingInputs.preferred(show) > show.getDayParts().size()) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling.");
}
running.add(showId);
return new SchedulingInput(
showId,
revisions.getOrDefault(showId, 0L),
show.getDayParts().size(),
current.stream()
.map((final DanceGroup g) -> new GroupRequirement(
g.getId(), show.getAppearances().get(g.getType())))
.toList());
return SchedulingInputs.of(
school(schoolId), show, school(schoolId).getRevision() + revisions.getOrDefault(showId, 0L));
}
/** Validates the complete result and input revision before replacing any saved assignments. */
@@ -80,7 +99,7 @@ public class SchoolManagementService {
throw new ManagementException(
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
}
if (revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
if (school(schoolId).getRevision() + revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
throw new ManagementException(
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
}
@@ -97,6 +116,7 @@ public class SchoolManagementService {
.build());
}
show.setDayParts(List.copyOf(replacement));
touch(show);
}
private Map<Long, DanceGroup> validateAssignments(
@@ -111,29 +131,13 @@ public class SchoolManagementService {
}
final Map<Long, DanceGroup> current = new HashMap<>();
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
validateParts(result.assignments(), current.keySet());
for (final GroupRequirement group : input.groups()) {
final long id = group.groupId();
if (result.assignments().stream()
.filter((final List<Long> p) -> p.contains(id))
.count()
!= group.appearances()) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Solver did not satisfy every required appearance.");
}
final List<String> violations = ScheduleRules.violations(input, result.assignments());
if (!violations.isEmpty()) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
}
return current;
}
private static void validateParts(final List<List<Long>> assignments, final Set<Long> groupIds) {
for (final List<Long> part : assignments) {
if (part == null || new HashSet<>(part).size() != part.size() || !groupIds.containsAll(part)) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Solver returned invalid group assignments.");
}
}
}
/** Releases the run slot after success or failure, without changing saved assignments. */
public synchronized void endScheduling(final long showId) {
running.remove(showId);
@@ -186,6 +190,7 @@ public class SchoolManagementService {
final Dancer dancer = dancer(schoolId, dancerId);
dancer.setName(name);
dancer.setBirthDate(birthDate);
school(schoolId).getShows().forEach(this::touch);
return dancer;
}
@@ -193,6 +198,10 @@ public class SchoolManagementService {
final DanceSchool school = school(schoolId);
final Dancer dancer = dancer(schoolId, dancerId);
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
school.getCoaches().stream()
.filter((final nl.agilecc.agiledancers.model.Coach c) -> c.getDancer() == dancer)
.forEach((final nl.agilecc.agiledancers.model.Coach c) -> c.setDancer(null));
school.getShows().forEach(this::touch);
school.removeDancer(dancer);
dancers.delete(dancerId);
}
@@ -224,7 +233,7 @@ public class SchoolManagementService {
final DanceGroup group = group(schoolId, groupId);
validateAgeRange(details.startAge(), details.endAge());
group.setCategory(details.category());
if (group.getType() != details.type()) {
if (group.getType() != details.type() || !group.getStyles().equals(new HashSet<>(details.styles()))) {
school(schoolId).getShows().forEach(this::touch);
}
group.setType(details.type());
@@ -248,11 +257,13 @@ public class SchoolManagementService {
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
final DanceGroup group = group(schoolId, groupId);
group.addDancer(dancer(schoolId, dancerId));
school(schoolId).getShows().forEach(this::touch);
}
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
final DanceGroup group = group(schoolId, groupId);
group.removeDancer(dancer(schoolId, dancerId));
school(schoolId).getShows().forEach(this::touch);
}
public synchronized List<Show> shows(final long schoolId) {
@@ -273,6 +284,7 @@ public class SchoolManagementService {
final Show show = shows.add(Show.builder()
.name(name)
.dayParts(List.copyOf(parts))
.preferredDayParts((parts.size() == 1 ? 1 : 2))
.appearances(Map.copyOf(counts))
.build());
school.addShow(show);
@@ -292,18 +304,7 @@ public class SchoolManagementService {
ShowConfiguration.validate(parts, counts);
final boolean changed =
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
final List<DayPart> retained = parts.stream()
.map((final DayPart p) -> DayPart.builder()
.date(p.getDate())
.type(p.getType())
.danceGroups(show.getDayParts().stream()
.filter((final DayPart old) ->
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.findFirst()
.map(DayPart::getDanceGroups)
.orElse(List.of()))
.build())
.toList();
final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
show.setName(name);
show.setDayParts(retained);
show.setAppearances(Map.copyOf(counts));
@@ -341,12 +342,14 @@ public class SchoolManagementService {
snapshot.groups().size(),
snapshot.shows().size(),
existing == null ? null : existing.getId(),
existing == null ? null : existing.getName());
existing == null ? null : existing.getName(),
existing == null ? null : existing.getRevision());
}
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
final DanceSchool staged = snapshot.restore();
final long revision = replaceId == null ? 0 : school(replaceId).getRevision();
final ImportPreview preview = preview(snapshot);
if (!Objects.equals(preview.existingId(), replaceId)) {
throw new ManagementException(
@@ -368,6 +371,7 @@ public class SchoolManagementService {
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
final DanceSchool imported = DanceSchool.builder()
.revision(revision)
.key(staged.getKey())
.name(staged.getName())
.dancers(new LinkedHashSet<>(importedDancers))
@@ -413,6 +417,7 @@ public class SchoolManagementService {
final Show rebuilt = Show.builder()
.name(show.getName())
.appearances(show.getAppearances())
.preferredDayParts(show.getPreferredDayParts())
.dayParts(show.getDayParts().stream()
.map((final DayPart p) -> DayPart.builder()
.date(p.getDate())

View File

@@ -0,0 +1,118 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.concurrent.Callable;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.repository.Database;
import nl.agilecc.agiledancers.repository.RevisionConflictException;
import nl.agilecc.agiledancers.repository.SchoolReader;
import nl.agilecc.agiledancers.repository.SchoolWriter;
import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.model.SchoolChange;
/** Serializes short domain units of work; solver computation never holds this lock or a database connection. */
@Singleton
public class SchoolTransactions {
private final SchoolManagementService schools;
private final Database database;
private final SchoolReader reader;
private final SchoolWriter writer;
@Inject
public SchoolTransactions(
final SchoolManagementService schools,
final Database database,
final SchoolReader reader,
final SchoolWriter writer) {
this.schools = schools;
this.database = database;
this.reader = reader;
this.writer = writer;
}
/** An expected revision is mandatory for existing-school writes; null school identifies a new-school operation. */
public Object request(final Long schoolId, final Long revision, final boolean mutation, final Callable<Object> work)
throws Exception {
return request(new SchoolChange(schoolId, revision, mutation, mutation), work);
}
public Object request(final SchoolChange change, final Callable<Object> work) throws Exception {
synchronized (schools) {
try {
return database.transaction(() -> {
final List<DanceSchool> before = reader.load();
schools.load(before, database::nextId);
if (change.mutation() && change.schoolId() != null) {
check(change);
schools.school(change.schoolId())
.setRevision(change.revision() + (change.changesInputs() ? 1 : 0));
}
final Object result = work.call();
if (change.changesInputs()) {
store(before, change.schoolId());
}
return result;
});
} catch (final RevisionConflictException failure) {
throw new ManagementException(ManagementExceptionKind.CONFLICT, failure.getMessage());
} catch (final StorageException failure) {
throw new ManagementException(ManagementExceptionKind.STORAGE_UNAVAILABLE, failure.getMessage());
}
}
}
private void check(final SchoolChange change) {
final long schoolId = change.schoolId();
final Long expected = change.revision();
final long actual = schools.school(schoolId).getRevision();
if (expected == null || actual != expected) {
throw new ManagementException(
ManagementExceptionKind.CONFLICT,
"Deze school is gewijzigd of de schoolversie ontbreekt. Herlaad de gegevens en probeer opnieuw.");
}
writer.checkRevision(schoolId, expected);
}
private void store(final List<DanceSchool> before, final Long schoolId) {
final List<DanceSchool> after = schools.schools();
if (schoolId != null) {
final DanceSchool retained = after.stream()
.filter((final DanceSchool school) -> school.getId().equals(schoolId))
.findFirst()
.orElse(null);
if (retained == null) {
writer.delete(schoolId);
} else {
writer.save(retained);
}
}
final List<Long> known = before.stream().map(DanceSchool::getId).toList();
after.stream()
.filter((final DanceSchool school) -> !known.contains(school.getId()))
.forEach(writer::save);
}
/** Refreshes current domain state and commits a worker's atomic result/status operation. */
public <T> T background(final Callable<T> work) {
synchronized (schools) {
try {
return database.transaction(() -> {
schools.load(reader.load(), database::nextId);
return work.call();
});
} catch (final RuntimeException failure) {
throw failure;
} catch (final Exception failure) {
throw new IllegalStateException(failure);
}
}
}
public void saveResult(final long schoolId) {
final DanceSchool school = schools.school(schoolId);
school.setRevision(school.getRevision() + 1);
writer.save(school);
}
}

View File

@@ -5,11 +5,27 @@ import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public final class ShowConfiguration {
private ShowConfiguration() {}
public static List<DayPart> retainAssignments(final Show show, final List<DayPart> parts) {
return parts.stream()
.map((final DayPart p) -> DayPart.builder()
.date(p.getDate())
.type(p.getType())
.danceGroups(show.getDayParts().stream()
.filter((final DayPart old) ->
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.findFirst()
.map(DayPart::getDanceGroups)
.orElse(List.of()))
.build())
.toList();
}
public static Map<DanceGroupType, Integer> defaults(final int n) {
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) {
@@ -18,7 +34,7 @@ public final class ShowConfiguration {
type,
switch (type) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE, PRE_EDUCATION -> n;
case SHOW_CASE -> n;
default -> Math.min(2, n);
});
}
@@ -43,7 +59,7 @@ public final class ShowConfiguration {
}
}
private static void validateParts(final List<DayPart> parts) {
public static void validateParts(final List<DayPart> parts) {
if (parts == null
|| parts.size() > 6
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)

View File

@@ -0,0 +1,125 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.ArrayList;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
@Singleton
public class ShowSchedules {
@Inject
private SchoolManagementService schools;
public ShowSchedules() {}
public ShowSchedules(final SchoolManagementService schools) {
this.schools = schools;
}
/** Retains assignments by date/type; removing dayparts may retain an invalid preferredDayParts in the draft. */
public Show configure(final long schoolId, final Long showId, final Show settings) {
synchronized (schools) {
ShowConfiguration.validateParts(settings.getDayParts());
final Show existing = showId == null ? null : schools.show(schoolId, showId);
final int preferredDayParts = settings.getPreferredDayParts() == null
? (existing == null
? (settings.getDayParts().size() == 1 ? 1 : 2)
: SchedulingInputs.preferred(existing))
: settings.getPreferredDayParts();
validatePreferredDayParts(
preferredDayParts, existing, settings.getDayParts().size());
final Show show = existing == null
? schools.createShow(schoolId, settings.getName(), settings.getDayParts(), null)
: existing;
final boolean changed = preferredDayParts != SchedulingInputs.preferred(show)
|| !keys(show.getDayParts()).equals(keys(settings.getDayParts()));
show.setDayParts(ShowConfiguration.retainAssignments(show, settings.getDayParts()));
show.setName(settings.getName());
show.setPreferredDayParts(preferredDayParts);
if (changed) {
schools.touch(show);
}
return show;
}
}
private static List<String> keys(final List<DayPart> parts) {
return parts.stream()
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
.toList();
}
private static void validatePreferredDayParts(final int preferredDayParts, final Show existing, final int parts) {
if (preferredDayParts < 1
|| preferredDayParts > 6
|| (parts > 0
&& preferredDayParts > parts
&& (existing == null || preferredDayParts != SchedulingInputs.preferred(existing)))) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"preferredDayParts must be between 1 and the number of dayparts. Retained preferredDayParts must"
+ " be corrected before scheduling.");
}
}
/** Saves even rule-breaking drafts, but rejects malformed lists and foreign group references atomically. */
public Show saveDraft(final long schoolId, final long showId, final List<List<Long>> assignments) {
synchronized (schools) {
final Show show = schools.show(schoolId, showId);
if (assignments == null || assignments.size() != show.getDayParts().size()) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Provide every daypart.");
}
final List<DayPart> replacement = new ArrayList<>();
for (int p = 0; p < assignments.size(); p++) {
final DayPart old = show.getDayParts().get(p);
final List<Long> ids = assignments.get(p);
if (ids == null || ids.stream().anyMatch(java.util.Objects::isNull)) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Invalid group IDs.");
}
final List<DanceGroup> groups = ids.stream()
.map((final Long id) -> schools.group(schoolId, id))
.toList();
replacement.add(DayPart.builder()
.date(old.getDate())
.type(old.getType())
.danceGroups(groups)
.build());
}
show.setDayParts(List.copyOf(replacement));
schools.touch(show);
return show;
}
}
/** Marks a schedule final only after checking all hard rules against current school data. */
public Show finalizeSchedule(final long schoolId, final long showId) {
synchronized (schools) {
final Show show = schools.show(schoolId, showId);
final List<String> errors = violations(schoolId, showId);
if (!errors.isEmpty()) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", errors));
}
show.setFinalized(true);
return show;
}
}
public List<String> violations(final long schoolId, final long showId) {
synchronized (schools) {
final Show show = schools.show(schoolId, showId);
final SchedulingInput input = SchedulingInputs.of(schools.school(schoolId), show, 0);
return ScheduleRules.violations(
input,
show.getDayParts().stream()
.map((final DayPart p) -> p.getDanceGroups().stream()
.map(DanceGroup::getId)
.toList())
.toList());
}
}
}

View File

@@ -1,3 +1,4 @@
package nl.agilecc.agiledancers.service.model;
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
public record ImportPreview(
String name, int dancers, int groups, int shows, Long existingId, String existingName, Long existingRevision) {}

View File

@@ -7,7 +7,21 @@ import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {
public record Performance(
String id,
String name,
List<Part> dayParts,
Map<DanceGroupType, Integer> appearances,
Integer preferredDayParts,
boolean finalized) {
public Performance(
final String id,
final String name,
final List<Part> dayParts,
final Map<DanceGroupType, Integer> appearances) {
this(id, name, dayParts, appearances, null, false);
}
public Performance {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.service.model;
/** Run acceptance checks the loaded revision but does not itself change scheduling inputs. */
public record SchoolChange(Long schoolId, Long revision, boolean mutation, boolean changesInputs) {}

View File

@@ -23,7 +23,7 @@ import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.ShowConfiguration;
/** Version 2 file format. IDs are local to the file, never server identifiers. */
/** Version 3 file format; version 2 imports remain supported. IDs are local to the file, never server identifiers. */
public record SchoolSnapshot(
Integer formatVersion,
UUID key,
@@ -45,14 +45,15 @@ public record SchoolSnapshot(
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
final Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot(
2,
3,
s.getKey(),
s.getName(),
s.getDancers().stream()
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
.toList(),
s.getCoaches().stream()
.map((final Coach c) -> new Trainer(coaches.get(c), c.getName()))
.map((final Coach c) -> new Trainer(
coaches.get(c), c.getName(), c.getDancer() == null ? null : dancers.get(c.getDancer())))
.toList(),
s.getDanceGroups().stream()
.map((final DanceGroup g) -> new Group(
@@ -77,7 +78,9 @@ public record SchoolSnapshot(
.map(groups::get)
.toList()))
.toList(),
show.getAppearances()))
show.getAppearances(),
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
show.isFinalized()))
.toList());
}
@@ -91,12 +94,14 @@ public record SchoolSnapshot(
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
public DanceSchool restore() {
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
check(
Objects.equals(formatVersion, 2) || Objects.equals(formatVersion, 3),
"Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt");
text(name, "schoolnaam");
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
final Map<String, Dancer> dancerMap = restoreDancers(school);
final Map<String, Coach> coachMap = restoreCoaches(school);
final Map<String, Coach> coachMap = restoreCoaches(school, dancerMap);
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
restoreShows(school, groupMap);
return school;
@@ -120,12 +125,15 @@ public record SchoolSnapshot(
return dancerMap;
}
private Map<String, Coach> restoreCoaches(final DanceSchool school) {
private Map<String, Coach> restoreCoaches(final DanceSchool school, final Map<String, Dancer> dancerMap) {
final Map<String, Coach> coachMap = new LinkedHashMap<>();
for (final Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach");
text(c.name(), "coachnaam");
final Coach coach = Coach.builder().name(c.name()).build();
final Coach coach = Coach.builder()
.name(c.name())
.dancer(c.dancerId() == null ? null : ref(dancerMap, c.dancerId(), "Onbekende coach-danser"))
.build();
coach.setId((long) coachMap.size());
put(coachMap, c.id(), coach);
school.addCoach(coach);
@@ -190,17 +198,25 @@ public record SchoolSnapshot(
parts.add(DayPart.builder()
.date(part.date())
.type(part.type())
.danceGroups(unique(part.groupIds(), "groupIds").stream()
.danceGroups(list(part.groupIds(), "groupIds").stream()
.map((final String id) ->
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
.toList())
.build());
}
ShowConfiguration.validate(parts, show.appearances());
ShowConfiguration.validateParts(parts);
final int preferredDayParts =
show.preferredDayParts() == null ? (parts.size() == 1 ? 1 : 2) : show.preferredDayParts();
check(preferredDayParts >= 1 && preferredDayParts <= 6, "preferredDayParts moet tussen 1 en 6 liggen");
final Show entity = Show.builder()
.name(show.name())
.dayParts(parts)
.appearances(Map.copyOf(show.appearances()))
.appearances(
show.appearances() == null
? ShowConfiguration.defaults(parts.size())
: Map.copyOf(show.appearances()))
.preferredDayParts(preferredDayParts)
.finalized(false)
.build();
entity.setId((long) showIds.size());
school.addShow(entity);

View File

@@ -1,3 +1,7 @@
package nl.agilecc.agiledancers.service.model;
public record Trainer(String id, String name) {}
public record Trainer(String id, String name, String dancerId) {
public Trainer(final String id, final String name) {
this(id, name, null);
}
}

View File

@@ -1,9 +1,15 @@
package nl.agilecc.agiledancers.service.port;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
public interface SchedulingEngine {
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
public SchedulingResult solve(final SchedulingInput input);
/** Reports phase changes and solution discovery while calculating. */
public default SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
return solve(input);
}
}

View File

@@ -1,73 +1,89 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader;
import com.google.ortools.sat.BoolVar;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.CpSolver;
import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.IntVar;
import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchedulingFailure;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
private final double timeLimitSeconds;
public OrToolsSchedulingEngine() {
this(1800);
}
/** Allows callers to impose a shorter run budget, for example in automated tests. */
public OrToolsSchedulingEngine(final double timeLimitSeconds) {
this.timeLimitSeconds = timeLimitSeconds;
}
@Override
public SchedulingResult solve(final SchedulingInput input) {
return solve(input, (final SchedulingProgress progress) -> {});
}
@Override
public SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries();
final CpModel model = new CpModel();
final BoolVar[][] assigned = assignments(model, input);
balance(model, assigned, input.dayParts());
final OrderedAssignments vars = OrderedAssignments.create(model, input);
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
final CpSolver solver = new CpSolver();
solver.getParameters().setMaxTimeInSeconds(1800);
// Quarkus owns process shutdown; do not install a native SIGINT handler.
solver.getParameters().setCatchSigintSignal(false);
// Single-worker search with a fixed seed makes identical inputs repeatable.
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
final CpSolverStatus status = solver.solve(model);
SchedulingResult best = null;
final List<LinearExpr> objectives = List.of(attendance, LinearExpr.term(vars.spread(), 1), variety);
final List<SchedulingPhase> phases =
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY);
for (int stage = 0; stage < objectives.size(); stage++) {
final LinearExpr objective = objectives.get(stage);
final SchedulingPhase phase = phases.get(stage);
observer.accept(new SchedulingProgress(phase, best != null));
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) {
if (best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(CpSolverStatus.UNKNOWN);
}
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
model.minimize(objective);
final CpSolverStatus status = solver.solve(model, new SchedulingSolutionObserver(observer, phase));
if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(status);
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, readAssignments(input, assigned, solver));
best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver));
if (status != CpSolverStatus.OPTIMAL) {
return best;
}
private static BoolVar[][] assignments(final CpModel model, final SchedulingInput input) {
final BoolVar[][] assigned = new BoolVar[input.groups().size()][input.dayParts()];
for (int g = 0; g < assigned.length; g++) {
for (int p = 0; p < input.dayParts(); p++) {
assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
model.addEquality(objective, Math.round(solver.objectiveValue()));
}
model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
}
return assigned;
}
private static void balance(final CpModel model, final BoolVar[][] assigned, final int dayParts) {
final int groups = assigned.length;
final IntVar[] loads = new IntVar[dayParts];
for (int p = 0; p < dayParts; p++) {
final BoolVar[] column = new BoolVar[groups];
for (int g = 0; g < groups; g++) {
column[g] = assigned[g][p];
}
loads[p] = model.newIntVar(0, groups, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
}
final IntVar max = model.newIntVar(0, groups, "max");
final IntVar min = model.newIntVar(0, groups, "min");
model.addMaxEquality(max, loads);
model.addMinEquality(min, loads);
model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
return best;
}
private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
throw new SchedulingFailure(
switch (status) {
case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE;
case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION;
default -> SchedulingFailureKind.SOLVER_FAILED;
},
switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN ->
@@ -77,19 +93,4 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
});
}
}
private static List<List<Long>> readAssignments(
final SchedulingInput input, final BoolVar[][] assigned, final CpSolver solver) {
final List<List<Long>> parts = new ArrayList<>();
for (int p = 0; p < input.dayParts(); p++) {
final List<Long> ids = new ArrayList<>();
for (int g = 0; g < assigned.length; g++) {
if (solver.booleanValue(assigned[g][p])) {
ids.add(input.groups().get(g).groupId());
}
}
parts.add(List.copyOf(ids));
}
return List.copyOf(parts);
}
}

View File

@@ -0,0 +1,125 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.BoolVar;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.IntVar;
import com.google.ortools.sat.LinearExpr;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.IntStream;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
/** Solver-owned variables. Negative positions are distinct sentinels for groups absent from a daypart. */
public record OrderedAssignments(BoolVar[][] present, IntVar[][] position, IntVar[] loads, IntVar spread) {
public static OrderedAssignments create(final CpModel model, final SchedulingInput input) {
final int n = input.groups().size();
final BoolVar[][] present = new BoolVar[n][input.dayParts()];
final IntVar[][] positions = new IntVar[n][input.dayParts()];
for (int g = 0; g < n; g++) {
for (int p = 0; p < input.dayParts(); p++) {
present[g][p] = model.newBoolVar("present" + g + ":" + p);
positions[g][p] = model.newIntVar(-g - 1, n - 1, "position" + g + ":" + p);
model.addGreaterOrEqual(positions[g][p], 0).onlyEnforceIf(present[g][p]);
model.addEquality(positions[g][p], -g - 1).onlyEnforceIf(present[g][p].not());
}
model.addEquality(LinearExpr.sum(present[g]), input.groups().get(g).appearances());
}
final IntVar[] loads = loads(model, present, positions, input.dayParts());
final IntVar max = model.newIntVar(0, n, "max");
final IntVar min = model.newIntVar(0, n, "min");
final IntVar spread = model.newIntVar(0, 2, "spread");
model.addMaxEquality(max, loads);
model.addMinEquality(min, loads);
model.addEquality(spread, LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
final OrderedAssignments result = new OrderedAssignments(present, positions, loads, spread);
result.boundaries(model, input);
result.changingTime(model, input);
return result;
}
private static IntVar[] loads(
final CpModel model, final BoolVar[][] present, final IntVar[][] positions, final int parts) {
final IntVar[] loads = new IntVar[parts];
for (int p = 0; p < parts; p++) {
final BoolVar[] column = new BoolVar[present.length];
final IntVar[] order = new IntVar[present.length];
for (int g = 0; g < present.length; g++) {
column[g] = present[g][p];
order[g] = positions[g][p];
}
loads[p] = model.newIntVar(0, present.length, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
model.addAllDifferent(order);
for (int g = 0; g < present.length; g++) {
model.addLessThan(order[g], loads[p]).onlyEnforceIf(column[g]);
}
}
return loads;
}
private void boundaries(final CpModel model, final SchedulingInput input) {
final List<Integer> showcases = IntStream.range(0, input.groups().size())
.filter((final int g) -> input.groups().get(g).showcase())
.boxed()
.toList();
if (showcases.isEmpty()) {
return;
}
for (int p = 0; p < input.dayParts(); p++) {
final List<BoolVar> first = new ArrayList<>();
final List<BoolVar> last = new ArrayList<>();
for (final int g : showcases) {
first.add(equals(model, position[g][p], LinearExpr.constant(0)));
last.add(equals(model, position[g][p], LinearExpr.affine(loads[p], 1, -1)));
}
if (showcases.size() == 1) {
model.addBoolOr(new com.google.ortools.sat.Literal[] {first.getFirst(), last.getFirst()});
} else {
model.addEquality(LinearExpr.sum(first.toArray(BoolVar[]::new)), 1);
model.addEquality(LinearExpr.sum(last.toArray(BoolVar[]::new)), 1);
}
}
}
private static BoolVar equals(final CpModel model, final IntVar position, final LinearExpr value) {
final BoolVar equal = model.newBoolVar("boundary");
model.addEquality(position, value).onlyEnforceIf(equal);
model.addDifferent(position, value).onlyEnforceIf(equal.not());
return equal;
}
private void changingTime(final CpModel model, final SchedulingInput input) {
for (int a = 0; a < input.groups().size(); a++) {
for (int b = a + 1; b < input.groups().size(); b++) {
if (!java.util.Collections.disjoint(
input.groups().get(a).dancerIds(), input.groups().get(b).dancerIds())) {
separate(model, a, b);
}
}
}
}
private void separate(final CpModel model, final int a, final int b) {
for (int p = 0; p < loads.length; p++) {
final IntVar gap = model.newIntVar(0, present.length * 2L, "gap");
model.addAbsEquality(
gap, LinearExpr.weightedSum(new IntVar[] {position[a][p], position[b][p]}, new long[] {1, -1}));
model.addGreaterOrEqual(gap, 3).onlyEnforceIf(new BoolVar[] {present[a][p], present[b][p]});
}
}
public List<List<Long>> read(final SchedulingInput input, final com.google.ortools.sat.CpSolver solver) {
final List<List<Long>> result = new ArrayList<>();
for (int p = 0; p < loads.length; p++) {
final int part = p;
result.add(IntStream.range(0, present.length)
.boxed()
.filter((final Integer g) -> solver.booleanValue(present[g][part]))
.sorted(Comparator.comparingLong((final Integer g) -> solver.value(position[g][part])))
.map((final Integer g) -> input.groups().get(g).groupId())
.toList());
}
return List.copyOf(result);
}
}

View File

@@ -0,0 +1,132 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.BoolVar;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.IntVar;
import com.google.ortools.sat.LinearExpr;
import com.google.ortools.sat.LinearExprBuilder;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.IntStream;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
public final class SchedulingObjectives {
private SchedulingObjectives() {}
public static LinearExpr attendance(
final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
final Set<Long> dancers = new HashSet<>();
final Set<Long> showcases = new HashSet<>();
input.groups().forEach((final GroupRequirement g) -> dancers.addAll(g.dancerIds()));
input.groups().stream()
.filter(GroupRequirement::showcase)
.forEach((final GroupRequirement g) -> showcases.addAll(g.dancerIds()));
dancers.removeAll(input.coachDancerIds());
final LinearExprBuilder penalty = LinearExpr.newBuilder();
for (final long dancer : dancers) {
final IntVar[] parts = dancerParts(model, input, vars, dancer);
if (showcases.contains(dancer)) {
final IntVar excess = model.newIntVar(0, input.dayParts(), "excess");
model.addMaxEquality(excess, new LinearExpr[] {
LinearExpr.affine(LinearExpr.sum(parts), 1, -input.preferredDayParts()), LinearExpr.constant(0)
});
penalty.add(excess);
} else {
model.addLessOrEqual(LinearExpr.sum(parts), input.preferredDayParts());
}
}
return penalty.build();
}
private static IntVar[] dancerParts(
final CpModel model, final SchedulingInput input, final OrderedAssignments vars, final long dancer) {
final List<Integer> groups = IntStream.range(0, input.groups().size())
.filter((final int g) -> !input.groups().get(g).showcase()
&& input.groups().get(g).dancerIds().contains(dancer))
.boxed()
.toList();
final IntVar[] result = new IntVar[input.dayParts()];
for (int p = 0; p < input.dayParts(); p++) {
final int part = p;
result[p] = model.newBoolVar("dancer" + dancer + ":" + p);
if (groups.isEmpty()) {
model.addEquality(result[p], 0);
} else {
model.addMaxEquality(
result[p],
groups.stream()
.map((final Integer g) -> vars.present()[g][part])
.toArray(IntVar[]::new));
}
}
return result;
}
public static LinearExpr variety(final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
final LinearExprBuilder penalty = LinearExpr.newBuilder();
// Every nonempty daypart has load - 1 adjacencies. Remove their common score
// to avoid exploring equivalent orders when groups have identical penalties.
final long baseline = minimumPenalty(input.groups());
final long performances =
input.groups().stream().mapToLong(GroupRequirement::appearances).sum();
penalty.add(performances * baseline);
for (final IntVar load : vars.loads()) {
final BoolVar active = model.newBoolVar("nonemptyPart");
model.addGreaterThan(load, 0).onlyEnforceIf(active);
model.addEquality(load, 0).onlyEnforceIf(active.not());
penalty.addTerm(active, -baseline);
}
for (int a = 0; a < input.groups().size(); a++) {
for (int b = a + 1; b < input.groups().size(); b++) {
final long score = VarietyScore.penalty(
input.groups().get(a), input.groups().get(b))
- baseline;
if (score > 0) {
addAdjacencies(model, vars, new int[] {a, b}, penalty, score);
}
}
}
return penalty.build();
}
private static long minimumPenalty(final List<GroupRequirement> groups) {
long minimum = 2_000_000;
for (int a = 0; a < groups.size(); a++) {
for (int b = a + 1; b < groups.size(); b++) {
minimum = Math.min(minimum, VarietyScore.penalty(groups.get(a), groups.get(b)));
}
}
return groups.size() < 2 ? 0 : minimum;
}
private static void addAdjacencies(
final CpModel model,
final OrderedAssignments vars,
final int[] pair,
final LinearExprBuilder penalty,
final long score) {
for (int p = 0; p < vars.loads().length; p++) {
penalty.addTerm(adjacent(model, vars, pair[0], pair[1], p), score);
}
}
private static BoolVar adjacent(
final CpModel model, final OrderedAssignments vars, final int a, final int b, final int p) {
final IntVar gap = model.newIntVar(0, vars.present().length * 2L, "adjacencyGap");
model.addAbsEquality(
gap,
LinearExpr.weightedSum(
new IntVar[] {vars.position()[a][p], vars.position()[b][p]}, new long[] {1, -1}));
final BoolVar adjacent = model.newBoolVar("adjacent");
model.addImplication(adjacent, vars.present()[a][p]);
model.addImplication(adjacent, vars.present()[b][p]);
model.addEquality(gap, 1).onlyEnforceIf(adjacent);
model.addDifferent(gap, 1).onlyEnforceIf(new com.google.ortools.sat.Literal[] {
adjacent.not(), vars.present()[a][p], vars.present()[b][p]
});
return adjacent;
}
}

View File

@@ -0,0 +1,26 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.CpSolverSolutionCallback;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
/** Publish only the first feasible solution in a phase, avoiding an event per solver improvement. */
public class SchedulingSolutionObserver extends CpSolverSolutionCallback {
private final Consumer<SchedulingProgress> observer;
private final SchedulingPhase phase;
private boolean reported;
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final SchedulingPhase phase) {
this.observer = observer;
this.phase = phase;
}
@Override
public void onSolutionCallback() {
if (!reported) {
reported = true;
observer.accept(new SchedulingProgress(phase, true));
}
}
}

View File

@@ -1,7 +1,7 @@
quarkus.application.name=agiledancers
quarkus.http.host=0.0.0.0
# Scheduling is synchronous with a 30-minute solver limit.
# Keep idle SSE connections alive alongside the 30-minute solver limit.
quarkus.http.idle-timeout=31M
quarkus.jackson.fail-on-unknown-properties=true
@@ -9,3 +9,17 @@ quarkus.jackson.fail-on-unknown-properties=true
%test.quarkus.jacoco.data-file=target/jacoco.exec
%test.quarkus.jacoco.reuse-data-file=true
%test.quarkus.jacoco.report=false
# Production requires an explicitly configured database; initialization failures prevent startup.
quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.jdbc.url=${DATABASE_URL}
%prod.quarkus.datasource.username=${DATABASE_USER}
%prod.quarkus.datasource.password=${DATABASE_PASSWORD}
quarkus.datasource.jdbc.acquisition-timeout=5S
quarkus.flyway.migrate-at-start=true
quarkus.datasource.devservices.image-name=postgres:17.6
# Flyway owns DDL; Hibernate validates its JPA mappings against the migrated schema.
quarkus.hibernate-orm.schema-management.strategy=validate
quarkus.hibernate-orm.request-scoped.enabled=false
quarkus.datasource.jdbc.transaction-isolation-level=repeatable-read

View File

@@ -0,0 +1,71 @@
CREATE SEQUENCE entity_id START 1;
CREATE TABLE school (
id bigint PRIMARY KEY, school_key uuid NOT NULL UNIQUE, name text NOT NULL,
revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0)
);
CREATE TABLE dancer (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, name text NOT NULL, birth_date date NOT NULL,
PRIMARY KEY (school_id, id)
);
CREATE TABLE coach (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, name text NOT NULL, dancer_id bigint,
PRIMARY KEY (school_id, id),
FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE dance_group (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, category text NOT NULL, group_type text NOT NULL,
start_age integer NOT NULL CHECK (start_age >= 0), end_age integer NOT NULL CHECK (end_age >= start_age),
coach_id bigint, PRIMARY KEY (school_id, id),
FOREIGN KEY (school_id, coach_id) REFERENCES coach DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE group_style (
school_id bigint NOT NULL, group_id bigint NOT NULL, style text NOT NULL,
PRIMARY KEY (school_id, group_id, style),
FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE
);
CREATE TABLE group_dancer (
school_id bigint NOT NULL, group_id bigint NOT NULL, dancer_id bigint NOT NULL,
PRIMARY KEY (school_id, group_id, dancer_id),
FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE,
FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE show (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
id bigint NOT NULL, name text NOT NULL, preferred_day_parts integer NOT NULL CHECK (preferred_day_parts BETWEEN 1 AND 6),
finalized boolean NOT NULL, PRIMARY KEY (school_id, id)
);
CREATE TABLE show_appearance (
school_id bigint NOT NULL, show_id bigint NOT NULL, group_type text NOT NULL, appearances integer NOT NULL CHECK (appearances >= 0),
PRIMARY KEY (school_id, show_id, group_type),
FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE
);
CREATE TABLE day_part (
school_id bigint NOT NULL, show_id bigint NOT NULL, position integer NOT NULL CHECK (position >= 0),
day date NOT NULL, part_type text NOT NULL,
PRIMARY KEY (school_id, show_id, position), UNIQUE (school_id, show_id, day, part_type),
FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE
);
CREATE TABLE assignment (
school_id bigint NOT NULL, show_id bigint NOT NULL, part_position integer NOT NULL,
position integer NOT NULL CHECK (position >= 0), group_id bigint NOT NULL,
PRIMARY KEY (school_id, show_id, part_position, position),
FOREIGN KEY (school_id, show_id, part_position) REFERENCES day_part ON DELETE CASCADE,
FOREIGN KEY (school_id, group_id) REFERENCES dance_group DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE scheduling_run (
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
show_id bigint NOT NULL, run_id uuid NOT NULL UNIQUE, show_name text NOT NULL,
started_at timestamptz NOT NULL, finished_at timestamptz,
state text NOT NULL CHECK (state IN ('RUNNING','OPTIMAL','VALID','STALE','INVALID_INPUT','INFEASIBLE','NO_SOLUTION','SOLVER_FAILED','INTERRUPTED')),
phase text NOT NULL CHECK (phase IN ('PREPARING','ATTENDANCE','SPREAD','VARIETY')),
feasible boolean NOT NULL, message text NOT NULL,
attendance_excess bigint, performance_spread integer, variety_penalty bigint,
PRIMARY KEY (school_id, show_id),
FOREIGN KEY (school_id, show_id) REFERENCES show DEFERRABLE INITIALLY DEFERRED,
CHECK ((state = 'RUNNING') = (finished_at IS NULL)),
CHECK ((attendance_excess IS NULL AND performance_spread IS NULL AND variety_penalty IS NULL)
OR (attendance_excess IS NOT NULL AND performance_spread IS NOT NULL AND variety_penalty IS NOT NULL))
);

View File

@@ -0,0 +1,15 @@
-- Child-only edits must dirty the school entity so @Version advances exactly once.
ALTER TABLE school ADD COLUMN change_stamp bigint NOT NULL DEFAULT 0;
-- Keep existing identifiers and start above every value already allocated by V1.
-- JPA allocates identifiers under a row lock, including when a transaction creates a whole import graph.
CREATE TABLE entity_identifiers (
id integer PRIMARY KEY CHECK (id = 1),
next_value bigint NOT NULL CHECK (next_value > 0)
);
INSERT INTO entity_identifiers (id, next_value) VALUES (1, nextval('entity_id'));
-- Hibernate updates retained positions in place; date/type swaps must be checked on the final graph.
ALTER TABLE day_part DROP CONSTRAINT day_part_school_id_show_id_day_part_type_key;
ALTER TABLE day_part ADD CONSTRAINT day_part_date_type_unique
UNIQUE (school_id, show_id, day, part_type) DEFERRABLE INITIALLY DEFERRED;

View File

@@ -0,0 +1,119 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import org.junit.jupiter.api.Test;
@QuarkusTest
class DomainViewMapperTest {
private @Inject DomainViewMapper views;
public @Test void relationshipsBecomeOrderedIdsAndUnlinkedCoachesRetainNull() {
final DanceSchool school =
DanceSchool.builder().name("School").revision(4).build();
school.setId(7L);
final Dancer first = dancer(9L);
final Dancer second = dancer(3L);
school.addDancer(first);
school.addDancer(second);
final SchoolView mapped = views.school(school);
assertEquals(List.of(9L, 3L), mapped.dancerIds());
assertEquals(4, mapped.revision());
final Coach coach = Coach.builder().name("Coach").dancer(first).build();
coach.setId(2L);
assertEquals(9L, views.coach(coach).dancerId());
coach.setDancer(null);
assertNull(views.coach(coach).dancerId());
school.removeDancer(first);
assertEquals(List.of(9L, 3L), mapped.dancerIds());
assertThrows(
UnsupportedOperationException.class, () -> mapped.dancerIds().clear());
}
public @Test void nestedViewsKeepAssignmentOrderAndExplicitValidationWithoutSharingCollections() {
final DanceGroup first = DanceGroup.builder().category("First").build();
first.setId(8L);
final DanceGroup second = DanceGroup.builder().category("Second").build();
second.setId(2L);
final List<DanceGroup> assigned = new ArrayList<>(List.of(first, second));
final DayPart part = DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(assigned)
.build();
final List<String> violations = new ArrayList<>(List.of("Draft validation"));
final Show show = Show.builder()
.name("Show")
.dayParts(new ArrayList<>(List.of(part)))
.appearances(Map.of())
.build();
show.setId(1L);
final ShowView mapped = views.show(show, violations);
assertEquals(1, mapped.preferredDayParts());
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
assigned.clear();
violations.clear();
show.getDayParts().clear();
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
assertEquals(List.of("Draft validation"), mapped.violations());
assertThrows(
UnsupportedOperationException.class,
() -> mapped.dayParts().getFirst().danceGroupIds().clear());
}
public @Test void preferredDefaultsUseTheExistingRuleAndStylesAreSnapshotValues() {
final DayPart part = DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build();
final Show show = Show.builder()
.dayParts(List.of(part, part))
.appearances(Map.of())
.build();
show.setId(1L);
assertEquals(2, views.show(show, List.of()).preferredDayParts());
show.setPreferredDayParts(1);
assertEquals(1, views.show(show, List.of()).preferredDayParts());
final DanceGroup group = DanceGroup.builder().category("Group").build();
group.setId(1L);
group.addStyle(DanceGroupStyle.JAZZ);
final Dancer dancer = dancer(4L);
group.addDancer(dancer);
final GroupView mapped = views.group(group);
group.replaceStyles(Set.of(DanceGroupStyle.MODERN));
group.removeDancer(dancer);
assertEquals(Set.of(DanceGroupStyle.JAZZ), mapped.styles());
assertEquals(List.of(4L), mapped.dancerIds());
}
private static Dancer dancer(final long id) {
final Dancer dancer = Dancer.builder()
.name("Dancer")
.birthDate(LocalDate.of(2000, 1, 1))
.build();
dancer.setId(id);
return dancer;
}
}

View File

@@ -0,0 +1,386 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.repository.Database;
import nl.agilecc.agiledancers.repository.RunRepository;
import nl.agilecc.agiledancers.repository.SchoolReader;
import nl.agilecc.agiledancers.repository.SchoolWriter;
import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.SchoolTransactions;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import org.junit.jupiter.api.Test;
@QuarkusTest
class PersistenceTest {
private @Inject SchoolTransactions transactions;
private @Inject SchoolManagementService schools;
private @Inject Database database;
private @Inject SchoolReader reader;
private @Inject SchoolWriter writer;
private @Inject RunRepository runs;
private @Inject jakarta.persistence.EntityManager entities;
public @Test void simultaneousEditsRequireTheLoadedRevision() throws Exception {
final long id = create();
final String path = "/api/dance-schools/" + id;
try (final java.util.concurrent.ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
final java.util.concurrent.Future<Integer> first = executor.submit(() -> rename(path, "First", 0));
final java.util.concurrent.Future<Integer> second = executor.submit(() -> rename(path, "Second", 0));
final List<Integer> statuses = List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS));
assertTrue(statuses.containsAll(List.of(200, 409)));
given().contentType("application/json")
.body("{\"name\":\"Missing revision\"}")
.put(path)
.then()
.statusCode(409);
given().get(path + "/data").then().statusCode(200);
assertEquals(1, given().get(path).jsonPath().getInt("revision"));
} finally {
delete(id);
}
}
public @Test void detachedRelationalGraphRetainsIdsReferencesDraftAndFinalState() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
transactions.background(() -> {
final DanceSchool loaded = schools.school(id);
assertEquals(1, loaded.getDancers().size());
assertEquals(
Set.of(DanceGroupStyle.JAZZ),
loaded.getDanceGroups().iterator().next().getStyles());
assertEquals(show.getId(), loaded.getShows().iterator().next().getId());
assertTrue(loaded.getShows().iterator().next().isFinalized());
assertEquals(
loaded.getDanceGroups().iterator().next(),
loaded.getShows()
.iterator()
.next()
.getDayParts()
.getFirst()
.getDanceGroups()
.getFirst());
return null;
});
given().get("/api/dance-schools/" + id + "/export").then().statusCode(200);
} finally {
delete(id);
}
}
public @Test void deferredConstraintFailureRollsBackDomainAndCompletedStatusTogether() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName());
database.transaction(() -> {
runs.save(run);
return null;
});
final java.util.concurrent.atomic.AtomicBoolean published = new java.util.concurrent.atomic.AtomicBoolean();
assertThrows(StorageException.class, () -> damageResult(id, show, run, published));
assertFalse(published.get());
database.transaction(() -> {
assertEquals(
"Persistent school",
reader.load().stream()
.filter((final DanceSchool school) -> school.getId() == id)
.findFirst()
.orElseThrow()
.getName());
assertEquals(
1,
entities.find(
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
.getGroupIds()
.size());
assertEquals(
SchedulingState.RUNNING,
runs.load().stream()
.filter((final SchedulingRun item) ->
item.runId().equals(run.runId()))
.findFirst()
.orElseThrow()
.state());
return null;
});
} finally {
delete(id);
}
}
public @Test void recoveryRetainsRunIdentityProgressAndExistingAssignments() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName())
.progressed(new SchedulingProgress(SchedulingPhase.SPREAD, true));
database.transaction(() -> {
runs.save(run);
return null;
});
database.transaction(() -> {
runs.recover();
return null;
});
database.transaction(() -> {
final SchedulingRun recovered = runs.load().stream()
.filter((final SchedulingRun item) -> item.runId().equals(run.runId()))
.findFirst()
.orElseThrow();
assertEquals(SchedulingState.INTERRUPTED, recovered.state());
assertEquals(run.progress(), recovered.progress());
assertTrue(recovered.finishedAt() != null);
assertTrue(recovered.message().contains("behouden"));
return null;
});
} finally {
delete(id);
}
}
public @Test void aFailedReplacementImportRetainsThePreviousGraphAndRevision() throws Exception {
final long id = create();
try {
fixture(id);
final nl.agilecc.agiledancers.service.model.SchoolSnapshot before =
transactions.background(() -> schools.exportSchool(id));
assertThrows(
nl.agilecc.agiledancers.service.ManagementException.class,
() -> transactions.request(id, 1L, true, () -> {
final DanceSchool imported = schools.importSchool(before, id);
imported.getDancers().iterator().next().setBirthDate(null);
return imported;
}));
transactions.background(() -> {
assertEquals(before, schools.exportSchool(id));
assertEquals(1, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
public @Test void changedSchoolInputsRejectAnOlderSolverSnapshot() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final nl.agilecc.agiledancers.model.scheduling.SchedulingInput input =
transactions.background(() -> schools.beginScheduling(id, show.getId()));
transactions.request(id, 1L, true, () -> schools.updateSchool(id, "Changed"));
final nl.agilecc.agiledancers.service.ManagementException failure = assertThrows(
nl.agilecc.agiledancers.service.ManagementException.class,
() -> transactions.background(() -> {
schools.finishScheduling(
id,
input,
new nl.agilecc.agiledancers.model.scheduling.SchedulingResult(
true, List.of(List.of())));
return null;
}));
assertEquals(nl.agilecc.agiledancers.service.ManagementExceptionKind.CONFLICT, failure.getKind());
transactions.background(() -> {
assertEquals(
1,
schools.show(id, show.getId())
.getDayParts()
.getFirst()
.getDanceGroups()
.size());
schools.endScheduling(show.getId());
return null;
});
} finally {
delete(id);
}
}
public @Test void hibernateVersionRejectsDetachedStaleEntities() throws Exception {
final long id = create();
try {
final nl.agilecc.agiledancers.repository.entity.SchoolEntity stale = database.transaction(
() -> entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id));
database.transaction(() -> {
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
.setName("Confirmed");
return null;
});
stale.setName("Stale");
assertThrows(
nl.agilecc.agiledancers.repository.RevisionConflictException.class,
() -> database.transaction(() -> entities.merge(stale)));
transactions.background(() -> {
assertEquals("Confirmed", schools.school(id).getName());
assertEquals(1, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
public @Test void childOnlyEditsAdvanceTheAggregateVersionOnce() throws Exception {
final long id = create();
try {
fixture(id);
transactions.request(id, 1L, true, () -> {
final long dancerId = schools.dancers(id).getFirst().getId();
return schools.updateDancer(id, dancerId, "Updated dancer", LocalDate.of(2000, 1, 1));
});
transactions.background(() -> {
assertEquals(2, schools.school(id).getRevision());
assertEquals("Updated dancer", schools.dancers(id).getFirst().getName());
return null;
});
} finally {
delete(id);
}
}
public @Test void hibernateRetainsAssignmentsWhenDayPartsAreReordered() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final DayPart added = DayPart.builder()
.date(LocalDate.of(2027, 1, 2))
.type(DayPartType.EVENING)
.danceGroups(List.of())
.build();
transactions.request(
id,
1L,
true,
() -> schools.updateShow(
id,
show.getId(),
"Two parts",
List.of(show.getDayParts().getFirst(), added),
null));
transactions.request(id, 2L, true, () -> {
final Show current = schools.show(id, show.getId());
return schools.updateShow(
id,
show.getId(),
"Swapped",
List.of(
current.getDayParts().getLast(),
current.getDayParts().getFirst()),
null);
});
transactions.background(() -> {
final Show restored = schools.show(id, show.getId());
assertEquals(added.getDate(), restored.getDayParts().getFirst().getDate());
assertEquals(
1, restored.getDayParts().getLast().getDanceGroups().size());
assertEquals(3, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
private void damageResult(
final long id,
final Show show,
final SchedulingRun run,
final java.util.concurrent.atomic.AtomicBoolean published)
throws Exception {
database.transaction(() -> {
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
.setName("Uncommitted");
entities.find(
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
.getGroupIds()
.clear();
runs.save(run.completed(SchedulingState.OPTIMAL, "Uncommitted", new SchedulingSummary(0, 0, 0)));
database.afterCommit(() -> published.set(true));
// This dancer FK is deferred: work returns normally, and commit itself must fail.
entities.find(
nl.agilecc.agiledancers.repository.entity.GroupEntity.class,
new nl.agilecc.agiledancers.repository.entity.SchoolMemberId(
id,
show.getDayParts()
.getFirst()
.getDanceGroups()
.getFirst()
.getId()))
.getDancerIds()
.add(999999L);
return null;
});
}
private long create() throws Exception {
return (Long) transactions.request(
null,
null,
true,
() -> schools.createSchool("Persistent school").getId());
}
private Show fixture(final long id) throws Exception {
return (Show) transactions.request(id, 0L, true, () -> {
final long dancer =
schools.createDancer(id, "Dancer", LocalDate.of(2000, 1, 1)).getId();
final long group = schools.createGroup(
id, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of(DanceGroupStyle.JAZZ)))
.getId();
schools.addGroupDancer(id, group, dancer);
final Show show = schools.createShow(
id,
"Saved show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of(schools.group(id, group)))
.build()),
null);
show.setFinalized(true);
return show;
});
}
private int rename(final String path, final String name, final long revision) {
return given().header("X-School-Revision", revision)
.contentType("application/json")
.body(java.util.Map.of("name", name))
.put(path)
.statusCode();
}
private void delete(final long id) {
transactions.background(() -> {
writer.delete(id);
return null;
});
}
}

View File

@@ -0,0 +1,50 @@
package nl.agilecc.agiledancers;
import io.restassured.RestAssured;
import io.restassured.response.Response;
import io.restassured.specification.FilterableRequestSpecification;
import io.restassured.specification.RequestSpecification;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Existing endpoint scenarios use fresh revisions; concurrency tests deliberately send explicit stale revisions. */
public class RevisionRequests {
private static final Pattern SCHOOL = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
private static final Pattern REPLACEMENT = Pattern.compile("[?&]replaceId=(\\d+)");
public static RequestSpecification given() {
return RestAssured.given()
.filter(
(final FilterableRequestSpecification request,
final io.restassured.specification.FilterableResponseSpecification response,
final io.restassured.filter.FilterContext context) -> {
addRevision(request);
return context.next(request, response);
});
}
private static void addRevision(final FilterableRequestSpecification request) {
final String id = schoolId(request);
if (!request.getMethod().equals("GET")
&& id != null
&& !request.getHeaders().hasHeaderWithName("X-School-Revision")) {
final Response school = RestAssured.given().get("/api/dance-schools/" + id);
if (school.statusCode() == 200) {
request.header("X-School-Revision", school.jsonPath().getLong("revision"));
}
}
}
private static String schoolId(final FilterableRequestSpecification request) {
final Matcher school = SCHOOL.matcher(request.getURI());
if (school.find()) {
return school.group(1);
}
final Matcher replacement = REPLACEMENT.matcher(request.getURI());
if (replacement.find()) {
return replacement.group(1);
}
final Object parameter = request.getQueryParams().get("replaceId");
return parameter == null ? null : parameter.toString();
}
}

View File

@@ -0,0 +1,148 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.SchedulingInputs;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class S18LifecycleTest {
private final SchoolManagementService schools = new SchoolManagementService();
private final long school = schools.createSchool("Studio").getId();
private final ShowSchedules schedules = new ShowSchedules(schools);
private List<DayPart> parts(final int n) {
return java.util.stream.IntStream.range(0, n)
.mapToObj((final int i) -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build())
.toList();
}
private Show settings(final int n, final Integer preferredDayParts) {
return Show.builder()
.name("Show")
.dayParts(parts(n))
.preferredDayParts(preferredDayParts)
.build();
}
private DanceGroup group(final DanceGroupType type) {
final DanceGroup group = schools.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
final long dancer =
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 2)).getId();
schools.addGroupDancer(school, group.getId(), dancer);
return group;
}
public @Test void draftEditingValidationFinalizationAndImportPreserveOrder() {
final DanceGroup group = group(DanceGroupType.REGULAR);
final Show show = schedules.configure(school, null, settings(2, 1));
assertThrows(ManagementException.class, () -> schedules.finalizeSchedule(school, show.getId()));
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId()), List.of()));
assertTrue(schedules.violations(school, show.getId()).isEmpty());
schedules.finalizeSchedule(school, show.getId());
assertTrue(show.isFinalized());
final SchoolSnapshot snapshot = schools.exportSchool(school);
assertTrue(snapshot.shows().getFirst().finalized());
final Show imported = snapshot.restore().getShows().iterator().next();
assertFalse(imported.isFinalized());
assertEquals(1, imported.getPreferredDayParts());
assertEquals(1, imported.getDayParts().getFirst().getDanceGroups().size());
schedules.configure(school, show.getId(), settings(2, 1));
assertTrue(show.isFinalized());
schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(group.getId())));
assertFalse(show.isFinalized());
schedules.finalizeSchedule(school, show.getId());
schools.removeGroupDancer(
school, group.getId(), group.getDancers().iterator().next().getId());
assertFalse(show.isFinalized());
assertFalse(schedules.violations(school, show.getId()).isEmpty());
}
public @Test void invalidDraftsAreSavedButStructuralErrorsAreAtomic() {
final DanceGroup group = group(DanceGroupType.REGULAR);
final Show show = schedules.configure(school, null, settings(2, 1));
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId(), group.getId()), List.of()));
assertFalse(schedules.violations(school, show.getId()).isEmpty());
final SchoolSnapshot before = schools.exportSchool(school);
assertThrows(ManagementException.class, () -> schedules.saveDraft(school, show.getId(), List.of()));
assertThrows(
ManagementException.class,
() -> schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(999L))));
assertEquals(before, schools.exportSchool(school));
}
public @Test void xDefaultsRetainedOnDaypartReductionAndInvalidConfigurationIsAtomic() {
final Show one = schedules.configure(school, null, settings(1, null));
assertEquals(1, one.getPreferredDayParts());
final Show show = schedules.configure(school, null, settings(3, null));
assertEquals(2, show.getPreferredDayParts());
schedules.configure(school, show.getId(), settings(1, null));
assertEquals(2, show.getPreferredDayParts());
assertThrows(ManagementException.class, () -> schools.beginScheduling(school, show.getId()));
schedules.configure(school, show.getId(), settings(1, 1));
assertTrue(schedules.violations(school, show.getId()).isEmpty());
final int before = schools.shows(school).size();
assertThrows(ManagementException.class, () -> schedules.configure(school, null, settings(1, 2)));
assertEquals(before, schools.shows(school).size());
schedules.configure(school, show.getId(), settings(0, null));
assertFalse(schedules.violations(school, show.getId()).isEmpty());
}
public @Test void timeoutPreservesAssignmentsAndIsNotReportedAsInfeasible() {
group(DanceGroupType.REGULAR);
final Show show = schedules.configure(school, null, settings(1, 1));
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
final SchoolSnapshot before = schools.exportSchool(school);
final ManagementException error = assertThrows(
ManagementException.class,
() -> new SchedulingService(schools, new OrToolsSchedulingEngine(0)).schedule(school, show.getId()));
assertTrue(error.getMessage().contains("limit"));
assertFalse(error.getMessage().contains("infeasible"));
assertEquals(before, schools.exportSchool(school));
}
public @Test void emptyGroupsSkippedAndAgeUsesFirstShowDateAcrossDates() {
final DanceGroup ordinary = group(DanceGroupType.PRE_EDUCATION);
group(DanceGroupType.PRE_SCHOOLER);
schools.createGroup(school, new GroupDetails("Empty", DanceGroupType.SHOW_CASE, 0, 99, Set.of()));
final Show show = schedules.configure(school, null, settings(3, 1));
final SchedulingInput input = schools.beginScheduling(school, show.getId());
assertEquals(2, input.groups().size());
assertEquals(26, input.groups().getFirst().averageAge());
assertEquals(1, input.groups().getFirst().appearances());
schools.endScheduling(show.getId());
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
assertFalse(show.isFinalized());
final SchedulingInput stale = schools.beginScheduling(school, show.getId());
final SchedulingResult result = new OrToolsSchedulingEngine().solve(stale);
schools.updateDancer(
school, ordinary.getDancers().iterator().next().getId(), "Dancer", LocalDate.of(2001, 1, 1));
assertThrows(ManagementException.class, () -> schools.finishScheduling(school, stale, result));
schools.endScheduling(show.getId());
assertEquals(
2, SchedulingInputs.of(schools.school(school), show, 0).groups().size());
}
}

View File

@@ -0,0 +1,146 @@
package nl.agilecc.agiledancers;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import io.quarkus.test.junit.QuarkusTest;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
@QuarkusTest
class S18ResourceTest {
private static final String ROOT = "/api/dance-schools";
private static long create(final String url, final Object body) {
return given().contentType("application/json")
.body(body)
.post(url)
.then()
.statusCode(201)
.extract()
.jsonPath()
.getLong("id");
}
private static long dancer(final String school, final String name) {
return create(school + "/dancers", Map.of("name", name, "birthDate", "1990-01-01"));
}
private static long group(final String school, final long dancer) {
final long group = create(
school + "/dance-groups",
Map.of("category", "Group", "type", "REGULAR", "startAge", 0, "endAge", 99, "styles", List.of()));
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then()
.statusCode(204);
return group;
}
private static String show(final String school) {
return school + "/shows/"
+ create(
school + "/shows",
Map.of(
"name",
"Show",
"preferredDayParts",
1,
"dayParts",
List.of(Map.of("date", "2027-01-01", "type", "MORNING"))));
}
public @Test void draftApiFinalizesOnlyValidSchedulesAndRelevantEditsInvalidateThem() {
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 lifecycle"));
try {
final long dancer = dancer(school, "Dancer");
final long group = group(school, dancer);
final String show = show(school);
given().post(show + "/finalize").then().statusCode(400);
given().contentType("application/json")
.body(Map.of("assignments", List.of(List.of(group))))
.put(show + "/assignments")
.then()
.statusCode(200)
.body("finalized", equalTo(false), "violations", empty());
given().post(show + "/finalize").then().statusCode(200).body("finalized", equalTo(true));
given().delete(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then()
.statusCode(204);
given().get(show).then().body("finalized", equalTo(false), "violations", hasSize(1));
given().contentType("application/json")
.body(Map.of("assignments", List.of(List.of(group, group))))
.put(show + "/assignments")
.then()
.statusCode(200);
final String snapshot = given().get(school + "/export").asString();
given().contentType("application/json")
.body(snapshot)
.post(ROOT + "/import/preview")
.then()
.statusCode(200);
} finally {
given().delete(school).then().statusCode(204);
}
}
public @Test void coachIdentityIsExplicitSchoolScopedAndRoundTrips() {
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 coaches"));
try {
final long dancer = dancer(school, "Coach");
group(school, dancer);
final String show = show(school);
given().post(show + "/schedule").then().statusCode(202);
awaitScheduling(show);
given().post(show + "/finalize").then().statusCode(200);
final long coach = given().contentType("application/json")
.body(Map.of("dancerId", dancer))
.post(school + "/coaches")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getLong("id");
given().get(show).then().body("finalized", equalTo(false));
given().get(school + "/coaches").then().body("[0].dancerId", equalTo((int) dancer));
final String exported = given().get(school + "/export").asString();
given().contentType("application/json")
.body(exported)
.post(ROOT + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then()
.statusCode(200);
given().get(school + "/export").then().body("coaches[0].dancerId", equalTo("1"));
final long importedDancer =
given().get(school + "/dancers").jsonPath().getLong("[0].id");
given().contentType("application/json")
.body(Map.of("dancerId", importedDancer))
.put(school + "/coaches/0")
.then()
.statusCode(200);
given().delete(school + "/coaches/0/dancer").then().statusCode(204);
given().contentType("application/json")
.body(Map.of("dancerId", -1))
.post(school + "/coaches")
.then()
.statusCode(404);
given().contentType("application/json")
.body(Map.of("dancerId", importedDancer))
.put(school + "/coaches/9999")
.then()
.statusCode(404);
} finally {
given().delete(school).then().statusCode(204);
}
}
private static void awaitScheduling(final String show) {
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
&& System.nanoTime() < deadline) {
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
}
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
}
}

View File

@@ -0,0 +1,133 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class S18SolverTest {
private GroupRequirement group(final long id, final int count, final boolean showcase, final long dancer) {
return new GroupRequirement(id, count, showcase, Set.of(dancer), Set.of(), 20);
}
private SchedulingResult solve(final SchedulingInput input) {
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
assertTrue(ScheduleRules.violations(input, result.assignments()).isEmpty());
assertTrue(result.optimal());
return result;
}
public @Test void showcasesBookendEveryPartAndSharedDancersHaveChangingSpace() {
final List<GroupRequirement> groups = List.of(
group(1, 2, true, 1),
group(2, 2, true, 2),
group(3, 2, true, 3),
group(4, 1, false, 1),
group(5, 1, false, 5),
group(6, 1, false, 6),
group(7, 1, false, 7));
final SchedulingInput input = new SchedulingInput(1, 0, 2, groups, 1, Set.of());
final SchedulingResult result = solve(input);
for (final List<Long> part : result.assignments()) {
assertTrue(part.getFirst() <= 3);
assertTrue(part.getLast() <= 3);
}
assertEquals(
1,
result.assignments().stream()
.filter((final List<Long> p) -> p.contains(4L))
.count());
}
public @Test void ordinaryDancerMustShareDaypartsAcrossTheirGroups() {
final SchedulingInput input = new SchedulingInput(
1,
0,
2,
List.of(group(1, 1, false, 1), group(2, 1, false, 1), group(3, 1, false, 3), group(4, 1, false, 4)),
1,
Set.of());
assertThrows(ManagementException.class, () -> solve(input));
final SchedulingInput coach = new SchedulingInput(1, 0, 2, input.groups(), 1, Set.of(1L));
final SchedulingResult result = solve(coach);
assertTrue(result.assignments().stream().allMatch((final List<Long> p) -> p.size() == 2));
}
public @Test void showcaseExcessTakesPriorityOverPerfectBalance() {
final SchedulingInput input = new SchedulingInput(
1,
0,
2,
List.of(
group(1, 2, true, 1),
group(2, 1, false, 1),
group(3, 1, false, 1),
group(4, 1, false, 4),
group(5, 1, false, 5),
group(6, 1, false, 6),
group(7, 1, false, 7),
group(8, 1, false, 8),
group(9, 1, false, 9),
group(10, 1, false, 10),
group(11, 1, false, 11)),
1,
Set.of());
final SchedulingResult result = solve(input);
assertTrue(result.assignments().stream().anyMatch((final List<Long> p) -> p.containsAll(List.of(2L, 3L))));
assertEquals(
2,
Math.abs(result.assignments().get(0).size()
- result.assignments().get(1).size()));
}
public @Test void changingTimeAppliesToCoachesAndShowcasesWithoutExceptions() {
final SchedulingInput input =
new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 1)), 1, Set.of(1L));
assertThrows(ManagementException.class, () -> solve(input));
assertTrue(ScheduleRules.violations(input, List.of(List.of(1L, 2L))).stream()
.anyMatch((final String s) -> s.contains("two intervening")));
}
public @Test void singleShowcaseCanUseEitherBoundaryAndNoShowcaseHasNoBoundaryRule() {
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 2)), 1, Set.of()));
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of()));
}
public @Test void sharedValidatorRejectsMalformedAndIncompleteDrafts() {
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of());
assertFalse(ScheduleRules.violations(input, null).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of()).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of(List.of())).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of(List.of(1L, 1L))).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of(List.of(99L))).isEmpty());
final SchedulingInput empty = new SchedulingInput(1, 0, 0, List.of(), 1, Set.of());
assertFalse(ScheduleRules.violations(empty, List.of()).isEmpty());
}
public @Test void varietyUsesStyleOverlapAndFiveYearLinearAgePenalty() {
final GroupRequirement hiphop =
new GroupRequirement(1, 1, false, Set.of(1L), Set.of(DanceGroupStyle.HIPHOP), 10);
final GroupRequirement mixed = new GroupRequirement(
2, 1, false, Set.of(2L), Set.of(DanceGroupStyle.HIPHOP, DanceGroupStyle.MODERN), 12.5);
final GroupRequirement modern =
new GroupRequirement(3, 1, false, Set.of(3L), Set.of(DanceGroupStyle.MODERN), 15);
assertEquals(1_000_000, VarietyScore.penalty(hiphop, mixed));
assertEquals(0, VarietyScore.penalty(hiphop, modern));
final List<Long> order = solve(new SchedulingInput(1, 0, 1, List.of(hiphop, mixed, modern), 1, Set.of()))
.assignments()
.getFirst();
assertEquals(1, Math.abs(order.indexOf(1L) - order.indexOf(3L)));
}
}

View File

@@ -0,0 +1,143 @@
package nl.agilecc.agiledancers;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import org.junit.jupiter.api.Test;
@QuarkusTest
class SchedulingEventsTest {
private @Inject SchoolManagementService schools;
private @Inject ObjectMapper json;
private @Inject nl.agilecc.agiledancers.service.SchoolTransactions transactions;
private @TestHTTPResource URI base;
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
final long school = (Long) transactions.request(
null, null, true, () -> schools.createSchool("SSE school").getId());
try {
final Show show = (Show) transactions.request(school, 0L, true, () -> show(school));
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
given().get(path).then().statusCode(204);
try (final HttpClient client = HttpClient.newHttpClient();
final ExecutorService executor = Executors.newSingleThreadExecutor();
final InputStream stream = stream(client);
final BufferedReader reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
assertNotNull(next(reader).get("runs"));
final String runId = given().post(path)
.then()
.statusCode(202)
.extract()
.jsonPath()
.getString("runId");
final JsonNode completion =
executor.submit(() -> completion(reader, runId)).get(15, TimeUnit.SECONDS);
assertEquals("OPTIMAL", completion.get("state").asText());
assertTrue(completion.get("progress").get("feasible").asBoolean());
given().get(path).then().statusCode(200);
}
assertReconnectContains(show.getId());
} finally {
given().delete("/api/dance-schools/" + school);
}
}
private void assertReconnectContains(final long showId) throws Exception {
try (final HttpClient client = HttpClient.newHttpClient();
final InputStream stream = stream(client);
final BufferedReader reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
final JsonNode runs = next(reader).get("runs");
boolean found = false;
for (final JsonNode run : runs) {
if (run.get("showId").asLong() == showId) {
assertEquals("OPTIMAL", run.get("state").asText());
found = true;
}
}
assertTrue(found);
}
}
private InputStream stream(final HttpClient client) throws Exception {
final HttpResponse<InputStream> response = client.send(
HttpRequest.newBuilder(base.resolve("/api/scheduling/events"))
.header("Accept", "text/event-stream")
.build(),
HttpResponse.BodyHandlers.ofInputStream());
assertEquals(200, response.statusCode());
assertTrue(response.headers().firstValue("content-type").orElse("").startsWith("text/event-stream"));
return response.body();
}
private JsonNode completion(final BufferedReader reader, final String runId) throws Exception {
while (true) {
final JsonNode event = next(reader);
if (event.get("runs").isNull()) {
continue;
}
for (final JsonNode run : event.get("runs")) {
if (run.get("runId").asText().equals(runId)
&& !run.get("state").asText().equals("RUNNING")) {
return run;
}
}
}
}
private JsonNode next(final BufferedReader reader) throws Exception {
String line = reader.readLine();
while (line != null && !line.startsWith("data:")) {
line = reader.readLine();
}
assertNotNull(line);
return json.readTree(line.substring(5).trim());
}
private Show show(final long school) {
final long group = schools.createGroup(
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
.getId();
schools.addGroupDancer(
school,
group,
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
return schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
}
}

View File

@@ -0,0 +1,61 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.service.SchedulingFailure;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingProgressTest {
public @Test void realSolverReportsDiscoveryDuringEachPhase() {
final SchedulingInput input = new SchedulingInput(1, 0, 2, List.of(new GroupRequirement(2, 1)));
final List<SchedulingProgress> updates = new ArrayList<>();
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input, updates::add);
assertTrue(result.optimal());
assertEquals(
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY),
updates.stream().map(SchedulingProgress::phase).distinct().toList());
assertEquals(new SchedulingProgress(SchedulingPhase.ATTENDANCE, false), updates.getFirst());
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.ATTENDANCE, true)));
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.VARIETY, true)));
}
public @Test void noSolutionAndInfeasibleHaveStableDistinctOutcomes() {
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 1)));
assertEquals(
SchedulingFailureKind.NO_SOLUTION,
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine(0).solve(input))
.getOutcome());
final SchedulingInput impossible = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 2)));
assertEquals(
SchedulingFailureKind.INFEASIBLE,
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine().solve(impossible))
.getOutcome());
}
public @Test void summaryMatchesTheThreeObjectiveDefinitionsAndCoachExemptions() {
final GroupRequirement showcase = new GroupRequirement(1, 1, true, Set.of(10L, 11L), Set.of(), 20);
final GroupRequirement regular =
new GroupRequirement(2, 2, false, Set.of(10L, 11L), Set.of(DanceGroupStyle.MODERN), 22);
final SchedulingInput input = new SchedulingInput(1, 0, 3, List.of(showcase, regular), 1, Set.of(11L));
final SchedulingResult result = new SchedulingResult(false, List.of(List.of(1L, 2L), List.of(2L), List.of()));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
assertEquals(1, summary.attendanceExcess());
assertEquals(2, summary.performanceSpread());
assertEquals(VarietyScore.penalty(showcase, regular), summary.varietyPenalty());
}
}

View File

@@ -41,7 +41,12 @@ class SchedulingTest {
}
private DanceGroup group(final DanceGroupType type) {
return service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
final DanceGroup group = service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
service.addGroupDancer(
school,
group.getId(),
service.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
return group;
}
private void run(final Show show) {
@@ -86,7 +91,7 @@ class SchedulingTest {
final Show show = service.createShow(school, "Show", List.of(), null);
assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
final var counts = new EnumMap<>(show.getAppearances());
counts.put(DanceGroupType.PRE_SCHOOLER, 2);

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
@@ -171,15 +171,18 @@ class SchoolManagementResourceTest {
final String show = school + "/shows/" + create(school + "/shows", SHOW);
given().post(show + "/schedule").then().statusCode(400);
final long group = create(school + "/dance-groups", GROUP);
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
final long dancer = create(school + "/dancers", DANCER);
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then()
.statusCode(204);
given().post(show + "/schedule").then().statusCode(202).body("state", equalTo("RUNNING"));
awaitScheduling(show);
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
final String before = given().get(show).asString();
final String counts =
"{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
for (final String count : new String[] {"0", "2", "1.5", "null"}) {
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
final String body =
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":"
+ counts.formatted(count) + "}";
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
+ count + "}";
put(show, body).statusCode(400);
given().get(show).then().body(equalTo(before));
}
@@ -289,4 +292,13 @@ class SchoolManagementResourceTest {
private static ValidatableResponse put(final String path, final Object body) {
return given().contentType("application/json").body(body).put(path).then();
}
private static void awaitScheduling(final String show) {
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
&& System.nanoTime() < deadline) {
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
}
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
}
}

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.equalTo;
@@ -26,7 +26,7 @@ class SchoolSnapshotResourceTest {
.then()
.statusCode(200)
.header("Content-Disposition", containsString(".json"))
.body("formatVersion", equalTo(2))
.body("formatVersion", equalTo(3))
.extract()
.asString();
try {
@@ -58,7 +58,7 @@ class SchoolSnapshotResourceTest {
for (final String bad : new String[] {
"{",
"null",
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"),
json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")

View File

@@ -58,7 +58,7 @@ class SnapshotCollectionsTest {
@Test
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1));
final ShowRequest show = new ShowRequest("Show", List.of(), counts);
final ShowRequest show = new ShowRequest("Show", List.of(), counts, null);
counts.clear();
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
assertThrows(

View File

@@ -0,0 +1,247 @@
package nl.agilecc.agiledancers.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingRunsTest {
private final SchoolManagementService schools = new SchoolManagementService();
private final long school = schools.createSchool("School").getId();
public @Test void startsInBackgroundRejectsDuplicatesAndRecoversCurrentStatus() throws Exception {
final Show show = show();
final var entered = new CountDownLatch(1);
final var release = new CountDownLatch(1);
final var updates = new AtomicReference<List<SchedulingRun>>();
final var runs = new SchedulingRuns(schools, blocking(entered, release));
try {
final Runnable unsubscribe = runs.subscribe(updates::set);
assertTrue(updates.get().isEmpty());
final SchedulingRun started = runs.start(school, show.getId());
assertEquals(SchedulingState.RUNNING, started.state());
assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(started, runs.status(school, show.getId()));
assertThrows(ManagementException.class, () -> runs.start(school, show.getId()));
unsubscribe.run();
final var reconnect = new AtomicReference<List<SchedulingRun>>();
final Runnable disconnect = runs.subscribe(reconnect::set);
assertEquals(started, reconnect.get().getFirst());
release.countDown();
final SchedulingRun finished = await(runs, show);
assertEquals(SchedulingState.OPTIMAL, finished.state());
assertEquals(0, finished.summary().performanceSpread());
assertTrue(finished.progress().feasible());
assertTrue(finished.finishedAt().compareTo(finished.startedAt()) >= 0);
assertFalse(show.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals(SchedulingState.RUNNING, updates.get().getFirst().state());
disconnect.run();
assertNotEquals(started.runId(), runs.start(school, show.getId()).runId());
await(runs, show);
} finally {
release.countDown();
runs.close();
}
}
public @Test void differentShowsRunConcurrentlyAndEditsRejectStaleResults() throws Exception {
final Show first = show();
final Show second = show();
final var entered = new CountDownLatch(2);
final var release = new CountDownLatch(1);
final var runs = new SchedulingRuns(schools, blocking(entered, release));
try {
runs.start(school, first.getId());
runs.start(school, second.getId());
assertTrue(entered.await(10, TimeUnit.SECONDS));
schools.touch(first);
release.countDown();
assertEquals(SchedulingState.STALE, await(runs, first).state());
assertTrue(first.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals(SchedulingState.OPTIMAL, await(runs, second).state());
} finally {
release.countDown();
runs.close();
}
}
public @Test void failuresRemainVisibleAndReleaseTheShowForRetry() {
final Show show = show();
final List<SchedulingEngine> engines = new ArrayList<>();
engines.add((final SchedulingInput input) -> {
throw new IllegalStateException("failure");
});
engines.add((final SchedulingInput input) -> {
throw new UnsatisfiedLinkError("native");
});
engines.add((final SchedulingInput input) -> null);
for (final SchedulingFailureKind kind : SchedulingFailureKind.values()) {
engines.add((final SchedulingInput input) -> {
throw new SchedulingFailure(kind, "Explained outcome");
});
}
for (final SchedulingEngine engine : engines) {
assertFailure(show, engine);
}
}
private void assertFailure(final Show show, final SchedulingEngine engine) {
final var runs = new SchedulingRuns(schools, engine);
try {
runs.start(school, show.getId());
final SchedulingRun failed = await(runs, show);
assertNotEquals(SchedulingState.RUNNING, failed.state());
assertTrue(failed.message().contains("behouden"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
} finally {
runs.close();
}
}
public @Test void validUnprovenResultIsAppliedAndShutdownRejectsNewWorkWithoutLeakingGuard() {
final Show show = show();
final SchedulingEngine engine = (final SchedulingInput input) -> new SchedulingResult(
false, new OrToolsSchedulingEngine().solve(input).assignments());
final var runs = new SchedulingRuns(schools, engine);
runs.start(school, show.getId());
assertEquals(SchedulingState.VALID, await(runs, show).state());
runs.close();
assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> runs.start(school, show.getId()));
assertEquals(
SchedulingState.SOLVER_FAILED, runs.status(school, show.getId()).state());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
}
public @Test void databaseRecoveryPersistsPendingFailureWithoutRestart() throws Exception {
final Show show = show();
final var entered = new CountDownLatch(1);
final var release = new CountDownLatch(1);
final var persistence = new OutagePersistence();
final var runs = new SchedulingRuns(schools, blocking(entered, release));
final java.lang.reflect.Field field = SchedulingRuns.class.getDeclaredField("persistence");
field.setAccessible(true);
field.set(runs, persistence);
try {
final SchedulingRun started = runs.start(school, show.getId());
assertTrue(entered.await(10, TimeUnit.SECONDS));
persistence.available.set(false);
release.countDown();
assertTrue(persistence.unavailable.await(10, TimeUnit.SECONDS));
assertEquals(started, persistence.stored.get());
assertEquals(started, runs.status(school, show.getId()));
assertThrows(ManagementException.class, () -> runs.start(school, show.getId()));
persistence.available.set(true);
final SchedulingRun completed = await(runs, show);
assertEquals(SchedulingState.SOLVER_FAILED, completed.state());
assertEquals(started.runId(), completed.runId());
assertEquals(completed, persistence.stored.get());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
} finally {
persistence.available.set(true);
release.countDown();
runs.close();
}
}
private static class OutagePersistence extends SchedulingPersistence {
private final CountDownLatch unavailable = new CountDownLatch(2);
private final java.util.concurrent.atomic.AtomicBoolean available =
new java.util.concurrent.atomic.AtomicBoolean(true);
private final AtomicReference<SchedulingRun> stored = new AtomicReference<>();
private OutagePersistence() {
super(null, null, null);
}
public @Override <T> T work(final java.util.concurrent.Callable<T> action) {
if (!available.get()) {
unavailable.countDown();
throw new nl.agilecc.agiledancers.repository.StorageException(new IllegalStateException("offline"));
}
try {
return action.call();
} catch (final RuntimeException failure) {
throw failure;
} catch (final Exception failure) {
throw new IllegalStateException(failure);
}
}
public @Override void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) {
save(run, launch);
}
public @Override void save(final SchedulingRun run, final Runnable publication) {
stored.set(run);
publication.run();
}
}
private Show show() {
final long group = schools.createGroup(
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
.getId();
schools.addGroupDancer(
school,
group,
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
return schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
}
private SchedulingEngine blocking(final CountDownLatch entered, final CountDownLatch release) {
return (final SchedulingInput input) -> {
entered.countDown();
try {
assertTrue(release.await(10, TimeUnit.SECONDS));
} catch (final InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
// The real solver's callbacks are covered separately; exercise the default port overload here.
return new OrToolsSchedulingEngine().solve(input);
};
}
private SchedulingRun await(final SchedulingRuns runs, final Show show) {
final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
SchedulingRun status = runs.status(school, show.getId());
while (status.state().equals(SchedulingState.RUNNING) && System.nanoTime() < deadline) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
status = runs.status(school, show.getId());
}
assertNotEquals(SchedulingState.RUNNING, status.state());
return status;
}
}

View File

@@ -29,7 +29,7 @@ class SchedulingServiceTest {
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
final var schools = new SchoolManagementService();
final long school = schools.createSchool("School").getId();
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
populatedGroup(schools, school);
final Show show = schools.createShow(
school,
"Show",
@@ -62,7 +62,7 @@ class SchedulingServiceTest {
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
final var schools = new SchoolManagementService();
final long school = schools.createSchool("School").getId();
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
populatedGroup(schools, school);
final Show show = schools.createShow(
school,
"Show",
@@ -84,7 +84,7 @@ class SchedulingServiceTest {
ManagementExceptionKind.CONFLICT,
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
.getKind());
schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of()));
populatedGroup(schools, school);
} finally {
release.countDown();
}
@@ -98,6 +98,16 @@ class SchedulingServiceTest {
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
}
private static void populatedGroup(final SchoolManagementService schools, final long school) {
final long group = schools.createGroup(
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
.getId();
schools.addGroupDancer(
school,
group,
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
}
private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
final SchedulingEngine engine = (final SchedulingInput input) -> {
entered.countDown();

Some files were not shown because too many files have changed in this diff Show More