Compare commits
66 Commits
f281200f47
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
| 15531ae9e3 | |||
| ad71d2302d | |||
| 53249055c8 | |||
| ec5d7f28d4 | |||
| dad8b0dd59 | |||
| 5b4d53f00f | |||
| 42160b72dc | |||
| c61e2560dd | |||
| 379fd33f95 | |||
| 22a908100e | |||
| 130b8c93e3 | |||
| 574749a2f9 | |||
| 9c343609ee | |||
| 4dd9318b8d | |||
| dc06353b15 | |||
| 8ccffe7b2b | |||
| 4119733bc3 | |||
| 821bcece4f | |||
| 7beeba33ed | |||
| f809d124e3 | |||
| 369689747d | |||
| 8dffbdbd6b | |||
| f5ac671b65 | |||
| 583935ae5d | |||
| 3fa47582a3 | |||
| ab5fe02328 | |||
| 2a900982e4 | |||
| cf0e829346 | |||
| 5c86b425d5 | |||
| a472bd8d08 | |||
| 23cb4d408f | |||
| 7750819d73 | |||
| 77b7159f0a | |||
| c6a2b70919 | |||
| c6a495a93c | |||
| bd898e92d2 | |||
| d2d4713c51 | |||
| 8546f222fe | |||
| 3c3ca1949c | |||
| 7603b3339b | |||
| 98d53e9cb5 | |||
| 36f4b15516 | |||
| 0f1c5dcd92 | |||
| 6acd005f98 | |||
| 01506ef837 | |||
| 7a358ae623 | |||
| 2d8822c1a4 | |||
| 694bf3fa86 | |||
| f59cad115a | |||
| a3cff48a92 | |||
| c241392132 | |||
| f1785ed174 | |||
| b92fac1d1a | |||
| 95430691e4 | |||
| 95aa8c6302 | |||
| 3369b9a81c | |||
| b0d6592761 | |||
| 60496de8bb | |||
| 525e9f9335 | |||
| c9db528756 | |||
| b1544357bf | |||
| 3a5203eb47 | |||
| a7f7730ea0 | |||
| c965f9b41d | |||
| 8fc430537b | |||
| fc307de64e |
@@ -5,7 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: agiledancers-mini-pc
|
runs-on: mini-pc
|
||||||
env:
|
env:
|
||||||
CI: 'true'
|
CI: 'true'
|
||||||
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
|
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
|
||||||
@@ -19,21 +19,23 @@ jobs:
|
|||||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Verify pinned engineering resources
|
||||||
|
run: python3 scripts/engineering.py --check
|
||||||
- name: Identify workflow attempt
|
- name: Identify workflow attempt
|
||||||
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
||||||
# container names and diagnostic artifacts, including reruns of one SHA.
|
# 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
|
- name: Build and test both images
|
||||||
run: java ci/Ci.java verify
|
run: java generated/ci/Ci.java verify
|
||||||
- name: Publish verified main images
|
- name: Publish verified main images
|
||||||
if: success() && gitea.ref == 'refs/heads/main'
|
if: success() && gitea.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
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
|
- name: Collect diagnostics and clean up test containers
|
||||||
if: always()
|
if: always()
|
||||||
run: java ci/Ci.java cleanup
|
run: java generated/ci/Ci.java cleanup
|
||||||
- name: Retain diagnostics for fourteen days
|
- name: Retain diagnostics for fourteen days
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||||
|
|||||||
70
.gitea/workflows/deploy.yaml
Normal file
70
.gitea/workflows/deploy.yaml
Normal 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
|
||||||
44
.gitea/workflows/rollback.yaml
Normal file
44
.gitea/workflows/rollback.yaml
Normal 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
|
||||||
32
AGENTS.md
32
AGENTS.md
@@ -1,22 +1,26 @@
|
|||||||
# Project skills and specifications
|
# Shared skills and specifications
|
||||||
|
|
||||||
- Project skills are stored in `skills/`; specifications belong in `specs/`.
|
- Shared agent workflows live in [landscape skills](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/README.md).
|
||||||
- For code reviews, read and follow `skills/code-review/SKILL.md`. If no
|
Read the requested SKILL.md there before applying it. A sibling checkout is optional.
|
||||||
dedicated Skill tool is available, load the file directly.
|
- For code reviews use landscape's skills/code-review/SKILL.md with docs/backend-review.md.
|
||||||
- Use `grill-me` only when the user explicitly requests it, for example
|
- Use grill-me only when explicitly requested, with its skills/grilling/SKILL.md dependency.
|
||||||
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
|
Installing or mentioning a skill starts no interview.
|
||||||
- Before using it, read `skills/grill-me/SKILL.md` and its dependency
|
- Keep upstream skills unchanged; licenses and revision records live in landscape/skills/.
|
||||||
`skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these
|
- Specifications and ADRs belong to landscape. Engineering owns pinned build resources.
|
||||||
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`.
|
|
||||||
|
|
||||||
# Backend changes and reviews
|
# Backend changes and reviews
|
||||||
|
|
||||||
- Before changing backend Java, read `CODING_STANDARDS.md`.
|
- 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.
|
`docs/backend-review.md`. Keep upstream skills unchanged.
|
||||||
- Obtain maintainer agreement before adding any standards waiver; record its
|
- Obtain maintainer agreement before adding any standards waiver; record its
|
||||||
narrow scope and reason alongside the affected code or configuration.
|
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.
|
||||||
|
|||||||
@@ -1,255 +1,11 @@
|
|||||||
# Backend coding standards
|
# Agile Dancers coding standards
|
||||||
|
|
||||||
This document is the authoritative policy for handwritten production and test Java
|
Read [the pinned engineering standards](generated/CODING_STANDARDS.md) before
|
||||||
in `agiledancers-be`. Generated code and Java helpers outside that module are out
|
changing Java. Their backend-module placeholder means `agiledancers-be` here.
|
||||||
of scope. Existing frontend conventions in README.md and agiledancers-fe remain in force.
|
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
|
Validation evidence: [S7](docs/validation/S7-validation.md).
|
||||||
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.
|
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -2,11 +2,17 @@ FROM maven:3.9-eclipse-temurin-25 AS build
|
|||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY pom.xml ./
|
COPY pom.xml ./
|
||||||
COPY .mvn .mvn
|
COPY .mvn .mvn
|
||||||
|
COPY generated generated
|
||||||
COPY tooling tooling
|
COPY tooling tooling
|
||||||
COPY ci ci
|
COPY engineering.properties engineering.properties
|
||||||
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
||||||
COPY agiledancers-be/src agiledancers-be/src
|
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
|
FROM eclipse-temurin:25-jre AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
121
README.md
121
README.md
@@ -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
|
# agiledancers
|
||||||
|
|
||||||
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
|
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:
|
Run the packaged application after building:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Set DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD first.
|
||||||
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
|
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
|
```sh
|
||||||
docker build -t agiledancers .
|
docker build --network=host \
|
||||||
docker run --rm --name agiledancers -p 8080:8080 agiledancers
|
--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,
|
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
|
Quarkus configuration can be overridden with environment variables, for example
|
||||||
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
|
`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:
|
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 |
|
| `model` | Domain entities and specifications | Model |
|
||||||
| `service` | Application use cases and future ports | Model, repositories |
|
| `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 |
|
| `rest` | Future REST services | Services, model |
|
||||||
| `solver` | Future OR-Tools calculations | Services (ports), 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.
|
ID and appearance count in one record.
|
||||||
|
|
||||||
`service.port.SchedulingEngine` is the application-owned interface for solving a
|
`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
|
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
|
||||||
types, so neither the caller nor the contract depends on OR-Tools or the concrete
|
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
|
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`),
|
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
|
||||||
with unique date/type combinations. The displayed date range comes from these
|
with unique date/type combinations. The displayed date range comes from these
|
||||||
dayparts. Each show stores `appearances` for every group type. Omitting counts
|
dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
|
||||||
initializes defaults on creation or the initial daypart configuration; later
|
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
|
||||||
edits preserve saved counts and reject counts outside 1..N. With no dayparts,
|
dayparts; preschool groups once; showcases every daypart. See
|
||||||
shows remain unscheduled and saved counts are retained.
|
[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
|
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a
|
||||||
assign every current school group to its required number of distinct dayparts,
|
run identifier and starts an ordered draft calculation in the background using
|
||||||
minimizing the busiest/quietest appearance gap. It replaces assignments only on
|
OR-Tools with a total thirty-minute time limit. Duplicate starts for the same
|
||||||
a valid, current result. The response distinguishes optimal balance from a valid
|
show return `409`; different shows may run concurrently. A feasible result is
|
||||||
result whose optimality is unproven. Concurrent runs return `409`; changed inputs
|
accepted without claiming optimality. Stale results and failures retain existing
|
||||||
also reject the result with `409`. Failed runs preserve existing assignments.
|
assignments and appear as distinct terminal run states.
|
||||||
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.
|
|
||||||
|
|
||||||
School JSON exports use format version **2**, including dated dayparts, assignments,
|
`GET .../shows/{showId}/schedule` returns the latest run (`204` if none exists).
|
||||||
and all appearance counts. Version 1 files are intentionally unsupported.
|
`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:
|
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)
|
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
|
||||||
for the current operational status.
|
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)
|
### Java formatting (S8)
|
||||||
|
|
||||||
Maven is the authoritative formatting path. The root build runs the actual
|
Maven is the authoritative formatting path. The root build runs the actual
|
||||||
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
|
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
|
`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.
|
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
|
||||||
No OpenRewrite AutoFormat or additional refactoring recipes run.
|
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.
|
standalone Maven apply command as an external tool.
|
||||||
|
|
||||||
Verification and measurements are recorded in
|
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
|
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
|
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
|
||||||
prerequisites). Results are written under `target/`.
|
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
|
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
|
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
|
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.
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<dependencies>
|
<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>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-rest-jackson</artifactId>
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
@@ -63,6 +68,16 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<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>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
|
<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>
|
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>
|
||||||
@@ -70,38 +85,38 @@
|
|||||||
</execution>
|
</execution>
|
||||||
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
|
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
|
||||||
<configuration><artifactItems>
|
<configuration><artifactItems>
|
||||||
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.14</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
|
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.16</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
|
||||||
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.14</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
|
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.16</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
|
||||||
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
|
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
|
||||||
</execution></executions>
|
</execution></executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version>
|
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.4</version>
|
||||||
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<executable>${maven.home}/bin/mvn</executable>
|
<executable>${maven.home}/bin/mvn</executable>
|
||||||
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
|
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
|
||||||
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
|
<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>
|
<argument>test</argument><argument>exec:java</argument>
|
||||||
</arguments>
|
</arguments>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
||||||
<configuration><executable>${java.home}/bin/java</executable><arguments>
|
<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>
|
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
|
||||||
</arguments></configuration>
|
</arguments></configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
|
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
|
||||||
<configuration><executable>${java.home}/bin/java</executable><arguments>
|
<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>
|
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
|
||||||
</arguments></configuration>
|
</arguments></configuration>
|
||||||
</execution></executions>
|
</execution></executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.14</version>
|
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.16</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
|
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
|
||||||
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
|
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import lombok.AccessLevel;
|
|||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@@ -13,4 +14,7 @@ import lombok.ToString;
|
|||||||
public class Coach extends AbstractEntity {
|
public class Coach extends AbstractEntity {
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
private final String name;
|
private final String name;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private Dancer dancer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public class DanceSchool extends AbstractEntity {
|
|||||||
@Setter
|
@Setter
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private long revision;
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public class Show extends AbstractEntity {
|
|||||||
@Setter
|
@Setter
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private Integer preferredDayParts;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private boolean finalized;
|
||||||
|
|
||||||
@Setter
|
@Setter
|
||||||
private Map<DanceGroupType, Integer> appearances;
|
private Map<DanceGroupType, Integer> appearances;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
package nl.agilecc.agiledancers.model.scheduling;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
|
public enum SchedulingFailureKind {
|
||||||
|
INFEASIBLE,
|
||||||
|
NO_SOLUTION,
|
||||||
|
SOLVER_FAILED
|
||||||
|
}
|
||||||
@@ -1,9 +1,27 @@
|
|||||||
package nl.agilecc.agiledancers.model.scheduling;
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
import java.util.List;
|
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 {
|
public SchedulingInput {
|
||||||
groups = List.copyOf(groups);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
|
public enum SchedulingPhase {
|
||||||
|
PREPARING,
|
||||||
|
ATTENDANCE,
|
||||||
|
SPREAD,
|
||||||
|
VARIETY
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,19 @@ import nl.agilecc.agiledancers.model.AbstractEntity;
|
|||||||
|
|
||||||
public abstract class AbstractRepository<T extends AbstractEntity> {
|
public abstract class AbstractRepository<T extends AbstractEntity> {
|
||||||
private long sequence = 0L;
|
private long sequence = 0L;
|
||||||
|
private java.util.function.LongSupplier identifiers;
|
||||||
|
|
||||||
protected final Map<Long, T> entities = new LinkedHashMap<>();
|
protected final Map<Long, T> entities = new LinkedHashMap<>();
|
||||||
|
|
||||||
public long nextId() {
|
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) {
|
public T add(final T entity) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,26 +19,30 @@ import java.util.List;
|
|||||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
|
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.SchoolManagementService;
|
||||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class DanceGroupResource {
|
public class DanceGroupResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @PathParam("schoolId") long schoolId;
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public List<GroupView> list() {
|
public List<GroupView> list() {
|
||||||
return service.groups(schoolId).stream().map(GroupView::of).toList();
|
return service.groups(schoolId).stream().map(views::group).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public GroupView get(final @PathParam("id") long id) {
|
public GroupView get(final @PathParam("id") long id) {
|
||||||
return GroupView.of(service.group(schoolId, id));
|
return views.group(service.group(schoolId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -50,14 +54,14 @@ public class DanceGroupResource {
|
|||||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(GroupView.of(entity))
|
.entity(views.group(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
|
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
|
||||||
return GroupView.of(service.updateGroup(
|
return views.group(service.updateGroup(
|
||||||
schoolId,
|
schoolId,
|
||||||
id,
|
id,
|
||||||
new GroupDetails(
|
new GroupDetails(
|
||||||
|
|||||||
@@ -19,23 +19,44 @@ import java.util.List;
|
|||||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||||
|
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools")
|
@Path("/api/dance-schools")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class DanceSchoolResource {
|
public class DanceSchoolResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
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
|
@GET
|
||||||
public List<SchoolView> list() {
|
public List<SchoolView> list() {
|
||||||
return service.schools().stream().map(SchoolView::of).toList();
|
return service.schools().stream().map(views::school).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public SchoolView get(final @PathParam("id") long id) {
|
public SchoolView get(final @PathParam("id") long id) {
|
||||||
return SchoolView.of(service.school(id));
|
return views.school(service.school(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -44,14 +65,14 @@ public class DanceSchoolResource {
|
|||||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(SchoolView.of(entity))
|
.entity(views.school(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
|
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
|
@DELETE
|
||||||
|
|||||||
@@ -19,25 +19,29 @@ import java.util.List;
|
|||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
||||||
|
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools/{schoolId}/dancers")
|
@Path("/api/dance-schools/{schoolId}/dancers")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class DancerResource {
|
public class DancerResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @PathParam("schoolId") long schoolId;
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public List<DancerView> list() {
|
public List<DancerView> list() {
|
||||||
return service.dancers(schoolId).stream().map(DancerView::of).toList();
|
return service.dancers(schoolId).stream().map(views::dancer).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public DancerView get(final @PathParam("id") long id) {
|
public DancerView get(final @PathParam("id") long id) {
|
||||||
return DancerView.of(service.dancer(schoolId, id));
|
return views.dancer(service.dancer(schoolId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -46,14 +50,14 @@ public class DancerResource {
|
|||||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(DancerView.of(entity))
|
.entity(views.dancer(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
|
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
|
@DELETE
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
|
|||||||
public Response toResponse(final ManagementException exception) {
|
public Response toResponse(final ManagementException exception) {
|
||||||
final int status = switch (exception.getKind()) {
|
final int status = switch (exception.getKind()) {
|
||||||
case NOT_FOUND -> 404;
|
case NOT_FOUND -> 404;
|
||||||
|
case STORAGE_UNAVAILABLE -> 503;
|
||||||
case INVALID_INPUT -> 400;
|
case INVALID_INPUT -> 400;
|
||||||
case CONFLICT -> 409;
|
case CONFLICT -> 409;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,15 +15,19 @@ import jakarta.ws.rs.QueryParam;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
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.ManagementException;
|
||||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
||||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools")
|
@Path("/api/dance-schools")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public class SchoolSnapshotResource {
|
public class SchoolSnapshotResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @Inject ObjectMapper mapper;
|
private @Inject ObjectMapper mapper;
|
||||||
@@ -48,7 +52,7 @@ public class SchoolSnapshotResource {
|
|||||||
@Path("/import")
|
@Path("/import")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) {
|
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) {
|
private SchoolSnapshot parse(final String json) {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -18,49 +18,81 @@ import jakarta.ws.rs.core.UriInfo;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
import nl.agilecc.agiledancers.model.Show;
|
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.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.ShowRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
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.SchoolManagementService;
|
||||||
|
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools/{schoolId}/shows")
|
@Path("/api/dance-schools/{schoolId}/shows")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class ShowResource {
|
public class ShowResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @Inject SchedulingService scheduling;
|
private @Inject SchedulingRuns scheduling;
|
||||||
|
|
||||||
|
private @Inject ShowSchedules schedules;
|
||||||
|
|
||||||
private @PathParam("schoolId") long schoolId;
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public List<ShowView> list() {
|
public List<ShowView> list() {
|
||||||
return service.shows(schoolId).stream().map(ShowView::of).toList();
|
return service.shows(schoolId).stream().map(this::view).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public ShowView get(final @PathParam("id") long id) {
|
public ShowView get(final @PathParam("id") long id) {
|
||||||
return ShowView.of(service.show(schoolId, id));
|
return view(service.show(schoolId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
|
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()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(ShowView.of(entity))
|
.entity(view(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
|
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) {
|
private static List<DayPart> parts(final ShowRequest request) {
|
||||||
@@ -76,10 +108,14 @@ public class ShowResource {
|
|||||||
@POST
|
@POST
|
||||||
@Path("/{id}/schedule")
|
@Path("/{id}/schedule")
|
||||||
@Consumes(MediaType.WILDCARD)
|
@Consumes(MediaType.WILDCARD)
|
||||||
public SchedulingView schedule(final @PathParam("id") long id) {
|
public Response schedule(final @PathParam("id") long id) {
|
||||||
final SchedulingResult result = scheduling.schedule(schoolId, id);
|
return Response.accepted(scheduling.start(schoolId, id)).build();
|
||||||
return new SchedulingView(
|
}
|
||||||
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/schedule")
|
||||||
|
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
|
||||||
|
return scheduling.status(schoolId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record CoachRequest(@NotNull Long dancerId) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
|
public record CoachView(long id, String name, Long dancerId) {}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
|
||||||
|
|
||||||
public record DancerView(long id, String name, LocalDate birthDate) {
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import java.util.Collections;
|
|||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
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.DanceGroupStyle;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
@@ -22,15 +20,4 @@ public record GroupView(
|
|||||||
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
|
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
|
||||||
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,12 @@ package nl.agilecc.agiledancers.rest.apimodel;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
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 {
|
public SchoolView {
|
||||||
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
||||||
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
|
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
|
||||||
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
|||||||
public record ShowRequest(
|
public record ShowRequest(
|
||||||
@NotBlank String name,
|
@NotBlank String name,
|
||||||
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||||
Map<DanceGroupType, Integer> appearances) {
|
Map<DanceGroupType, Integer> appearances,
|
||||||
|
Integer preferredDayParts) {
|
||||||
public ShowRequest {
|
public ShowRequest {
|
||||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||||
|
|||||||
@@ -5,29 +5,19 @@ import java.util.Collections;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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;
|
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 {
|
public ShowView {
|
||||||
|
violations = List.copyOf(violations);
|
||||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package nl.agilecc.agiledancers.service;
|
|||||||
|
|
||||||
public enum ManagementExceptionKind {
|
public enum ManagementExceptionKind {
|
||||||
NOT_FOUND,
|
NOT_FOUND,
|
||||||
|
STORAGE_UNAVAILABLE,
|
||||||
INVALID_INPUT,
|
INVALID_INPUT,
|
||||||
CONFLICT
|
CONFLICT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import nl.agilecc.agiledancers.model.DanceSchool;
|
|||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
import nl.agilecc.agiledancers.model.Show;
|
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.SchedulingInput;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
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.ImportPreview;
|
||||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
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
|
@Singleton
|
||||||
public class SchoolManagementService {
|
public class SchoolManagementService {
|
||||||
private final DanceSchoolRepository schools = new DanceSchoolRepository();
|
private final DanceSchoolRepository schools = new DanceSchoolRepository();
|
||||||
@@ -41,7 +41,29 @@ public class SchoolManagementService {
|
|||||||
private final Map<Long, Long> revisions = new HashMap<>();
|
private final Map<Long, Long> revisions = new HashMap<>();
|
||||||
private final Set<Long> running = new HashSet<>();
|
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);
|
revisions.merge(show.getId(), 1L, Long::sum);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +80,13 @@ public class SchoolManagementService {
|
|||||||
ManagementExceptionKind.INVALID_INPUT,
|
ManagementExceptionKind.INVALID_INPUT,
|
||||||
"Scheduling requires at least one school group and one daypart.");
|
"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);
|
running.add(showId);
|
||||||
return new SchedulingInput(
|
return SchedulingInputs.of(
|
||||||
showId,
|
school(schoolId), show, school(schoolId).getRevision() + revisions.getOrDefault(showId, 0L));
|
||||||
revisions.getOrDefault(showId, 0L),
|
|
||||||
show.getDayParts().size(),
|
|
||||||
current.stream()
|
|
||||||
.map((final DanceGroup g) -> new GroupRequirement(
|
|
||||||
g.getId(), show.getAppearances().get(g.getType())))
|
|
||||||
.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates the complete result and input revision before replacing any saved assignments. */
|
/** Validates the complete result and input revision before replacing any saved assignments. */
|
||||||
@@ -80,7 +99,7 @@ public class SchoolManagementService {
|
|||||||
throw new ManagementException(
|
throw new ManagementException(
|
||||||
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
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(
|
throw new ManagementException(
|
||||||
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||||
}
|
}
|
||||||
@@ -97,6 +116,7 @@ public class SchoolManagementService {
|
|||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
show.setDayParts(List.copyOf(replacement));
|
show.setDayParts(List.copyOf(replacement));
|
||||||
|
touch(show);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Long, DanceGroup> validateAssignments(
|
private Map<Long, DanceGroup> validateAssignments(
|
||||||
@@ -111,29 +131,13 @@ public class SchoolManagementService {
|
|||||||
}
|
}
|
||||||
final Map<Long, DanceGroup> current = new HashMap<>();
|
final Map<Long, DanceGroup> current = new HashMap<>();
|
||||||
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
||||||
validateParts(result.assignments(), current.keySet());
|
final List<String> violations = ScheduleRules.violations(input, result.assignments());
|
||||||
for (final GroupRequirement group : input.groups()) {
|
if (!violations.isEmpty()) {
|
||||||
final long id = group.groupId();
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return current;
|
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. */
|
/** Releases the run slot after success or failure, without changing saved assignments. */
|
||||||
public synchronized void endScheduling(final long showId) {
|
public synchronized void endScheduling(final long showId) {
|
||||||
running.remove(showId);
|
running.remove(showId);
|
||||||
@@ -186,6 +190,7 @@ public class SchoolManagementService {
|
|||||||
final Dancer dancer = dancer(schoolId, dancerId);
|
final Dancer dancer = dancer(schoolId, dancerId);
|
||||||
dancer.setName(name);
|
dancer.setName(name);
|
||||||
dancer.setBirthDate(birthDate);
|
dancer.setBirthDate(birthDate);
|
||||||
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
return dancer;
|
return dancer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +198,10 @@ public class SchoolManagementService {
|
|||||||
final DanceSchool school = school(schoolId);
|
final DanceSchool school = school(schoolId);
|
||||||
final Dancer dancer = dancer(schoolId, dancerId);
|
final Dancer dancer = dancer(schoolId, dancerId);
|
||||||
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
|
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);
|
school.removeDancer(dancer);
|
||||||
dancers.delete(dancerId);
|
dancers.delete(dancerId);
|
||||||
}
|
}
|
||||||
@@ -224,7 +233,7 @@ public class SchoolManagementService {
|
|||||||
final DanceGroup group = group(schoolId, groupId);
|
final DanceGroup group = group(schoolId, groupId);
|
||||||
validateAgeRange(details.startAge(), details.endAge());
|
validateAgeRange(details.startAge(), details.endAge());
|
||||||
group.setCategory(details.category());
|
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);
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
}
|
}
|
||||||
group.setType(details.type());
|
group.setType(details.type());
|
||||||
@@ -248,11 +257,13 @@ public class SchoolManagementService {
|
|||||||
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||||
final DanceGroup group = group(schoolId, groupId);
|
final DanceGroup group = group(schoolId, groupId);
|
||||||
group.addDancer(dancer(schoolId, dancerId));
|
group.addDancer(dancer(schoolId, dancerId));
|
||||||
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||||
final DanceGroup group = group(schoolId, groupId);
|
final DanceGroup group = group(schoolId, groupId);
|
||||||
group.removeDancer(dancer(schoolId, dancerId));
|
group.removeDancer(dancer(schoolId, dancerId));
|
||||||
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized List<Show> shows(final long schoolId) {
|
public synchronized List<Show> shows(final long schoolId) {
|
||||||
@@ -273,6 +284,7 @@ public class SchoolManagementService {
|
|||||||
final Show show = shows.add(Show.builder()
|
final Show show = shows.add(Show.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.dayParts(List.copyOf(parts))
|
.dayParts(List.copyOf(parts))
|
||||||
|
.preferredDayParts((parts.size() == 1 ? 1 : 2))
|
||||||
.appearances(Map.copyOf(counts))
|
.appearances(Map.copyOf(counts))
|
||||||
.build());
|
.build());
|
||||||
school.addShow(show);
|
school.addShow(show);
|
||||||
@@ -292,18 +304,7 @@ public class SchoolManagementService {
|
|||||||
ShowConfiguration.validate(parts, counts);
|
ShowConfiguration.validate(parts, counts);
|
||||||
final boolean changed =
|
final boolean changed =
|
||||||
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||||
final List<DayPart> retained = parts.stream()
|
final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
|
||||||
.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();
|
|
||||||
show.setName(name);
|
show.setName(name);
|
||||||
show.setDayParts(retained);
|
show.setDayParts(retained);
|
||||||
show.setAppearances(Map.copyOf(counts));
|
show.setAppearances(Map.copyOf(counts));
|
||||||
@@ -341,12 +342,14 @@ public class SchoolManagementService {
|
|||||||
snapshot.groups().size(),
|
snapshot.groups().size(),
|
||||||
snapshot.shows().size(),
|
snapshot.shows().size(),
|
||||||
existing == null ? null : existing.getId(),
|
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. */
|
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
|
||||||
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
|
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
|
||||||
final DanceSchool staged = snapshot.restore();
|
final DanceSchool staged = snapshot.restore();
|
||||||
|
final long revision = replaceId == null ? 0 : school(replaceId).getRevision();
|
||||||
final ImportPreview preview = preview(snapshot);
|
final ImportPreview preview = preview(snapshot);
|
||||||
if (!Objects.equals(preview.existingId(), replaceId)) {
|
if (!Objects.equals(preview.existingId(), replaceId)) {
|
||||||
throw new ManagementException(
|
throw new ManagementException(
|
||||||
@@ -368,6 +371,7 @@ public class SchoolManagementService {
|
|||||||
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
|
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
|
||||||
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
|
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
|
||||||
final DanceSchool imported = DanceSchool.builder()
|
final DanceSchool imported = DanceSchool.builder()
|
||||||
|
.revision(revision)
|
||||||
.key(staged.getKey())
|
.key(staged.getKey())
|
||||||
.name(staged.getName())
|
.name(staged.getName())
|
||||||
.dancers(new LinkedHashSet<>(importedDancers))
|
.dancers(new LinkedHashSet<>(importedDancers))
|
||||||
@@ -413,6 +417,7 @@ public class SchoolManagementService {
|
|||||||
final Show rebuilt = Show.builder()
|
final Show rebuilt = Show.builder()
|
||||||
.name(show.getName())
|
.name(show.getName())
|
||||||
.appearances(show.getAppearances())
|
.appearances(show.getAppearances())
|
||||||
|
.preferredDayParts(show.getPreferredDayParts())
|
||||||
.dayParts(show.getDayParts().stream()
|
.dayParts(show.getDayParts().stream()
|
||||||
.map((final DayPart p) -> DayPart.builder()
|
.map((final DayPart p) -> DayPart.builder()
|
||||||
.date(p.getDate())
|
.date(p.getDate())
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,27 @@ import java.util.EnumMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
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.DanceGroupType;
|
||||||
|
|
||||||
public final class ShowConfiguration {
|
public final class ShowConfiguration {
|
||||||
private 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) {
|
public static Map<DanceGroupType, Integer> defaults(final int n) {
|
||||||
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
@@ -18,7 +34,7 @@ public final class ShowConfiguration {
|
|||||||
type,
|
type,
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case PRE_SCHOOLER -> 1;
|
case PRE_SCHOOLER -> 1;
|
||||||
case SHOW_CASE, PRE_EDUCATION -> n;
|
case SHOW_CASE -> n;
|
||||||
default -> Math.min(2, 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
|
if (parts == null
|
||||||
|| parts.size() > 6
|
|| parts.size() > 6
|
||||||
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
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) {}
|
||||||
|
|||||||
@@ -7,7 +7,21 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
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 {
|
public Performance {
|
||||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||||
|
|||||||
@@ -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) {}
|
||||||
@@ -23,7 +23,7 @@ import nl.agilecc.agiledancers.service.ManagementException;
|
|||||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||||
import nl.agilecc.agiledancers.service.ShowConfiguration;
|
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(
|
public record SchoolSnapshot(
|
||||||
Integer formatVersion,
|
Integer formatVersion,
|
||||||
UUID key,
|
UUID key,
|
||||||
@@ -45,14 +45,15 @@ public record SchoolSnapshot(
|
|||||||
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
||||||
final Map<Show, String> shows = ids(s.getShows());
|
final Map<Show, String> shows = ids(s.getShows());
|
||||||
return new SchoolSnapshot(
|
return new SchoolSnapshot(
|
||||||
2,
|
3,
|
||||||
s.getKey(),
|
s.getKey(),
|
||||||
s.getName(),
|
s.getName(),
|
||||||
s.getDancers().stream()
|
s.getDancers().stream()
|
||||||
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
|
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
|
||||||
.toList(),
|
.toList(),
|
||||||
s.getCoaches().stream()
|
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(),
|
.toList(),
|
||||||
s.getDanceGroups().stream()
|
s.getDanceGroups().stream()
|
||||||
.map((final DanceGroup g) -> new Group(
|
.map((final DanceGroup g) -> new Group(
|
||||||
@@ -77,7 +78,9 @@ public record SchoolSnapshot(
|
|||||||
.map(groups::get)
|
.map(groups::get)
|
||||||
.toList()))
|
.toList()))
|
||||||
.toList(),
|
.toList(),
|
||||||
show.getAppearances()))
|
show.getAppearances(),
|
||||||
|
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
|
||||||
|
show.isFinalized()))
|
||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +94,14 @@ public record SchoolSnapshot(
|
|||||||
|
|
||||||
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
|
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
|
||||||
public DanceSchool restore() {
|
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");
|
check(key != null, "key ontbreekt");
|
||||||
text(name, "schoolnaam");
|
text(name, "schoolnaam");
|
||||||
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
||||||
final Map<String, Dancer> dancerMap = restoreDancers(school);
|
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);
|
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
|
||||||
restoreShows(school, groupMap);
|
restoreShows(school, groupMap);
|
||||||
return school;
|
return school;
|
||||||
@@ -120,12 +125,15 @@ public record SchoolSnapshot(
|
|||||||
return dancerMap;
|
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<>();
|
final Map<String, Coach> coachMap = new LinkedHashMap<>();
|
||||||
for (final Trainer c : list(coaches, "coaches")) {
|
for (final Trainer c : list(coaches, "coaches")) {
|
||||||
check(c != null, "Lege coach");
|
check(c != null, "Lege coach");
|
||||||
text(c.name(), "coachnaam");
|
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());
|
coach.setId((long) coachMap.size());
|
||||||
put(coachMap, c.id(), coach);
|
put(coachMap, c.id(), coach);
|
||||||
school.addCoach(coach);
|
school.addCoach(coach);
|
||||||
@@ -190,17 +198,25 @@ public record SchoolSnapshot(
|
|||||||
parts.add(DayPart.builder()
|
parts.add(DayPart.builder()
|
||||||
.date(part.date())
|
.date(part.date())
|
||||||
.type(part.type())
|
.type(part.type())
|
||||||
.danceGroups(unique(part.groupIds(), "groupIds").stream()
|
.danceGroups(list(part.groupIds(), "groupIds").stream()
|
||||||
.map((final String id) ->
|
.map((final String id) ->
|
||||||
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
|
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
|
||||||
.toList())
|
.toList())
|
||||||
.build());
|
.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()
|
final Show entity = Show.builder()
|
||||||
.name(show.name())
|
.name(show.name())
|
||||||
.dayParts(parts)
|
.dayParts(parts)
|
||||||
.appearances(Map.copyOf(show.appearances()))
|
.appearances(
|
||||||
|
show.appearances() == null
|
||||||
|
? ShowConfiguration.defaults(parts.size())
|
||||||
|
: Map.copyOf(show.appearances()))
|
||||||
|
.preferredDayParts(preferredDayParts)
|
||||||
|
.finalized(false)
|
||||||
.build();
|
.build();
|
||||||
entity.setId((long) showIds.size());
|
entity.setId((long) showIds.size());
|
||||||
school.addShow(entity);
|
school.addShow(entity);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package nl.agilecc.agiledancers.service.port;
|
package nl.agilecc.agiledancers.service.port;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
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.SchedulingResult;
|
||||||
|
|
||||||
public interface SchedulingEngine {
|
public interface SchedulingEngine {
|
||||||
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
|
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
|
||||||
public SchedulingResult solve(final SchedulingInput input);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,89 @@
|
|||||||
package nl.agilecc.agiledancers.solver;
|
package nl.agilecc.agiledancers.solver;
|
||||||
|
|
||||||
import com.google.ortools.Loader;
|
import com.google.ortools.Loader;
|
||||||
import com.google.ortools.sat.BoolVar;
|
|
||||||
import com.google.ortools.sat.CpModel;
|
import com.google.ortools.sat.CpModel;
|
||||||
import com.google.ortools.sat.CpSolver;
|
import com.google.ortools.sat.CpSolver;
|
||||||
import com.google.ortools.sat.CpSolverStatus;
|
import com.google.ortools.sat.CpSolverStatus;
|
||||||
import com.google.ortools.sat.IntVar;
|
|
||||||
import com.google.ortools.sat.LinearExpr;
|
import com.google.ortools.sat.LinearExpr;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
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.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.SchedulingResult;
|
||||||
import nl.agilecc.agiledancers.service.ManagementException;
|
import nl.agilecc.agiledancers.service.SchedulingFailure;
|
||||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
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
|
@Override
|
||||||
public SchedulingResult solve(final SchedulingInput input) {
|
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();
|
Loader.loadNativeLibraries();
|
||||||
final CpModel model = new CpModel();
|
final CpModel model = new CpModel();
|
||||||
final BoolVar[][] assigned = assignments(model, input);
|
final OrderedAssignments vars = OrderedAssignments.create(model, input);
|
||||||
balance(model, assigned, input.dayParts());
|
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
|
||||||
|
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
|
||||||
final CpSolver solver = new CpSolver();
|
final CpSolver solver = new CpSolver();
|
||||||
solver.getParameters().setMaxTimeInSeconds(1800);
|
|
||||||
// Quarkus owns process shutdown; do not install a native SIGINT handler.
|
|
||||||
solver.getParameters().setCatchSigintSignal(false);
|
solver.getParameters().setCatchSigintSignal(false);
|
||||||
// Single-worker search with a fixed seed makes identical inputs repeatable.
|
|
||||||
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
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);
|
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;
|
||||||
}
|
}
|
||||||
|
model.addEquality(objective, Math.round(solver.objectiveValue()));
|
||||||
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(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
|
return best;
|
||||||
}
|
|
||||||
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}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void requireSolution(final CpSolverStatus status) {
|
private static void requireSolution(final CpSolverStatus status) {
|
||||||
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
|
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
|
||||||
throw new ManagementException(
|
throw new SchedulingFailure(
|
||||||
ManagementExceptionKind.INVALID_INPUT,
|
switch (status) {
|
||||||
|
case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE;
|
||||||
|
case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION;
|
||||||
|
default -> SchedulingFailureKind.SOLVER_FAILED;
|
||||||
|
},
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
|
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
|
||||||
case UNKNOWN ->
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
quarkus.application.name=agiledancers
|
quarkus.application.name=agiledancers
|
||||||
quarkus.http.host=0.0.0.0
|
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.http.idle-timeout=31M
|
||||||
quarkus.jackson.fail-on-unknown-properties=true
|
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.data-file=target/jacoco.exec
|
||||||
%test.quarkus.jacoco.reuse-data-file=true
|
%test.quarkus.jacoco.reuse-data-file=true
|
||||||
%test.quarkus.jacoco.report=false
|
%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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,12 @@ class SchedulingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private DanceGroup group(final DanceGroupType type) {
|
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) {
|
private void run(final Show show) {
|
||||||
@@ -86,7 +91,7 @@ class SchedulingTest {
|
|||||||
final Show show = service.createShow(school, "Show", List.of(), null);
|
final Show show = service.createShow(school, "Show", List.of(), null);
|
||||||
assertTrue(show.getAppearances().isEmpty());
|
assertTrue(show.getAppearances().isEmpty());
|
||||||
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
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));
|
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
||||||
final var counts = new EnumMap<>(show.getAppearances());
|
final var counts = new EnumMap<>(show.getAppearances());
|
||||||
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package nl.agilecc.agiledancers;
|
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.contains;
|
||||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
import static org.hamcrest.Matchers.empty;
|
import static org.hamcrest.Matchers.empty;
|
||||||
@@ -171,15 +171,18 @@ class SchoolManagementResourceTest {
|
|||||||
final String show = school + "/shows/" + create(school + "/shows", SHOW);
|
final String show = school + "/shows/" + create(school + "/shows", SHOW);
|
||||||
given().post(show + "/schedule").then().statusCode(400);
|
given().post(show + "/schedule").then().statusCode(400);
|
||||||
final long group = create(school + "/dance-groups", GROUP);
|
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));
|
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
||||||
final String before = given().get(show).asString();
|
final String before = given().get(show).asString();
|
||||||
final String counts =
|
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
|
||||||
"{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
|
|
||||||
for (final String count : new String[] {"0", "2", "1.5", "null"}) {
|
|
||||||
final String body =
|
final String body =
|
||||||
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":"
|
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
|
||||||
+ counts.formatted(count) + "}";
|
+ count + "}";
|
||||||
put(show, body).statusCode(400);
|
put(show, body).statusCode(400);
|
||||||
given().get(show).then().body(equalTo(before));
|
given().get(show).then().body(equalTo(before));
|
||||||
}
|
}
|
||||||
@@ -289,4 +292,13 @@ class SchoolManagementResourceTest {
|
|||||||
private static ValidatableResponse put(final String path, final Object body) {
|
private static ValidatableResponse put(final String path, final Object body) {
|
||||||
return given().contentType("application/json").body(body).put(path).then();
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package nl.agilecc.agiledancers;
|
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.containsString;
|
||||||
import static org.hamcrest.Matchers.emptyOrNullString;
|
import static org.hamcrest.Matchers.emptyOrNullString;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
@@ -26,7 +26,7 @@ class SchoolSnapshotResourceTest {
|
|||||||
.then()
|
.then()
|
||||||
.statusCode(200)
|
.statusCode(200)
|
||||||
.header("Content-Disposition", containsString(".json"))
|
.header("Content-Disposition", containsString(".json"))
|
||||||
.body("formatVersion", equalTo(2))
|
.body("formatVersion", equalTo(3))
|
||||||
.extract()
|
.extract()
|
||||||
.asString();
|
.asString();
|
||||||
try {
|
try {
|
||||||
@@ -58,7 +58,7 @@ class SchoolSnapshotResourceTest {
|
|||||||
for (final String bad : new String[] {
|
for (final String bad : new String[] {
|
||||||
"{",
|
"{",
|
||||||
"null",
|
"null",
|
||||||
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
|
json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"),
|
||||||
json.replace("\"name\" :", "\"unknown\" :"),
|
json.replace("\"name\" :", "\"unknown\" :"),
|
||||||
json + " {}",
|
json + " {}",
|
||||||
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
|
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class SnapshotCollectionsTest {
|
|||||||
@Test
|
@Test
|
||||||
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
|
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
|
||||||
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1));
|
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();
|
counts.clear();
|
||||||
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
|
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
|
||||||
assertThrows(
|
assertThrows(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class SchedulingServiceTest {
|
|||||||
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
||||||
final var schools = new SchoolManagementService();
|
final var schools = new SchoolManagementService();
|
||||||
final long school = schools.createSchool("School").getId();
|
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(
|
final Show show = schools.createShow(
|
||||||
school,
|
school,
|
||||||
"Show",
|
"Show",
|
||||||
@@ -62,7 +62,7 @@ class SchedulingServiceTest {
|
|||||||
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
||||||
final var schools = new SchoolManagementService();
|
final var schools = new SchoolManagementService();
|
||||||
final long school = schools.createSchool("School").getId();
|
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(
|
final Show show = schools.createShow(
|
||||||
school,
|
school,
|
||||||
"Show",
|
"Show",
|
||||||
@@ -84,7 +84,7 @@ class SchedulingServiceTest {
|
|||||||
ManagementExceptionKind.CONFLICT,
|
ManagementExceptionKind.CONFLICT,
|
||||||
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
|
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
|
||||||
.getKind());
|
.getKind());
|
||||||
schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
populatedGroup(schools, school);
|
||||||
} finally {
|
} finally {
|
||||||
release.countDown();
|
release.countDown();
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,16 @@ class SchedulingServiceTest {
|
|||||||
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
|
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) {
|
private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
|
||||||
final SchedulingEngine engine = (final SchedulingInput input) -> {
|
final SchedulingEngine engine = (final SchedulingInput input) -> {
|
||||||
entered.countDown();
|
entered.countDown();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user