Compare commits

..
73 Commits
Author SHA1 Message Date
fromme 3c34cfa76a feat: offer the questionnaire for placements ended within a rolling window 2026-11-10 09:23:23 +01:00
fromme 9d0cdd7f52 chore: update inline documentation of feedback config files 2026-11-08 14:41:38 +01:00
fromme 9583612fdc refactor: use english naming for the n8n assignments api 2026-11-07 09:33:54 +01:00
fromme 0bc77b7463 feat: expose winter placements to n8n over an api endpoint 2026-11-06 10:09:56 +01:00
fromme 4ff963d1e2 feat: let managers edit the staffing matrix 2026-11-05 17:18:26 +01:00
fromme f9d2ff084d feat: decide the staffing split per ski level 2026-11-05 17:04:24 +01:00
fromme 7aefe88610 chore: replace contract template pdf 2026-11-05 10:57:18 +01:00
fromme 09eea86183 chore: update project dependencies 2026-11-04 16:29:04 +01:00
fromme ddfaa4b440 chore: streamlined naming of feedback menu items 2026-10-28 11:23:21 +01:00
fromme d3b9b0611b chore: update project dependencies 2026-10-28 09:19:05 +01:00
fromme fe0c854f1d refactor: offer the questionnaire test from the assignment overview 2026-10-27 17:37:47 +01:00
fromme 0b736b51fb feat: calmer questionnaire form with selects and clickable answer rows 2026-10-27 17:32:41 +01:00
fromme af97468b2d feat: put the placement in one line above the questionnaire 2026-10-27 17:27:40 +01:00
fromme a0170f5952 refactor: split the questionnaire into teamer and bus feedback 2026-10-27 17:22:22 +01:00
fromme 2f4f9800dd fix: offer the questionnaire after every placement, the bus part to escorts 2026-10-27 17:12:24 +01:00
fromme 9fedc5b5d3 feat: let the office test the questionnaire on any assignment 2026-10-27 17:07:17 +01:00
fromme a35f91ac63 feat: list and show submitted questionnaires to the office 2026-10-27 17:02:51 +01:00
fromme 890e04f8a6 feat: remind teamers of open questionnaires on the dashboard 2026-10-27 16:59:44 +01:00
fromme 5e4e574076 feat: let teamers fill in and read their questionnaire 2026-10-27 16:56:49 +01:00
fromme 2a041d25a0 feat: let the teamer fill in the questionnaire after a bus escort 2026-10-27 16:53:23 +01:00
fromme df6734c939 feat: store questionnaire responses per disposition 2026-10-27 16:51:56 +01:00
fromme 8070fda64d feat: build the questionnaire form from its definition 2026-10-27 16:47:38 +01:00
fromme d06b647dac feat: decide when a conditional questionnaire question is shown 2026-10-27 16:43:14 +01:00
fromme a84275b40e feat: add questionnaire definitions and their catalog 2026-10-27 16:41:46 +01:00
fromme 2ec2a4c93f chore: fix typo in menu item 2026-10-20 17:14:41 +02:00
fromme de4aedd26e refactor: name the per-teamer disposition lists correctly 2026-10-20 17:14:13 +02:00
frommeandClaude Opus 5.5 5090f72fab fix: log a called-off disposition under its own id key
The disposition call-off logged the disposition's id as "assignment_id" and
set the status from the Assignment constant; both now use the disposition's
own name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:13:53 +02:00
fromme c00dd86eb4 feat: list upcoming season assignments among a teamer's dispositions 2026-10-20 17:13:45 +02:00
fromme 300bb2058f feat: apply season task deadlines to all open checklist entries 2026-10-20 16:52:45 +02:00
fromme 3fe510e64e fix: render form help texts smaller and muted 2026-10-20 16:52:32 +02:00
fromme 48691227cb chore: move menu entry to better suiting submenu 2026-10-20 16:34:12 +02:00
fromme 34ff630b75 chore: update project dependencies 2026-10-20 14:21:22 +02:00
fromme 3f806669c6 fix: show scrollbars only when an inner area overflows 2026-10-20 14:17:58 +02:00
fromme e31f607f66 chore: declare browser targets in .browserslistrc 2026-10-20 14:05:05 +02:00
fromme 8c53676133 feat: let the remaining textareas grow with their content 2026-10-20 14:04:55 +02:00
fromme 2de985b009 chore: upgrade webpack encore from 4.7 to 7.2 2026-10-20 14:04:43 +02:00
fromme 1cda9d378a chore: upgrade tailwind css from 3.4 to 4.3 2026-10-20 14:04:24 +02:00
fromme 9fcba26942 chore: bump node to 24 in ddev 2026-10-16 10:46:40 +02:00
fromme 13e5b6c3af feat: boost pagination and sorting links with htmx 2026-10-16 10:39:47 +02:00
fromme 493a43d952 fix: give the empty-field test case its own data provider 2026-10-16 10:12:30 +02:00
fromme 7493afde45 chore: update project dependencies 2026-10-16 10:06:22 +02:00
fromme d159cb212c feat: paginate the staffing matrix preview 2026-10-16 10:05:42 +02:00
fromme c7e0ad67e6 fix: put the guiding fee on one guide place only 2026-10-16 09:56:04 +02:00
fromme f0ad5374e9 docs: remove the myep api consumer guide 2026-10-16 09:15:04 +02:00
fromme 96d49fc488 docs: drop code references to local plan files 2026-10-16 09:14:44 +02:00
fromme 2a554bb7bf feat: stop keeping buspro password hash in the session 2026-10-16 09:00:00 +02:00
fromme c16e4bf53f feat: update profile in buspro without the teamer's password 2026-10-16 08:59:39 +02:00
fromme 18fb3382c6 feat: redirect to the deeplink after the form login 2026-10-16 08:38:05 +02:00
fromme 0e2021e96d refactor: derive the bus escort from the travel date 2026-10-16 08:36:54 +02:00
fromme 2fc9806c69 feat: generate draft assignments from a staffing matrix 2026-10-15 18:11:51 +02:00
fromme 3116d23199 fix: refuse deleting a document assigned to a season task 2026-10-15 13:02:13 +02:00
fromme 4aa2d7a5e6 fix: keep upload files until their deletion is committed 2026-10-15 12:49:48 +02:00
frommeandClaude Opus 5.5 7e182e3d51 feat: per-disposition feedback waiver
The overdue-feedback lists are a live query without stored state, so a
feedback nobody will provide any more could not be written off. The new
disposition.feedback_waived flag takes a placement out of the admin and
manager dashboards, the house-manager list and the reminder mail.

Statistics still count a waived placement as a missing feedback. The
flag has no UI yet and is set by SQL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 13:53:25 +02:00
fromme c04d9e0faf chore: update project dependencies 2026-10-13 12:53:44 +02:00
fromme 3188f5f3da feat: additional 'add' button on top of list view 2026-10-13 12:52:28 +02:00
fromme 57781ba347 fix: teamer dashboard crashing on isContractDue() without deadline 2026-10-06 15:04:17 +02:00
fromme 849da8d471 chore: upgrade phpunit to 12.5 2026-10-01 10:07:05 +02:00
fromme da039d0527 fix: resolve the remaining deprecations reported by phpunit 2026-10-01 10:06:59 +02:00
fromme 1446211d22 chore: bump project to php 8.5 2026-10-01 09:55:34 +02:00
fromme 838b8944a2 feat: rearranged admin menu, proper gendering 2026-10-01 08:23:06 +02:00
fromme a4206f51eb chore: update project dependencies 2026-09-29 15:43:21 +02:00
fromme 2329fe17a0 fix: report an unresolvable hotel instead of failing on it 2026-09-29 15:27:44 +02:00
fromme 72be779038 chore: name the current import command in the probe's docblock 2026-09-29 15:27:34 +02:00
fromme d8ff148f86 fix: adopt changed export api url 2026-09-29 14:45:51 +02:00
fromme db529d2dc9 refactor: slim the hotel and country base-data models 2026-09-29 13:06:20 +02:00
fromme aa66c50979 refactor: delete the BusProNet base-data read path 2026-09-29 13:06:12 +02:00
fromme 0f76c5a7b6 feat: import destinations from the MyE&P travel export 2026-09-29 12:57:54 +02:00
fromme 5c7e702eab fix: accept the ISO-2 country codes when authoring a destination 2026-09-29 12:47:25 +02:00
fromme 169a17e19c chore: verify the ISO-2 country move and renumber its migration 2026-09-29 12:47:25 +02:00
fromme 6c0a512c05 fix: refuse the import while MyE&P reports no cost unit or country code 2026-09-29 11:08:04 +02:00
Björn Fromme 2e9328fc51 feat: read BusProNet base data from the MyE&P API 2026-09-29 11:08:04 +02:00
fromme 1505f9b50b chore: update project dependencies 2026-09-27 18:10:15 +02:00
fromme 392e67448e feat: order and format the pickup fields 2026-09-27 18:05:12 +02:00
338 changed files with 18047 additions and 8279 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
# app's CSS, so the JS isn't transpiled for them either.
defaults and fully supports css-cascade-layers
not chrome < 111
+2 -2
View File
@@ -1,6 +1,6 @@
type: symfony type: symfony
docroot: public docroot: public
php_version: "8.4" php_version: "8.5"
webserver_type: apache-fpm webserver_type: apache-fpm
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: [] additional_hostnames: []
@@ -17,7 +17,7 @@ use_dns_when_possible: true
timezone: Europe/Berlin timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: [] web_environment: []
nodejs_version: "18" nodejs_version: "24"
corepack_enable: false corepack_enable: false
disable_upload_dirs_warning: true disable_upload_dirs_warning: true
+9 -5
View File
@@ -78,11 +78,13 @@ [email protected]
# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com' # Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com'
APP_MAIL_RECIPIENT_WHITELIST= APP_MAIL_RECIPIENT_WHITELIST=
SFTP_XML_EXPORT_HOST=
SFTP_XML_EXPORT_PORT=22 # Machine to machine access to the MyE&P API. This is a separate confidential client
SFTP_XML_EXPORT_USER= # from the login one below, so it can be rotated without locking anyone out. The base
SFTP_XML_EXPORT_PASSWORD= # url is the host root: both the token endpoint and the api paths hang off it.
SFTP_XML_EXPORT_ROOT= MYEP_API_BASE_URL=https://my.ep-reisen.de/
MYEP_API_CLIENT_ID=
MYEP_API_CLIENT_SECRET=
MYEP_OAUTH2_CLIENT_ID= MYEP_OAUTH2_CLIENT_ID=
MYEP_OAUTH2_CLIENT_SECRET= MYEP_OAUTH2_CLIENT_SECRET=
@@ -92,6 +94,8 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
MYEP_OAUTH2_SCOPES=email,id,roles,profile MYEP_OAUTH2_SCOPES=email,id,roles,profile
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage. # Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
APPLICATION_FORM_API_KEY= APPLICATION_FORM_API_KEY=
# Shared key n8n reads the assignments feed with, in the X-Api-Key header. Empty here: set it per stage.
N8N_API_KEY=
# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form # Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form
# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all - # receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all -
# an invitation with a dead booking link is worse than none. # an invitation with a dead booking link is worse than none.
+3 -1
View File
@@ -1,7 +1,7 @@
# define your env variables for the test env here # define your env variables for the test env here
KERNEL_CLASS='App\Kernel' KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 SYMFONY_DEPRECATIONS_HELPER=max[self]=0
PANTHER_APP_ENV=panther PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
# the test client requests http://localhost/ # the test client requests http://localhost/
@@ -10,5 +10,7 @@ APP_TRUSTED_HOSTS='^localhost$'
# loaded in the test environment - so the throwaway key the tests send belongs here. # loaded in the test environment - so the throwaway key the tests send belongs here.
# Keep it in sync with ApplicationFormControllerTest::KEY. # Keep it in sync with ApplicationFormControllerTest::KEY.
APPLICATION_FORM_API_KEY=test-application-form-api-key APPLICATION_FORM_API_KEY=test-application-form-api-key
# Keep in sync with Api\Assignment\ListControllerTest::KEY.
N8N_API_KEY=test-n8n-api-key
# The receipt mail refuses to render without it, and several tests send that mail. # The receipt mail refuses to render without it, and several tests send that mail.
APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall
+2 -2
View File
@@ -12,11 +12,11 @@
###> phpunit/phpunit ### ###> phpunit/phpunit ###
/phpunit.xml /phpunit.xml
.phpunit.result.cache /.phpunit.cache/
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> symfony/phpunit-bridge ### ###> symfony/phpunit-bridge ###
.phpunit.result.cache /.phpunit.cache/
/phpunit.xml /phpunit.xml
###< symfony/phpunit-bridge ### ###< symfony/phpunit-bridge ###
+1 -1
View File
@@ -1 +1 @@
18 24
+9
View File
@@ -3,6 +3,15 @@ htmx.config.includeIndicatorStyles = false
htmx.config.historyCacheSize = 0 htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false htmx.config.allowScriptTags = false
// A boosted link is a page navigation: show the error page like the browser would, instead of
// swallowing the 4xx/5xx and leaving the click without effect.
document.addEventListener('htmx:beforeSwap', (event) => {
if (true === event.detail.boosted && event.detail.xhr.status >= 400) {
event.detail.shouldSwap = true
event.detail.isError = false
}
})
import './bootstrap.js'; import './bootstrap.js';
/* /*
* Welcome to your app's main JavaScript file! * Welcome to your app's main JavaScript file!
@@ -0,0 +1,74 @@
import { Controller } from '@hotwired/stimulus'
/*
* Shows a questionnaire question only while its show_if holds, mirroring
* App\Model\Questionnaire\Definition::isVisible(): the condition must be met and the question it
* depends on must itself be shown.
*
* Every question row carries data-question-key; a conditional one also data-show-if, the JSON
* {question, operator, value} that App\Form\QuestionnaireType writes. A hidden row stays in the
* form and is submitted: a draft keeps its answer, and the server drops it on submit.
*/
export default class extends Controller {
connect() {
this.update()
}
update() {
const visible = {}
// Rows come in definition order, and a condition may only name an earlier question, so
// the question a row depends on has always been decided by the time the row is reached.
for (const row of this.element.querySelectorAll('[data-question-key]')) {
const key = row.dataset.questionKey
const condition = row.dataset.showIf ? JSON.parse(row.dataset.showIf) : null
visible[key] = null === condition
|| (true === visible[condition.question] && this.isMet(condition))
row.hidden = false === visible[key]
}
}
isMet({ question, operator, value }) {
const answer = this.answer(question)
if (null === answer) {
return false
}
switch (operator) {
case 'equals':
return answer === value
case 'in':
return value.includes(answer)
case 'lte':
return Number(answer) <= value
default:
return false
}
}
answer(key) {
const row = this.element.querySelector(`[data-question-key="${key}"]`)
if (null === row) {
return null
}
const checked = row.querySelector('input[type="radio"]:checked')
if (null !== checked) {
return checked.value
}
const select = row.querySelector('select')
if (null !== select) {
return '' === select.value ? null : select.value
}
return null
}
}
+4 -4
View File
@@ -198,11 +198,11 @@
<path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/> <path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/>
</symbol> </symbol>
<symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol> <symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-flag-a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol> <symbol id="icon-flag-at" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
<symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol> <symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol>
<symbol id="icon-flag-d" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol> <symbol id="icon-flag-de" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
<symbol id="icon-flag-f" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol> <symbol id="icon-flag-fr" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
<symbol id="icon-flag-i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol> <symbol id="icon-flag-it" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
<symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol> <symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol>
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL --> <!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> <symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
+9 -9
View File
@@ -1,9 +1,9 @@
@import "components/badge.css"; @import "./components/badge.css";
@import "components/autocomplete.css"; @import "./components/autocomplete.css";
@import "components/button.css"; @import "./components/button.css";
@import "components/datatable.css"; @import "./components/datatable.css";
@import "components/datepicker.css"; @import "./components/datepicker.css";
@import "components/toast.css"; @import "./components/toast.css";
@import "components/tooltip.css"; @import "./components/tooltip.css";
@import "components/timeline.css"; @import "./components/timeline.css";
@import "components/mail-body.css"; @import "./components/mail-body.css";
+44 -5
View File
@@ -1,7 +1,46 @@
@import "tailwindcss/base"; @import "tailwindcss";
@import "_base.css"; @import "./_base.css" layer(base);
@import "./_components.css" layer(components);
@import "tailwindcss/components"; @plugin "@tailwindcss/forms";
@import "_components.css";
@import "tailwindcss/utilities"; @source inline("rotate-90");
@theme {
--font-sans: Lato, sans-serif;
--font-weight-semibold: 700;
--font-weight-bold: 900;
--color-primary: #3d8ccb;
--color-secondary: #f9c700;
}
/*
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
* and buttons had a pointer cursor.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/*
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
* Safari keeps its own overlay scrollbars.
*/
@layer base {
body * {
scrollbar-width: thin;
scrollbar-color: var(--color-gray-300) transparent;
}
}
+3 -3
View File
@@ -3,11 +3,11 @@
.flatpickr-day.selected, .flatpickr-day.selected,
.flatpickr-day.selected:hover, .flatpickr-day.selected:hover,
.flatpickr-day.selected:focus { .flatpickr-day.selected:focus {
background-color: theme('colors.secondary'); background-color: var(--color-secondary);
border-color: theme('colors.secondary'); border-color: var(--color-secondary);
} }
.flatpickr-monthDropdown-months, .flatpickr-monthDropdown-months,
.flatpickr-current-month .flatpickr-monthDropdown-months { .flatpickr-current-month .flatpickr-monthDropdown-months {
@apply form-select pt-0 pb-0 focus:outline-none focus:shadow-none; @apply form-select pt-0 pb-0 focus:outline-hidden focus:shadow-none;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
.scroll-container { .scroll-container {
@apply overflow-scroll; @apply overflow-auto;
max-height: calc(100vh - 320px); max-height: calc(100vh - 320px);
} }
+4 -4
View File
@@ -1,20 +1,20 @@
@import "toastify-js/src/toastify.css"; @import "toastify-js/src/toastify.css";
.toastify { .toastify {
@apply shadow-lg rounded; @apply shadow-lg rounded-sm;
} }
.toastify--success { .toastify--success {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.emerald.600'); background: var(--color-emerald-600);
} }
.toastify--warning { .toastify--warning {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.red.600'); background: var(--color-red-600);
} }
.toastify--info { .toastify--info {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.blue.600'); background: var(--color-blue-600);
} }
+5 -5
View File
@@ -1,18 +1,18 @@
@import "tippy.js/dist/tippy.css"; @import "tippy.js/dist/tippy.css";
.tippy-box { .tippy-box {
background-color: theme('colors.primary'); background-color: var(--color-primary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.primary'); border-top-color: var(--color-primary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.primary'); border-bottom-color: var(--color-primary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.primary'); border-left-color: var(--color-primary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.primary'); border-right-color: var(--color-primary);
} }
+5 -11
View File
@@ -5,15 +5,9 @@ if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC'); ini_set('date.timezone', 'UTC');
} }
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) { if (!is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); echo "Unable to find PHPUnit in `vendor/phpunit/phpunit/`. Run `composer install`.\n";
require PHPUNIT_COMPOSER_INSTALL; exit(1);
PHPUnit\TextUI\Command::main();
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
} }
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
+2 -2
View File
@@ -89,7 +89,7 @@
}, },
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "8.4.24" "php": "8.5.9"
} }
}, },
"autoload": { "autoload": {
@@ -138,7 +138,7 @@
"fakerphp/faker": "*", "fakerphp/faker": "*",
"friendsofphp/php-cs-fixer": "^3.84", "friendsofphp/php-cs-fixer": "^3.84",
"marcocesarato/php-conventional-changelog": "^1.17", "marcocesarato/php-conventional-changelog": "^1.17",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^12.5",
"rector/rector": "^2.6", "rector/rector": "^2.6",
"symfony/browser-kit": "7.4.*", "symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*", "symfony/css-selector": "7.4.*",
Generated
+1162 -1063
View File
File diff suppressed because it is too large. Load diff
-7
View File
@@ -1,13 +1,6 @@
# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md # Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
flysystem: flysystem:
storages: storages:
xml_export.storage:
sftp:
host: '%env(SFTP_XML_EXPORT_HOST)%'
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
username: '%env(SFTP_XML_EXPORT_USER)%'
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
root: '%env(SFTP_XML_EXPORT_ROOT)%'
xml_dump.storage: xml_dump.storage:
local: local:
directory: '%kernel.project_dir%/var/bpn' directory: '%kernel.project_dir%/var/bpn'
+16
View File
@@ -28,6 +28,22 @@ framework:
router: router:
default_uri: '%env(APP_BASE_URI)%' default_uri: '%env(APP_BASE_URI)%'
# Every call to the sibling MyE&P application, both the token request and the API
# itself. Paths are given relative to the base uri, so without a leading slash.
http_client:
scoped_clients:
myep.client:
base_uri: '%env(MYEP_API_BASE_URL)%'
timeout: 10
max_duration: 30
headers:
Accept: application/json
retry_failed:
max_retries: 2
# 401 is deliberately absent: it needs the cached token dropped
# before the retry, which App\MyEp\Api\ApiClient does itself.
http_codes: [423, 425, 429, 500, 502, 503, 504]
when@test: when@test:
framework: framework:
test: true test: true
+17 -4
View File
@@ -3,6 +3,7 @@ monolog:
- deprecation - deprecation
- bpn - bpn
- saas - saas
- myep
- audit - audit
- api - api
@@ -13,7 +14,7 @@ monolog:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!saas", "!audit", "!api"] channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -31,6 +32,12 @@ monolog:
path: '%kernel.logs_dir%/saas.%kernel.environment%.log' path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
myep:
channels: ["myep"]
type: rotating_file
path: '%kernel.logs_dir%/myep.%kernel.environment%.log'
max_files: 5
level: info
audit: audit:
channels: [ "audit" ] channels: [ "audit" ]
type: rotating_file type: rotating_file
@@ -52,7 +59,7 @@ monolog:
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!bpn", "!saas", "!api", "!doctrine", "!console"] channels: ["!event", "!bpn", "!saas", "!myep", "!api", "!doctrine", "!console"]
when@dev: when@dev:
monolog: monolog:
@@ -89,7 +96,7 @@ when@prod:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!saas", "!audit", "!api"] channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -107,6 +114,12 @@ when@prod:
path: '%kernel.logs_dir%/saas.%kernel.environment%.log' path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
myep:
channels: ["myep"]
type: rotating_file
path: '%kernel.logs_dir%/myep.%kernel.environment%.log'
max_files: 5
level: info
audit: audit:
channels: [ "audit" ] channels: [ "audit" ]
type: rotating_file type: rotating_file
@@ -126,4 +139,4 @@ when@prod:
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine", "!bpn", "!saas", "!audit", "!api"] channels: ["!event", "!doctrine", "!bpn", "!saas", "!myep", "!audit", "!api"]
+1
View File
@@ -13,6 +13,7 @@ security:
memory: memory:
users: users:
api: { roles: [ ROLE_API ] } api: { roles: [ ROLE_API ] }
n8n: { roles: [ ROLE_API_ASSIGNMENTS ] }
role_hierarchy: role_hierarchy:
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ] ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
+1 -1
View File
@@ -1,7 +1,7 @@
# Enabled for #[MapRequestPayload] on the api controllers - nothing else in this application # Enabled for #[MapRequestPayload] on the api controllers - nothing else in this application
# serializes. ALLOW_EXTRA_ATTRIBUTES stays at its default (true) on purpose: the TYPO3 form # serializes. ALLOW_EXTRA_ATTRIBUTES stays at its default (true) on purpose: the TYPO3 form
# payload grows keys without a release here, and rejecting an unknown one would turn every # payload grows keys without a release here, and rejecting an unknown one would turn every
# later mapping change into a 400 (see .ddev/plans/application-form-endpoint.md). # later mapping change into a 400.
framework: framework:
serializer: serializer:
name_converter: ~ name_converter: ~
+8 -6
View File
@@ -15,9 +15,11 @@ zenstruck_schedule:
when@prod: when@prod:
zenstruck_schedule: zenstruck_schedule:
tasks: tasks:
- task: app:bpn-import # 03:20 rather than 03:00: MyE&P mirrors from BusProNet hourly overnight, on the
frequency: '0 3 * * *' # hour, so an import at 03:00 can read a half written export.
description: "Imports dates, products and hotels from BusProNet" - task: app:myep-import
frequency: '20 3 * * *'
description: "Imports travel dates, products and hotels from the MyE&P API"
- task: app:cron - task: app:cron
frequency: '0 1 * * *' frequency: '0 1 * * *'
@@ -45,9 +47,9 @@ when@staging:
subject_prefix: "[E&P-Team STAGING]" subject_prefix: "[E&P-Team STAGING]"
tasks: tasks:
- task: app:bpn-import - task: app:myep-import
frequency: '30 3 * * *' frequency: '50 3 * * *'
description: "Imports dates, products and hotels from BusProNet" description: "Imports travel dates, products and hotels from the MyE&P API"
- task: app:cron - task: app:cron
frequency: '30 2 * * *' frequency: '30 2 * * *'
+271
View File
@@ -0,0 +1,271 @@
# Questionnaire a teamer fills in after a placement with bus escort, in addition to
# teamer_feedback.yaml and on the same page (App\Service\Questionnaire\DispositionContext).
#
# Replaces the Google form "Bus-Feedback 25-26"; the question-by-question spec, with every decision
# taken, is kept locally outside the repository. Questions whose answer the
# disposition already knows (travel date, destination, Reiseleitung, first pickup) are not asked:
# they are snapshotted into the response's context instead.
#
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to
# bus_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
#
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: bus_feedback
version: 1
title: "Bus-Feedback"
sections:
- key: bus_trip
title: "Busfahrt"
questions:
- key: direction
label: "Hin- oder Rückfahrt"
type: choice
required: true
options:
- { key: outbound, label: "Hinfahrt" }
- { key: return, label: "Rückfahrt" }
- key: bus_company
label: "Bus-Unternehmen:"
type: choice
widget: dropdown
required: true
options:
- { key: boonk, label: "Boonk Reisen" }
- { key: piccolonia, label: "Piccolonia" }
- { key: heess, label: "Heeß" }
- { key: finkbeiner, label: "Finkbeiner" }
- { key: other, label: "Anderes Unternehmen" }
- key: bus_company_other
label: "Name des Busunternehmens:"
type: text
required: true
show_if: { question: bus_company, equals: other }
- key: bus_team
title: "Busteam"
questions:
- key: driver_names
label: "Busfahrer-Namen:"
type: text
required: true
- key: bus_first_pickup
title: "1. Abfahrtsort"
questions:
- key: on_time_at_first_pickup
label: "Bus pünktlich am 1. Abfahrtsort?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: delay_at_first_pickup
label: "Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?"
type: text
required: true
show_if: { question: on_time_at_first_pickup, equals: "no" }
- key: bus_rating
title: "Bewertung Bus & Fahrer"
questions:
- key: overall_impression
label: "Gesamteindruck des Busses"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: bad, label: "schlecht" }
- key: vehicle_age
label: "Alter des Fahrzeugs"
type: choice
widget: dropdown
required: true
options:
- { key: very_new, label: "Sehr neu" }
- { key: new, label: "neu" }
- { key: ok, label: "OK" }
- { key: somewhat_old, label: "etwas älter" }
- { key: very_old, label: "sehr alt" }
- key: seating_comfort
label: "Sitzkomfort im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: bad, label: "schlecht" }
- key: driver_friendliness
label: "Freundlichkeit der Fahrer"
type: choice
widget: dropdown
required: true
options:
- { key: very_friendly, label: "Sehr freundlich" }
- { key: friendly, label: "freundlich" }
- { key: ok, label: "OK" }
- { key: not_so_friendly, label: "nicht so freundlich" }
- { key: unfriendly, label: "unfreundlich" }
- key: breaks
label: "Pausenzeiten"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: temperature
label: "Temperatur im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: guest_behaviour
label: "Verhalten der Gäste im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: bus_pickups
title: "Ladestellen"
questions:
- key: drivers_knew_pickups
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: unknown_pickups
label: "Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?"
type: textarea
required: true
show_if: { question: drivers_knew_pickups, equals: "no" }
- key: bus_destination
title: "Zielkenntnis"
questions:
- key: drivers_knew_destination
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: bus_equipment
title: "Bus, Technik & Allgemeines"
questions:
- key: video
label: "Video/DVD-Gerät"
type: choice
widget: dropdown
required: true
options:
- { key: working, label: "funktionstüchtig" }
- { key: broken, label: "defekt" }
- { key: missing, label: "nicht vorhanden" }
- key: wc
label: "WC"
type: choice
widget: dropdown
required: true
options:
- { key: working, label: "funktionstüchtig" }
- { key: broken, label: "defekt" }
- { key: closed, label: "nicht geöffnet" }
- key: luggage
label: "Gepäck"
type: choice
widget: dropdown
required: true
options:
- { key: trailer, label: "Anhänger" }
- { key: ski_box, label: "Skikoffer" }
- { key: trailer_and_ski_box, label: "Anhänger und Skikoffer" }
- { key: none, label: "Kein Anhänger oder Skikoffer" }
- key: smoking
label: "Rauchen im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: driver, label: "Busfahrer hat im Bus geraucht" }
- { key: driver_and_guests, label: "Busfahrer und Gäste haben im Bus geraucht" }
- { key: nobody, label: "Es wurde nicht geraucht" }
- key: snow_chains
label: "Schneeketten"
type: choice
widget: dropdown
required: true
options:
- { key: fitted, label: "Fahrer hat Ketten aufgezogen" }
- { key: not_needed, label: "Ketten wurden nicht gebraucht" }
- { key: not_fitted, label: "Ketten nicht aufgezogen (trotz starkem Schneefall)" }
- key: bus_damage
title: "Schäden"
questions:
- key: bus_damage
label: "Entstanden während der Fahrt Schäden am Bus?"
type: choice
widget: dropdown
required: true
options:
- { key: none, label: "Keine Schäden" }
- { key: settled_with_driver, label: "Bereits mit Fahrer abgeklärt" }
- { key: claim_expected, label: "Da wird vom Busunternehmen noch was kommen" }
- key: damage_description
label: "Was ist passiert?"
type: textarea
required: true
show_if: { question: bus_damage, equals: claim_expected }
- key: bus_remarks
title: "Anmerkungen zur Busfahrt"
questions:
- key: bus_remarks
label: "Das ging gar nicht / ist besonders hervorzuheben:"
type: textarea
+137
View File
@@ -0,0 +1,137 @@
# Questionnaire every teamer fills in after a placement (Disposition, not SeasonAssignment).
#
# Replaces the Google form "RL-Feedback 25-26"; the question-by-question spec, with every decision
# taken, is kept locally outside the repository. A teamer who escorted the bus
# also gets bus_feedback.yaml, on the same page. Questions whose answer the disposition already
# knows (name, job profile, house, arrival day) are not asked: they are snapshotted into the
# response's context instead.
#
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to
# teamer_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
#
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: teamer_feedback
version: 1
title: "Wochenteamer:innen-Feedback"
description: |-
Nur durch euer Feedback können wir besser werden! 🙂
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
sections:
- key: organisation
title: "Organisation & Ablauf"
questions:
- key: office_briefing_rating
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
type: rating
required: true
- key: office_briefing_low_reason
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
type: textarea
required: true
show_if: { question: office_briefing_rating, lte: 3 }
- key: team_week_seminar
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
type: choice
widget: dropdown
required: true
options:
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
- key: onsite_organisation_rating
label: "Wie gut lief die Reiseorganisation vor Ort?"
type: rating
required: true
- key: had_organisational_problems
label: "Gab es organisatorische Probleme?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: organisational_problems
label: "Wenn ja, welche?"
type: text
show_if: { question: had_organisational_problems, equals: "yes" }
- key: communication
title: "Kommunikation & Zusammenarbeit"
questions:
- key: teamer_collaboration_rating
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
type: rating
required: true
- key: support_wished
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
type: textarea
- key: house_manager_problem_handling
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
- key: felt_overwhelmed
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: overwhelmed_reason
label: "Wenn ja, wodurch?"
type: textarea
show_if: { question: felt_overwhelmed, equals: "yes" }
- key: improvement
title: "Verbesserung"
questions:
- key: wished_differently
label: "Was hättest du dir anders oder besser gewünscht?"
type: textarea
- key: would_work_again
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: would_not_work_again_reason
label: "Wenn nein, warum nicht?"
type: textarea
show_if: { question: would_work_again, equals: "no" }
- key: other
title: "Sonstiges"
questions:
- key: anything_else
label: |-
Du hast nun schon vieles beantwortet,
aber nirgendwo konntest du folgende Aspekte loswerden:
type: textarea
+94 -3
View File
@@ -40,6 +40,37 @@ parameters:
# rejects every request instead of letting them through. # rejects every request instead of letting them through.
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%' application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
# Shared key n8n reads the assignments feed with (GET /api/assignments). Same rules as above.
n8n_api_key: '%env(string:N8N_API_KEY)%'
# What the n8n assignments feed covers: assignments in these houses (matched against the first
# three characters of destination.hotelCode) for these job profiles - guide, course and
# service-team work. Job profile ids are database ids, so check them against production.
assignment_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
assignment_export.job_profile_ids:
- 5 # Reiseleitung
- 9 # Reiseleitung Ski
- 10 # Reiseleitung Snowboard
- 3 # Kursleitung Ski Anfänger:innen
- 14 # Kursleitung Ski Fortgeschrittene
- 18 # Kursleitung Ski Anfänger:innen + Fortgeschrittene
- 36 # Kursleitung Ski Kinder Anfänger:innen & Kinderbetreuung
- 37 # Kursleitung Ski Kinder Fortgeschrittene
- 38 # Kursleitung Ski Kinder
- 4 # Kursleitung Snowboard Anfänger:innen
- 15 # Kursleitung Snowboard Fortgeschrittene
- 19 # Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
- 47 # Kursleitung Snowboard Kinder/Jugendliche Anfänger:innen
- 20 # Reise- und Kursleitung Ski Anfänger:innen
- 21 # Reise- und Kursleitung Ski Fortgeschrittene
- 22 # Reise- und Kursleitung Ski Anfänger:innen + Fortgeschrittene
- 23 # Reise- und Kursleitung Snowboard Anfänger:innen
- 24 # Reise- und Kursleitung Snowboard Fortgeschrittene
- 25 # Reise- und Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
- 6 # Serviceteam 40h (ST40)
- 13 # Serviceteam 25h (ST25)
- 16 # Serviceteam 10h (ST10)
# Booking link the application form receipt mail invites the applicant to. A deployment # Booking link the application form receipt mail invites the applicant to. A deployment
# value rather than part of the wording, so a new booking system does not need an edit in # value rather than part of the wording, so a new booking system does not need an edit in
# every stage's mail text. # every stage's mail text.
@@ -90,12 +121,31 @@ parameters:
season_start: '11-01' season_start: '11-01'
season_end: '04-01' season_end: '04-01'
# The teamer questionnaires after a placement (config/questionnaires/teamer_feedback.yaml, plus
# bus_feedback.yaml for a bus escort) are offered for placements that ended within this many
# days. Without a window every past placement would show up as pending on the teamers' dashboards.
questionnaire_window_days: 30
# How far ahead of the destination's first travel day a season assignment's checklist # How far ahead of the destination's first travel day a season assignment's checklist
# entries fall due. A relative date expression, applied to destination.dateFrom, and the # entries fall due. A relative date expression, applied to destination.dateFrom, and the
# single definition of both deadlines: the first is this offset, the second defaults to # single definition of both deadlines: the first is this offset, the second defaults to
# the first. Per-entry overrides are stored on the task row; this is what they override. # the first. Per-entry overrides are stored on the task row; this is what they override.
season_task_deadline_offset: '-1 month' season_task_deadline_offset: '-1 month'
# What the staffing matrix generates (App\Service\Assignment\AssignmentPlanner), keyed by
# App\Enum\StaffingRole / StaffingFee value. Ids and not names, so renaming a job profile
# or fee in the admin cannot silently change what the drafts get. A missing or deleted id
# makes App\Service\Assignment\StaffingResolver refuse rather than skip.
auto_assignment_job_profiles:
guide: 5 # Reiseleitung
ski_beginner: 3 # Kursleitung Ski Anfänger:innen
ski_advanced: 14 # Kursleitung Ski Fortgeschrittene
guide_ski_beginner: 20 # Reise- und Kursleitung Ski Anfänger:innen
guide_ski_advanced: 21 # Reise- und Kursleitung Ski Fortgeschrittene
auto_assignment_fees:
guiding: 3 # Skigebietsguiding (pro Einheit)
course: 4 # Ski-/Snowboardkurs (pro Stunde)
# Houses, hotel code: name. The name is used both as a label and to match # Houses, hotel code: name. The name is used both as a label and to match
# destination.hotel, the code to match destination.hotelCode. # destination.hotel, the code to match destination.hotelCode.
houses: houses:
@@ -130,7 +180,6 @@ services:
# it drops its records from that table. # it drops its records from that table.
$logger: '@monolog.logger.audit' $logger: '@monolog.logger.audit'
$houses: '%houses%' $houses: '%houses%'
$xmlExport: '@xml_export.storage'
$xmlDump: '@xml_dump.storage' $xmlDump: '@xml_dump.storage'
$teamerInactivePeriod: '%teamer_inactive_period%' $teamerInactivePeriod: '%teamer_inactive_period%'
$contractUploadDeadlineDays: '%contract_upload_deadline_days%' $contractUploadDeadlineDays: '%contract_upload_deadline_days%'
@@ -138,10 +187,15 @@ services:
# Only the path enters the container - the file itself is parsed on demand by # Only the path enters the container - the file itself is parsed on demand by
# App\Config\EmailTextCatalog, since its contents matter on few requests. # App\Config\EmailTextCatalog, since its contents matter on few requests.
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' $emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
$questionnaireDir: '%kernel.project_dir%/config/questionnaires'
$questionnaireWindowDays: '%questionnaire_window_days%'
$mailingBusTransport: '%mailing_bus_transport%' $mailingBusTransport: '%mailing_bus_transport%'
$seasonStart: '%season_start%' $seasonStart: '%season_start%'
$seasonEnd: '%season_end%' $seasonEnd: '%season_end%'
$seasonTaskDeadlineOffset: '%season_task_deadline_offset%' $seasonTaskDeadlineOffset: '%season_task_deadline_offset%'
$autoAssignmentJobProfiles: '%auto_assignment_job_profiles%'
$autoAssignmentFees: '%auto_assignment_fees%'
# Tagged by interface rather than listed by hand: order between collectors is # Tagged by interface rather than listed by hand: order between collectors is
# irrelevant, so the only thing an explicit list could add here is the chance to # irrelevant, so the only thing an explicit list could add here is the chance to
@@ -165,7 +219,7 @@ services:
- name: liip_imagine.cache.resolver - name: liip_imagine.cache.resolver
resolver: format_extension resolver: format_extension
App\Command\BpnImportCommand: App\Command\MyEpImportCommand:
arguments: arguments:
$connection: '@doctrine.dbal.default_connection' $connection: '@doctrine.dbal.default_connection'
@@ -217,6 +271,31 @@ services:
# new starter is picked up the same day. # new starter is picked up the same day.
$ttl: 21600 $ttl: 21600
App\MyEp\Api\AccessTokenProvider:
arguments:
$logger: '@monolog.logger.myep'
$cache: '@cache.app'
$options:
client_id: '%env(MYEP_API_CLIENT_ID)%'
client_secret: '%env(MYEP_API_CLIENT_SECRET)%'
App\MyEp\Api\ApiClient:
arguments:
$logger: '@monolog.logger.myep'
App\MyEp\Api\ResponseMapper:
arguments:
$logger: '@monolog.logger.myep'
App\MyEp\Cache\StaleWhileErrorCache:
arguments:
$logger: '@monolog.logger.myep'
$cache: '@cache.app'
App\MyEp\Import\TravelExportClient:
arguments:
$logger: '@monolog.logger.myep'
App\Twig\AppRuntime: App\Twig\AppRuntime:
arguments: arguments:
$intlExtension: '@twig.extension.intl' $intlExtension: '@twig.extension.intl'
@@ -388,7 +467,10 @@ services:
App\Security\AccessToken\ApiKeyTokenHandler: App\Security\AccessToken\ApiKeyTokenHandler:
arguments: arguments:
$apiKey: '%application_form_api_key%' # user identifier in security.yaml's api_user_provider => key
$apiKeys:
api: '%application_form_api_key%'
n8n: '%n8n_api_key%'
$logger: '@monolog.logger.api' $logger: '@monolog.logger.api'
App\Controller\Api\ApplicationFormController: App\Controller\Api\ApplicationFormController:
@@ -396,6 +478,13 @@ services:
$logger: '@monolog.logger.api' $logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ] tags: [ 'controller.service_arguments' ]
App\Controller\Api\Assignment\ListController:
arguments:
$houseCodes: '%assignment_export.house_codes%'
$jobProfileIds: '%assignment_export.job_profile_ids%'
$logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ]
App\EventListener\EmailRecipientWhitelistSubscriber: App\EventListener\EmailRecipientWhitelistSubscriber:
arguments: arguments:
$allowedRecipients: '%mail_recipient_whitelist%' $allowedRecipients: '%mail_recipient_whitelist%'
@@ -423,4 +512,6 @@ when@dev:
when@test: when@test:
parameters: parameters:
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
assignment_export.job_profile_ids: [ 900001 ]
mailing_bus_transport: 'sync' mailing_bus_transport: 'sync'
+2 -1
View File
@@ -38,6 +38,7 @@ $rsyncOptions = [
'.env.test.local', '.env.test.local',
'.gitignore', '.gitignore',
'.nvmrc', '.nvmrc',
'.browserslistrc',
'.php-cs-fixer.cache', '.php-cs-fixer.cache',
'.php-cs-fixer.dist.php', '.php-cs-fixer.dist.php',
'.phpunit.result.cache', '.phpunit.result.cache',
@@ -51,7 +52,7 @@ $rsyncOptions = [
'phpunit.xml.dist', 'phpunit.xml.dist',
'public/.htaccess', 'public/.htaccess',
'tailwind.config.js', 'tailwind.config.js',
'webpack.config.js', 'webpack.config.mjs',
'rapidapi.paw', 'rapidapi.paw',
'bin/.phpunit', 'bin/.phpunit',
'temp/*', 'temp/*',
-354
View File
@@ -1,354 +0,0 @@
# API Consumer Guide
This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it.
## Overview
A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes.
## Authentication
- Token endpoint: `POST /token` (unauthenticated).
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently.
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
- Send the token as `Authorization: Bearer <access_token>`.
Scope required per endpoint:
| Scope needed | Endpoints |
|---|---|
| `api` | everything except the two below |
| `profile` | `GET /api/crm-attributes` |
| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) |
`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials.
### Machine-to-machine example
```
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=...&client_secret=...&scope=api
```
```
GET /api/hotels
Authorization: Bearer eyJ0eXAi...
```
## Error conventions
There is no single error envelope; three shapes occur:
- `{"message": "..."}` — commonly with 404 / 400.
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
---
## Endpoints
### Reference data (BusProNet master data)
#### `GET /api/hotels` — scope `api`
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
#### `GET /api/hotels/{id}` — scope `api`
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
#### `GET /api/countries` — scope `api`
Array of `{id, name, token, nationality}`.
#### `GET /api/products` — scope `api`
Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**.
#### `GET /api/last-update` — scope `api`
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
---
### Pickups (bus boarding points)
#### `GET /api/pickups` — scope `api`
Array of `{id, code, city, postalCode, street}`.
#### `GET /api/pickups/{id}` — scope `api`
Single pickup by numeric id, same fields as the list.
#### `GET /api/pickups-planning/{travelCode}` — scope `api`
Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted:
```json
[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}]
```
`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors.
#### `POST /api/pickups-planning` — scope `api`
Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys:
```json
[
{
"PlanungsArt": "HIN",
"Reisecode": "ABC123",
"Zustieg/Ausstieg Ort": "Köln",
"Zustieg/Ausstieg Strasse": "Hauptbahnhof",
"Zeit": "24.12.2026 07:30",
"BusNummer": "1"
}
]
```
Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`.
This is a full overwrite, not a merge.
---
### Travels
#### `GET /api/travels` — scope `api`
Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`.
#### `GET /api/travels/{dateId}/{hotelId}` — scope `api`
Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.
#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api`
Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists.
Both variants accept optional query parameters:
- `source=local` — force the local XML/snapshot data.
- `source=remote` — force a live upstream call.
- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback.
Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`.
#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api`
Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**.
---
### Contingents & accommodation prices
#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api`
Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`.
- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code).
- `year`: required int, 1000–9999.
Each row:
```json
{
"dateFrom": "2026-06-01",
"dateTo": "2026-06-14",
"season": "high|low|...|null",
"includedPax": 2,
"pricePerNight": 89.5,
"priceAdditionalPerson": 25.0,
"defaultPricePerNight": 99.0,
"defaultPriceAdditionalPerson": 30.0,
"currency": "EUR",
"type": "discount|null"
}
```
Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters.
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. Upstream contingent data is cached 1 hour per (hotelCode, dateFrom, dateTo).
Each entry:
```json
{
"date": "2026-06-01",
"status": "Free|Blocked|...",
"type": "discount|null",
"pricePerNight": 89.5,
"defaultPricePerNight": 99.0,
"priceAdditionalPerson": 25.0,
"defaultPriceAdditionalPerson": 30.0,
"currency": "EUR",
"includedPax": 2,
"minNights": 2
}
```
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case.
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `502 {"error":"Failed to fetch contingent data."}` when the upstream contingent service fails, `400` with violations for invalid parameters.
---
### Accommodation bookings (group bookings)
#### `GET /api/accommodation-bookings/{uuid}` — scope `api`
Single booking by UUID. `404 {"message":"Not found"}` if unknown.
```json
{
"uuid": "...",
"status": "...",
"type": "...",
"dateFrom": "2026-06-01",
"dateTo": "2026-06-08",
"nights": 7,
"paxCount": 40,
"minorsCount": 12,
"childrenCount": 3,
"groupName": "...",
"acceptedAt": "2026-05-01T10:00:00+00:00",
"personalData": {},
"accommodation": {},
"boardService": {},
"additionalServices": [],
"accommodationDiscount": null,
"boardServiceDiscount": null,
"additionalServicesDiscount": null,
"totalPrice": 123456,
"pricingCurrency": "EUR",
"pricingVersion": 3,
"priceBreakdown": {}
}
```
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once.
---
### Contact form
#### `POST /api/contactform` — scope `api`
Creates or matches an address record in the upstream CRM.
Request body (all fields optional strings; the upstream system decides what is required):
```json
{
"lastName": "...", "firstName": "...", "gender": "...",
"street": "...", "zipCode": "...", "city": "...",
"email": "...", "phone": "..."
}
```
- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}`
- `409` — `{"success": false, "message": "<upstream notification>"}` (upstream rejected/conflicted)
- `400` — `{"success": false, "message": "<error>"}` (upstream call failed)
- `422` — malformed payload
---
### Newsletter
#### `GET /api/newsletters` — scope `api`
The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below.
#### `POST /api/newsletter-subscriptions` — scope `api`
Request:
```json
{
"email": "[email protected]",
"firstName": "Ada",
"lastName": "Lovelace",
"listIds": [1, 2]
}
```
- `email`: required, strict email validation.
- `firstName` / `lastName`: optional, max 255 chars.
- `listIds`: required, at least one positive integer; every id must be one of the configured lists.
Responses:
- `200` — subscription completed, `state: "subscribed"`.
- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`.
- Body in both cases:
```json
{
"success": true,
"email": "[email protected]",
"lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}],
"state": "subscribed|pending_confirmation|confirmation_requested",
"confirmationRequested": true
}
```
- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations.
- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry.
---
### End-user endpoints (require a user-bound token)
#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`)
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
- always: `email`
- scope `id`: `person_id`, `address_id`
- scope `roles`: `roles` (array; the implicit baseline role is stripped, only explicitly assigned roles are exported)
- scope `profile`: `profile` object:
```json
{
"first_name": "...", "last_name": "...", "title": "...", "salutation": "...",
"gender": "...", "date_of_birth": "1990-01-01", "nationality": "...",
"address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."},
"communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true},
"hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..."
}
```
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
#### `GET /api/crm-attributes` — scope `profile`
CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure.
---
## Client implementation notes
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401.
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
5. **Server-side caching** (1 h on products and the contingent calendar) means upstream changes may not be visible immediately.
6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.
+1 -1
View File
@@ -131,7 +131,7 @@ Three failure modes worth knowing because they are quiet rather than loud:
## Scheduled tasks are a different mechanism ## Scheduled tasks are a different mechanism
`config/packages/zenstruck_schedule.yaml` holds the daily jobs (`app:cron`, `config/packages/zenstruck_schedule.yaml` holds the daily jobs (`app:cron`,
`app:bpn-import`, `app:teamer-status`, …), driven by a separate `schedule:run` cron entry. `app:myep-import`, `app:teamer-status`, …), driven by a separate `schedule:run` cron entry.
**Do not put `messenger:consume` in there.** The bundle runs tasks in-process and **Do not put `messenger:consume` in there.** The bundle runs tasks in-process and
sequentially, so a consume task holds the entire schedule for its duration — every later sequentially, so a consume task holds the entire schedule for its duration — every later
+1 -2
View File
@@ -9,8 +9,7 @@ use Doctrine\Migrations\AbstractMigration;
/** /**
* What it takes to provision a teamer from a received application: the applicant's gender, and the * What it takes to provision a teamer from a received application: the applicant's gender, and the
* outcome of each of the three provisioning writes * outcome of each of the three provisioning writes.
* (.ddev/plans/application-form-teamer-provisioning.md).
* *
* Emptying the table first is deliberate. gender is NOT NULL because BusPro refuses an address * Emptying the table first is deliberate. gender is NOT NULL because BusPro refuses an address
* without one and Teamer::$gender is not nullable either, and the existing rows predate the * without one and Teamer::$gender is not nullable either, and the existing rows predate the
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Moves destination.country from the raw BusProNet abbreviations to lowercase ISO-2.
*
* The column used to hold whatever BusProNet reported as hotel/land, lowercased: a,
* ch, d, f, i, nl. That set is not a standard - the same country appears as A, AT and
* AUT upstream - so the import now stores the ISO code MyE&P resolves instead, and the
* existing rows are converted to match. The flag icons and the country filters use the
* same vocabulary and are switched with it.
*/
final class Version20260927090000 extends AbstractMigration
{
private const TO_ISO = [
'a' => 'at',
'd' => 'de',
'f' => 'fr',
'i' => 'it',
];
public function getDescription(): string
{
return 'Convert destination.country to lowercase ISO-3166-1 alpha-2';
}
public function up(Schema $schema): void
{
foreach (self::TO_ISO as $legacy => $iso) {
$this->addSql('UPDATE destination SET country = ? WHERE country = ?', [$iso, $legacy]);
}
// ch and nl are already ISO; empty strings were never a country
$this->addSql("UPDATE destination SET country = NULL WHERE country = ''");
}
public function down(Schema $schema): void
{
foreach (self::TO_ISO as $legacy => $iso) {
$this->addSql('UPDATE destination SET country = ? WHERE country = ?', [$legacy, $iso]);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the per-placement feedback waiver.
*
* The overdue-feedback lists are a live query without any stored state, so there was no way to
* write off a feedback nobody will provide any more. The flag takes a placement out of the
* dashboards' overdue lists, the house-manager list and the reminder mail.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261013120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the feedback_waived flag to dispositions';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE disposition ADD feedback_waived TINYINT DEFAULT 0 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE disposition DROP feedback_waived');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the staffing matrix and what the assignment generator needs from the travel import.
*
* destination.product_code is the travel code MyE&P reports per date; destination.imported_at
* tells the dates the latest import reported from ones cancelled upstream, which the import
* never deletes. Both stay NULL until the next app:myep-import.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261015154754 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the staffing_rule table and destination.product_code / imported_at';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE staffing_rule (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, product_code VARCHAR(32) NOT NULL, guides INT NOT NULL, ski_beginner TINYINT NOT NULL, ski_advanced TINYINT NOT NULL, split TINYINT NOT NULL, guiding TINYINT NOT NULL, bus_escort TINYINT NOT NULL, enabled TINYINT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, deleted_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_91DFB1A2D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE destination ADD product_code VARCHAR(32) DEFAULT NULL, ADD imported_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE staffing_rule');
$this->addSql('ALTER TABLE destination DROP product_code, DROP imported_at');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Drops the staffing matrix's Busbegleitung flag.
*
* Whether a date gets a bus escort follows from the date itself: one with boarding points has
* bus travel, and App\Service\Assignment\AssignmentPlanner pre-selects the first of them. A flag
* per product could only repeat that, and less precisely, since bus travel varies between the
* dates of one product.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261016090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop staffing_rule.bus_escort';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE staffing_rule DROP bus_escort');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE staffing_rule ADD bus_escort TINYINT NOT NULL');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds questionnaire_response: a respondent's answers to one questionnaire from
* config/questionnaires/, for now the teamer questionnaire after a placement.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261027140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add questionnaire_response';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE questionnaire_response (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, questionnaire VARCHAR(64) NOT NULL, version INT NOT NULL, status VARCHAR(16) NOT NULL, submitted_at DATETIME DEFAULT NULL, answers JSON NOT NULL, context JSON NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, disposition_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_A0400276D17F50A6 (uuid), UNIQUE INDEX questionnaire_disposition (questionnaire, disposition_id), INDEX IDX_A0400276287B65ED (disposition_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`');
$this->addSql('ALTER TABLE questionnaire_response ADD CONSTRAINT FK_A0400276287B65ED FOREIGN KEY (disposition_id) REFERENCES disposition (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE questionnaire_response DROP FOREIGN KEY FK_A0400276287B65ED');
$this->addSql('DROP TABLE questionnaire_response');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Replaces the staffing matrix's split flag with one flag per ski level.
*
* A split row takes over every level it has, so it keeps planning the same assignments. The way
* back folds the two flags into one, which loses a row that takes over one level of two.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261105100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace staffing_rule.split with split_beginner / split_advanced';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE staffing_rule ADD split_beginner TINYINT NOT NULL, ADD split_advanced TINYINT NOT NULL');
$this->addSql('UPDATE staffing_rule SET split_beginner = split AND ski_beginner, split_advanced = split AND ski_advanced');
$this->addSql('ALTER TABLE staffing_rule DROP split');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE staffing_rule ADD split TINYINT NOT NULL');
$this->addSql('UPDATE staffing_rule SET split = split_beginner OR split_advanced');
$this->addSql('ALTER TABLE staffing_rule DROP split_beginner, DROP split_advanced');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Numbers the places of an assignment, for the n8n placements feed ("platz_id").
*
* Existing active placements are numbered 1..n per assignment in creation order; called-off ones
* stay without a number, they hold no place. n8n has not seen a place number yet, so the initial
* order is free to choose.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261106100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add disposition.slot and number the active placements per assignment';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE disposition ADD slot SMALLINT DEFAULT NULL');
$this->addSql(<<<'SQL'
UPDATE disposition
INNER JOIN (
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignment_id ORDER BY id) AS slot
FROM disposition
WHERE status <> 'called_off' AND assignment_id IS NOT NULL
) AS numbered ON numbered.id = disposition.id
SET disposition.slot = numbered.slot
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE disposition DROP slot');
}
}
+1676 -5443
View File
File diff suppressed because it is too large. Load diff
+12 -14
View File
@@ -1,39 +1,37 @@
{ {
"name": "myep-team", "name": "myep-team",
"dependencies": { "dependencies": {
"@babel/core": "^7.17.0", "@babel/core": "^8.0.0",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^8.0.0",
"@hotwired/stimulus": "^3.0.0", "@hotwired/stimulus": "^3.0.0",
"@symfony/stimulus-bridge": "^3.2.0", "@symfony/stimulus-bridge": "^3.2.0",
"@symfony/webpack-encore": "^4.0.0", "@symfony/webpack-encore": "^7.2.0",
"@tailwindcss/forms": "^0.5.6", "@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/postcss": "^4.3.3",
"@trevoreyre/autocomplete-js": "^2.4.1", "@trevoreyre/autocomplete-js": "^2.4.1",
"autoprefixer": "^10.4.15", "babel-plugin-polyfill-corejs3": "^1.0.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"core-js": "^3.23.0", "core-js": "^3.50.0",
"dropzone": "^6.0.0-beta.2", "dropzone": "^6.0.0-beta.2",
"file-loader": "^6.2.0",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"htmx.org": "^1.9.10", "htmx.org": "^2.0.11",
"lightningcss": "^1.32.0",
"mjml": "^4.15.3", "mjml": "^4.15.3",
"postcss": "^8.4.29", "postcss": "^8.4.29",
"postcss-import": "^15.1.0", "postcss-loader": "^8.1.1",
"postcss-loader": "^7.3.3",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"stimulus-use": "^0.52.2", "stimulus-use": "^0.52.2",
"tailwindcss": "^3.3.3", "tailwindcss": "^4.3.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toastify-js": "^1.12.0", "toastify-js": "^1.12.0",
"webpack": "^5.74.0", "webpack": "^5.101.0",
"webpack-cli": "^4.10.0" "webpack-cli": "^7.0.0"
}, },
"license": "UNLICENSED", "license": "UNLICENSED",
"private": true, "private": true,
"scripts": { "scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev", "dev": "encore dev",
"watch": "encore dev --watch", "watch": "encore dev --watch",
"build": "encore production --progress", "build": "encore production --progress",
+18 -10
View File
@@ -1,20 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html --> <!-- https://docs.phpunit.de/en/12.5/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false" backupGlobals="false"
colors="true" colors="true"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false" cacheDirectory=".phpunit.cache"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true"
displayDetailsOnPhpunitNotices="true"
> >
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" /> <ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" /> <server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" /> <server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php> </php>
<testsuites> <testsuites>
@@ -23,13 +29,15 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage processUncoveredFiles="true"> <!-- Symfony triggers its deprecations with @trigger_error(); without
ignoreSuppressionOfDeprecations PHPUnit would swallow them all. -->
<source ignoreSuppressionOfDeprecations="true">
<include> <include>
<directory suffix=".php">src</directory> <directory>src</directory>
</include> </include>
</coverage> </source>
<listeners> <extensions>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" /> <bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension" />
</listeners> </extensions>
</phpunit> </phpunit>
+1 -3
View File
@@ -1,7 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
'postcss-import': {}, '@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
} }
} }
+31 -25
View File
@@ -2,7 +2,6 @@
namespace App\BusProNet; namespace App\BusProNet;
use App\BusProNet\Model\BaseDataResponse;
use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\ProfileResponse; use App\BusProNet\Model\ProfileResponse;
@@ -17,15 +16,24 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/**
* The socket protocol against BusProNet: length prefixed German XML over fsockopen.
*
* This is the **write** path and the user facing reads that go with it - profile lookup and
* update, registration, password reset, the CRM selections behind the leads roles and hotel
* codes. All of it is per user and authenticated as that user, which is why it stays here.
*
* It is not legacy. The base data reads that used to sit alongside it - STAMMLAENDER,
* STAMMZUSTIEGE, STAMMHOTELS - are gone: hotels, pickups and countries now come from MyE&P's
* REST api (App\BusProNet\DataProvider\*, App\MyEp\Api\ApiClient), which is the one place
* that understands BusProNet's XML.
*/
class ApiClient class ApiClient
{ {
use ApiClientTrait; use ApiClientTrait;
public const TYPE_NOTIFICATION = 'HINWEIS'; public const TYPE_NOTIFICATION = 'HINWEIS';
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO'; public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO';
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
public const TYPE_BASE_DATA_PICKUPS = 'STAMMZUSTIEGE';
public const TYPE_BASE_DATA_HOTELS = 'STAMMHOTELS';
private array $config; private array $config;
@@ -60,15 +68,28 @@ class ApiClient
} }
/** /**
* Writes the teamer's profile to their BusPro address without their password.
*
* "login" switches the password check off, so the two ids are the whole authorisation of
* this write: an address can hold several persons, and only "idadresseperson" names the
* person, while "email" is ignored. The ids must therefore only ever come from a verified
* source - MyE&P userinfo or a BusPro answer - never from a form or a request parameter.
* Verified on the BusPro test system (2026-10-16): BusPro accepts "login" on KUNDENKONTO
* requests made with our API credentials.
*
* @throws ApiClientException * @throws ApiClientException
* @throws ResponseParserException * @throws ResponseParserException
*/ */
public function updateProfile(User $user, string $password): NotificationResponse|ProfileUpdateResponse public function updateProfile(User $user): NotificationResponse|ProfileUpdateResponse
{ {
if (null === $teamer = $user->getTeamer()) { if (null === $teamer = $user->getTeamer()) {
throw new ApiClientException('Invalid argument'); throw new ApiClientException('Invalid argument');
} }
if (null === $user->getBusProAddressId() || null === $user->getBusProPersonId()) {
throw new ApiClientException('User has no BusPro address and person id');
}
$data = [ $data = [
'anfrage' => [ 'anfrage' => [
'user' => $this->config['bpn_username'], 'user' => $this->config['bpn_username'],
@@ -76,8 +97,9 @@ class ApiClient
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adressdaten_Ändern', 'art' => 'Adressdaten_Ändern',
'email' => $user->getEmail(), 'email' => $user->getEmail(),
'passwort' => $password, 'login' => 'J',
'idadresse' => $user->getBusProAddressId(), 'idadresse' => $user->getBusProAddressId(),
'idadresseperson' => $user->getBusProPersonId(),
'adressdaten' => $teamer->toPayload(), 'adressdaten' => $teamer->toPayload(),
], ],
]; ];
@@ -124,8 +146,9 @@ class ApiClient
* "login" switches the password check off, which only works when the request identifies the * "login" switches the password check off, which only works when the request identifies the
* person by id: both ids are required, and the person id is "idadresseperson" on the way in * person by id: both ids are required, and the person id is "idadresseperson" on the way in
* although the response calls it "idperson". The element order and the single-selection * although the response calls it "idperson". The element order and the single-selection
* payload are what BusPro was verified to accept - the art is undocumented, see * payload are what BusPro accepted when verified against production (2026-09-25, selection
* .ddev/plans/buspro-crm-selection-write.md before changing the shape. * 1070 toggled off and back on) - the art is undocumented, so re-verify before changing the
* shape.
* *
* Idempotent: setting a selection that is already set answers änderung=True and changes * Idempotent: setting a selection that is already set answers änderung=True and changes
* nothing, so a caller may write unconditionally and take isUpdated() as the success check. * nothing, so a caller may write unconditionally and take isUpdated() as the success check.
@@ -204,23 +227,6 @@ class ApiClient
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
} }
/**
* @throws ApiClientException
* @throws ResponseParserException
*/
public function getBaseData(string $type): NotificationResponse|BaseDataResponse
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], $type),
'satz' => ['@typ' => $type],
],
];
return $this->sendRequest($type, $data);
}
/** /**
* @throws ApiClientException * @throws ApiClientException
* @throws ResponseParserException * @throws ResponseParserException
@@ -2,46 +2,39 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Country; use App\BusProNet\Model\Country;
use App\BusProNet\Model\NotificationResponse; use App\MyEp\Api\ApiClient;
use App\BusProNet\ResponseParserException; use App\MyEp\Api\ResponseMapper;
use Psr\Log\LoggerInterface; use App\MyEp\Cache\StaleWhileErrorCache;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Country base data, read from MyE&P rather than from BusProNet directly.
*
* Keyed by the raw BusProNet token, not by an ISO code: the token is what a hotel's
* country and a teamer's nationality join against, and that vocabulary stays raw.
*/
class CountryDataProvider class CountryDataProvider
{ {
private const CACHE_KEY = 'myep_countries';
private const TTL = 3600;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly ResponseMapper $responseMapper,
private readonly LoggerInterface $logger, private readonly StaleWhileErrorCache $cache,
) { ) {
} }
/**
* @return array<string, Country> keyed by token
*/
public function getAll(): array public function getAll(): array
{ {
try { return $this->cache->get(
$countries = $this->cache->get('bpn_countries', function (ItemInterface $item) { self::CACHE_KEY,
$item->expiresAfter(3600); self::TTL,
fn (): array => $this->responseMapper->mapCountries($this->apiClient->get('api/countries')),
$response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES); );
if ($response instanceof NotificationResponse) {
$this->logger->error('Unable to fetch country base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage());
throw new ApiClientException('Unable to fetch country base data from BusProNet');
}
return $response->getItems();
});
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch country base data from BusProNet: '.$e->getMessage());
$countries = [];
}
return $countries;
} }
public function get(?string $token): ?Country public function get(?string $token): ?Country
@@ -2,47 +2,40 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse; use App\MyEp\Api\ApiClient;
use App\BusProNet\ResponseParserException; use App\MyEp\Api\ResponseMapper;
use Psr\Cache\InvalidArgumentException; use App\MyEp\Cache\StaleWhileErrorCache;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Hotel base data, read from MyE&P rather than from BusProNet directly.
*
* The models and the public interface are unchanged from when this spoke the BusProNet
* socket protocol: every caller keys on the BusProNet idbuspro, which is exactly what
* MyE&P reports as the hotel id.
*/
class HotelDataProvider class HotelDataProvider
{ {
private const CACHE_KEY = 'myep_hotels';
private const TTL = 3600;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly ResponseMapper $responseMapper,
private readonly LoggerInterface $logger, private readonly StaleWhileErrorCache $cache,
) { ) {
} }
/**
* @return array<int, Hotel> keyed by busProId
*/
public function getAll(): array public function getAll(): array
{ {
try { return $this->cache->get(
$hotels = $this->cache->get('bpn_hotels', function (ItemInterface $item) { self::CACHE_KEY,
$item->expiresAfter(3600); self::TTL,
fn (): array => $this->responseMapper->mapHotels($this->apiClient->get('api/hotels')),
$response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_HOTELS); );
if ($response instanceof NotificationResponse) {
$this->logger->error('Unable to fetch hotel base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage());
throw new ApiClientException('Unable to fetch hotel base data from BusProNet');
}
return $response->getItems();
});
} catch (ApiClientException|InvalidArgumentException|ResponseParserException $e) {
$this->logger->error('Unable to fetch hotel base data from BusProNet: '.$e->getMessage());
$hotels = [];
}
return $hotels;
} }
public function get(int $busProId): ?Hotel public function get(int $busProId): ?Hotel
@@ -50,12 +43,21 @@ class HotelDataProvider
return $this->getAll()[$busProId] ?? null; return $this->getAll()[$busProId] ?? null;
} }
/**
* @return list<Hotel>
*/
public function findByCode(string $code): array public function findByCode(string $code): array
{ {
$hotels = []; $hotels = [];
foreach ($this->getAll() as $hotel) { foreach ($this->getAll() as $hotel) {
if (str_starts_with($hotel->getCode(), $code) || str_ends_with($hotel->getCode(), $code)) { $hotelCode = $hotel->getCode();
if (null === $hotelCode) {
continue;
}
if (str_starts_with($hotelCode, $code) || str_ends_with($hotelCode, $code)) {
$hotels[] = $hotel; $hotels[] = $hotel;
} }
} }
@@ -2,46 +2,39 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
use App\BusProNet\ResponseParserException; use App\MyEp\Api\ApiClient;
use Psr\Log\LoggerInterface; use App\MyEp\Api\ResponseMapper;
use Symfony\Contracts\Cache\CacheInterface; use App\MyEp\Cache\StaleWhileErrorCache;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Boarding point base data, read from MyE&P rather than from BusProNet directly.
*
* These are the stops themselves, without a departure time: a time belongs to a stop on
* a given travel date and is carried by the destination's pickups instead.
*/
class PickupDataProvider class PickupDataProvider
{ {
private const CACHE_KEY = 'myep_pickups';
private const TTL = 3600;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly ResponseMapper $responseMapper,
private readonly LoggerInterface $logger, private readonly StaleWhileErrorCache $cache,
) { ) {
} }
/**
* @return array<int, Pickup> keyed by busProId
*/
public function getAll(): array public function getAll(): array
{ {
try { return $this->cache->get(
$pickups = $this->cache->get('bpn_pickups', function (ItemInterface $item) { self::CACHE_KEY,
$item->expiresAfter(3600); self::TTL,
fn (): array => $this->responseMapper->mapPickups($this->apiClient->get('api/pickups')),
$response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_PICKUPS); );
if ($response instanceof NotificationResponse) {
$this->logger->error('Unable to fetch pickup base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage());
throw new ApiClientException('Unable to fetch pickup base data from BusProNet');
}
return $response->getItems();
});
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch pickup base data from BusProNet: '.$e->getMessage());
$pickups = [];
}
return $pickups;
} }
public function get(int $busProId): ?Pickup public function get(int $busProId): ?Pickup
-15
View File
@@ -1,15 +0,0 @@
<?php
namespace App\BusProNet\Model;
class BaseDataResponse
{
public function __construct(private readonly array $items)
{
}
public function getItems(): array
{
return $this->items;
}
}
-13
View File
@@ -4,23 +4,10 @@ namespace App\BusProNet\Model;
class Country class Country
{ {
private ?int $id = null;
private ?string $name = null; private ?string $name = null;
private ?string $token = null; private ?string $token = null;
private ?string $nationality = null; private ?string $nationality = null;
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): static
{
$this->id = $id;
return $this;
}
public function getName(): ?string public function getName(): ?string
{ {
return $this->name; return $this->name;
@@ -90,15 +90,20 @@ class CrmAttributesResponse
// a response the CRM sent no attributes in still has to flatten to an empty set // a response the CRM sent no attributes in still has to flatten to an empty set
foreach ($this->getAttributeGroups() ?? [] as $group) { foreach ($this->getAttributeGroups() ?? [] as $group) {
/** @var CrmAttributeGroup $group */ /** @var CrmAttributeGroup $group */
if (false === isset($crmSelections[$group->getLabel()])) { // The CRM may report a group without a label. PHP used to coerce that null
$crmSelections[$group->getLabel()] = []; // to an empty string on its own; since 8.5 it deprecates it, so the same
// key is now formed explicitly and nothing about the result changes.
$label = $group->getLabel() ?? '';
if (false === isset($crmSelections[$label])) {
$crmSelections[$label] = [];
} }
foreach ($group->getAttributes() ?? [] as $attribute) { foreach ($group->getAttributes() ?? [] as $attribute) {
/** @var CrmAttribute $attribute */ /** @var CrmAttribute $attribute */
if (false === $attribute->isSelected()) { if (false === $attribute->isSelected()) {
continue; continue;
} }
$crmSelections[$group->getLabel()][] = $attribute->getLabel(); $crmSelections[$label][] = $attribute->getLabel();
} }
} }
+22 -65
View File
@@ -4,29 +4,22 @@ namespace App\BusProNet\Model;
class Hotel class Hotel
{ {
private ?int $id = null;
private ?int $busProId = null; private ?int $busProId = null;
private ?string $code = null; private ?string $code = null;
private ?string $name = null; private ?string $name = null;
private ?string $city = null; /**
private ?string $street = null; * The raw BusProNet abbreviation, which is not a standard: the same country turns
private ?string $phone = null; * up as D, DEU, A, AT or AUT. It joins against the country base data by token.
private ?string $type = null; */
private ?string $country = null; private ?string $country = null;
/**
* The same country as lowercase ISO-3166-1 alpha-2, resolved by MyE&P. Null when
* the source carries no country or none can be determined.
*/
private ?string $countryCode = null;
private ?string $costUnit = null; private ?string $costUnit = null;
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): static
{
$this->id = $id;
return $this;
}
public function getBusProId(): ?int public function getBusProId(): ?int
{ {
return $this->busProId; return $this->busProId;
@@ -63,54 +56,6 @@ class Hotel
return $this; return $this;
} }
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): static
{
$this->type = $type;
return $this;
}
public function getCountry(): ?string public function getCountry(): ?string
{ {
return $this->country; return $this->country;
@@ -123,6 +68,18 @@ class Hotel
return $this; return $this;
} }
public function getCountryCode(): ?string
{
return $this->countryCode;
}
public function setCountryCode(?string $countryCode): static
{
$this->countryCode = $countryCode;
return $this;
}
public function getCostUnit(): ?string public function getCostUnit(): ?string
{ {
return $this->costUnit; return $this->costUnit;
+1 -80
View File
@@ -3,15 +3,11 @@
namespace App\BusProNet; namespace App\BusProNet;
use App\BusProNet\Model\Address; use App\BusProNet\Model\Address;
use App\BusProNet\Model\BaseDataResponse;
use App\BusProNet\Model\Communication; use App\BusProNet\Model\Communication;
use App\BusProNet\Model\Country;
use App\BusProNet\Model\CrmAttribute; use App\BusProNet\Model\CrmAttribute;
use App\BusProNet\Model\CrmAttributeGroup; use App\BusProNet\Model\CrmAttributeGroup;
use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\ProfileResponse; use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\Model\ProfileUpdateResponse; use App\BusProNet\Model\ProfileUpdateResponse;
use App\BusProNet\Model\RegistrationResponse; use App\BusProNet\Model\RegistrationResponse;
@@ -58,18 +54,12 @@ class ResponseParser
// to be routed to createCrmAttributesResponse(), which walks a // to be routed to createCrmAttributesResponse(), which walks a
// "selektionsmerkmale" node that is absent here and would have returned an // "selektionsmerkmale" node that is absent here and would have returned an
// empty response with every role flag false - dead code until something // empty response with every role flag false - dead code until something
// sent the art (.ddev/plans/buspro-crm-selection-write.md). // sent the art.
return $this->createProfileUpdateResponse($xml); return $this->createProfileUpdateResponse($xml);
case 'Adresse_Neu': case 'Adresse_Neu':
return $this->createRegistrationResponse($xml); return $this->createRegistrationResponse($xml);
} }
break; break;
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
return $this->createCountriesResponse($xml);
case ApiClient::TYPE_BASE_DATA_PICKUPS:
return $this->createPickupsResponse($xml);
case ApiClient::TYPE_BASE_DATA_HOTELS:
return $this->createHotelsResponse($xml);
} }
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType)); throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
@@ -268,75 +258,6 @@ class ResponseParser
return $response; return $response;
} }
public function createCountriesResponse(\SimpleXMLElement $xml): BaseDataResponse
{
$countries = [];
foreach ($xml->laender->land as $item) {
$itemAttributes = $item->attributes();
$token = (string) $itemAttributes['kuerzel'];
$country = new Country();
$country
->setId((int) $itemAttributes['id'])
->setName((string) $itemAttributes['bezeichnung'])
->setToken((string) $itemAttributes['kuerzel'])
->setNationality((string) $itemAttributes['nationalitaet'])
;
$countries[$token] = $country;
}
return new BaseDataResponse($countries);
}
public function createPickupsResponse(\SimpleXMLElement $xml): BaseDataResponse
{
$pickups = [];
foreach ($xml->zustieg as $item) {
$itemAttributes = $item->attributes();
$id = (int) $itemAttributes['id'];
$busProId = (int) $itemAttributes['idbuspro'];
$pickup = new Pickup();
$pickup
->setId($id)
->setBusProId($busProId)
->setCode((string) $itemAttributes['code'])
->setCity((string) $item->ort)
->setStreet((string) $item->strasse)
;
$pickups[$busProId] = $pickup;
}
return new BaseDataResponse($pickups);
}
public function createHotelsResponse(\SimpleXMLElement $xml): BaseDataResponse
{
$hotels = [];
foreach ($xml->hotel as $item) {
$itemAttributes = $item->attributes();
$id = (int) $itemAttributes['id'];
$busProId = (int) $itemAttributes['idbuspro'];
$hotel = new Hotel();
$hotel
->setId($id)
->setBusProId($busProId)
->setCode((string) $itemAttributes['code'])
->setName((string) $item->name)
->setCountry(strtolower((string) $item->land))
->setCity((string) $item->ort)
->setStreet((string) $item->strasse)
->setPhone((string) $item->telefon)
->setType((string) $item->art)
->setCostUnit($item->kostenstelle)
;
$hotels[$busProId] = $hotel;
}
return new BaseDataResponse($hotels);
}
/** /**
* The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml * The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml
* reason instead of collapsing every shape of broken response into one message. * reason instead of collapsing every shape of broken response into one message.
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Command;
use App\Model\PlannedAssignment;
use App\Model\StaffingCandidate;
use App\Service\Assignment\AssignmentGenerator;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Assignment\StaffingResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Creates draft assignments for the future dates of every enabled staffing matrix row.
*
* Safe to repeat: a date that has ever had an assignment is skipped, so drafts the office
* edited or deleted are never recreated. Run it after app:myep-import - it only considers
* the dates the latest import reported.
*/
#[AsCommand(
name: 'app:assignment-generate',
description: 'Creates draft assignments from the staffing matrix',
)]
class AssignmentGenerateCommand extends Command
{
public function __construct(
private readonly AssignmentGenerator $generator,
private readonly StaffingResolver $resolver,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only list what would be created');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = true === $input->getOption('dry-run');
try {
$this->resolver->assertConfigured();
} catch (StaffingConfigurationException $e) {
$io->error('Jobprofil oder Honorar der Besetzungsmatrix nicht gefunden – abgebrochen: '.$e->getMessage());
$this->logger->error('Assignment generation aborted', ['reason' => $e->getMessage()]);
return Command::FAILURE;
}
$candidates = $this->generator->getCandidates(enabledOnly: true);
$open = array_values(array_filter($candidates, static fn (StaffingCandidate $c): bool => false === $c->skipped));
$io->info(sprintf(
'%d Reisetermine aus der Besetzungsmatrix, davon %d schon mit Einsätzen (übersprungen)',
count($candidates),
count($candidates) - count($open),
));
if ([] === $open) {
return Command::SUCCESS;
}
$io->table(
['Termin', 'Produkt', 'Beginn', 'Einsätze'],
array_map($this->describe(...), $open),
);
if (true === $dryRun) {
$io->note('Probelauf – es wurde nichts angelegt.');
return Command::SUCCESS;
}
$created = 0;
foreach ($open as $candidate) {
$created += count($this->generator->generate($candidate));
}
$io->success(sprintf('%d Einsätze für %d Reisetermine als Entwurf angelegt', $created, count($open)));
$this->logger->info('Assignment generation finished', [
'destinations' => count($open),
'assignments' => $created,
'skipped' => count($candidates) - count($open),
]);
return Command::SUCCESS;
}
private function describe(StaffingCandidate $candidate): array
{
return [
$candidate->destination->getCode(),
$candidate->rule->getProductCode(),
$candidate->destination->getDateFrom()->format('d.m.Y'),
implode("\n", array_map(
fn (PlannedAssignment $planned): string => sprintf(
'%d× %s%s',
$planned->places,
$this->resolver->jobProfile($planned->role)->getName(),
null === $planned->pickup ? '' : ' (Busbegleitung)',
),
$candidate->planned,
)),
];
}
}
-195
View File
@@ -1,195 +0,0 @@
<?php
namespace App\Command;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\DataProvider\PickupDataProvider;
use Doctrine\DBAL\Connection;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:bpn-import',
description: 'Imports dates, products and hotels from BusProNet',
)]
class BpnImportCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly HotelDataProvider $hotelDataProvider,
private readonly PickupDataProvider $pickupDataProvider,
private readonly LoggerInterface $logger,
private readonly FilesystemOperator $xmlExport,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Get all xml files
try {
$xmlFiles = $this
->xmlExport
->listContents('')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'))
->toArray();
} catch (FilesystemException $e) {
$io->error('Unable to list XML files');
return Command::FAILURE;
}
$totalCount = count($xmlFiles);
if (0 === $totalCount) {
$io->error('No XML files available or found');
return Command::FAILURE;
}
// Resolve the BusProNet base data once, before touching a single row. A destination
// cannot be written without its hotel, so an unavailable hotel list would otherwise
// skip every record and still report success. Holding both lists locally also keeps
// the loop off the providers: on a failed fetch nothing is cached, and a per-lookup
// ->get() would re-open the socket for every pickup of all 174 files.
$hotels = $this->hotelDataProvider->getAll();
$pickups = $this->pickupDataProvider->getAll();
if ([] === $hotels) {
$io->error('Hotel-Stammdaten konnten nicht von BusProNet geladen werden – Import abgebrochen.');
$this->logger->error('BPN import aborted: hotel base data unavailable');
return Command::FAILURE;
}
$io->info('Found '.$totalCount.' XML files');
$addedCount = 0;
$updatedCount = 0;
$warnings = [];
if ([] === $pickups) {
$warnings[] = 'Zustiegs-Stammdaten konnten nicht von BusProNet geladen werden';
}
$progressBar = $io->createProgressBar($totalCount);
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
$progressBar->setMessage('Starting');
$progressBar->start();
// Iterate over xml files
foreach ($xmlFiles as $xmlFile) {
$progressBar->setMessage($xmlFile->path());
// Load xml file into simplexml object
try {
$xml = simplexml_load_string($this->xmlExport->read($xmlFile->path()));
} catch (FilesystemException $e) {
$warnings[] = 'Unable to read XML file '.$xmlFile->path();
$progressBar->advance();
continue;
}
// Iterate over all destinations
foreach ($xml->xpath('reise/termin') as $destinationXml) {
$product = (string) $destinationXml->xpath('text')[0];
$busProId = (int) $destinationXml->attributes()['idbuspro'];
$code = (string) $destinationXml->attributes()['code'];
$dateFrom = \DateTimeImmutable::createFromFormat('d.m.Y', (string) $destinationXml->attributes()['termin']);
$dateTo = \DateTimeImmutable::createFromFormat('d.m.Y', (string) $destinationXml->attributes()['bis']);
// Find pickups
$datePickups = [];
foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) {
$pickupBusProId = (int) $pickupXml->attributes()['idbuspro'];
if ($pickupBusProId) {
if (null === $pickup = $pickups[$pickupBusProId] ?? null) {
$warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found';
continue;
}
$datePickups[] = [
'busProId' => $pickupBusProId,
'city' => $pickup->getCity(),
'street' => $pickup->getStreet(),
'time' => (string) $pickupXml->attributes()['zeit'],
];
}
}
// Iterate over all hotel entries
foreach ($destinationXml->xpath('hotel') as $hotelXml) {
$hotelBusProId = (int) $hotelXml->attributes()['idbuspro'];
if (null === $hotel = $hotels[$hotelBusProId] ?? null) {
$warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found';
continue;
}
// Finally update or store collected data
if ($id = $this->connection->fetchOne('SELECT id FROM destination WHERE bus_pro_id=? AND hotel_bus_pro_id=?', [$busProId, $hotelBusProId])) {
$this
->connection
->update('destination', [
'code' => $code,
'date_from' => $dateFrom->format('Y-m-d'),
'date_to' => $dateTo->format('Y-m-d'),
'product' => $product,
'pickups' => json_encode($datePickups),
'cost_unit' => $hotel->getCostUnit(),
'country' => $hotel->getCountry(),
'updated_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
], [
'id' => $id,
'hotel_bus_pro_id' => $hotelBusProId,
])
;
++$updatedCount;
} else {
$this
->connection
->insert('destination', [
'code' => $code,
'bus_pro_id' => $busProId,
'date_from' => $dateFrom->format('Y-m-d'),
'date_to' => $dateTo->format('Y-m-d'),
'product' => $product,
'hotel' => $hotel->getName(),
'hotel_code' => $hotel->getCode(),
'hotel_bus_pro_id' => $hotelBusProId,
'pickups' => json_encode($datePickups),
'cost_unit' => $hotel->getCostUnit(),
'country' => $hotel->getCountry(),
'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
])
;
++$addedCount;
}
}
}
$progressBar->advance();
}
$progressBar->setMessage('Done');
$progressBar->finish();
$io->newLine(2);
if ($warnings) {
$io->warning(array_unique($warnings));
}
$logMessage = 'Added '.$addedCount.' and updated '.$updatedCount.' destinations';
$io->info($logMessage);
$this->logger->info($logMessage);
return Command::SUCCESS;
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace App\Command;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\DataProvider\PickupDataProvider;
use App\MyEp\Api\ApiClient;
use App\MyEp\Import\DestinationRow;
use App\MyEp\Import\InvalidRowException;
use App\Service\Assignment\PickupDepartureOrder;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Read-only probe of the MyE&P API: does it answer, and does it carry what the import needs.
*
* Writes nothing and touches no database. It exists because the interesting failures in this
* integration are silent ones - a field named `id` where the import reads `busProId`, a time
* in a shape the departure ordering cannot parse - and they are only visible against a real
* response. `app:myep-import` cannot serve as the probe: its own guard passes or fails on the
* hotel payload, and once past it the command writes.
*/
#[AsCommand(
name: 'app:myep-check',
description: 'Checks that the MyE&P API answers and carries the fields the import needs',
)]
class MyEpCheckCommand extends Command
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly HotelDataProvider $hotelDataProvider,
private readonly PickupDataProvider $pickupDataProvider,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->section('Base data');
$hotels = $this->hotelDataProvider->getAll();
$pickups = $this->pickupDataProvider->getAll();
if ([] === $hotels) {
$io->error('No hotels returned - the import would refuse to run.');
return Command::FAILURE;
}
$withCostUnit = count(array_filter($hotels, fn ($h) => null !== $h->getCostUnit()));
$withCountry = count(array_filter($hotels, fn ($h) => null !== $h->getCountryCode()));
$io->definitionList(
['hotels' => (string) count($hotels)],
['with costUnit' => sprintf('%d%s', $withCostUnit, 0 === $withCostUnit ? ' <- import would refuse' : '')],
['with countryCode' => sprintf('%d%s', $withCountry, 0 === $withCountry ? ' <- import would refuse' : '')],
['pickups' => (string) count($pickups)],
);
$io->section('Travel export');
// A whole page rather than one row: the first row often has no boarding points, and an
// unread pickups array is exactly the failure this probe exists to catch.
$payload = $this->apiClient->get('api/exports/travels', ['page' => 1, 'perPage' => 500]);
$meta = $payload['meta'] ?? [];
$io->definitionList(...array_map(
fn (string $key) => [$key => var_export($meta[$key] ?? null, true)],
['total', 'totalPages', 'perPage', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'],
));
$rows = array_values(array_filter((array) ($payload['data'] ?? []), 'is_array'));
if ([] === $rows) {
$io->error('The export returned no rows.');
return Command::FAILURE;
}
$withPickups = array_values(array_filter($rows, fn (array $r) => [] !== ($r['pickups'] ?? [])));
$io->definitionList(
['rows on this page' => (string) count($rows)],
['of them with pickups' => (string) count($withPickups)],
);
$row = $withPickups[0] ?? $rows[0];
// Does `datetime` actually resolve the overnight leg? A coach leaves in the evening and
// keeps boarding through the night, so a 00:30 stop belongs to the day after a 23:30 one.
// If every entry on such a row carries the same calendar date, the field is unresolved and
// ordering by it inverts the route.
$straddling = 0;
$unresolved = [];
foreach ($withPickups as $candidate) {
$times = array_filter(array_column($candidate['pickups'], 'time'));
$dates = array_unique(array_map(
fn (?string $d) => null === $d ? null : substr($d, 0, 10),
array_column($candidate['pickups'], 'datetime'),
));
$late = array_filter($times, fn (string $t) => $t >= '18:00');
$early = array_filter($times, fn (string $t) => $t <= '06:00');
if ([] === $late || [] === $early) {
continue;
}
++$straddling;
if (1 === count($dates)) {
$unresolved[] = $candidate['busProId'];
}
}
$io->definitionList(
['rows straddling midnight' => (string) $straddling],
['of them with one date only' => sprintf('%d%s', count($unresolved), [] === $unresolved ? '' : ' <- datetime unresolved')],
);
// The decisive question is not whether a date looks odd but whether adopting `datetime`
// would reorder a route. Compared against our own anchor-based ordering, which reads the
// bare clock times and is what runs today.
$disagree = [];
foreach ($withPickups as $candidate) {
$pickups = $candidate['pickups'];
if (2 > count($pickups)) {
continue;
}
$legacy = array_column(
PickupDepartureOrder::sort(array_map(
function (array $p): array {
unset($p['datetime']);
return $p;
},
$pickups,
)),
'busProId',
);
$dated = $pickups;
usort($dated, fn (array $a, array $b) => ($a['datetime'] ?? '') <=> ($b['datetime'] ?? ''));
if ($legacy !== array_column($dated, 'busProId')) {
$disagree[] = $candidate['busProId'];
}
}
$io->definitionList(
['rows where datetime order differs' => sprintf(
'%d of %d%s',
count($disagree),
count($withPickups),
[] === $disagree ? '' : ' <- adopting datetime would reorder these',
)],
);
if ([] !== $unresolved) {
$io->warning(sprintf(
'datetime does not roll past midnight on %d of %d straddling rows (e.g. busProId %s). '
.'Ordering by it would put the early-morning stops before the evening departure.',
count($unresolved),
$straddling,
implode(', ', array_slice($unresolved, 0, 5)),
));
}
$resolvedStraddlers = array_values(array_filter(
$withPickups,
function (array $r) use ($unresolved): bool {
$times = array_filter(array_column($r['pickups'], 'time'));
$late = array_filter($times, fn (string $t) => $t >= '18:00');
$early = array_filter($times, fn (string $t) => $t <= '06:00');
return [] !== $late && [] !== $early && false === in_array($r['busProId'], $unresolved, true);
},
));
if ([] !== $resolvedStraddlers) {
$sample = $resolvedStraddlers[0];
$io->writeln(sprintf('<comment>A straddling row where datetime DID roll (busProId %d, dateFrom %s)</comment>', $sample['busProId'], $sample['dateFrom']));
foreach ($sample['pickups'] as $pickup) {
$io->writeln(sprintf(' %-14s %-6s %s', $pickup['city'] ?? '?', $pickup['time'] ?? '-', $pickup['datetime'] ?? '-'));
}
$io->newLine();
}
$io->writeln('<comment>Raw first row</comment>');
$io->writeln(json_encode($row, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$io->section('As the import reads it');
try {
$destinationRow = DestinationRow::fromArray($row);
} catch (InvalidRowException $e) {
$io->error(sprintf('The import would skip this row: %s', $e->getMessage()));
return Command::FAILURE;
}
$io->definitionList(
['busProId' => (string) $destinationRow->busProId],
['code' => var_export($destinationRow->code, true)],
['product' => var_export($destinationRow->product, true)],
['dateFrom' => $destinationRow->dateFrom->format('Y-m-d')],
['dateTo' => $destinationRow->dateTo->format('Y-m-d')],
['hotelBusProId' => (string) $destinationRow->hotelBusProId],
['hotelCode' => var_export($destinationRow->hotelCode, true)],
['hotelName' => var_export($destinationRow->hotelName, true)],
['costUnit' => var_export($destinationRow->costUnit, true)],
['country' => var_export($destinationRow->country, true)],
['pickups' => json_encode($destinationRow->pickups, JSON_UNESCAPED_UNICODE)],
);
if ([] !== $row['pickups'] && [] === $destinationRow->pickups) {
$io->error('The export carries pickups but the import read none - check the id field name.');
return Command::FAILURE;
}
$io->success('MyE&P answers and the import can read the row.');
return Command::SUCCESS;
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Command;
use App\MyEp\Api\ApiException;
use App\MyEp\Import\DestinationRow;
use App\MyEp\Import\TravelExportClient;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Fills the destination table from MyE&P's travel export.
*
* Replaces the Ziel_*.xml parsing this command used to do. MyE&P now owns every part of that:
* it reads BusProNet's German XML, flattens it to one row per travel date x hotel - the pair
* this table is keyed on - joins the hotel and pickup base data in, and resolves the boarding
* times. What is left here is the upsert.
*
* Still raw DBAL rather than the ORM: the rows carry no behaviour, nothing listens for their
* changes, and a Destination is soft deletable, so hydrating 400 entities to write scalars
* onto them would buy nothing.
*/
#[AsCommand(
name: 'app:myep-import',
description: 'Imports travel dates, products and hotels from the MyE&P API',
)]
class MyEpImportCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly TravelExportClient $travelExportClient,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$export = $this->travelExportClient->fetchAll();
} catch (ApiException $e) {
$io->error('Der Reise-Export konnte nicht von MyE&P geladen werden – Import abgebrochen.');
$this->logger->error('Import aborted: the MyE&P travel export could not be read', [
'reason' => $e->getMessage(),
]);
return Command::FAILURE;
}
// An empty export would otherwise be a run that touches nothing and reports success,
// which reads as "nothing changed upstream" rather than as a failure to read it.
if ([] === $export->rows) {
$io->error('Der Reise-Export von MyE&P enthält keine Reisen – Import abgebrochen.');
$this->logger->error('Import aborted: the MyE&P travel export was empty');
return Command::FAILURE;
}
// Every destination this import touches gets its cost unit and its country written from
// the export, on update as well as on insert. If MyE&P stops reporting those two fields,
// a run would quietly blank them on rows that have them: the cost unit then drops off
// the invoices (App\Service\Pdf\InvoiceApprover) and a missing country code blanks the
// flag icons and takes the row out of the country filters. Refusing to run is the
// recoverable failure; overwriting good data is not.
if ([] !== $missingFields = $this->missingHotelFields($export->rows)) {
$io->error(sprintf(
'Die MyE&P-API liefert keine %s – Import abgebrochen, damit bestehende Werte nicht überschrieben werden.',
implode(' und ', $missingFields),
));
$this->logger->error('Import aborted: the MyE&P travel export carries no '.implode(', ', $missingFields));
return Command::FAILURE;
}
$io->info(sprintf('%d Reisen von MyE&P geladen', count($export->rows)));
// Both are losses MyE&P made silently on its side: a skipped pair is a travel the
// disposition will never offer, and a boarding point without master data reaches the
// form with no town. Logged every run so they get chased instead of settling in.
$this->logger->info('MyE&P travel export read', [
'rows' => count($export->rows),
'skipped' => $export->skipped,
'pickups_without_master_data' => $export->pickupsWithoutMasterData,
'generated_at' => $export->generatedAt,
]);
if (0 < $export->skipped || 0 < $export->pickupsWithoutMasterData) {
$io->warning(sprintf(
'MyE&P hat %d Reise-Hotel-Kombinationen verworfen und %d Zustiege ohne Stammdaten gemeldet.',
$export->skipped,
$export->pickupsWithoutMasterData,
));
}
$now = new \DateTimeImmutable();
$addedCount = 0;
$updatedCount = 0;
$progressBar = $io->createProgressBar(count($export->rows));
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
$progressBar->setMessage('Starting');
$progressBar->start();
foreach ($export->rows as $row) {
$progressBar->setMessage(sprintf('%s %s', $row->dateFrom->format('d.m.Y'), $row->product));
$id = $this->connection->fetchOne(
'SELECT id FROM destination WHERE bus_pro_id=? AND hotel_bus_pro_id=?',
[$row->busProId, $row->hotelBusProId],
);
if ($id) {
$this->connection->update('destination', $row->toUpdatePayload($now), ['id' => $id]);
++$updatedCount;
} else {
$this->connection->insert('destination', $row->toInsertPayload($now));
++$addedCount;
}
$progressBar->advance();
}
$progressBar->setMessage('Done');
$progressBar->finish();
$io->newLine(2);
$io->success(sprintf('%d Destinationen angelegt, %d aktualisiert', $addedCount, $updatedCount));
$this->logger->info('MyE&P destination import finished', [
'added' => $addedCount,
'updated' => $updatedCount,
]);
return Command::SUCCESS;
}
/**
* Names the hotel fields MyE&P is not reporting, German labels for the operator.
*
* Asserted across the whole export rather than per row, because a row legitimately has
* neither: only a minority of hotels carry a kostenstelle upstream, and some carry no
* country BusProNet can resolve. An export where not one row has the field is a field
* missing from the response, not a gap in the data.
*
* @param DestinationRow[] $rows
*
* @return list<string>
*/
private function missingHotelFields(array $rows): array
{
$required = [
'Kostenstellen' => static fn (DestinationRow $row): bool => '' !== (string) $row->costUnit,
'Länderkennungen' => static fn (DestinationRow $row): bool => '' !== (string) $row->country,
];
$missing = [];
foreach ($required as $label => $isReported) {
foreach ($rows as $row) {
if ($isReported($row)) {
continue 2;
}
}
$missing[] = $label;
}
return $missing;
}
}
+389
View File
@@ -0,0 +1,389 @@
<?php
namespace App\Config;
use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator;
use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition;
use App\Model\Questionnaire\Option;
use App\Model\Questionnaire\Question;
use App\Model\Questionnaire\Section;
use Symfony\Component\Yaml\Yaml;
/**
* Reads questionnaire definitions from config/questionnaires/.
*
* `<key>.yaml` is the current version of a questionnaire; `<key>.v<n>.yaml` keeps an older one that
* responses were submitted under, so they can still be rendered with the labels they were given.
*
* Files are parsed lazily and memoised, like EmailTextCatalog. A definition is checked strictly
* while it is built - unknown fields, unknown types, duplicate keys and show_if conditions that
* point at a later question or a non-existent option all throw - because a typo in the YAML should
* fail in QuestionnaireCatalogTest, not in front of a teamer.
*/
class QuestionnaireCatalog
{
/**
* The questionnaire every teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
*/
public const string TEAMER_FEEDBACK = 'teamer_feedback';
/**
* The one a teamer who escorted the bus fills in as well, on the same page.
*/
public const string BUS_FEEDBACK = 'bus_feedback';
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
private const array OPTION_FIELDS = ['key', 'label'];
// A question key becomes a form field name and a JSON key in stored answers.
private const string KEY_PATTERN = '/^[a-z][a-z0-9_]*$/';
private const int RATING_MIN = 1;
private const int RATING_MAX = 5;
/**
* @var array<string, Definition> keyed by file path
*/
private array $definitions = [];
public function __construct(
private readonly string $questionnaireDir,
) {
}
/**
* The first end date the teamer questionnaire is still offered for: the services.yaml
* parameter questionnaire_window_days, in days before today. Placements that ended earlier are neither
* pending nor open, so a deploy does not offer it for every past placement at once.
*/
public static function windowStart(int $windowDays): \DateTimeImmutable
{
if ($windowDays < 0) {
throw new \InvalidArgumentException(sprintf('questionnaire_window_days must not be negative, got %d', $windowDays));
}
return new \DateTimeImmutable(sprintf('today -%d days', $windowDays));
}
/**
* @param int|null $version null for the current version
*/
public function get(string $key, ?int $version = null): Definition
{
$current = $this->load($this->questionnaireDir.'/'.$key.'.yaml');
if (null === $version || $version === $current->version) {
return $current;
}
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
}
/**
* The keys of the questionnaires there are, i.e. of their current files.
*
* @return list<string>
*/
public function keys(): array
{
$keys = [];
foreach (glob($this->questionnaireDir.'/*.yaml') ?: [] as $file) {
if (1 === preg_match('/^([a-z0-9_]+)\.yaml$/', basename($file), $match)) {
$keys[] = $match[1];
}
}
sort($keys);
return $keys;
}
/**
* Every definition in the directory, archived versions included.
*
* @return list<Definition>
*/
public function all(): array
{
$files = glob($this->questionnaireDir.'/*.yaml') ?: [];
sort($files);
return array_map($this->load(...), $files);
}
private function load(string $file): Definition
{
if (!is_file($file)) {
throw new \InvalidArgumentException(sprintf('Questionnaire definition "%s" does not exist', $file));
}
return $this->definitions[$file] ??= $this->build(Yaml::parseFile($file), basename($file));
}
private function build(mixed $data, string $file): Definition
{
$this->assertFields($data, self::ROOT_FIELDS, ['key', 'version', 'title', 'sections'], $file);
if (1 !== preg_match('/^(?<key>[a-z0-9_]+)(\.v(?<version>\d+))?\.yaml$/', $file, $name)) {
throw $this->error($file, 'file name must be <key>.yaml or <key>.v<version>.yaml');
}
if ($data['key'] !== $name['key']) {
throw $this->error($file, sprintf('key "%s" does not match the file name', $data['key']));
}
if (!is_int($data['version']) || $data['version'] < 1) {
throw $this->error($file, 'version must be a positive integer');
}
if (isset($name['version']) && (int) $name['version'] !== $data['version']) {
throw $this->error($file, sprintf('version %d does not match the file name', $data['version']));
}
if (!is_array($data['sections']) || [] === $data['sections'] || !array_is_list($data['sections'])) {
throw $this->error($file, 'sections must be a non-empty list');
}
$sections = [];
$sectionKeys = [];
/** @var array<string, Question> $questions every question parsed so far, for show_if */
$questions = [];
foreach ($data['sections'] as $index => $sectionData) {
$context = sprintf('%s: section #%d', $file, $index + 1);
$this->assertFields($sectionData, self::SECTION_FIELDS, ['key', 'questions'], $context);
$key = $this->key($sectionData['key'], $context);
if (in_array($key, $sectionKeys, true)) {
throw $this->error($context, sprintf('duplicate section key "%s"', $key));
}
$sectionKeys[] = $key;
$context = sprintf('%s: section "%s"', $file, $key);
if (!is_array($sectionData['questions']) || [] === $sectionData['questions'] || !array_is_list($sectionData['questions'])) {
throw $this->error($context, 'questions must be a non-empty list');
}
$sectionQuestions = [];
foreach ($sectionData['questions'] as $questionData) {
$question = $this->buildQuestion($questionData, $questions, $context);
$questions[$question->key] = $question;
$sectionQuestions[] = $question;
}
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
}
return new Definition(
$data['key'],
$data['version'],
$this->string($data['title'], 'title', $file),
$this->optionalString($data['description'] ?? null, 'description', $file),
$sections,
);
}
/**
* @param array<string, Question> $earlier
*/
private function buildQuestion(mixed $data, array $earlier, string $context): Question
{
$this->assertFields($data, self::QUESTION_FIELDS, ['key', 'label', 'type'], $context.': question');
$key = $this->key($data['key'], $context.': question');
$context = sprintf('%s: question "%s"', $context, $key);
if (isset($earlier[$key])) {
throw $this->error($context, 'duplicate question key');
}
$type = QuestionType::tryFrom((string) $data['type'])
?? throw $this->error($context, sprintf('unknown type "%s"', $data['type']));
$required = $data['required'] ?? false;
if (!is_bool($required)) {
throw $this->error($context, 'required must be true or false');
}
$widget = null;
if (array_key_exists('widget', $data)) {
if (QuestionType::CHOICE !== $type) {
throw $this->error($context, 'widget is only allowed on a choice question');
}
$widget = ChoiceWidget::tryFrom((string) $data['widget'])
?? throw $this->error($context, sprintf('unknown widget "%s"', $data['widget']));
} elseif (QuestionType::CHOICE === $type) {
$widget = ChoiceWidget::RADIO;
}
$options = [];
if ($type->hasOptions()) {
$options = $this->buildOptions($data['options'] ?? null, $context);
} elseif (array_key_exists('options', $data)) {
throw $this->error($context, sprintf('options are not allowed on a %s question', $type->value));
}
$showIf = array_key_exists('show_if', $data)
? $this->buildCondition($data['show_if'], $earlier, $context)
: null;
return new Question(
$key,
$this->string($data['label'], 'label', $context),
$type,
$required,
$this->optionalString($data['help'] ?? null, 'help', $context),
$widget,
$options,
$showIf,
);
}
/**
* @return list<Option>
*/
private function buildOptions(mixed $data, string $context): array
{
if (!is_array($data) || [] === $data || !array_is_list($data)) {
throw $this->error($context, 'options must be a non-empty list');
}
$options = [];
$keys = [];
foreach ($data as $optionData) {
$this->assertFields($optionData, self::OPTION_FIELDS, self::OPTION_FIELDS, $context.': option');
// An option key is stored as the answer; YAML would turn an unquoted 1 into an int.
$key = $this->key((string) $optionData['key'], $context.': option');
if (in_array($key, $keys, true)) {
throw $this->error($context, sprintf('duplicate option key "%s"', $key));
}
$keys[] = $key;
$options[] = new Option($key, $this->string($optionData['label'], 'option label', $context));
}
return $options;
}
/**
* @param array<string, Question> $earlier
*/
private function buildCondition(mixed $data, array $earlier, string $context): Condition
{
$context .= ': show_if';
$operators = array_map(static fn (ConditionOperator $operator): string => $operator->value, ConditionOperator::cases());
$this->assertFields($data, ['question', ...$operators], ['question'], $context);
$given = array_values(array_intersect($operators, array_keys($data)));
if (1 !== count($given)) {
throw $this->error($context, sprintf('needs exactly one of %s', implode(', ', $operators)));
}
$operator = ConditionOperator::from($given[0]);
$value = $data[$given[0]];
// Only an earlier question can be answered by the time this one is reached, and it rules
// out cycles without having to look for them.
$controlling = $earlier[$data['question']]
?? throw $this->error($context, sprintf('question "%s" is not an earlier question', $data['question']));
match ($operator) {
ConditionOperator::EQUALS, ConditionOperator::IN => $this->assertOptionValue($controlling, $operator, $value, $context),
ConditionOperator::LTE => $this->assertRatingValue($controlling, $value, $context),
};
return new Condition($controlling->key, $operator, $value);
}
private function assertOptionValue(Question $controlling, ConditionOperator $operator, mixed $value, string $context): void
{
if (QuestionType::CHOICE !== $controlling->type) {
throw $this->error($context, sprintf('%s needs a choice question, "%s" is %s', $operator->value, $controlling->key, $controlling->type->value));
}
$values = ConditionOperator::IN === $operator ? $value : [$value];
if (!is_array($values) || [] === $values || !array_is_list($values)) {
throw $this->error($context, 'in needs a non-empty list of option keys');
}
foreach ($values as $optionKey) {
if (!is_string($optionKey) || null === $controlling->getOption($optionKey)) {
throw $this->error($context, sprintf('"%s" is not an option of "%s"', is_string($optionKey) ? $optionKey : json_encode($optionKey), $controlling->key));
}
}
}
private function assertRatingValue(Question $controlling, mixed $value, string $context): void
{
if (QuestionType::RATING !== $controlling->type) {
throw $this->error($context, sprintf('lte needs a rating question, "%s" is %s', $controlling->key, $controlling->type->value));
}
if (!is_int($value) || $value < self::RATING_MIN || $value > self::RATING_MAX) {
throw $this->error($context, sprintf('lte needs a rating from %d to %d', self::RATING_MIN, self::RATING_MAX));
}
}
/**
* @param list<string> $allowed
* @param list<string> $required
*/
private function assertFields(mixed $data, array $allowed, array $required, string $context): void
{
if (!is_array($data) || array_is_list($data)) {
throw $this->error($context, 'must be a mapping');
}
if ([] !== $unknown = array_diff(array_keys($data), $allowed)) {
throw $this->error($context, sprintf('unknown field(s) %s', implode(', ', $unknown)));
}
if ([] !== $missing = array_diff($required, array_keys($data))) {
throw $this->error($context, sprintf('missing field(s) %s', implode(', ', $missing)));
}
}
private function key(mixed $value, string $context): string
{
if (!is_string($value) || 1 !== preg_match(self::KEY_PATTERN, $value)) {
throw $this->error($context, sprintf('key %s must be lower snake_case', json_encode($value)));
}
return $value;
}
private function string(mixed $value, string $field, string $context): string
{
if (!is_string($value) || '' === trim($value)) {
throw $this->error($context, sprintf('%s must be a non-empty string', $field));
}
return $value;
}
private function optionalString(mixed $value, string $field, string $context): ?string
{
return null === $value ? null : $this->string($value, $field, $context);
}
private function error(string $context, string $message): \UnexpectedValueException
{
return new \UnexpectedValueException(sprintf('%s: %s', $context, $message));
}
}
@@ -8,6 +8,7 @@ use App\Form\ApplicationFormSubmissionCallsType;
use App\Model\ApplicationFormSubmissionCallsDto; use App\Model\ApplicationFormSubmissionCallsDto;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -30,8 +31,11 @@ class CallsController extends AbstractController
#[Route('/admin/application-form-submission/calls/{uuid}', name: 'app_admin_application_form_submission_calls', methods: ['POST'])] #[Route('/admin/application-form-submission/calls/{uuid}', name: 'app_admin_application_form_submission_calls', methods: ['POST'])]
#[IsGranted('ROLE_TEAM_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response public function index(
{ #[MapEntity(mapping: ['uuid' => 'uuid'])]
ApplicationFormSubmission $submission,
Request $request,
): Response {
$callsDto = new ApplicationFormSubmissionCallsDto($submission); $callsDto = new ApplicationFormSubmissionCallsDto($submission);
$form = $this->createForm(ApplicationFormSubmissionCallsType::class, $callsDto); $form = $this->createForm(ApplicationFormSubmissionCallsType::class, $callsDto);
@@ -8,6 +8,7 @@ use App\Model\TeamerProvisioningResult;
use App\Service\Teamer\TeamerProvisioner; use App\Service\Teamer\TeamerProvisioner;
use App\Service\Teamer\TeamerProvisioningException; use App\Service\Teamer\TeamerProvisioningException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -32,8 +33,11 @@ class CreateTeamerController extends AbstractController
#[Route('/admin/application-form-submission/create-teamer/{uuid}', name: 'app_admin_application_form_submission_create_teamer')] #[Route('/admin/application-form-submission/create-teamer/{uuid}', name: 'app_admin_application_form_submission_create_teamer')]
#[IsGranted('ROLE_TEAM_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response public function index(
{ #[MapEntity(mapping: ['uuid' => 'uuid'])]
ApplicationFormSubmission $submission,
Request $request,
): Response {
if (false === $request->isMethod(Request::METHOD_POST)) { if (false === $request->isMethod(Request::METHOD_POST)) {
return $this->render('admin/application_form_submission/modal_create_teamer.html.twig', [ return $this->render('admin/application_form_submission/modal_create_teamer.html.twig', [
'submission' => $submission, 'submission' => $submission,
@@ -7,6 +7,7 @@ use App\Entity\ApplicationFormSubmission;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -30,8 +31,11 @@ class DeleteController extends AbstractController
#[Route('/admin/application-form-submission/delete/{uuid}', name: 'app_admin_application_form_submission_delete')] #[Route('/admin/application-form-submission/delete/{uuid}', name: 'app_admin_application_form_submission_delete')]
#[IsGranted('ROLE_TEAM_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response public function index(
{ #[MapEntity(mapping: ['uuid' => 'uuid'])]
ApplicationFormSubmission $submission,
Request $request,
): Response {
if (true === $request->isMethod(Request::METHOD_POST)) { if (true === $request->isMethod(Request::METHOD_POST)) {
$uuid = $submission->getUuid(); $uuid = $submission->getUuid();
@@ -6,6 +6,7 @@ use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\ApplicationFormSubmission; use App\Entity\ApplicationFormSubmission;
use App\Form\ApplicationFormSubmissionCallsType; use App\Form\ApplicationFormSubmissionCallsType;
use App\Model\ApplicationFormSubmissionCallsDto; use App\Model\ApplicationFormSubmissionCallsDto;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -18,8 +19,11 @@ class DetailController extends AbstractController
#[Route('/admin/application-form-submission/detail/{uuid}', name: 'app_admin_application_form_submission_detail')] #[Route('/admin/application-form-submission/detail/{uuid}', name: 'app_admin_application_form_submission_detail')]
#[IsGranted('ROLE_TEAM_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response public function index(
{ #[MapEntity(mapping: ['uuid' => 'uuid'])]
ApplicationFormSubmission $submission,
Request $request,
): Response {
// Rendered here, taken by CallsController - the form has no validation to fail, so it // Rendered here, taken by CallsController - the form has no validation to fail, so it
// never has to come back to this page with errors. // never has to come back to this page with errors.
$callsForm = $this->createForm( $callsForm = $this->createForm(
@@ -5,6 +5,7 @@ namespace App\Controller\Admin\ApplicationFormSubmission;
use App\Email\ApplicationFormSubmissionMailer; use App\Email\ApplicationFormSubmissionMailer;
use App\Entity\ApplicationFormSubmission; use App\Entity\ApplicationFormSubmission;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -25,8 +26,11 @@ class SendFollowUpMailController extends AbstractController
#[Route('/admin/application-form-submission/send-follow-up/{uuid}', name: 'app_admin_application_form_submission_send_follow_up')] #[Route('/admin/application-form-submission/send-follow-up/{uuid}', name: 'app_admin_application_form_submission_send_follow_up')]
#[IsGranted('ROLE_TEAM_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response public function index(
{ #[MapEntity(mapping: ['uuid' => 'uuid'])]
ApplicationFormSubmission $submission,
Request $request,
): Response {
if (true === $request->isMethod(Request::METHOD_POST)) { if (true === $request->isMethod(Request::METHOD_POST)) {
$this->mailer->sendFollowUp($submission); $this->mailer->sendFollowUp($submission);
@@ -5,6 +5,7 @@ namespace App\Controller\Admin\ApplicationFormSubmission;
use App\Email\ApplicationFormSubmissionMailer; use App\Email\ApplicationFormSubmissionMailer;
use App\Entity\ApplicationFormSubmission; use App\Entity\ApplicationFormSubmission;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -24,8 +25,11 @@ class SendReceiptMailController extends AbstractController
#[Route('/admin/application-form-submission/send-receipt/{uuid}', name: 'app_admin_application_form_submission_send_receipt')] #[Route('/admin/application-form-submission/send-receipt/{uuid}', name: 'app_admin_application_form_submission_send_receipt')]
#[IsGranted('ROLE_TEAM_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response public function index(
{ #[MapEntity(mapping: ['uuid' => 'uuid'])]
ApplicationFormSubmission $submission,
Request $request,
): Response {
if (true === $request->isMethod(Request::METHOD_POST)) { if (true === $request->isMethod(Request::METHOD_POST)) {
$this->mailer->sendReceipt($submission); $this->mailer->sendReceipt($submission);
@@ -5,8 +5,8 @@ namespace App\Controller\Admin\Teamer;
use App\Entity\Teamer; use App\Entity\Teamer;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use App\Repository\ApplicationRepository; use App\Repository\ApplicationRepository;
use App\Repository\DispositionRepository;
use App\Service\Teamer\AccountDeletionHandler; use App\Service\Teamer\AccountDeletionHandler;
use App\Service\Teamer\UpcomingPlacementCollector;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -18,7 +18,7 @@ class DeleteAccountController extends AbstractController
public function __construct( public function __construct(
private readonly AccountDeletionHandler $accountDeletionHandler, private readonly AccountDeletionHandler $accountDeletionHandler,
private readonly ApplicationRepository $applicationRepository, private readonly ApplicationRepository $applicationRepository,
private readonly DispositionRepository $dispositionRepository, private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
) { ) {
} }
@@ -39,7 +39,8 @@ class DeleteAccountController extends AbstractController
return $this->render('admin/teamer/modal_delete_account.html.twig', [ return $this->render('admin/teamer/modal_delete_account.html.twig', [
'teamer' => $teamer, 'teamer' => $teamer,
'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)), 'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)),
'upcomingDispositionCount' => count($this->dispositionRepository->getUpcomingDispositionsByTeamer($teamer, 100)), // season assignments included: an active season placement is an open commitment as well
'upcomingPlacementCount' => count($this->upcomingPlacementCollector->collect($teamer)),
]); ]);
} }
@@ -2,7 +2,6 @@
namespace App\Controller\Administrative\Disposition; namespace App\Controller\Administrative\Disposition;
use App\Entity\Assignment;
use App\Entity\Disposition; use App\Entity\Disposition;
use App\Event\DispositionCalledOffEvent; use App\Event\DispositionCalledOffEvent;
use App\Form\DispositionCallOffType; use App\Form\DispositionCallOffType;
@@ -33,7 +32,7 @@ class CallOffController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$disposition->setStatus(Assignment::STATUS_CALLED_OFF); $disposition->setStatus(Disposition::STATUS_CALLED_OFF);
$sendNotification = $form->get('sendNotification')->getData(); $sendNotification = $form->get('sendNotification')->getData();
$this->entityManager->flush(); $this->entityManager->flush();
@@ -46,7 +45,7 @@ class CallOffController extends AbstractController
$assignment = $disposition->getAssignment(); $assignment = $disposition->getAssignment();
$this->logger->info('Call off disposition', [ $this->logger->info('Call off disposition', [
'assignment_id' => $disposition->getId(), 'disposition_id' => $disposition->getId(),
'destination' => (string) $assignment->getDestination(), 'destination' => (string) $assignment->getDestination(),
'job_profile' => $assignment->getJobProfile()->getName(), 'job_profile' => $assignment->getJobProfile()->getName(),
]); ]);
@@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface; use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -49,6 +50,12 @@ class DocumentUploadController extends AbstractController
$form = $this->createFormBuilder()->getForm(); $form = $this->createFormBuilder()->getForm();
$form->handleRequest($request); $form->handleRequest($request);
// Checked before processUpload() removes the existing document, which it replaces.
if ($form->isSubmitted()
&& true === $this->uploadHandler->getUploadSession()->getUploadsByType($type)->isEmpty()) {
$form->addError(new FormError('Bitte lade zuerst eine Datei hoch'));
}
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->processUpload($disposition, $type); $this->processUpload($disposition, $type);
@@ -110,15 +117,17 @@ class DocumentUploadController extends AbstractController
$disposition->addDocument($upload); $disposition->addDocument($upload);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage($type, $uploadSession); $this->uploadHandler->moveUploadSessionFilesFromOrphanage($type, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType($type); $this->uploadHandler->removeUploadSessionUploadsByType($type);
// Before the flush: converting a non-PDF renames the file, and the new filename has to
// be persisted with the row.
if (true === $this->featureManager->isActive('sanitize_uploads')) { if (true === $this->featureManager->isActive('sanitize_uploads')) {
$this->uploadHandler->ensurePdf($upload); $this->uploadHandler->ensurePdf($upload);
} }
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME); $this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME);
if (Upload::TYPE_CONTRACT === $type) { if (Upload::TYPE_CONTRACT === $type) {
$this->eventDispatcher->dispatch(new DocumentConfirmedEvent($upload), DocumentConfirmedEvent::NAME); $this->eventDispatcher->dispatch(new DocumentConfirmedEvent($upload), DocumentConfirmedEvent::NAME);
@@ -0,0 +1,61 @@
<?php
namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\QuestionnaireResponse;
use App\Repository\QuestionnaireResponseRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* One submitted questionnaire: what the disposition knew when it was submitted, then the answers,
* rendered with the definition version they were given under.
*/
class DetailController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly QuestionnaireCatalog $catalog,
private readonly QuestionnaireResponseRepository $responseRepository,
) {
}
// the requirement keeps /administrative/questionnaire/list from being read as a uuid
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail', requirements: ['uuid' => Requirement::UUID])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(
#[MapEntity(mapping: ['uuid' => 'uuid'])]
QuestionnaireResponse $response,
Request $request,
): Response {
// a draft is still the teamer's
if (false === $response->isSubmitted()) {
throw new NotFoundHttpException();
}
// the other questionnaire of the same placement, e.g. the bus feedback beside the teamer feedback
$siblings = array_filter(
$this->responseRepository->findByDisposition($response->getDisposition()),
static fn (QuestionnaireResponse $sibling): bool => $sibling !== $response && $sibling->isSubmitted(),
);
return $this->render('administrative/questionnaire/detail.html.twig', [
'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()),
'response' => $response,
'siblings' => array_map(fn (QuestionnaireResponse $sibling): array => [
'response' => $sibling,
'title' => $this->catalog->get($sibling->getQuestionnaire(), $sibling->getVersion())->title,
], array_values($siblings)),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_questionnaire_index'),
]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Repository\QuestionnaireResponseRepository;
use App\Service\Common\QuestionnaireResponseFilterHandler;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The submitted responses to one questionnaire, teamer or bus feedback. Only administrative users
* read them: they rate the house manager among others, so house managers are deliberately not
* let in.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly QuestionnaireResponseFilterHandler $filterHandler,
private readonly QuestionnaireResponseRepository $responseRepository,
private readonly QuestionnaireCatalog $catalog,
private readonly PaginatorInterface $paginator,
) {
}
#[Route(
'/administrative/questionnaire/list/{questionnaire}',
name: 'app_administrative_questionnaire_index',
requirements: ['questionnaire' => '[a-z0-9_]+'],
defaults: ['questionnaire' => QuestionnaireCatalog::TEAMER_FEEDBACK],
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(string $questionnaire, Request $request): Response
{
$keys = $this->catalog->keys();
if (!in_array($questionnaire, $keys, true)) {
throw $this->createNotFoundException(sprintf('No questionnaire "%s"', $questionnaire));
}
$filterDto = $this->filterHandler->getFilterSettings();
$pagination = $this->paginator->paginate(
$this->responseRepository->getListQuery($questionnaire, $filterDto),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
'defaultSortFieldName' => 'response.submittedAt',
'defaultSortDirection' => 'desc',
]
);
return $this->render('administrative/questionnaire/index.html.twig', [
'definition' => $this->catalog->get($questionnaire),
// for switching between the questionnaires
'definitions' => array_map($this->catalog->get(...), $keys),
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Assignment;
use App\Form\QuestionnaireType;
use App\Model\Questionnaire\Definition;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The questionnaires as a teamer gets them, for trying out on any assignment - before or after the
* trip, placed or not. It shows what a placement on this assignment gets - the teamer feedback,
* and the bus feedback too for a bus escort; ?bus_escort=1 or 0 tries the other case. Nothing is
* saved: "Absenden" shows the answers as they would be stored, "Entwurf speichern" just keeps them
* in the form.
*/
class TestController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly QuestionnaireCatalog $catalog,
) {
}
#[Route('/administrative/questionnaire/test/{uuid}', name: 'app_administrative_questionnaire_test')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(
#[MapEntity(mapping: ['uuid' => 'uuid'])]
Assignment $assignment,
Request $request,
): Response {
$busEscort = $request->query->has('bus_escort')
? $request->query->getBoolean('bus_escort')
: $assignment->hasBusEscort();
$definitions = [$this->catalog->get(QuestionnaireCatalog::TEAMER_FEEDBACK)];
if ($busEscort) {
$definitions[] = $this->catalog->get(QuestionnaireCatalog::BUS_FEEDBACK);
}
$form = $this->createForm(QuestionnaireType::class, [], ['definitions' => $definitions]);
$form->handleRequest($request);
$submitted = null;
$draft = false;
if ($form->isSubmitted() && $form->isValid()) {
if (QuestionnaireType::isFinal($form)) {
$submitted = array_map(static fn (Definition $definition): array => [
'definition' => $definition,
'answers' => $definition->withoutHiddenAnswers($form->getData()[$definition->key] ?? []),
], $definitions);
} else {
$draft = true;
}
}
return $this->render('administrative/questionnaire/test.html.twig', [
'assignment' => $assignment,
'form' => $form,
'submitted' => $submitted,
'draft' => $draft,
'busEscort' => $busEscort,
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_index'),
]);
}
}
@@ -46,6 +46,9 @@ class TaskDeadlineController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$applyToAll = true === $form->get('applyToAll')->getData();
$appliedCount = $applyToAll ? ($seasonAssignment?->applyDeadlinesToOpenTasks($task) ?? 0) : 0;
$this->entityManager->flush(); $this->entityManager->flush();
$this->logger->info('Change season assignment task deadlines', [ $this->logger->info('Change season assignment task deadlines', [
@@ -54,9 +57,13 @@ class TaskDeadlineController extends AbstractController
'task' => $task->getTaskKey(), 'task' => $task->getTaskKey(),
'deadline_override_1' => $task->getDeadlineOverride1()?->format('Y-m-d'), 'deadline_override_1' => $task->getDeadlineOverride1()?->format('Y-m-d'),
'deadline_override_2' => $task->getDeadlineOverride2()?->format('Y-m-d'), 'deadline_override_2' => $task->getDeadlineOverride2()?->format('Y-m-d'),
'apply_to_all' => $applyToAll,
'applied_count' => $appliedCount,
]); ]);
$this->addFlash('success', 'Die Fristen wurden gespeichert'); $this->addFlash('success', $applyToAll
? 'Die Fristen wurden für alle offenen Einträge gespeichert'
: 'Die Fristen wurden gespeichert');
return new HxRedirectResponse($returnUrl); return new HxRedirectResponse($returnUrl);
} }
@@ -7,27 +7,51 @@ use App\Model\DestinationDto;
trait DestinationTrait trait DestinationTrait
{ {
/**
* Resolves the chosen boarding points against the base data.
*
* A stop the base data cannot resolve is kept with null city and street rather than dropped,
* the same choice App\Command\MyEpImportCommand makes for the export's own unresolvable ids:
* it is still a real departure point, and App\Service\Common\PickupOptions falls back to
* "Zustieg {busProId}" for one with no town. Dropping it would hide a departure the coach
* actually makes.
*
* The id comes from the form rather than from the resolved stop, so it survives a lookup
* that does not.
*/
private function mapPickups(DestinationDto $destinationDto): array private function mapPickups(DestinationDto $destinationDto): array
{ {
$pickups = []; $pickups = [];
foreach ($destinationDto->getPickups() as $pickupId) { foreach ($destinationDto->getPickups() as $pickupId) {
$pickup = $this->pickupDataProvider->get($pickupId); $pickup = $this->pickupDataProvider->get($pickupId);
$pickups[] = [ $pickups[] = [
'busProId' => $pickup->getBusProId(), 'busProId' => (int) $pickupId,
'city' => $pickup->getCity(), 'city' => $pickup?->getCity(),
'street' => $pickup->getStreet(), 'street' => $pickup?->getStreet(),
'time' => $pickup->getTime(), 'time' => $pickup?->getTime(),
]; ];
} }
return $pickups; return $pickups;
} }
/**
* @throws \LogicException when the hotel does not resolve
*/
private function updateEntity(Destination $destination, DestinationDto $destinationDto): void private function updateEntity(Destination $destination, DestinationDto $destinationDto): void
{ {
$hotel = $this->hotelDataProvider->get($destinationDto->getHotelBusProId()); $hotelBusProId = $destinationDto->getHotelBusProId();
$pickups = $this->mapPickups($destinationDto);
// App\Validator\Constraints\ResolvableHotel is what reports this to the operator, on the
// field and in German, and every caller here runs behind $form->isValid(). This is the
// second line only: a caller that skips validation would otherwise write a destination
// with no house name, no cost unit and no country, and nothing reads those back until an
// invoice is stamped. Failing loudly beats a half built row.
if (null === $hotel = $this->hotelDataProvider->get((int) $hotelBusProId)) {
throw new \LogicException(sprintf('Refusing to write destination %s: hotel %s does not resolve against the MyE&P base data', $destination->getId() ?? 'new', var_export($hotelBusProId, true)));
}
$destination $destination
->setProduct($destinationDto->getProduct()) ->setProduct($destinationDto->getProduct())
@@ -37,7 +61,7 @@ trait DestinationTrait
->setHotelCode($hotel->getCode()) ->setHotelCode($hotel->getCode())
->setHotelBusProId($hotel->getBusProId()) ->setHotelBusProId($hotel->getBusProId())
->setCountry($destinationDto->getCountry()) ->setCountry($destinationDto->getCountry())
->setPickups($pickups) ->setPickups($this->mapPickups($destinationDto))
->setCostUnit($hotel->getCostUnit()) ->setCostUnit($hotel->getCostUnit())
; ;
} }
@@ -4,8 +4,10 @@ namespace App\Controller\Administrative\System\Document;
use App\Entity\Upload; use App\Entity\Upload;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use App\Repository\SeasonTaskDocumentRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -17,14 +19,32 @@ class DeleteController extends AbstractController
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
) { ) {
} }
#[Route('/administrative/system/document/delete/{uuid}', name: 'app_administrative_system_document_delete')] #[Route('/administrative/system/document/delete/{uuid}', name: 'app_administrative_system_document_delete')]
#[IsGranted('ROLE_ADMINISTRATIVE')] #[IsGranted('ROLE_ADMINISTRATIVE')]
#[IsGranted('DELETE', subject: 'document')] #[IsGranted('DELETE', subject: 'document')]
public function index(Upload $document, Request $request): Response public function index(
{ #[MapEntity(mapping: ['uuid' => 'uuid'])]
Upload $document,
Request $request,
): Response {
// A document handed out for a season task is referenced by a NOT NULL foreign key, so
// deleting it fails in the database. It has to be replaced in that mapping first.
$seasonTaskDocuments = $this->seasonTaskDocumentRepository->findByDocument($document);
if (0 < count($seasonTaskDocuments) && true === $request->isMethod('POST')) {
$this->addFlash('error', 'Das Dokument ist als Saisonteam-Dokument hinterlegt und kann nicht gelöscht werden');
$this->logger->info('Refuse deleting document assigned to a season task', [
'document_id' => $document->getId(),
'document_filename' => $document->getOriginalFilename(),
]);
return new HxRedirectResponse($this->generateUrl('app_administrative_system_document_index'));
}
if (true === $request->isMethod('POST')) { if (true === $request->isMethod('POST')) {
$this->entityManager->remove($document); $this->entityManager->remove($document);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -40,6 +60,7 @@ class DeleteController extends AbstractController
return $this->render('administrative/system/document/modal_delete.html.twig', [ return $this->render('administrative/system/document/modal_delete.html.twig', [
'document' => $document, 'document' => $document,
'seasonTaskDocuments' => $seasonTaskDocuments,
]); ]);
} }
} }
@@ -2,6 +2,7 @@
namespace App\Controller\Administrative\System\Document; namespace App\Controller\Administrative\System\Document;
use App\Repository\SeasonTaskDocumentRepository;
use App\Repository\UploadRepository; use App\Repository\UploadRepository;
use Knp\Component\Pager\PaginatorInterface; use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,6 +16,7 @@ class IndexController extends AbstractController
public function __construct( public function __construct(
private readonly UploadRepository $uploadRepository, private readonly UploadRepository $uploadRepository,
private readonly PaginatorInterface $paginator, private readonly PaginatorInterface $paginator,
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
) { ) {
} }
@@ -36,6 +38,7 @@ class IndexController extends AbstractController
return $this->render('administrative/system/document/index.html.twig', [ return $this->render('administrative/system/document/index.html.twig', [
'pagination' => $pagination, 'pagination' => $pagination,
'seasonTaskDocuments' => $this->seasonTaskDocumentRepository->findGroupedByDocumentId(),
]); ]);
} }
} }
@@ -0,0 +1,49 @@
<?php
namespace App\Controller\Administrative\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Form\StaffingRuleType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/system/staffing-rule/create', name: 'app_administrative_system_staffing_rule_create')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$rule = new StaffingRule();
$form = $this->createForm(StaffingRuleType::class, $rule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($rule);
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde angelegt');
$this->logger->info('Create staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
}
return $this->render('administrative/system/staffing_rule/modal_create.html.twig', [
'form' => $form->createView(),
]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Controller\Administrative\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/system/staffing-rule/delete/{uuid}', name: 'app_administrative_system_staffing_rule_delete')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(StaffingRule $rule, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$rule->setDeleted();
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde gelöscht');
$this->logger->info('Delete staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
}
return $this->render('administrative/system/staffing_rule/modal_delete.html.twig', [
'rule' => $rule,
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Controller\Administrative\System\StaffingRule;
use App\Entity\StaffingRule;
use App\Form\StaffingRuleType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/system/staffing-rule/edit/{uuid}', name: 'app_administrative_system_staffing_rule_edit')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(StaffingRule $rule, Request $request): Response
{
$form = $this->createForm(StaffingRuleType::class, $rule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Die Zeile der Besetzungsmatrix wurde aktualisiert');
$this->logger->info('Edit staffing rule', [
'staffing_rule_id' => $rule->getId(),
'product_code' => $rule->getProductCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_administrative_system_staffing_rule_index'));
}
return $this->render('administrative/system/staffing_rule/modal_edit.html.twig', [
'form' => $form->createView(),
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Controller\Administrative\System\StaffingRule;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Export\StaffingPreviewExporter;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ExportController extends AbstractController
{
public function __construct(
private readonly StaffingPreviewExporter $previewExporter,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/system/staffing-rule/export', name: 'app_administrative_system_staffing_rule_export')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(): Response
{
try {
$rows = $this->previewExporter->rows();
} catch (StaffingConfigurationException $e) {
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
return $this->redirectToRoute('app_administrative_system_staffing_rule_index');
}
if ([] === $rows) {
$this->addFlash('error', 'Keine Daten für den Export vorhanden');
return $this->redirectToRoute('app_administrative_system_staffing_rule_preview');
}
$spreadsheet = $this->previewExporter->export($rows);
$filename = sprintf('besetzungsmatrix-vorschau_%s.xlsx', date('YmdHi'));
$response = new StreamedResponse(static function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
});
$response->headers->set(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
);
$response->headers->set(
'Content-Disposition',
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename))
);
$this->logger->info('Export staffing preview', [
'count' => count($rows),
]);
return $response;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Controller\Administrative\System\StaffingRule;
use App\Repository\StaffingRuleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(private readonly StaffingRuleRepository $staffingRuleRepository)
{
}
#[Route('/administrative/system/staffing-rule', name: 'app_administrative_system_staffing_rule_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(): Response
{
return $this->render('administrative/system/staffing_rule/index.html.twig', [
'rules' => $this->staffingRuleRepository->getList(),
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Controller\Administrative\System\StaffingRule;
use App\Service\Assignment\StaffingConfigurationException;
use App\Service\Export\StaffingPreviewExporter;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PreviewController extends AbstractController
{
public function __construct(
private readonly StaffingPreviewExporter $previewExporter,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/administrative/system/staffing-rule/preview', name: 'app_administrative_system_staffing_rule_preview')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
try {
$rows = $this->previewExporter->rows();
} catch (StaffingConfigurationException $e) {
$this->addFlash('error', 'Ein Jobprofil oder Honorar der Besetzungsmatrix ist nicht (mehr) vorhanden: '.$e->getMessage());
return $this->redirectToRoute('app_administrative_system_staffing_rule_index');
}
// The candidates are a computed list, not a query: page the finished rows. The Excel
// export stays the full list.
$pagination = $this->paginator->paginate(
$rows,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
);
return $this->render('administrative/system/staffing_rule/preview.html.twig', [
'pagination' => $pagination,
]);
}
}
@@ -11,7 +11,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
class RecentAssignmentsController extends AbstractController class RecentDispositionsController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly DispositionRepository $dispositionRepository, private readonly DispositionRepository $dispositionRepository,
@@ -19,7 +19,7 @@ class RecentAssignmentsController extends AbstractController
) { ) {
} }
#[Route('/administrative/teamer/recent-assignments/{uuid}', name: 'app_administrative_teamer_recent_assignments')] #[Route('/administrative/teamer/recent-dispositions/{uuid}', name: 'app_administrative_teamer_recent_dispositions')]
#[IsGranted('ROLE_ADMINISTRATIVE')] #[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Teamer $teamer, Request $request): Response public function index(Teamer $teamer, Request $request): Response
{ {
@@ -38,7 +38,7 @@ class RecentAssignmentsController extends AbstractController
] ]
); );
return $this->render('administrative/teamer/recent_assignments.html.twig', [ return $this->render('administrative/teamer/recent_dispositions.html.twig', [
'teamer' => $teamer, 'teamer' => $teamer,
'pagination' => $pagination, 'pagination' => $pagination,
]); ]);
@@ -3,7 +3,7 @@
namespace App\Controller\Administrative\Teamer; namespace App\Controller\Administrative\Teamer;
use App\Entity\Teamer; use App\Entity\Teamer;
use App\Repository\DispositionRepository; use App\Service\Teamer\UpcomingPlacementCollector;
use Knp\Component\Pager\PaginatorInterface; use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -11,34 +11,26 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
class UpcomingAssignmentsController extends AbstractController class UpcomingDispositionsController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly DispositionRepository $dispositionRepository, private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
private readonly PaginatorInterface $paginator, private readonly PaginatorInterface $paginator,
) { ) {
} }
#[Route('/administrative/teamer/upcoming-assignments/{uuid}', name: 'app_administrative_teamer_upcoming_assignments')] #[Route('/administrative/teamer/upcoming-dispositions/{uuid}', name: 'app_administrative_teamer_upcoming_dispositions')]
#[IsGranted('ROLE_ADMINISTRATIVE')] #[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Teamer $teamer, Request $request): Response public function index(Teamer $teamer, Request $request): Response
{ {
$query = $this // an array rather than a query: dispositions and season assignments are merged in PHP
->dispositionRepository
->getUpcomingDispositionsByTeamerQuery($teamer)
;
$pagination = $this->paginator->paginate( $pagination = $this->paginator->paginate(
$query, $this->upcomingPlacementCollector->collect($teamer),
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
10, 10
[
'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc',
]
); );
return $this->render('administrative/teamer/upcoming_assignments.html.twig', [ return $this->render('administrative/teamer/upcoming_dispositions.html.twig', [
'teamer' => $teamer, 'teamer' => $teamer,
'pagination' => $pagination, 'pagination' => $pagination,
]); ]);
@@ -21,8 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
* *
* The visitor waits for this response inside their own form submission and TYPO3 gives up after * The visitor waits for this response inside their own form submission and TYPO3 gives up after
* a few seconds without retrying, so this stores the row and answers - notification mail and any * a few seconds without retrying, so this stores the row and answers - notification mail and any
* matching against known people belong in a worker, not here * matching against known people belong in a worker, not here.
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §6).
*/ */
class ApplicationFormController extends AbstractController class ApplicationFormController extends AbstractController
{ {
@@ -58,7 +57,7 @@ class ApplicationFormController extends AbstractController
'formLabel' => $submission->getFormLabel(), 'formLabel' => $submission->getFormLabel(),
'pageUid' => $submission->getPageUid(), 'pageUid' => $submission->getPageUid(),
'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM), 'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM),
// the labels and the answers are personal data (§7), their number is not // the labels and the answers are personal data, their number is not
'detailCount' => count($submission->getDetails()), 'detailCount' => count($submission->getDetails()),
'receiptSent' => $receiptSent, 'receiptSent' => $receiptSent,
]); ]);
@@ -0,0 +1,130 @@
<?php
namespace App\Controller\Api\Assignment;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Enum\PlaceStatus;
use App\Model\AssignmentExportQueryDto;
use App\Repository\AssignmentRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The assignments feed n8n reads: one row per place of the houses' guide, course and service-team
* assignments overlapping a date range. A row is a place, not a person - re-staffing a place
* changes the person on the same platz_id, and an open place has person_id null. A called-off
* assignment is left out entirely, as is a place the office struck.
*
* The field names are the consumer's (German), the n8n workflow is built on them; the path and the
* query parameters follow the app's English URL vocabulary, and errors are English, like every
* response of the api firewall.
*/
class ListController extends AbstractController
{
/**
* @param list<string> $houseCodes
* @param list<int> $jobProfileIds
*/
public function __construct(
private readonly AssignmentRepository $assignmentRepository,
private readonly array $houseCodes,
private readonly array $jobProfileIds,
private readonly LoggerInterface $logger,
) {
}
#[Route('/api/assignments', name: 'app_api_assignment_list', methods: ['GET'])]
#[IsGranted('ROLE_API_ASSIGNMENTS')]
public function index(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
// Deliberately without a default: the resolver would hand that over unvalidated for a
// request without any query string, instead of the 400 naming from and to.
AssignmentExportQueryDto $query,
): JsonResponse {
$rows = [];
$assignments = $this->assignmentRepository->findForExport(
$query->getFromDate(),
$query->getToDate(),
$this->houseCodes,
$this->jobProfileIds,
);
foreach ($assignments as $assignment) {
foreach ($assignment->getPlaces() as $slot => $disposition) {
$rows[] = $this->row($assignment, $slot, $disposition);
}
}
// the range and the count, never the rows - they name people
$this->logger->info('Assignments exported', [
'from' => $query->from,
'to' => $query->to,
'assignments' => count($assignments),
'places' => count($rows),
]);
return new JsonResponse($rows);
}
/**
* @return array<string, mixed>
*/
private function row(Assignment $assignment, int $slot, ?Disposition $disposition): array
{
$destination = $assignment->getDestination();
$jobProfile = $assignment->getJobProfile();
$teamer = $disposition?->getTeamer();
$code = (string) $destination->getCode();
return [
'platz_id' => sprintf('%d-%d', $assignment->getId(), $slot),
'einsatz_id' => $assignment->getId(),
'person_id' => $teamer?->getId(),
'vorname' => $teamer?->getFirstName(),
'nachname' => $teamer?->getLastName(),
'haus' => substr((string) $destination->getHotelCode(), 0, 3),
'von' => $destination->getDateFrom()->format('Y-m-d'),
'bis' => $destination->getDateTo()->format('Y-m-d'),
'jobprofil_id' => $jobProfile->getId(),
'jobprofil' => $jobProfile->getName(),
'status' => PlaceStatus::fromDisposition($disposition)->value,
'unter_26' => $this->isUnder26($disposition, $assignment),
'vertrag_liegt_vor' => $this->hasCheckedContract($disposition),
// Older codes carry a slash ("DPWBSP/030229"), current ones do not.
'reise_code' => '' === $code ? null : str_replace('/', '', $code),
];
}
/**
* Under 26 on the first day of the assignment.
*/
private function isUnder26(?Disposition $disposition, Assignment $assignment): ?bool
{
if (null === $dateOfBirth = $disposition?->getTeamer()?->getDateOfBirth()) {
return null;
}
return $dateOfBirth->modify('+26 years') > $assignment->getDestination()->getDateFrom();
}
/**
* Only once the office has checked it - an uploaded contract alone does not count. Null where
* there is no contract to have: an open place, or an assignment that skips the formalities.
*/
private function hasCheckedContract(?Disposition $disposition): ?bool
{
if (null === $disposition || true === $disposition->isSkipFormalities()) {
return null;
}
return true === $disposition->getDocumentByType(Upload::TYPE_CONTRACT)?->isConfirmed();
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\QuestionnaireResponseFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\QuestionnaireResponseFilterHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* One filter for both questionnaire lists; it returns to the list it was opened from.
*/
class QuestionnaireResponseFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly QuestionnaireResponseFilterHandler $filterHandler)
{
}
#[Route('/common/questionnaire-response/filter', name: 'app_common_questionnaire_response_filter')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(QuestionnaireResponseFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
return new HxRedirectResponse($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
}
return $this->render('common/modal_questionnaire_response_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/questionnaire-response/filter/reset', name: 'app_common_questionnaire_response_filter_reset')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
return $this->redirect($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
}
}
+8 -1
View File
@@ -31,7 +31,14 @@ class ContactController extends AbstractController
]; ];
foreach ($allContacts as $contact) { foreach ($allContacts as $contact) {
$contacts[$contact->getCategory()][] = $contact; // Only the three categories seeded above are rendered, so a contact without
// one would land in a bucket nobody shows.
$category = $contact->getCategory();
if (null === $category || false === isset($contacts[$category])) {
continue;
}
$contacts[$category][] = $contact;
} }
return $this->render('teamer/contact/index.html.twig', [ return $this->render('teamer/contact/index.html.twig', [
@@ -79,6 +79,14 @@ class DetailController extends AbstractController
private function uploadContract(Disposition $disposition, UploadSessionDto $uploadSession): void private function uploadContract(Disposition $disposition, UploadSessionDto $uploadSession): void
{ {
// The session can hold files of other types, so a non-empty session alone does not
// mean there is anything to replace the existing document with.
if (true === $uploadSession->getUploadsByType(Upload::TYPE_CONTRACT)->isEmpty()) {
$this->addFlash('error', 'Bitte lade zuerst den Honorarvertrag hoch');
return;
}
if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_CONTRACT)) { if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_CONTRACT)) {
$this->entityManager->remove($existingUpload); $this->entityManager->remove($existingUpload);
} }
@@ -116,6 +124,14 @@ class DetailController extends AbstractController
private function uploadInvoice(Disposition $disposition, UploadSessionDto $uploadSession): void private function uploadInvoice(Disposition $disposition, UploadSessionDto $uploadSession): void
{ {
// The session can hold files of other types, so a non-empty session alone does not
// mean there is anything to replace the existing document with.
if (true === $uploadSession->getUploadsByType(Upload::TYPE_INVOICE)->isEmpty()) {
$this->addFlash('error', 'Bitte lade zuerst die Honorarnote hoch');
return;
}
if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_INVOICE)) { if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_INVOICE)) {
$this->entityManager->remove($existingUpload); $this->entityManager->remove($existingUpload);
} }
@@ -3,7 +3,8 @@
namespace App\Controller\Teamer\Disposition; namespace App\Controller\Teamer\Disposition;
use App\Entity\User; use App\Entity\User;
use App\Repository\DispositionRepository; use App\Service\Season\SeasonAssignmentResolver;
use App\Service\Teamer\UpcomingPlacementCollector;
use Knp\Component\Pager\PaginatorInterface; use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -14,7 +15,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class UpcomingController extends AbstractController class UpcomingController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly DispositionRepository $dispositionRepository, private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
private readonly PaginatorInterface $paginator, private readonly PaginatorInterface $paginator,
) { ) {
} }
@@ -27,24 +29,18 @@ class UpcomingController extends AbstractController
$user = $this->getUser(); $user = $this->getUser();
$teamer = $user->getTeamer(); $teamer = $user->getTeamer();
$query = $this // an array rather than a query: dispositions and season assignments are merged in PHP
->dispositionRepository
->getUpcomingDispositionsByTeamerQuery($teamer)
;
$pagination = $this->paginator->paginate( $pagination = $this->paginator->paginate(
$query, $this->upcomingPlacementCollector->collect($teamer),
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
9, 9
[
'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc',
]
); );
return $this->render('teamer/disposition/upcoming.html.twig', [ return $this->render('teamer/disposition/upcoming.html.twig', [
'teamer' => $teamer, 'teamer' => $teamer,
'pagination' => $pagination, 'pagination' => $pagination,
// the season page shows only this one, so only this one is linked
'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer),
]); ]);
} }
} }
+38 -8
View File
@@ -2,12 +2,15 @@
namespace App\Controller\Teamer; namespace App\Controller\Teamer;
use App\Config\QuestionnaireCatalog;
use App\Entity\Application; use App\Entity\Application;
use App\Entity\Assignment; use App\Entity\Assignment;
use App\Entity\Disposition; use App\Entity\Disposition;
use App\Entity\Feedback; use App\Entity\Feedback;
use App\Entity\News; use App\Entity\News;
use App\Entity\User; use App\Entity\User;
use App\Service\Season\SeasonAssignmentResolver;
use App\Service\Teamer\UpcomingPlacementCollector;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -16,8 +19,11 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
public function __construct(private readonly EntityManagerInterface $entityManager) public function __construct(
{ private readonly EntityManagerInterface $entityManager,
private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
) {
} }
#[Route('/teamer', name: 'app_teamer_index')] #[Route('/teamer', name: 'app_teamer_index')]
@@ -66,6 +72,18 @@ class IndexController extends AbstractController
$dispositionDocuments = $this->processDocuments($upcomingDispositions); $dispositionDocuments = $this->processDocuments($upcomingDispositions);
$pendingQuestionnaires = $this
->entityManager
->getRepository(Disposition::class)
->findPendingQuestionnaireByTeamer(
$teamer,
QuestionnaireCatalog::windowStart((int) $this->getParameter('questionnaire_window_days')),
)
;
// the list merges in season assignments; the document notices above stay on dispositions
$upcomingPlacements = array_slice($this->upcomingPlacementCollector->collect($teamer), 0, 5);
return $this->render('teamer/index.html.twig', [ return $this->render('teamer/index.html.twig', [
'news' => $news, 'news' => $news,
'teamer' => $teamer, 'teamer' => $teamer,
@@ -73,24 +91,36 @@ class IndexController extends AbstractController
'recentFeedback' => $recentFeedback, 'recentFeedback' => $recentFeedback,
'pendingApplications' => $pendingApplications, 'pendingApplications' => $pendingApplications,
'currentDispositions' => $currentDispositions, 'currentDispositions' => $currentDispositions,
'upcomingDispositions' => $upcomingDispositions, 'upcomingPlacements' => $upcomingPlacements,
// the season page shows only this one, so only this one is linked
'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer),
'dispositionDocuments' => $dispositionDocuments, 'dispositionDocuments' => $dispositionDocuments,
'pendingQuestionnaires' => $pendingQuestionnaires,
]); ]);
} }
/**
* @return list<array{disposition: Disposition, reason: 'contract_due'|'contract_rejected'|'invoice_due'}>
*/
private function processDocuments(array $upcomingDispositions): array private function processDocuments(array $upcomingDispositions): array
{ {
$contractUploadDeadlineDays = (int) $this->getParameter('contract_upload_deadline_days'); $contractUploadDeadlineDays = (int) $this->getParameter('contract_upload_deadline_days');
$invoiceUploadDeadlineDays = (int) $this->getParameter('invoice_upload_deadline_days'); $invoiceUploadDeadlineDays = (int) $this->getParameter('invoice_upload_deadline_days');
$dispositions = []; $dispositions = [];
// The due checks need the configured deadlines, which the template cannot pass, so the
// reason is resolved here. Order matters: the first matching reason is the one shown.
foreach ($upcomingDispositions as $disposition) { foreach ($upcomingDispositions as $disposition) {
/** @var Disposition $disposition */ /** @var Disposition $disposition */
if ($disposition->isContractDue($contractUploadDeadlineDays) $reason = match (true) {
|| $disposition->isContractRejected() $disposition->isContractDue($contractUploadDeadlineDays) => 'contract_due',
|| $disposition->isInvoiceDue($invoiceUploadDeadlineDays) $disposition->isContractRejected() => 'contract_rejected',
) { $disposition->isInvoiceDue($invoiceUploadDeadlineDays) => 'invoice_due',
$dispositions[] = $disposition; default => null,
};
if (null !== $reason) {
$dispositions[] = ['disposition' => $disposition, 'reason' => $reason];
} }
} }
@@ -4,6 +4,9 @@ namespace App\Controller\Teamer\Profile;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException; use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\ProfileUpdateResponse;
use App\BusProNet\ResponseParserException;
use App\Entity\Teamer; use App\Entity\Teamer;
use App\Entity\Upload; use App\Entity\Upload;
use App\Entity\User; use App\Entity\User;
@@ -59,12 +62,7 @@ class IndexController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { $this->updateBusProProfile($user);
$bpnPassword = $request->getSession()->get('bpn_password');
$this->apiClient->updateProfile($user, $bpnPassword);
$this->entityManager->flush();
} catch (ApiClientException $e) {
}
// Saving the full profile form is a stronger statement than ticking the // Saving the full profile form is a stronger statement than ticking the
// confirmation box, so it clears the verification check too. // confirmation box, so it clears the verification check too.
@@ -88,6 +86,44 @@ class IndexController extends AbstractController
]); ]);
} }
/**
* The local save does not depend on BusPro: a failed write is logged, and the teamer's data
* is kept here either way.
*/
private function updateBusProProfile(User $user): void
{
$context = [
'user_id' => $user->getId(),
'bus_pro_address_id' => $user->getBusProAddressId(),
'bus_pro_person_id' => $user->getBusProPersonId(),
];
if (null === $user->getBusProAddressId() || null === $user->getBusProPersonId()) {
$this->logger->warning('Skip BusPro profile update: user has no BusPro IDs', $context);
return;
}
try {
$response = $this->apiClient->updateProfile($user);
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('BusPro profile update failed', $context + [
'error_message' => $e->getMessage(),
]);
return;
}
if ($response instanceof NotificationResponse && false === $response->isSuccessful()) {
$this->logger->error('BusPro rejected profile update', $context + [
'bpn_code' => $response->getCode(),
'bpn_message' => $response->getMessage(),
]);
} elseif ($response instanceof ProfileUpdateResponse && false === $response->isUpdated()) {
$this->logger->error('BusPro did not update profile', $context);
}
}
private function updatePhoto(User $user, UploadSessionDto $uploadSession): void private function updatePhoto(User $user, UploadSessionDto $uploadSession): void
{ {
$teamer = $user->getTeamer(); $teamer = $user->getTeamer();
@@ -0,0 +1,64 @@
<?php
namespace App\Controller\Teamer\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Disposition;
use App\Repository\QuestionnaireResponseRepository;
use App\Service\Questionnaire\DispositionContext;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer's own submitted questionnaires for a placement, read-only, each rendered with the
* definition version it was submitted under.
*/
class DetailController extends AbstractController
{
public function __construct(
private readonly QuestionnaireCatalog $catalog,
private readonly QuestionnaireResponseRepository $responseRepository,
) {
}
#[Route('/teamer/questionnaire/{uuid}/detail', name: 'app_teamer_questionnaire_detail')]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('QUESTIONNAIRE', subject: 'disposition')]
public function index(
#[MapEntity(mapping: ['uuid' => 'uuid'])]
Disposition $disposition,
): Response {
$submitted = [];
$open = false;
$responses = $this->responseRepository->findByDisposition($disposition);
// what applies now, plus anything submitted under an earlier state of the assignment
$keys = array_unique([...DispositionContext::questionnaires($disposition->getAssignment()), ...array_keys($responses)]);
foreach ($keys as $key) {
$response = $responses[$key] ?? null;
if (true === $response?->isSubmitted()) {
$submitted[] = [
'definition' => $this->catalog->get($key, $response->getVersion()),
'response' => $response,
];
} else {
$open = true;
}
}
if ([] === $submitted) {
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
}
return $this->render('teamer/questionnaire/detail.html.twig', [
'disposition' => $disposition,
'submitted' => $submitted,
'open' => $open,
]);
}
}
@@ -0,0 +1,124 @@
<?php
namespace App\Controller\Teamer\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Disposition;
use App\Entity\QuestionnaireResponse;
use App\Form\QuestionnaireType;
use App\Repository\QuestionnaireResponseRepository;
use App\Service\Questionnaire\DispositionContext;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer's questionnaires after a placement on one page: the teamer feedback, and after a bus
* escort the bus feedback too. Saved as drafts until submitted; one submit submits all of them,
* each as a response of its own. Submitted ones are final and only shown read-only
* (DetailController).
*
* Only the questionnaires not yet submitted are offered, so one that applies later - an escort
* added after the teamer submitted - can still be filled in.
*/
class EditController extends AbstractController
{
public function __construct(
private readonly QuestionnaireCatalog $catalog,
private readonly QuestionnaireResponseRepository $responseRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/teamer/questionnaire/{uuid}', name: 'app_teamer_questionnaire_edit')]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('QUESTIONNAIRE', subject: 'disposition')]
public function index(
#[MapEntity(mapping: ['uuid' => 'uuid'])]
Disposition $disposition,
Request $request,
): Response {
$responses = $this->responseRepository->findByDisposition($disposition);
$definitions = [];
foreach (DispositionContext::questionnaires($disposition->getAssignment()) as $key) {
if (true !== ($responses[$key] ?? null)?->isSubmitted()) {
$definitions[] = $this->catalog->get($key);
}
}
if ([] === $definitions) {
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
}
$data = [];
foreach ($definitions as $definition) {
$data[$definition->key] = ($responses[$definition->key] ?? null)?->getAnswers() ?? [];
}
$form = $this->createForm(QuestionnaireType::class, $data, ['definitions' => $definitions]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$context = DispositionContext::build($disposition);
$final = QuestionnaireType::isFinal($form);
foreach ($definitions as $definition) {
$response = $responses[$definition->key] ?? null;
if (null === $response) {
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
$this->entityManager->persist($response);
$responses[$definition->key] = $response;
}
$answers = $form->getData()[$definition->key] ?? [];
if (true === $final) {
$response->submit($definition, $answers, $context, new \DateTimeImmutable());
} else {
$response->saveDraft($answers, $context);
}
}
$this->entityManager->flush();
foreach ($definitions as $definition) {
$this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [
'questionnaire' => $definition->key,
'questionnaire_response_id' => $responses[$definition->key]->getId(),
'disposition_id' => $disposition->getId(),
'teamer_id' => $disposition->getTeamer()->getId(),
]);
}
if (true === $final) {
$this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.');
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
}
$this->addFlash('success', 'Dein Entwurf wurde gespeichert.');
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
}
$drafts = array_filter($responses, static fn (QuestionnaireResponse $response): bool => !$response->isSubmitted());
return $this->render('teamer/questionnaire/edit.html.twig', [
'disposition' => $disposition,
'form' => $form,
'lastSaved' => [] === $drafts ? null : max(array_map(
static fn (QuestionnaireResponse $response): \DateTimeInterface => $response->getUpdatedAt() ?? $response->getCreatedAt(),
$drafts,
)),
]);
}
}
@@ -61,7 +61,7 @@ class ApplicationFormSubmissionMailer
if (true !== $submission->isEu()) { if (true !== $submission->isEu()) {
$this->logger->info('Application form receipt mail skipped, not eligible', [ $this->logger->info('Application form receipt mail skipped, not eligible', [
'uuid' => $submission->getUuid(), 'uuid' => $submission->getUuid(),
// The value, not the applicant's nationality: a flag the form set (§7). // The value, not the applicant's nationality: a flag the form set.
'euFieldPresent' => null !== $submission->isEu(), 'euFieldPresent' => null !== $submission->isEu(),
]); ]);
+5 -6
View File
@@ -32,8 +32,7 @@ use Symfony\Component\Uid\Uuid;
* *
* The typed columns are the mapped core agreed with the TYPO3 side. "details" is everything * The typed columns are the mapped core agreed with the TYPO3 side. "details" is everything
* else the visitor filled in, keyed by the German field label in the order the form asked - * else the visitor filled in, keyed by the German field label in the order the form asked -
* display only, never a schema, never queried on (see * display only, never a schema, never queried on.
* .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
*/ */
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)] #[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
// What a list and a dedupe check ask for: the submissions of one form, newest first. // What a list and a dedupe check ask for: the submissions of one form, newest first.
@@ -129,7 +128,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
* of the payload - fromDto() must not touch them. * of the payload - fromDto() must not touch them.
* *
* The mails carry a time because a send time is a machine fact and stays one once the * The mails carry a time because a send time is a machine fact and stays one once the
* notification mail of §6 is automated. A held call does not: the reviewer records it * notification mail is automated. A held call does not: the reviewer records it
* afterwards and knows the day, so storing an invented time would only look precise. * afterwards and knows the day, so storing an invented time would only look precise.
*/ */
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
@@ -175,7 +174,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
/** /**
* Everything the visitor filled in beyond the mapped core, keyed by the German field label. * Everything the visitor filled in beyond the mapped core, keyed by the German field label.
* Display only: the labels are staff-written and change on a reword, so nothing may query * Display only: the labels are staff-written and change on a reword, so nothing may query
* this, build a column from it or migrate old rows to a new spelling (§3.3). * this, build a column from it or migrate old rows to a new spelling.
* *
* The key order is whatever the engine hands back and is not part of what this carries: on * The key order is whatever the engine hands back and is not part of what this carries: on
* MariaDB (dev and production) JSON is an alias for LONGTEXT and the document survives * MariaDB (dev and production) JSON is an alias for LONGTEXT and the document survives
@@ -189,7 +188,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
/** /**
* The request body as received. Mapped keys this application does not know yet are ignored * The request body as received. Mapped keys this application does not know yet are ignored
* on the way in (§3.2) and the TYPO3 side widens a form's mapping without a release here, * on the way in and the TYPO3 side widens a form's mapping without a release here,
* so without this copy those answers would be lost for good instead of backfillable. * so without this copy those answers would be lost for good instead of backfillable.
* *
* @var array<string, mixed> * @var array<string, mixed>
@@ -214,7 +213,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
* *
* The raw payload is passed in separately because the DTO no longer has it: the resolver * The raw payload is passed in separately because the DTO no longer has it: the resolver
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3 * drops every key the DTO has no property for, and those are the mapped fields the TYPO3
* side has added and this application does not know yet (§3.2). Keeping the body is what * side has added and this application does not know yet. Keeping the body is what
* makes them backfillable instead of lost. * makes them backfillable instead of lost.
* *
* @param array<string, mixed> $payload * @param array<string, mixed> $payload
+82 -1
View File
@@ -256,13 +256,22 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
return $this; return $this;
} }
/**
* Whether the teamer escorts the bus: the "Busbegleitung ab" pickup is set. 0 is stored for
* "Keine Busbegleitung" as well as null, so this is not a plain null check.
*/
public function hasBusEscort(): bool
{
return 0 !== (int) $this->pickup;
}
public function getEffectivePickupDate(): ?\DateTimeImmutable public function getEffectivePickupDate(): ?\DateTimeImmutable
{ {
if (null !== $this->pickupDate) { if (null !== $this->pickupDate) {
return $this->pickupDate; return $this->pickupDate;
} }
if (0 !== (int) $this->pickup && null !== $destination = $this->getDestination()) { if ($this->hasBusEscort() && null !== $destination = $this->getDestination()) {
return $destination return $destination
->getDateFrom() ->getDateFrom()
->modify('-1 day') ->modify('-1 day')
@@ -472,6 +481,78 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
}); });
} }
/**
* The lowest place number no active placement holds - the one a new placement takes.
*/
public function getNextFreeSlot(): int
{
return $this->nextFreeSlotIn(array_flip($this->getHeldSlots()));
}
/**
* The places the n8n feed reports, as place number => the active placement filling it, or
* null for an open place. A place is a row there, not a person, so a called-off teamer's place
* is open again under the same number rather than gone.
*
* Every held place is listed, topped up with the lowest free numbers to availableDispositions.
* Lowering the count while a high number is still held therefore drops an open low number, not
* the held one; a held place beyond the count is still listed, it has a person on it.
*
* @return array<int, Disposition|null>
*/
public function getPlaces(): array
{
$places = [];
$unnumbered = [];
foreach ($this->getActiveDispositions() as $disposition) {
if (null === $slot = $disposition->getSlot()) {
$unnumbered[] = $disposition;
} else {
$places[$slot] = $disposition;
}
}
// Without a number only when created outside DisposeController; such a placement takes
// the next free number after the numbered ones, so that nobody drops out of the feed.
foreach ($unnumbered as $disposition) {
$places[$this->nextFreeSlotIn($places)] = $disposition;
}
while (count($places) < (int) $this->availableDispositions) {
$places[$this->nextFreeSlotIn($places)] = null;
}
ksort($places);
return $places;
}
/**
* @return list<int>
*/
private function getHeldSlots(): array
{
return array_values(array_filter(
$this->getActiveDispositions()->map(fn (Disposition $disposition) => $disposition->getSlot())->toArray(),
fn (?int $slot) => null !== $slot,
));
}
/**
* @param array<int, mixed> $places
*/
private function nextFreeSlotIn(array $places): int
{
$slot = 1;
while (array_key_exists($slot, $places)) {
++$slot;
}
return $slot;
}
public function getConfirmedDispositions(): Collection public function getConfirmedDispositions(): Collection
{ {
return $this->dispositions->filter(function (Disposition $disposition) { return $this->dispositions->filter(function (Disposition $disposition) {
+38
View File
@@ -55,6 +55,20 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
#[ORM\Column(length: 2, nullable: true)] #[ORM\Column(length: 2, nullable: true)]
private ?string $country = null; private ?string $country = null;
/**
* The travel this date belongs to, as MyE&P reports it. Not the date code minus its date
* suffix - see App\MyEp\Import\DestinationRow::readProductCode().
*/
#[ORM\Column(length: 32, nullable: true)]
private ?string $productCode = null;
/**
* When app:myep-import last saw this row. Not updatedAt: a destination is editable in the
* UI, so updatedAt cannot tell a row the latest import touched from one it no longer reports.
*/
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $importedAt = null;
public function __toString(): string public function __toString(): string
{ {
return sprintf( return sprintf(
@@ -234,4 +248,28 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
return $this; return $this;
} }
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$this->productCode = $productCode;
return $this;
}
public function getImportedAt(): ?\DateTimeImmutable
{
return $this->importedAt;
}
public function setImportedAt(?\DateTimeImmutable $importedAt): static
{
$this->importedAt = $importedAt;
return $this;
}
} }
+43
View File
@@ -72,6 +72,24 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
#[ORM\Column(type: Types::BOOLEAN)] #[ORM\Column(type: Types::BOOLEAN)]
private bool $syncedWithBusProNet = false; private bool $syncedWithBusProNet = false;
/**
* The office no longer expects a feedback for this placement, so nothing chases one: not the
* dashboards' overdue lists, not the house-manager list, not the reminder mail. It still
* counts as missing in the statistics.
*/
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
private bool $feedbackWaived = false;
/**
* The numbered place on the assignment this placement fills - the n8n feed's "platz_id" is
* "<assignment id>-<slot>", and n8n hangs the Wechselpass off it. Taken once, here at creation,
* and never renumbered: a replacement takes the number the called-off teamer freed, whoever
* stays keeps theirs. A called-off placement keeps its number for the record but no longer
* holds it (Assignment::getPlaces()).
*/
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $slot = null;
public function __construct(Application $application) public function __construct(Application $application)
{ {
$this->uuid = Uuid::v4(); $this->uuid = Uuid::v4();
@@ -81,6 +99,7 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
? static::STATUS_CONFIRMED ? static::STATUS_CONFIRMED
: static::STATUS_NEW; : static::STATUS_NEW;
$this->assignment = $application->getAssignment(); $this->assignment = $application->getAssignment();
$this->slot = $this->assignment?->getNextFreeSlot();
$this->teamer = $application->getTeamer(); $this->teamer = $application->getTeamer();
$this->pickup = $application->getPickup(); $this->pickup = $application->getPickup();
$this->specialAgreements = $application->getRequests(); $this->specialAgreements = $application->getRequests();
@@ -353,4 +372,28 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $this; return $this;
} }
public function isFeedbackWaived(): bool
{
return $this->feedbackWaived;
}
public function setFeedbackWaived(bool $feedbackWaived): static
{
$this->feedbackWaived = $feedbackWaived;
return $this;
}
public function getSlot(): ?int
{
return $this->slot;
}
public function setSlot(?int $slot): static
{
$this->slot = $slot;
return $this;
}
} }
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Enum\QuestionnaireResponseStatus;
use App\Model\Questionnaire\Definition;
use App\Repository\QuestionnaireResponseRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* One respondent's answers to one questionnaire (config/questionnaires/), a draft until submitted.
*
* Answers are keyed by question key and only mean something together with the definition of
* $questionnaire in $version. $context holds what the subject already knew - for a disposition,
* who, where, when and the escort pickup - so those questions are not asked and the response stays
* readable when the assignment is edited later.
*
* Its subject is a Disposition today. A questionnaire about something else gets a nullable foreign
* key of its own beside $disposition, not a generic subject column.
*
* Not soft-deletable: nothing in the UI deletes a response, and it goes with its disposition.
*/
#[ORM\Entity(repositoryClass: QuestionnaireResponseRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\UniqueConstraint(name: 'questionnaire_disposition', columns: ['questionnaire', 'disposition_id'])]
class QuestionnaireResponse implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(length: 64)]
private string $questionnaire;
#[ORM\Column]
private int $version;
#[ORM\Column(length: 16, enumType: QuestionnaireResponseStatus::class)]
private QuestionnaireResponseStatus $status = QuestionnaireResponseStatus::DRAFT;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $submittedAt = null;
/**
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
private array $answers = [];
/**
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
private array $context = [];
#[ORM\ManyToOne]
#[ORM\JoinColumn(onDelete: 'CASCADE')]
private ?Disposition $disposition = null;
private function __construct(Definition $definition)
{
$this->uuid = Uuid::v4();
$this->questionnaire = $definition->key;
$this->version = $definition->version;
}
public static function forDisposition(Definition $definition, Disposition $disposition): static
{
$instance = new static($definition);
$instance->disposition = $disposition;
return $instance;
}
/**
* Keeps every answer, including those to questions that are hidden right now: switching an
* answer back restores the follow-up already written.
*
* @param array<string, mixed> $answers
* @param array<string, mixed> $context
*/
public function saveDraft(array $answers, array $context): static
{
$this->assertDraft();
$this->answers = $answers;
$this->context = $context;
return $this;
}
/**
* Final. Keeps only the answers to questions that are shown, and records the definition version
* they were given under - the draft may have been started under an older one.
*
* @param array<string, mixed> $answers
* @param array<string, mixed> $context
*/
public function submit(Definition $definition, array $answers, array $context, \DateTimeImmutable $now): static
{
$this->assertDraft();
if ($definition->key !== $this->questionnaire) {
throw new \LogicException(sprintf('Response to "%s" cannot be submitted with "%s"', $this->questionnaire, $definition->key));
}
$this->answers = $definition->withoutHiddenAnswers($answers);
$this->context = $context;
$this->version = $definition->version;
$this->status = QuestionnaireResponseStatus::SUBMITTED;
$this->submittedAt = $now;
return $this;
}
public function isSubmitted(): bool
{
return QuestionnaireResponseStatus::SUBMITTED === $this->status;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getQuestionnaire(): string
{
return $this->questionnaire;
}
public function getVersion(): int
{
return $this->version;
}
public function getStatus(): QuestionnaireResponseStatus
{
return $this->status;
}
public function getSubmittedAt(): ?\DateTimeImmutable
{
return $this->submittedAt;
}
/**
* @return array<string, mixed>
*/
public function getAnswers(): array
{
return $this->answers;
}
/**
* @return array<string, mixed>
*/
public function getContext(): array
{
return $this->context;
}
public function getDisposition(): ?Disposition
{
return $this->disposition;
}
private function assertDraft(): void
{
if ($this->isSubmitted()) {
throw new \LogicException(sprintf('Questionnaire response %s is already submitted', $this->uuid));
}
}
}
+24
View File
@@ -208,6 +208,30 @@ class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityI
return $this; return $this;
} }
/**
* Copies both deadline overrides of $source onto every other entry that is not ticked off,
* empty ones included - an empty override resets the entry to its computed deadline. Entries
* already done keep theirs: their deadline is history by then.
*
* @return int the number of entries changed, $source not counted
*/
public function applyDeadlinesToOpenTasks(SeasonAssignmentTask $source): int
{
$count = 0;
foreach ($this->tasks as $task) {
if ($task === $source || $task->isDone()) {
continue;
}
$task->setDeadlineOverride1($source->getDeadlineOverride1());
$task->setDeadlineOverride2($source->getDeadlineOverride2());
++$count;
}
return $count;
}
/** /**
* @return Collection<int, Upload> * @return Collection<int, Upload>
*/ */
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\StaffingRuleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* One row of the stakeholders' staffing matrix: how every date of a product is staffed.
* App\Service\Assignment\AssignmentPlanner turns it into draft assignments.
*
* Unique among live rows only, so no database index: a soft-deleted row keeps its code.
*/
#[ORM\Entity(repositoryClass: StaffingRuleRepository::class)]
#[UniqueEntity(
fields: ['productCode'],
message: 'Für dieses Produkt gibt es bereits eine Zeile',
repositoryMethod: 'findLiveByProductCode'
)]
class StaffingRule implements BlameableEntityInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
use SoftDeletableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
/** The travel code MyE&P reports as productCode, not a date-code prefix. */
#[ORM\Column(length: 32)]
#[Assert\NotBlank(message: 'Bitte gib den Produktcode an')]
#[Assert\Length(max: 32, maxMessage: 'Der Produktcode darf höchstens {{ limit }} Zeichen lang sein')]
private ?string $productCode = null;
#[ORM\Column]
#[Assert\NotNull(message: 'Bitte gib die Anzahl der Reiseleitungen an')]
#[Assert\PositiveOrZero(message: 'Die Anzahl darf nicht negativ sein')]
private ?int $guides = 1;
#[ORM\Column]
private bool $skiBeginner = false;
#[ORM\Column]
private bool $skiAdvanced = false;
/** A Reiseleitung takes over the beginners' course (Reise- und Kursleitung), one place. */
#[ORM\Column]
private bool $splitBeginner = false;
/** A Reiseleitung takes over the advanced course (Reise- und Kursleitung), one place. */
#[ORM\Column]
private bool $splitAdvanced = false;
#[ORM\Column]
private bool $guiding = false;
/** Previewed either way; only enabled rows generate. */
#[ORM\Column]
private bool $enabled = false;
public function __construct()
{
$this->uuid = Uuid::v4();
}
#[Assert\Callback]
public function validateCombination(ExecutionContextInterface $context): void
{
if (true === $this->splitBeginner && false === $this->skiBeginner) {
$context
->buildViolation('Die Reiseleitung kann nur einen Kurs übernehmen, der angeboten wird')
->atPath('splitBeginner')
->addViolation()
;
}
if (true === $this->splitAdvanced && false === $this->skiAdvanced) {
$context
->buildViolation('Die Reiseleitung kann nur einen Kurs übernehmen, der angeboten wird')
->atPath('splitAdvanced')
->addViolation()
;
}
if ($this->getSplitLevelCount() > (int) $this->guides) {
$context
->buildViolation('Jeder übernommene Kurs braucht eine eigene Reiseleitung')
->atPath('guides')
->addViolation()
;
}
if (true === $this->guiding && 0 === (int) $this->guides) {
$context
->buildViolation('Guiding braucht mindestens eine Reiseleitung')
->atPath('guiding')
->addViolation()
;
}
}
public function getSplitLevelCount(): int
{
return (int) $this->splitBeginner + (int) $this->splitAdvanced;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$productCode = strtoupper(trim((string) $productCode));
$this->productCode = '' === $productCode ? null : $productCode;
return $this;
}
public function getGuides(): ?int
{
return $this->guides;
}
public function setGuides(?int $guides): static
{
$this->guides = $guides;
return $this;
}
public function isSkiBeginner(): bool
{
return $this->skiBeginner;
}
public function setSkiBeginner(bool $skiBeginner): static
{
$this->skiBeginner = $skiBeginner;
return $this;
}
public function isSkiAdvanced(): bool
{
return $this->skiAdvanced;
}
public function setSkiAdvanced(bool $skiAdvanced): static
{
$this->skiAdvanced = $skiAdvanced;
return $this;
}
public function isSplitBeginner(): bool
{
return $this->splitBeginner;
}
public function setSplitBeginner(bool $splitBeginner): static
{
$this->splitBeginner = $splitBeginner;
return $this;
}
public function isSplitAdvanced(): bool
{
return $this->splitAdvanced;
}
public function setSplitAdvanced(bool $splitAdvanced): static
{
$this->splitAdvanced = $splitAdvanced;
return $this;
}
public function isGuiding(): bool
{
return $this->guiding;
}
public function setGuiding(bool $guiding): static
{
$this->guiding = $guiding;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enum;
/**
* How a single-choice question is rendered: a radio list or a select.
*/
enum ChoiceWidget: string
{
case RADIO = 'radio';
case DROPDOWN = 'dropdown';
}
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more