Compare commits
84
Commits
5141673bcd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c34cfa76a | ||
|
|
9d0cdd7f52 | ||
|
|
9583612fdc | ||
|
|
0bc77b7463 | ||
|
|
4ff963d1e2 | ||
|
|
f9d2ff084d | ||
|
|
7aefe88610 | ||
|
|
09eea86183 | ||
|
|
ddfaa4b440 | ||
|
|
d3b9b0611b | ||
|
|
fe0c854f1d | ||
|
|
0b736b51fb | ||
|
|
af97468b2d | ||
|
|
a0170f5952 | ||
|
|
2f4f9800dd | ||
|
|
9fedc5b5d3 | ||
|
|
a35f91ac63 | ||
|
|
890e04f8a6 | ||
|
|
5e4e574076 | ||
|
|
2a041d25a0 | ||
|
|
df6734c939 | ||
|
|
8070fda64d | ||
|
|
d06b647dac | ||
|
|
a84275b40e | ||
|
|
2ec2a4c93f | ||
|
|
de4aedd26e | ||
|
|
5090f72fab | ||
|
|
c00dd86eb4 | ||
|
|
300bb2058f | ||
|
|
3fe510e64e | ||
|
|
48691227cb | ||
|
|
34ff630b75 | ||
|
|
3f806669c6 | ||
|
|
e31f607f66 | ||
|
|
8c53676133 | ||
|
|
2de985b009 | ||
|
|
1cda9d378a | ||
|
|
9fcba26942 | ||
|
|
13e5b6c3af | ||
|
|
493a43d952 | ||
|
|
7493afde45 | ||
|
|
d159cb212c | ||
|
|
c7e0ad67e6 | ||
|
|
f0ad5374e9 | ||
|
|
96d49fc488 | ||
|
|
2a554bb7bf | ||
|
|
c16e4bf53f | ||
|
|
18fb3382c6 | ||
|
|
0e2021e96d | ||
|
|
2fc9806c69 | ||
|
|
3116d23199 | ||
|
|
4aa2d7a5e6 | ||
|
|
7e182e3d51 | ||
|
|
c04d9e0faf | ||
|
|
3188f5f3da | ||
|
|
57781ba347 | ||
|
|
849da8d471 | ||
|
|
da039d0527 | ||
|
|
1446211d22 | ||
|
|
838b8944a2 | ||
|
|
a4206f51eb | ||
|
|
2329fe17a0 | ||
|
|
72be779038 | ||
|
|
d8ff148f86 | ||
|
|
db529d2dc9 | ||
|
|
aa66c50979 | ||
|
|
0f76c5a7b6 | ||
|
|
5c7e702eab | ||
|
|
169a17e19c | ||
|
|
6c0a512c05 | ||
|
|
2e9328fc51 | ||
|
|
1505f9b50b | ||
|
|
392e67448e | ||
|
|
8da9af46f5 | ||
|
|
ca4a73eb4f | ||
|
|
1a7c272414 | ||
|
|
7cf39850e6 | ||
|
|
4cab57d136 | ||
|
|
80c6016da3 | ||
|
|
69b8e549ee | ||
|
|
77986776c2 | ||
|
|
cf876ed224 | ||
|
|
675cec1b69 | ||
|
|
e460b18661 |
No files matched your search
@@ -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
|
||||||
+6
-12
@@ -1,6 +1,6 @@
|
|||||||
type: php
|
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: []
|
||||||
@@ -9,26 +9,17 @@ database:
|
|||||||
type: mariadb
|
type: mariadb
|
||||||
version: "11.8"
|
version: "11.8"
|
||||||
hooks:
|
hooks:
|
||||||
# The agent hooks in config.codex-claude.yaml are merged with these, not replaced by them.
|
|
||||||
post-start:
|
post-start:
|
||||||
# ddev creates "db" alone, so the test database has to be created and granted here.
|
|
||||||
# Without it the first test run after a fresh start fails with "Access denied for user
|
|
||||||
# 'db'@'%' to database 'db_test'".
|
|
||||||
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
|
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
|
||||||
# Schema from the entity mapping, never from the migrations: the squashed history is not
|
|
||||||
# replayable from an empty database (Version20260824124850 alters a table that
|
|
||||||
# Version20260901090029 only creates later). No fixtures - a test persists its own rows.
|
|
||||||
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
|
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
|
||||||
omit_containers: [ddev-ssh-agent]
|
omit_containers: [ddev-ssh-agent]
|
||||||
use_dns_when_possible: true
|
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
|
||||||
xhgui_https_port: "8142"
|
|
||||||
xhgui_http_port: "8143"
|
|
||||||
|
|
||||||
# Key features of DDEV's config.yaml:
|
# Key features of DDEV's config.yaml:
|
||||||
|
|
||||||
@@ -317,3 +308,6 @@ xhgui_http_port: "8143"
|
|||||||
# information on the commands that can be extended and the tasks you can define
|
# information on the commands that can be extended and the tasks you can define
|
||||||
# for them. Example:
|
# for them. Example:
|
||||||
#hooks:
|
#hooks:
|
||||||
|
## Un-comment to consume async message.
|
||||||
|
# post-start:
|
||||||
|
# - exec: symfony run --daemon --watch=config,src,templates,vendor bin/console messenger:consume async -vv
|
||||||
@@ -70,11 +70,21 @@ APP_BPN_CRM_ID_TEAMER=1070
|
|||||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||||
|
|
||||||
SFTP_XML_EXPORT_HOST=
|
# Regular expressions of the only recipients a mail may actually reach. Everything else is
|
||||||
SFTP_XML_EXPORT_PORT=22
|
# stripped from the envelope and a mail left without a recipient is not sent at all, see
|
||||||
SFTP_XML_EXPORT_USER=
|
# App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what
|
||||||
SFTP_XML_EXPORT_PASSWORD=
|
# production runs with. Set this on staging: it runs the real transports against real teamer
|
||||||
SFTP_XML_EXPORT_ROOT=
|
# addresses. The patterns have to match the full address.
|
||||||
|
# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com'
|
||||||
|
APP_MAIL_RECIPIENT_WHITELIST=
|
||||||
|
|
||||||
|
|
||||||
|
# Machine to machine access to the MyE&P API. This is a separate confidential client
|
||||||
|
# from the login one below, so it can be rotated without locking anyone out. The base
|
||||||
|
# url is the host root: both the token endpoint and the api paths hang off it.
|
||||||
|
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=
|
||||||
@@ -84,11 +94,19 @@ 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.
|
||||||
APPLICATION_FORM_BOOKING_URL=
|
APPLICATION_FORM_BOOKING_URL=
|
||||||
|
|
||||||
|
# SaaS time tracking. Season teamers clock their hours here; app:saas:work-hours imports them.
|
||||||
|
# The API key travels in the URL path, so it never belongs in the committed file - set both
|
||||||
|
# values in .env.local / the server environment.
|
||||||
|
APP_SAAS_BASE_URL=
|
||||||
|
APP_SAAS_API_KEY=
|
||||||
|
|
||||||
###> symfony/mailjet-mailer ###
|
###> symfony/mailjet-mailer ###
|
||||||
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
|
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
|
||||||
# credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
|
# credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
|
||||||
@@ -114,8 +132,7 @@ FEATURE_SANITIZE_UPLOADS=false
|
|||||||
FEATURE_STAMP_INVOICES=false
|
FEATURE_STAMP_INVOICES=false
|
||||||
FEATURE_DATEV_EMAIL=false
|
FEATURE_DATEV_EMAIL=false
|
||||||
FEATURE_MYEP_LOGIN=false
|
FEATURE_MYEP_LOGIN=false
|
||||||
|
FEATURE_UPLOAD_REPLACEMENT=false
|
||||||
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf
|
|
||||||
|
|
||||||
MAINTENANCE_MODE_ENABLED=false
|
MAINTENANCE_MODE_ENABLED=false
|
||||||
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
||||||
|
|||||||
@@ -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
@@ -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 ###
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
@@ -241,4 +241,8 @@
|
|||||||
</circle>
|
</circle>
|
||||||
</g>
|
</g>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="icon-typo3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||||
|
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||||
|
<path d="M178.7 78.4c0-24.7 5.4-32.4 13.9-39.4-69.5 8.5-149.3 34-176.3 66.4-5.4 7.7-9.3 20.8-9.3 37.1C7 246 113.8 480 191.1 480c36.3 0 97.3-59.5 146.7-139-7 2.3-11.6 2.3-18.5 2.3-57.2 0-140.6-198.5-140.6-264.9zM301.5 32c-30.1 0-41.7 5.4-41.7 36.3 0 66.4 53.8 198.5 101.7 198.5 26.3 0 78.8-99.7 78.8-182.3 0-40.9-67-52.5-138.8-52.5z"/>
|
||||||
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
Binary file not shown.
@@ -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
@@ -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,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,4 +1,4 @@
|
|||||||
.scroll-container {
|
.scroll-container {
|
||||||
@apply overflow-scroll;
|
@apply overflow-auto;
|
||||||
max-height: calc(100vh - 320px);
|
max-height: calc(100vh - 320px);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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';
|
||||||
+3
-2
@@ -19,6 +19,7 @@
|
|||||||
"doctrine/doctrine-bundle": "^2.15",
|
"doctrine/doctrine-bundle": "^2.15",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
|
"dompdf/dompdf": "^3.1",
|
||||||
"flagception/flagception-bundle": "^6.0",
|
"flagception/flagception-bundle": "^6.0",
|
||||||
"knplabs/knp-menu-bundle": "^3.2",
|
"knplabs/knp-menu-bundle": "^3.2",
|
||||||
"knplabs/knp-paginator-bundle": "^6.2",
|
"knplabs/knp-paginator-bundle": "^6.2",
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
},
|
},
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.4.24"
|
"php": "8.5.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -137,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
+1464
-1134
File diff suppressed because it is too large.
Load diff
@@ -176,6 +176,20 @@ texts:
|
|||||||
|
|
||||||
**{destination}**
|
**{destination}**
|
||||||
|
|
||||||
|
# Weekly, for as long as an entry of the season checklist is not ticked off. It is not tied
|
||||||
|
# to a deadline, so it must not name one.
|
||||||
|
reminder_season_tasks:
|
||||||
|
label: 'Erinnerung: Saisonunterlagen'
|
||||||
|
placeholders: ['destination', 'tasks']
|
||||||
|
subject: 'Reminder: Fehlende Saisonunterlagen'
|
||||||
|
headline: 'Hallo aus Köln,'
|
||||||
|
body: |-
|
||||||
|
für deinen Saisoneinsatz **{destination}** fehlen uns noch folgende Unterlagen:
|
||||||
|
|
||||||
|
{tasks}
|
||||||
|
|
||||||
|
Im E&P-Team Portal findest du unter "Saisonunterlagen" die Vorlagen zum Herunterladen und kannst deine unterschriebenen Unterlagen direkt hochladen.
|
||||||
|
|
||||||
reminder_invoice_upload:
|
reminder_invoice_upload:
|
||||||
label: 'Erinnerung: Honorarnote hochladen'
|
label: 'Erinnerung: Honorarnote hochladen'
|
||||||
placeholders: ['destination', 'invoiceUploadDeadlineDays']
|
placeholders: ['destination', 'invoiceUploadDeadlineDays']
|
||||||
@@ -214,6 +228,32 @@ texts:
|
|||||||
|
|
||||||
# Descriptions shown next to the form fields. They describe the placeholder itself rather
|
# Descriptions shown next to the form fields. They describe the placeholder itself rather
|
||||||
# than any one mail, so they are shared: a name means the same thing wherever it appears.
|
# than any one mail, so they are shared: a name means the same thing wherever it appears.
|
||||||
|
# Daily, for as long as a work month has no signed Arbeitszeitnachweis on it. The month
|
||||||
|
# is named rather than a deadline: there is none, the office simply wants it back.
|
||||||
|
reminder_work_month_upload:
|
||||||
|
label: 'Erinnerung: Arbeitszeitnachweis hochladen'
|
||||||
|
placeholders: ['workMonth']
|
||||||
|
subject: 'Reminder: Fehlender Arbeitszeitnachweis'
|
||||||
|
headline: 'Hallo aus Köln,'
|
||||||
|
body: |-
|
||||||
|
bitte denke daran, deinen unterschriebenen Arbeitszeitnachweis hochzuladen. Ohne ihn können wir den Monat nicht abrechnen.
|
||||||
|
|
||||||
|
**{workMonth}**
|
||||||
|
|
||||||
|
Du findest ihn im Portal unter "Mein Profil - Arbeitszeiten": dort herunterladen, unterschreiben und wieder hochladen.
|
||||||
|
|
||||||
|
work_month_rejected:
|
||||||
|
label: 'Arbeitszeitnachweis abgelehnt'
|
||||||
|
placeholders: ['workMonth', 'comment']
|
||||||
|
subject: 'Dein Arbeitszeitnachweis wurde abgelehnt'
|
||||||
|
headline: 'Hallo aus Köln,'
|
||||||
|
body: |-
|
||||||
|
leider können wir deinen Arbeitszeitnachweis für **{workMonth}** so nicht annehmen.
|
||||||
|
|
||||||
|
Begründung: {comment}
|
||||||
|
|
||||||
|
Bitte lade ihn im Portal unter "Mein Profil - Arbeitszeiten" noch einmal hoch.
|
||||||
|
|
||||||
placeholders:
|
placeholders:
|
||||||
bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)'
|
bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)'
|
||||||
season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt'
|
season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt'
|
||||||
@@ -232,3 +272,5 @@ placeholders:
|
|||||||
dateFrom: 'Beginn des Einsatzes'
|
dateFrom: 'Beginn des Einsatzes'
|
||||||
dateTo: 'Ende des Einsatzes'
|
dateTo: 'Ende des Einsatzes'
|
||||||
diffInDays: 'Tage bis zum Beginn des Einsatzes'
|
diffInDays: 'Tage bis zum Beginn des Einsatzes'
|
||||||
|
tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt'
|
||||||
|
workMonth: 'Monat der Arbeitszeiten, z. B. "März 2026"'
|
||||||
@@ -37,6 +37,13 @@ when@test:
|
|||||||
dbal:
|
dbal:
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
# schema:create otherwise emits MySQL 8's platform default (utf8mb4_0900_ai_ci),
|
||||||
|
# while the migrations that built dev and production declare utf8mb4_unicode_ci.
|
||||||
|
# Different collation means different string comparison and ORDER BY semantics,
|
||||||
|
# so tests would sort and match differently than production.
|
||||||
|
default_table_options:
|
||||||
|
charset: utf8mb4
|
||||||
|
collation: utf8mb4_unicode_ci
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ flagception:
|
|||||||
myep_login:
|
myep_login:
|
||||||
default: false
|
default: false
|
||||||
env: FEATURE_MYEP_LOGIN
|
env: FEATURE_MYEP_LOGIN
|
||||||
|
upload_replacement:
|
||||||
|
default: false
|
||||||
|
env: FEATURE_UPLOAD_REPLACEMENT
|
||||||
@@ -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'
|
||||||
@@ -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,7 +17,13 @@ liip_imagine:
|
|||||||
format: jpeg
|
format: jpeg
|
||||||
jpeg_quality: 75
|
jpeg_quality: 75
|
||||||
filters:
|
filters:
|
||||||
thumbnail: { size: [ 128, 128 ], mode: outbound }
|
# Phone photos carry their orientation in EXIF rather than in the pixels, so
|
||||||
|
# without this the table renders them sideways while every other filter set,
|
||||||
|
# which has always had it, shows them upright.
|
||||||
|
auto_rotate: ~
|
||||||
|
# outbound crops to the box but never scales up, so a photo narrower than
|
||||||
|
# 128px came out oblong and the rounded-full avatar with it.
|
||||||
|
thumbnail: { size: [ 128, 128 ], mode: outbound, allow_upscale: true }
|
||||||
profile:
|
profile:
|
||||||
format: jpeg
|
format: jpeg
|
||||||
jpeg_quality: 75
|
jpeg_quality: 75
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ monolog:
|
|||||||
channels:
|
channels:
|
||||||
- deprecation
|
- deprecation
|
||||||
- bpn
|
- bpn
|
||||||
|
- saas
|
||||||
|
- myep
|
||||||
- audit
|
- audit
|
||||||
- api
|
- api
|
||||||
|
|
||||||
@@ -12,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", "!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"
|
||||||
@@ -24,6 +26,18 @@ monolog:
|
|||||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||||
max_files: 5
|
max_files: 5
|
||||||
level: info
|
level: info
|
||||||
|
saas:
|
||||||
|
channels: ["saas"]
|
||||||
|
type: rotating_file
|
||||||
|
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||||
|
max_files: 5
|
||||||
|
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
|
||||||
@@ -45,7 +59,7 @@ monolog:
|
|||||||
console:
|
console:
|
||||||
type: console
|
type: console
|
||||||
process_psr_3_messages: false
|
process_psr_3_messages: false
|
||||||
channels: ["!event", "!bpn", "!api", "!doctrine", "!console"]
|
channels: ["!event", "!bpn", "!saas", "!myep", "!api", "!doctrine", "!console"]
|
||||||
|
|
||||||
when@dev:
|
when@dev:
|
||||||
monolog:
|
monolog:
|
||||||
@@ -82,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", "!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"
|
||||||
@@ -94,6 +108,18 @@ when@prod:
|
|||||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||||
max_files: 5
|
max_files: 5
|
||||||
level: info
|
level: info
|
||||||
|
saas:
|
||||||
|
channels: ["saas"]
|
||||||
|
type: rotating_file
|
||||||
|
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||||
|
max_files: 5
|
||||||
|
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
|
||||||
@@ -113,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", "!audit", "!api"]
|
channels: ["!event", "!doctrine", "!bpn", "!saas", "!myep", "!audit", "!api"]
|
||||||
@@ -45,6 +45,29 @@ oneup_uploader:
|
|||||||
namer: app.upload_namer
|
namer: app.upload_namer
|
||||||
storage:
|
storage:
|
||||||
directory: '%kernel.project_dir%/uploads/receipt'
|
directory: '%kernel.project_dir%/uploads/receipt'
|
||||||
|
# Saisonunterlagen. Like receipt above, both the mapping key and the directory have to
|
||||||
|
# be exactly the Upload::TYPE_* value - out for what the office issues to one teamer,
|
||||||
|
# in for what that teamer sends back signed.
|
||||||
|
season_document_out:
|
||||||
|
frontend: dropzone
|
||||||
|
use_orphanage: true
|
||||||
|
namer: app.upload_namer
|
||||||
|
storage:
|
||||||
|
directory: '%kernel.project_dir%/uploads/season_document_out'
|
||||||
|
season_document_in:
|
||||||
|
frontend: dropzone
|
||||||
|
use_orphanage: true
|
||||||
|
namer: app.upload_namer
|
||||||
|
storage:
|
||||||
|
directory: '%kernel.project_dir%/uploads/season_document_in'
|
||||||
|
# Signed Arbeitszeitnachweise coming back from a teamer. Same rule again: the key and
|
||||||
|
# the directory are exactly Upload::TYPE_WORK_MONTH.
|
||||||
|
work_month:
|
||||||
|
frontend: dropzone
|
||||||
|
use_orphanage: true
|
||||||
|
namer: app.upload_namer
|
||||||
|
storage:
|
||||||
|
directory: '%kernel.project_dir%/uploads/work_month'
|
||||||
chunks:
|
chunks:
|
||||||
maxage: 86400
|
maxage: 86400
|
||||||
storage:
|
storage:
|
||||||
|
|||||||
@@ -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,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: ~
|
||||||
@@ -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 * * *'
|
||||||
@@ -27,6 +29,10 @@ when@prod:
|
|||||||
frequency: '30 1 * * *'
|
frequency: '30 1 * * *'
|
||||||
description: "Updates status of teamers depending on their disposition"
|
description: "Updates status of teamers depending on their disposition"
|
||||||
|
|
||||||
|
- task: app:saas:work-hours
|
||||||
|
frequency: '0 4 10 * *'
|
||||||
|
description: "Imports last month's working hours of season teamers from SaaS"
|
||||||
|
|
||||||
- task: oneup:uploader:clear-orphans
|
- task: oneup:uploader:clear-orphans
|
||||||
frequency: '30 * * * *'
|
frequency: '30 * * * *'
|
||||||
description: "Removes orphaned uploads"
|
description: "Removes orphaned uploads"
|
||||||
@@ -41,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 * * *'
|
||||||
@@ -53,6 +59,10 @@ when@staging:
|
|||||||
frequency: '30 1 * * *'
|
frequency: '30 1 * * *'
|
||||||
description: "Updates status of teamers depending on their disposition"
|
description: "Updates status of teamers depending on their disposition"
|
||||||
|
|
||||||
|
- task: app:saas:work-hours
|
||||||
|
frequency: '0 4 10 * *'
|
||||||
|
description: "Imports last month's working hours of season teamers from SaaS"
|
||||||
|
|
||||||
- task: oneup:uploader:clear-orphans
|
- task: oneup:uploader:clear-orphans
|
||||||
frequency: '30 * * * *'
|
frequency: '30 * * * *'
|
||||||
description: "Removes orphaned uploads"
|
description: "Removes orphaned uploads"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+148
-5
@@ -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.
|
||||||
@@ -48,8 +79,19 @@ parameters:
|
|||||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||||
|
|
||||||
|
# Per deployment and not per environment: staging and production run the same compiled
|
||||||
|
# environment and differ only in their .env.local. The csv processor turns an unset
|
||||||
|
# variable into an empty list, which switches the filtering off - so production is
|
||||||
|
# unaffected without anything having to be remembered at deploy time.
|
||||||
|
mail_recipient_whitelist: '%env(csv:APP_MAIL_RECIPIENT_WHITELIST)%'
|
||||||
|
|
||||||
teamer_inactive_period: '-2 years'
|
teamer_inactive_period: '-2 years'
|
||||||
|
|
||||||
|
# The stand-in PDF served instead of real uploads while the upload_replacement feature is
|
||||||
|
# on. Relative to the project dir. Which file is a fixed fact of the repo; whether it is
|
||||||
|
# used at all is the feature flag's job alone.
|
||||||
|
upload_replacement_file: 'assets/pdf/chicken.pdf'
|
||||||
|
|
||||||
# The contract upload period, counted from the creation of the disposition. This is the
|
# The contract upload period, counted from the creation of the disposition. This is the
|
||||||
# single definition: the reminder mail, the "Honorarvertrag fällig" flag on the teamer
|
# single definition: the reminder mail, the "Honorarvertrag fällig" flag on the teamer
|
||||||
# dashboard, the admin list of overdue contracts and the wording of the mails all read it,
|
# dashboard, the admin list of overdue contracts and the wording of the mails all read it,
|
||||||
@@ -79,6 +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
|
||||||
|
# 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
|
||||||
|
# the first. Per-entry overrides are stored on the task row; this is what they override.
|
||||||
|
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:
|
||||||
@@ -113,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%'
|
||||||
@@ -121,9 +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%'
|
||||||
|
$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
|
||||||
@@ -147,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'
|
||||||
|
|
||||||
@@ -185,6 +257,45 @@ services:
|
|||||||
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
|
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
|
||||||
bpn_default_hotel_code: '%bpn_default_hotel_code%'
|
bpn_default_hotel_code: '%bpn_default_hotel_code%'
|
||||||
|
|
||||||
|
App\Saas\ApiClient:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.saas'
|
||||||
|
$options:
|
||||||
|
base_url: '%env(APP_SAAS_BASE_URL)%'
|
||||||
|
api_key: '%env(APP_SAAS_API_KEY)%'
|
||||||
|
|
||||||
|
App\Saas\DataProvider\EmployeeDataProvider:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.saas'
|
||||||
|
# The roster is slow and barely changes; six hours means one fetch per run and a
|
||||||
|
# new starter is picked up the same day.
|
||||||
|
$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'
|
||||||
@@ -196,6 +307,15 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- name: monolog.processor
|
- name: monolog.processor
|
||||||
|
|
||||||
|
# Every SaaS url carries the api key as a path segment, so the request lines Symfony's
|
||||||
|
# http client writes - and the exception messages that repeat those urls - are redacted
|
||||||
|
# on the way into the log rather than suppressed.
|
||||||
|
App\Logging\RedactSaasCredentialsProcessor:
|
||||||
|
arguments:
|
||||||
|
$apiKey: '%env(APP_SAAS_API_KEY)%'
|
||||||
|
tags:
|
||||||
|
- name: monolog.processor
|
||||||
|
|
||||||
App\Logging\RequestInfoProcessor:
|
App\Logging\RequestInfoProcessor:
|
||||||
arguments:
|
arguments:
|
||||||
- '@request_stack'
|
- '@request_stack'
|
||||||
@@ -255,8 +375,7 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$orphanageManager: '@oneup_uploader.orphanage_manager'
|
$orphanageManager: '@oneup_uploader.orphanage_manager'
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
$environment: '%kernel.environment%'
|
$uploadReplacementFile: '%upload_replacement_file%'
|
||||||
$uploadReplacementFile: '%env(default::UPLOAD_REPLACEMENT_FILE)%'
|
|
||||||
|
|
||||||
App\Service\Pdf\Sanitizer:
|
App\Service\Pdf\Sanitizer:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -329,6 +448,11 @@ services:
|
|||||||
$options:
|
$options:
|
||||||
project_dir: '%kernel.project_dir%'
|
project_dir: '%kernel.project_dir%'
|
||||||
|
|
||||||
|
App\Service\Pdf\WorkMonthRenderer:
|
||||||
|
arguments:
|
||||||
|
$options:
|
||||||
|
project_dir: '%kernel.project_dir%'
|
||||||
|
|
||||||
App\Email\Mailer:
|
App\Email\Mailer:
|
||||||
arguments:
|
arguments:
|
||||||
$defaults:
|
$defaults:
|
||||||
@@ -343,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:
|
||||||
@@ -351,6 +478,20 @@ 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:
|
||||||
|
arguments:
|
||||||
|
$allowedRecipients: '%mail_recipient_whitelist%'
|
||||||
|
# Not the audit channel this argument is bound to by default: a mail dropped by a
|
||||||
|
# staging guard is a technical event and has no place in the application log table.
|
||||||
|
$logger: '@logger'
|
||||||
|
|
||||||
App\Security\OAuth2\MyEpClient:
|
App\Security\OAuth2\MyEpClient:
|
||||||
arguments:
|
arguments:
|
||||||
$options:
|
$options:
|
||||||
@@ -371,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'
|
||||||
+3
-1
@@ -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',
|
||||||
@@ -46,11 +47,12 @@ $rsyncOptions = [
|
|||||||
'package.json',
|
'package.json',
|
||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
'*.http',
|
'*.http',
|
||||||
|
'http-client*.json',
|
||||||
'postcss.config.js',
|
'postcss.config.js',
|
||||||
'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/*',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"prod": {
|
||||||
|
"saas_base_url": "https://desktop.saas.de/rest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260824124850 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE teamer ADD all_season TINYINT(1) NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE teamer DROP all_season');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the season_assignment table: the placement of an all-season teamer at a destination.
|
||||||
|
*
|
||||||
|
* Written by hand rather than taken from doctrine:migrations:diff - the diff against this
|
||||||
|
* database also carries a large amount of pre-existing drift (datetime and JSON column
|
||||||
|
* declarations across nearly every table) that has nothing to do with this feature.
|
||||||
|
*/
|
||||||
|
final class Version20260909124625 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add season_assignment table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE season_assignment (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, remarks LONGTEXT DEFAULT 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, teamer_id INT DEFAULT NULL, destination_id INT DEFAULT NULL, job_profile_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_FB891DD17F50A6 (uuid), INDEX IDX_FB891D4302FF75 (teamer_id), INDEX IDX_FB891D816C6140 (destination_id), INDEX IDX_FB891DA68D82A5 (job_profile_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D4302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891DA68D82A5 FOREIGN KEY (job_profile_id) REFERENCES job_profile (id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D4302FF75');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891DA68D82A5');
|
||||||
|
$this->addSql('DROP TABLE season_assignment');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the season assignment checklist: one row per checklist entry per assignment, the binding
|
||||||
|
* of an entry to a document from the global library, and the two links a season assignment needs
|
||||||
|
* to carry its own files.
|
||||||
|
*
|
||||||
|
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
|
||||||
|
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||||
|
* declarations, which has nothing to do with this feature.
|
||||||
|
*/
|
||||||
|
final class Version20260915103000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add season assignment tasks, task document mapping and season uploads';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE season_assignment_task (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, done TINYINT(1) NOT NULL, done_at DATETIME DEFAULT NULL, done_by VARCHAR(255) DEFAULT NULL, uploaded TINYINT(1) NOT NULL, deadline_override1 DATE DEFAULT NULL, deadline_override2 DATE DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, season_assignment_id INT NOT NULL, UNIQUE INDEX UNIQ_BEEB9D8DD17F50A6 (uuid), UNIQUE INDEX season_assignment_task_unique (season_assignment_id, task_key), INDEX IDX_BEEB9D8DB291FD85 (season_assignment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('CREATE TABLE season_task_document (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, document_id INT NOT NULL, UNIQUE INDEX UNIQ_FBAB9F47D17F50A6 (uuid), UNIQUE INDEX UNIQ_FBAB9F47E512532E (task_key), INDEX IDX_FBAB9F47C33F7837 (document_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)');
|
||||||
|
$this->addSql('ALTER TABLE season_task_document ADD CONSTRAINT FK_FBAB9F47C33F7837 FOREIGN KEY (document_id) REFERENCES upload (id)');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE upload ADD season_assignment_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_17BDE61FB291FD85 ON upload (season_assignment_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FB291FD85');
|
||||||
|
$this->addSql('DROP INDEX IDX_17BDE61FB291FD85 ON upload');
|
||||||
|
$this->addSql('ALTER TABLE upload DROP season_assignment_id');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DB291FD85');
|
||||||
|
$this->addSql('ALTER TABLE season_task_document DROP FOREIGN KEY FK_FBAB9F47C33F7837');
|
||||||
|
$this->addSql('DROP TABLE season_assignment_task');
|
||||||
|
$this->addSql('DROP TABLE season_task_document');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the season assignment's Destination relation with its own period and house.
|
||||||
|
*
|
||||||
|
* A Destination is one travel with one departure window; an all-season placement runs a whole
|
||||||
|
* season at one house, so the relation only ever meant "borrow three scalars from an arbitrary
|
||||||
|
* travel row". The house is now a key of the configured "houses" map, which is keyed by the
|
||||||
|
* normalized hotel code - hence the SER stripping in the backfill, mirroring
|
||||||
|
* App\Entity\Destination::getHotelCodeNormalized().
|
||||||
|
*
|
||||||
|
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
|
||||||
|
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||||
|
* declarations, which has nothing to do with this feature.
|
||||||
|
*/
|
||||||
|
final class Version20260916160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Move season assignment period and house onto the assignment itself';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// nullable first, so the existing rows can be backfilled before the columns are tightened
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD date_from DATE DEFAULT NULL, ADD date_to DATE DEFAULT NULL, ADD hotel_code VARCHAR(32) DEFAULT NULL');
|
||||||
|
|
||||||
|
$this->addSql('UPDATE season_assignment sa JOIN destination d ON d.id = sa.destination_id SET sa.date_from = d.date_from, sa.date_to = d.date_to, sa.hotel_code = CASE WHEN d.hotel_code LIKE \'SER%\' THEN SUBSTRING(d.hotel_code, 4, 3) ELSE SUBSTRING(d.hotel_code, 1, 3) END');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE season_assignment CHANGE date_from date_from DATE NOT NULL, CHANGE date_to date_to DATE NOT NULL, CHANGE hotel_code hotel_code VARCHAR(32) NOT NULL');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140');
|
||||||
|
$this->addSql('DROP INDEX IDX_FB891D816C6140 ON season_assignment');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP destination_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD destination_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_FB891D816C6140 ON season_assignment (destination_id)');
|
||||||
|
|
||||||
|
// The destination a placement was cut from cannot be recovered from a period and a house
|
||||||
|
// code, so the relation comes back empty.
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP date_from, DROP date_to, DROP hotel_code');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the season assignment's done_teamer flag.
|
||||||
|
*
|
||||||
|
* It was a note the office set by hand and it gated nothing - neither the weekly teamer digest
|
||||||
|
* nor the office deadline summary ever read it. Both stages it was meant to express follow from
|
||||||
|
* the checklist rows, so App\Service\Season\SeasonChecklistBuilder derives them instead.
|
||||||
|
*
|
||||||
|
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260916160000:
|
||||||
|
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||||
|
* declarations, which has nothing to do with this change.
|
||||||
|
*/
|
||||||
|
final class Version20260917120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Drop the informative done_teamer flag from season assignments';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// The hand-set notes themselves cannot be recovered, so the column comes back unset.
|
||||||
|
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames the checklist entry's uploaded flag to office_done.
|
||||||
|
*
|
||||||
|
* The flag is the office's own step on an entry, and that step differs per entry - filing a
|
||||||
|
* returned contract is not the same act as registering someone with the municipality. Named
|
||||||
|
* after a file arriving, it read as a second kind of "done" everywhere else, next to the done
|
||||||
|
* flag that actually closes the entry.
|
||||||
|
*
|
||||||
|
* A rename, not a new column: the ticks themselves stay.
|
||||||
|
*/
|
||||||
|
final class Version20260917123000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Rename season_assignment_task.uploaded to office_done';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task CHANGE uploaded office_done TINYINT(1) NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task CHANGE office_done uploaded TINYINT(1) NOT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the library document onto the checklist row.
|
||||||
|
*
|
||||||
|
* The library holds what the next assignments get and is replaced from season to season, so
|
||||||
|
* resolving it on read rewrote what an assignment that already exists - a finished one included -
|
||||||
|
* hands out. The row carries its own reference from now on.
|
||||||
|
*
|
||||||
|
* The backfill maps every existing row to what it resolves to today, so nothing changes for the
|
||||||
|
* rows that are already out there. ON DELETE SET NULL keeps a row readable when the library file
|
||||||
|
* itself is deleted: "kein Dokument hinterlegt" is a state every view already handles.
|
||||||
|
*/
|
||||||
|
final class Version20260917140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Give each season checklist row its own document reference';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task ADD document_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DC33F7837 FOREIGN KEY (document_id) REFERENCES upload (id) ON DELETE SET NULL');
|
||||||
|
$this->addSql('CREATE INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task (document_id)');
|
||||||
|
|
||||||
|
$this->addSql('UPDATE season_assignment_task t JOIN season_task_document d ON d.task_key = t.task_key SET t.document_id = d.document_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DC33F7837');
|
||||||
|
$this->addSql('DROP INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task');
|
||||||
|
$this->addSql('ALTER TABLE season_assignment_task DROP document_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work months imported from the SaaS time tracking, and the signed Arbeitszeitnachweis that
|
||||||
|
* belongs to each of them.
|
||||||
|
*
|
||||||
|
* Hand-trimmed: doctrine:migrations:diff also produced a long tail of datetime, JSON and
|
||||||
|
* TINYINT redeclarations across almost every table. That is the long-standing drift between
|
||||||
|
* the dev database and the mapping, not anything this feature changed, so none of it is here.
|
||||||
|
*/
|
||||||
|
final class Version20260921122743 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds teamer_work_month, the SaaS employee id on teamer and the work month relation on upload';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE teamer_work_month (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, days JSON NOT NULL, total_minutes INT NOT NULL, worked_days INT NOT NULL, absence_days INT NOT NULL, source_ist_minutes INT NOT NULL, status VARCHAR(255) NOT NULL, submitted_at DATETIME DEFAULT NULL, reviewed_at DATETIME DEFAULT NULL, reviewed_by VARCHAR(8) DEFAULT NULL, review_comment LONGTEXT DEFAULT NULL, synced_at DATETIME NOT NULL, warnings JSON DEFAULT NULL, mismatch_detected_at DATETIME DEFAULT NULL, mismatch_total_minutes INT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, teamer_id INT NOT NULL, UNIQUE INDEX UNIQ_28857572D17F50A6 (uuid), UNIQUE INDEX teamer_work_month_unique (teamer_id, year, month), INDEX IDX_288575724302FF75 (teamer_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month ADD CONSTRAINT FK_288575724302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE teamer ADD saas_employee_uuid VARCHAR(36) DEFAULT NULL');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE upload ADD teamer_work_month_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FFEAE833A FOREIGN KEY (teamer_work_month_id) REFERENCES teamer_work_month (id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_17BDE61FFEAE833A ON upload (teamer_work_month_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FFEAE833A');
|
||||||
|
$this->addSql('DROP INDEX IDX_17BDE61FFEAE833A ON upload');
|
||||||
|
$this->addSql('ALTER TABLE upload DROP teamer_work_month_id');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE teamer DROP saas_employee_uuid');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month DROP FOREIGN KEY FK_288575724302FF75');
|
||||||
|
$this->addSql('DROP TABLE teamer_work_month');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The monthly target and the month's total break time, both shown in the head of the
|
||||||
|
* Arbeitszeitnachweis next to the hours actually worked.
|
||||||
|
*
|
||||||
|
* Hand-trimmed, like Version20260921122743: doctrine:migrations:diff sweeps in the
|
||||||
|
* long-standing datetime and JSON drift of the dev database, none of which belongs here.
|
||||||
|
*
|
||||||
|
* The per-day start, end and break times that go with these live in the existing "days" JSON
|
||||||
|
* column, so they need no schema change. Rows imported before this migration simply carry
|
||||||
|
* neither, and are refreshed by the next import of a month that is not yet signed.
|
||||||
|
*/
|
||||||
|
final class Version20260921160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds the monthly target and total break time to teamer_work_month';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month ADD target_minutes INT DEFAULT 0 NOT NULL, ADD break_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month DROP target_minutes, DROP break_minutes');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The employee's department, shown in the head of the Arbeitszeitnachweis.
|
||||||
|
*
|
||||||
|
* Hand-trimmed like its two predecessors: doctrine:migrations:diff sweeps in the long-standing
|
||||||
|
* datetime and JSON drift of the dev database, none of which belongs here.
|
||||||
|
*
|
||||||
|
* Nullable, and not only because existing rows have none: a month made up entirely of holiday
|
||||||
|
* carries no booking to read a department from.
|
||||||
|
*/
|
||||||
|
final class Version20260921170000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds the department to teamer_work_month';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month ADD department VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month DROP department');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1676
-5443
File diff suppressed because it is too large.
Load diff
+12
-14
@@ -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
@@ -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
@@ -1,7 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
'postcss-import': {},
|
'@tailwindcss/postcss': {},
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
### GET all employees
|
||||||
|
GET {{saas_base_url}}/api/v2/employees/{{saas_api_key}}
|
||||||
|
|
||||||
|
### GET all departments
|
||||||
|
GET {{saas_base_url}}/api/v2/employee/departments/{{saas_api_key}}
|
||||||
|
|
||||||
|
### GET single time account
|
||||||
|
GET {{saas_base_url}}/api/v2/time/account/{{saas_api_key}}/{{saas_employee_email}}/2026/2
|
||||||
|
|
||||||
+31
-25
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,9 @@ use App\Service\Cron\DispositionReminderService;
|
|||||||
use App\Service\Cron\DispositionStatusService;
|
use App\Service\Cron\DispositionStatusService;
|
||||||
use App\Service\Cron\FeedbackReminderService;
|
use App\Service\Cron\FeedbackReminderService;
|
||||||
use App\Service\Cron\FlushLogsService;
|
use App\Service\Cron\FlushLogsService;
|
||||||
|
use App\Service\Cron\SeasonTaskReminderService;
|
||||||
use App\Service\Cron\UploadReminderService;
|
use App\Service\Cron\UploadReminderService;
|
||||||
|
use App\Service\Cron\WorkMonthReminderService;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
@@ -25,6 +27,8 @@ class CronCommand extends Command
|
|||||||
private readonly FeedbackReminderService $feedbackReminderService,
|
private readonly FeedbackReminderService $feedbackReminderService,
|
||||||
private readonly DispositionStatusService $dispositionStatusService,
|
private readonly DispositionStatusService $dispositionStatusService,
|
||||||
private readonly DispositionReminderService $dispositionReminderService,
|
private readonly DispositionReminderService $dispositionReminderService,
|
||||||
|
private readonly SeasonTaskReminderService $seasonTaskReminderService,
|
||||||
|
private readonly WorkMonthReminderService $workMonthReminderService,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,15 @@ class CronCommand extends Command
|
|||||||
$message = $this->dispositionReminderService->sendDispositionReminders();
|
$message = $this->dispositionReminderService->sendDispositionReminders();
|
||||||
$io->info($message);
|
$io->info($message);
|
||||||
|
|
||||||
|
$message = $this->seasonTaskReminderService->sendDeadlineSummary();
|
||||||
|
$io->info($message);
|
||||||
|
|
||||||
|
$message = $this->seasonTaskReminderService->sendTeamerDigest();
|
||||||
|
$io->info($message);
|
||||||
|
|
||||||
|
$message = $this->workMonthReminderService->sendReminders();
|
||||||
|
$io->info($message);
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Enum\WorkHoursSyncOutcome;
|
||||||
|
use App\Model\WorkHoursSyncResult;
|
||||||
|
use App\Service\Saas\WorkHoursSynchronizer;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:saas:work-hours',
|
||||||
|
description: "Imports season teamers' working hours of one month from the SaaS",
|
||||||
|
)]
|
||||||
|
class SaasWorkHoursCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WorkHoursSynchronizer $synchronizer,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument(
|
||||||
|
'month',
|
||||||
|
InputArgument::OPTIONAL,
|
||||||
|
'The month to import as YYYY-MM. Defaults to the previous month, which is what the schedule wants.',
|
||||||
|
)
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be written without writing it')
|
||||||
|
->addOption(
|
||||||
|
'teamer',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Limit the run to one teamer, by uuid or email address. Use this to try the API out.',
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'limit',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Stop after this many teamers, in the order the list comes back',
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled for the 10th of each month, importing the month before.
|
||||||
|
*
|
||||||
|
* A teamer that cannot be imported does not stop the others: only genuine failures
|
||||||
|
* (the request or the payload) make the command fail, because the scheduler mails on a
|
||||||
|
* non-zero exit and an incomplete profile would otherwise mail every month forever.
|
||||||
|
*/
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$year, $month] = $this->resolveMonth($input->getArgument('month'));
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$io->error($e->getMessage());
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->title(sprintf('Arbeitszeiten %04d-%02d', $year, $month));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$io->note('Dry run - nothing is written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$teamers = $this->synchronizer->findEligibleTeamers($year, $month);
|
||||||
|
|
||||||
|
if (null !== $only = $input->getOption('teamer')) {
|
||||||
|
$teamers = $this->onlyThisTeamer($teamers, (string) $only);
|
||||||
|
|
||||||
|
if ([] === $teamers) {
|
||||||
|
$io->error(sprintf('"%s" matches no season teamer placed in this month.', $only));
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $limit = $input->getOption('limit')) {
|
||||||
|
$teamers = array_slice($teamers, 0, max(1, (int) $limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $teamers) {
|
||||||
|
$io->warning('No season teamer is placed in this month.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->writeln(sprintf('%d teamer(s) to fetch, one request each.', count($teamers)));
|
||||||
|
|
||||||
|
$io->progressStart(count($teamers));
|
||||||
|
|
||||||
|
/** @var WorkHoursSyncResult[] $results */
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($teamers as $teamer) {
|
||||||
|
$results[] = $this->synchronizer->syncTeamer($teamer, $year, $month, $dryRun);
|
||||||
|
$io->progressAdvance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->progressFinish();
|
||||||
|
|
||||||
|
$this->report($io, $results);
|
||||||
|
|
||||||
|
return $this->hasFailures($results) ? Command::FAILURE : Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param WorkHoursSyncResult[] $results
|
||||||
|
*/
|
||||||
|
private function report(SymfonyStyle $io, array $results): void
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($results as $result) {
|
||||||
|
if (WorkHoursSyncOutcome::IMPORTED === $result->getOutcome() && [] === $result->getWarnings()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
$result->getTeamer()->getFullName(),
|
||||||
|
$result->getOutcome()->value,
|
||||||
|
$result->getMessage() ?? implode(' | ', $result->getWarnings()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $rows) {
|
||||||
|
$io->section('Needs a look');
|
||||||
|
$io->table(['Teamer', 'Outcome', 'Detail'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = array_count_values(array_map(
|
||||||
|
static fn (WorkHoursSyncResult $result) => $result->getOutcome()->value,
|
||||||
|
$results,
|
||||||
|
));
|
||||||
|
|
||||||
|
$summary = [];
|
||||||
|
|
||||||
|
foreach (WorkHoursSyncOutcome::cases() as $outcome) {
|
||||||
|
$summary[] = sprintf('%s: %d', $outcome->value, $counts[$outcome->value] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$minutes = array_sum(array_map(
|
||||||
|
static fn (WorkHoursSyncResult $result) => WorkHoursSyncOutcome::IMPORTED === $result->getOutcome()
|
||||||
|
? (int) $result->getTotalMinutes()
|
||||||
|
: 0,
|
||||||
|
$results,
|
||||||
|
));
|
||||||
|
|
||||||
|
$io->writeln(implode(' ', $summary));
|
||||||
|
$io->writeln(sprintf('Imported %d minutes in total.', $minutes));
|
||||||
|
|
||||||
|
if ($this->hasFailures($results)) {
|
||||||
|
$io->error('At least one teamer could not be imported.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('%d teamer(s) processed.', count($results)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Teamer[] $teamers
|
||||||
|
*
|
||||||
|
* @return Teamer[]
|
||||||
|
*/
|
||||||
|
private function onlyThisTeamer(array $teamers, string $needle): array
|
||||||
|
{
|
||||||
|
$needle = strtolower(trim($needle));
|
||||||
|
|
||||||
|
return array_values(array_filter($teamers, static function (Teamer $teamer) use ($needle) {
|
||||||
|
return strtolower($teamer->getUuid()) === $needle
|
||||||
|
|| strtolower((string) $teamer->getCommunication()?->getEmail()) === $needle;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param WorkHoursSyncResult[] $results
|
||||||
|
*/
|
||||||
|
private function hasFailures(array $results): bool
|
||||||
|
{
|
||||||
|
foreach ($results as $result) {
|
||||||
|
if ($result->isFailure()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{int, int}
|
||||||
|
*/
|
||||||
|
private function resolveMonth(?string $argument): array
|
||||||
|
{
|
||||||
|
if (null === $argument) {
|
||||||
|
$previous = (new \DateTimeImmutable('first day of this month'))->modify('-1 month');
|
||||||
|
|
||||||
|
return [(int) $previous->format('Y'), (int) $previous->format('n')];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 !== preg_match('/^(\d{4})-(\d{2})$/', $argument, $matches)) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||||
|
}
|
||||||
|
|
||||||
|
$month = (int) $matches[2];
|
||||||
|
|
||||||
|
if ($month < 1 || $month > 12) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [(int) $matches[1], $month];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,4 +22,7 @@ enum EmailTextKey: string
|
|||||||
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
|
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
|
||||||
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
|
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
|
||||||
case REMINDER_DISPOSITION = 'reminder_disposition';
|
case REMINDER_DISPOSITION = 'reminder_disposition';
|
||||||
|
case REMINDER_SEASON_TASKS = 'reminder_season_tasks';
|
||||||
|
case REMINDER_WORK_MONTH_UPLOAD = 'reminder_work_month_upload';
|
||||||
|
case WORK_MONTH_REJECTED = 'work_month_rejected';
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,23 +57,52 @@ final class SeasonCalendar
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The season a mail written now would call "die kommende Saison": the one currently running,
|
* The season that is running today, or - when today lies outside any window - the one that
|
||||||
* or - between two seasons - the next one to start. Never null, unlike seasonYearFor(): a
|
* starts next.
|
||||||
* recruiting mail sent in September is outside every season window but is unmistakably about
|
*
|
||||||
* the winter ahead.
|
* The fall-forward is what keeps the teamer's season page reachable all year: without it the
|
||||||
|
* page would disappear between 02.04. and 31.10. for the default window, which covers the
|
||||||
|
* default first deadline of 01.10. for a season starting 01.11. - teamers would be reminded
|
||||||
|
* about documents with nowhere to upload them.
|
||||||
*/
|
*/
|
||||||
public function currentOrUpcomingSeasonYear(\DateTimeInterface $date): int
|
public function currentOrUpcomingSeasonYear(?\DateTimeInterface $date = null): int
|
||||||
{
|
{
|
||||||
if (null !== $seasonYear = $this->seasonYearFor($date)) {
|
$day = CarbonImmutable::instance($date ?? new \DateTimeImmutable())->startOfDay();
|
||||||
|
|
||||||
|
if (null !== $seasonYear = $this->seasonYearFor($day)) {
|
||||||
return $seasonYear;
|
return $seasonYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
$day = CarbonImmutable::instance($date)->startOfDay();
|
|
||||||
$year = (int) $day->format('Y');
|
$year = (int) $day->format('Y');
|
||||||
|
|
||||||
// Outside any window, so the next start is this year's unless it has already passed,
|
// Off-season, so the next window is the one whose start still lies ahead. The candidates
|
||||||
// which for a wrapping window can only mean the season already began - handled above.
|
// bracket the turn of the year, which is all a MM-DD window can ever straddle.
|
||||||
return $day <= $this->boundaryFor($this->seasonStart, $year) ? $year : $year + 1;
|
foreach ([$year - 1, $year, $year + 1] as $candidate) {
|
||||||
|
if ($this->boundaryFor($this->seasonStart, $candidate) > $day) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \LogicException(sprintf('No season starts after %s.', $day->format('Y-m-d')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inclusive first and last day of a season, for queries that cannot call seasonYearFor()
|
||||||
|
* per row - which is every query, since DQL knows nothing about this class.
|
||||||
|
*
|
||||||
|
* @return array{CarbonImmutable, CarbonImmutable}
|
||||||
|
*/
|
||||||
|
public function windowFor(int $seasonYear): array
|
||||||
|
{
|
||||||
|
$start = $this->boundaryFor($this->seasonStart, $seasonYear);
|
||||||
|
$end = $this->boundaryFor($this->seasonEnd, $seasonYear);
|
||||||
|
|
||||||
|
// Wrapping window: the season ends in the following calendar year.
|
||||||
|
if ($end < $start) {
|
||||||
|
$end = $this->boundaryFor($this->seasonEnd, $seasonYear + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$start, $end];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +126,15 @@ final class SeasonCalendar
|
|||||||
return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100);
|
return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The season label for a date, for the common case of "which season does this belong to" -
|
||||||
|
* "?" when the date is not known yet, which a half-filled create form can be in.
|
||||||
|
*/
|
||||||
|
public function labelFor(?\DateTimeInterface $date): string
|
||||||
|
{
|
||||||
|
return null === $date ? '?' : $this->label($this->seasonYearFor($date));
|
||||||
|
}
|
||||||
|
|
||||||
private function boundaryFor(string $monthDay, int $year): CarbonImmutable
|
private function boundaryFor(string $monthDay, int $year): CarbonImmutable
|
||||||
{
|
{
|
||||||
$boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay));
|
$boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay));
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the office has worked through one season assignment's checklist.
|
||||||
|
*
|
||||||
|
* Derived from the rows by App\Service\Season\SeasonChecklistBuilder, never stored: the two
|
||||||
|
* completed stages follow from the entries themselves, so a column would only be a second,
|
||||||
|
* hand-maintained copy of what the rows already say.
|
||||||
|
*/
|
||||||
|
enum SeasonChecklistStatus: string
|
||||||
|
{
|
||||||
|
case OPEN = 'open';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the teamer has to send back has arrived and been ticked off, so the weekly
|
||||||
|
* digest has nothing left to chase. Office-internal entries can still be open.
|
||||||
|
*/
|
||||||
|
case RETURNS_COMPLETE = 'returns_complete';
|
||||||
|
|
||||||
|
case COMPLETE = 'complete';
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The formalities the office works through for every season assignment.
|
||||||
|
*
|
||||||
|
* This enum is the single source of truth: the entries themselves, their German labels, their
|
||||||
|
* order and both of their properties live here and nowhere else. Rows in season_assignment_task
|
||||||
|
* only carry state, and App\Service\Season\SeasonTaskSynchronizer creates the ones an assignment
|
||||||
|
* is missing - so adding a case here is all it takes to add an entry everywhere, including on
|
||||||
|
* assignments that already exist.
|
||||||
|
*
|
||||||
|
* Removing a case leaves its rows behind on purpose. Every read resolves a stored key through
|
||||||
|
* tryFrom() and skips what no longer exists, which keeps the history recoverable instead of
|
||||||
|
* deleting it.
|
||||||
|
*/
|
||||||
|
enum SeasonTask: string
|
||||||
|
{
|
||||||
|
case MASTER_DATA = 'master_data';
|
||||||
|
case VEHICLE = 'vehicle';
|
||||||
|
case CONTRACT = 'contract';
|
||||||
|
case ADDITIONAL_CONTRACT = 'additional_contract';
|
||||||
|
case SAAS_REGISTERED = 'saas_registered';
|
||||||
|
case MUNICIPALITY_REGISTERED = 'municipality_registered';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::MASTER_DATA => 'Stammdaten',
|
||||||
|
self::VEHICLE => 'KfZ',
|
||||||
|
self::CONTRACT => 'Vertrag',
|
||||||
|
self::ADDITIONAL_CONTRACT => 'Zusatzvertrag',
|
||||||
|
self::SAAS_REGISTERED => 'SaaS angemeldet',
|
||||||
|
self::MUNICIPALITY_REGISTERED => 'Gemeinde angemeldet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provides(): SeasonTaskProvision
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::MASTER_DATA, self::VEHICLE => SeasonTaskProvision::STANDARD,
|
||||||
|
self::CONTRACT, self::ADDITIONAL_CONTRACT => SeasonTaskProvision::INDIVIDUAL,
|
||||||
|
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => SeasonTaskProvision::NONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the teamer has to send something back. This is what puts a task on the teamer's
|
||||||
|
* own page and into their weekly reminder; everything else is office-internal.
|
||||||
|
*/
|
||||||
|
public function requiresReturn(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::MASTER_DATA, self::VEHICLE, self::CONTRACT, self::ADDITIONAL_CONTRACT => true,
|
||||||
|
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return self[]
|
||||||
|
*/
|
||||||
|
public static function requiringReturn(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
self::cases(),
|
||||||
|
static fn (self $task): bool => true === $task->requiresReturn()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the office hands out for a season task.
|
||||||
|
*
|
||||||
|
* NONE nothing is issued, the task is either a pure return or an internal check
|
||||||
|
* INDIVIDUAL a document produced for this one teamer and uploaded onto their season assignment
|
||||||
|
* STANDARD the same document for everyone, taken from the global document library
|
||||||
|
*/
|
||||||
|
enum SeasonTaskProvision: string
|
||||||
|
{
|
||||||
|
case NONE = 'none';
|
||||||
|
case INDIVIDUAL = 'individual';
|
||||||
|
case STANDARD = 'standard';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,22 @@ class TeamerController extends AbstractController
|
|||||||
|
|
||||||
return $this->json($dates);
|
return $this->json($dates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Season assignments are only ever made for all-season teamers, so their form must not be
|
||||||
|
* able to offer anybody else.
|
||||||
|
*/
|
||||||
|
#[Route('/admin/autocomplete/season-teamer', name: 'app_admin_autocomplete_season_teamer')]
|
||||||
|
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||||
|
public function season(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$query = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$queryString = $query['search'];
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw $this->createNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($this->teamerRepository->getAutocompletionData($queryString, true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class MailingController extends AbstractController
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$filterDto = $this->filterHandler->getFilterSettings();
|
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
|
||||||
|
|
||||||
// No redirect after the preview mail, the composed mail has to survive it. The
|
// No redirect after the preview mail, the composed mail has to survive it. The
|
||||||
// preview pane is rendered here so the page arrives complete; from then on the
|
// preview pane is rendered here so the page arrives complete; from then on the
|
||||||
@@ -69,7 +69,8 @@ class MailingController extends AbstractController
|
|||||||
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
||||||
'placeholders' => TeamerMailingService::PLACEHOLDERS,
|
'placeholders' => TeamerMailingService::PLACEHOLDERS,
|
||||||
'hasDraft' => $this->draftHandler->hasDraft(),
|
'hasDraft' => $this->draftHandler->hasDraft(),
|
||||||
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_teamer_index'),
|
'returnUrl' => $this->getReturnUrl($request, $this->getListRoute($request)),
|
||||||
|
'scope' => $request->query->get('scope'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +95,13 @@ class MailingController extends AbstractController
|
|||||||
|
|
||||||
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
|
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
|
||||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||||
public function discard(): Response
|
public function discard(Request $request): Response
|
||||||
{
|
{
|
||||||
$this->draftHandler->resetDraft();
|
$this->draftHandler->resetDraft();
|
||||||
|
|
||||||
return $this->redirectToRoute('app_admin_teamer_mailing');
|
return $this->redirectToRoute('app_admin_teamer_mailing', [
|
||||||
|
'scope' => $request->query->get('scope'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
|
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
|
||||||
@@ -106,7 +109,7 @@ class MailingController extends AbstractController
|
|||||||
public function confirm(Request $request): Response
|
public function confirm(Request $request): Response
|
||||||
{
|
{
|
||||||
$form = $this->createMailingForm($request);
|
$form = $this->createMailingForm($request);
|
||||||
$filterDto = $this->filterHandler->getFilterSettings();
|
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
|
||||||
|
|
||||||
// the composed mail is carried on in hidden fields so no half written mailing has
|
// the composed mail is carried on in hidden fields so no half written mailing has
|
||||||
// to be parked in the session between the confirmation and the send
|
// to be parked in the session between the confirmation and the send
|
||||||
@@ -115,6 +118,7 @@ class MailingController extends AbstractController
|
|||||||
'mailingDto' => $form->getData(),
|
'mailingDto' => $form->getData(),
|
||||||
'valid' => $form->isSubmitted() && $form->isValid(),
|
'valid' => $form->isSubmitted() && $form->isValid(),
|
||||||
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
||||||
|
'scope' => $request->query->get('scope'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +131,14 @@ class MailingController extends AbstractController
|
|||||||
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
||||||
$this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen');
|
$this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen');
|
||||||
|
|
||||||
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing'));
|
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing', [
|
||||||
|
'scope' => $request->query->get('scope'),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
$recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings());
|
$recipients = $this->mailingService->resolveRecipients(
|
||||||
|
$this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings()
|
||||||
|
);
|
||||||
$mailingDto = $form->getData();
|
$mailingDto = $form->getData();
|
||||||
|
|
||||||
// the recipients travel with the message, so the mailing reaches exactly the
|
// the recipients travel with the message, so the mailing reaches exactly the
|
||||||
@@ -151,7 +159,18 @@ class MailingController extends AbstractController
|
|||||||
$recipients->getEligibleCount()
|
$recipients->getEligibleCount()
|
||||||
));
|
));
|
||||||
|
|
||||||
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
|
return new HxRedirectResponse($this->generateUrl($this->getListRoute($request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list this mailing was started from, so a send returns to it and an unscoped
|
||||||
|
* mailing never falls back to the wrong one.
|
||||||
|
*/
|
||||||
|
private function getListRoute(Request $request): string
|
||||||
|
{
|
||||||
|
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
|
||||||
|
? 'app_administrative_season_teamer_index'
|
||||||
|
: 'app_administrative_teamer_index';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class SkillsController extends AbstractController
|
|||||||
{
|
{
|
||||||
$trainings = $this->trainingRepository->getList();
|
$trainings = $this->trainingRepository->getList();
|
||||||
|
|
||||||
|
$allSeasonStatus = $teamer->isAllSeason();
|
||||||
|
|
||||||
$form = $this->createStatusForm($teamer);
|
$form = $this->createStatusForm($teamer);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
@@ -42,6 +44,15 @@ class SkillsController extends AbstractController
|
|||||||
'teamer' => $teamer->getFullName(),
|
'teamer' => $teamer->getFullName(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($teamer->isAllSeason() !== $allSeasonStatus) {
|
||||||
|
$this->addFlash('info', 'Der Saisonteamer:innern-Status wurde '.($teamer->isAllSeason() ? 'hinzugefügt' : 'entfernt'));
|
||||||
|
|
||||||
|
$logMessage = $teamer->isAllSeason() ? 'Assign all-season status' : 'Remove all-season status';
|
||||||
|
$this->logger->info($logMessage, [
|
||||||
|
'teamer' => $teamer->getFullName(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('app_admin_teamer_skills', [
|
return $this->redirectToRoute('app_admin_teamer_skills', [
|
||||||
'uuid' => $teamer->getUuid(),
|
'uuid' => $teamer->getUuid(),
|
||||||
'r' => $request->query->get('r'),
|
'r' => $request->query->get('r'),
|
||||||
@@ -62,10 +73,14 @@ class SkillsController extends AbstractController
|
|||||||
->add('status', ChoiceType::class, [
|
->add('status', ChoiceType::class, [
|
||||||
'label' => 'Status',
|
'label' => 'Status',
|
||||||
'choices' => [
|
'choices' => [
|
||||||
'Neuteamer' => Teamer::STATUS_NEW,
|
'Neuteamer:in' => Teamer::STATUS_NEW,
|
||||||
'Bestandsteamer' => Teamer::STATUS_EXISTING,
|
'Bestandsteamer:in' => Teamer::STATUS_EXISTING,
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
->add('allSeason', CheckboxType::class, [
|
||||||
|
'label' => 'Saisonteamer:in',
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
->add('allowOverlappingApplications', CheckboxType::class, [
|
->add('allowOverlappingApplications', CheckboxType::class, [
|
||||||
'label' => 'Überlappende Bewerbungen erlauben',
|
'label' => 'Überlappende Bewerbungen erlauben',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\WorkMonth;
|
||||||
|
|
||||||
|
use App\Repository\TeamerWorkMonthRepository;
|
||||||
|
use App\Service\Common\WorkMonthFilterHandler;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every imported work month with the state of its Arbeitszeitnachweis.
|
||||||
|
*
|
||||||
|
* Deliberately not the existing document list: UploadRepository::getUploadListQuery() inner
|
||||||
|
* joins a disposition, so a document hanging off a month can never appear there, and it is
|
||||||
|
* the month rather than the file that carries the status anyway.
|
||||||
|
*/
|
||||||
|
class IndexController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TeamerWorkMonthRepository $workMonthRepository,
|
||||||
|
private readonly WorkMonthFilterHandler $filterHandler,
|
||||||
|
private readonly PaginatorInterface $paginator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/work-month', name: 'app_admin_work_month_index')]
|
||||||
|
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$filterDto = $this->filterHandler->getFilterSettings();
|
||||||
|
|
||||||
|
$pagination = $this->paginator->paginate(
|
||||||
|
$this->workMonthRepository->getListQuery($filterDto),
|
||||||
|
$request->query->getInt('page', 1),
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('admin/work_month/index.html.twig', [
|
||||||
|
'pagination' => $pagination,
|
||||||
|
'filterDto' => $filterDto,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\WorkMonth;
|
||||||
|
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Service\Pdf\WorkMonthRenderer;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same sheet the teamer gets, for the office.
|
||||||
|
*
|
||||||
|
* This is what a month's hours look like in full: there is no separate day-by-day screen,
|
||||||
|
* because it would only restate the document that actually gets signed.
|
||||||
|
*/
|
||||||
|
class PdfController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WorkMonthRenderer $renderer,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/work-month/pdf/{uuid}', name: 'app_admin_work_month_pdf')]
|
||||||
|
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||||
|
public function index(TeamerWorkMonth $workMonth): Response
|
||||||
|
{
|
||||||
|
$this->logger->info('Download work month pdf', [
|
||||||
|
'work_month' => $workMonth->getUuid(),
|
||||||
|
'teamer' => $workMonth->getTeamer()?->getFullName(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = new Response($this->renderer->render($workMonth));
|
||||||
|
$response->headers->set('Content-Type', 'application/pdf');
|
||||||
|
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
|
||||||
|
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||||
|
$this->renderer->filename($workMonth),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\WorkMonth;
|
||||||
|
|
||||||
|
use App\Config\EmailTextKey;
|
||||||
|
use App\Controller\Traits\ReturnUrlTrait;
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Entity\Upload;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Enum\WorkMonthStatus;
|
||||||
|
use App\Form\WorkMonthReviewType;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Model\WorkMonthReviewDto;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The office's verdict on a signed Arbeitszeitnachweis.
|
||||||
|
*
|
||||||
|
* A rejection does not undo the upload: the file stays on the month and the teamer uploads a
|
||||||
|
* new one over it, which puts the month back into "eingereicht".
|
||||||
|
*/
|
||||||
|
class ReviewController extends AbstractController
|
||||||
|
{
|
||||||
|
use ReturnUrlTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly Mailer $mailer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/work-month/review/{uuid}', name: 'app_admin_work_month_review')]
|
||||||
|
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||||
|
public function index(TeamerWorkMonth $workMonth, Request $request): Response
|
||||||
|
{
|
||||||
|
if (WorkMonthStatus::SUBMITTED !== $workMonth->getStatus()) {
|
||||||
|
throw $this->createNotFoundException('This work month is not waiting for a review');
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
|
||||||
|
$formData = new WorkMonthReviewDto($workMonth);
|
||||||
|
$form = $this->createForm(WorkMonthReviewType::class, $formData);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
$status = $formData->getStatus();
|
||||||
|
|
||||||
|
$workMonth
|
||||||
|
->setStatus($status)
|
||||||
|
->setReviewedAt(new \DateTimeImmutable())
|
||||||
|
->setReviewedBy($user->getInitials())
|
||||||
|
->setReviewComment($formData->getComment())
|
||||||
|
;
|
||||||
|
|
||||||
|
// Keep the file's own status in step, so the existing upload badge and the
|
||||||
|
// UploadVoter's CHECK rule stay meaningful for it.
|
||||||
|
$workMonth->getCurrentDocument()?->setStatus(
|
||||||
|
WorkMonthStatus::APPROVED === $status ? Upload::STATUS_CHECKED : Upload::STATUS_REJECTED
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
if (WorkMonthStatus::REJECTED === $status) {
|
||||||
|
$this->notifyRejection($workMonth, (string) $formData->getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFlash(
|
||||||
|
'success',
|
||||||
|
WorkMonthStatus::APPROVED === $status
|
||||||
|
? 'Der Arbeitszeitnachweis wurde bestätigt'
|
||||||
|
: 'Der Arbeitszeitnachweis wurde abgelehnt'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->info('Review work month', [
|
||||||
|
'work_month' => $workMonth->getUuid(),
|
||||||
|
'teamer' => $workMonth->getTeamer()?->getFullName(),
|
||||||
|
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
|
||||||
|
'status' => $status->value,
|
||||||
|
'by' => $user->getInitials(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new HxRedirectResponse($returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/work_month/modal_review.html.twig', [
|
||||||
|
'workMonth' => $workMonth,
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'returnUrl' => $returnUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyRejection(TeamerWorkMonth $workMonth, string $comment): void
|
||||||
|
{
|
||||||
|
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
|
||||||
|
|
||||||
|
if (null === $email || '' === trim($email)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailer->createAndSendText(EmailTextKey::WORK_MONTH_REJECTED, [
|
||||||
|
'workMonth' => $workMonth->getPeriodLabel(),
|
||||||
|
'comment' => $comment,
|
||||||
|
], [
|
||||||
|
'to' => $email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\WorkMonth;
|
||||||
|
|
||||||
|
use App\Controller\Traits\ReturnUrlTrait;
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Enum\WorkHoursSyncOutcome;
|
||||||
|
use App\Service\Saas\WorkHoursSynchronizer;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetching one month again by hand, for a correction the schedule has already passed by.
|
||||||
|
*
|
||||||
|
* A month the teamer has signed is frozen, so re-fetching it only records that the SaaS now
|
||||||
|
* disagrees. Releasing it is a separate, deliberate step: it throws away the signature and
|
||||||
|
* sends the teamer back to the start.
|
||||||
|
*/
|
||||||
|
class SyncController extends AbstractController
|
||||||
|
{
|
||||||
|
use ReturnUrlTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly WorkHoursSynchronizer $synchronizer,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/work-month/sync/{uuid}', name: 'app_admin_work_month_sync', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||||
|
public function sync(TeamerWorkMonth $workMonth, Request $request): Response
|
||||||
|
{
|
||||||
|
if (null === $teamer = $workMonth->getTeamer()) {
|
||||||
|
throw $this->createNotFoundException('No teamer on this work month');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($teamer, $workMonth->getYear(), $workMonth->getMonth());
|
||||||
|
|
||||||
|
match ($result->getOutcome()) {
|
||||||
|
WorkHoursSyncOutcome::IMPORTED => $this->addFlash('success', 'Die Arbeitszeiten wurden aktualisiert'),
|
||||||
|
WorkHoursSyncOutcome::LOCKED => $this->addFlash('success', $result->getMessage() ?? 'Der Monat ist bereits eingereicht'),
|
||||||
|
WorkHoursSyncOutcome::SKIPPED => $this->addFlash('error', $result->getMessage() ?? 'Der Abruf war nicht möglich'),
|
||||||
|
WorkHoursSyncOutcome::FAILED => $this->addFlash('error', 'Der Abruf ist fehlgeschlagen: '.$result->getMessage()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/work-month/unlock/{uuid}', name: 'app_admin_work_month_unlock', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||||
|
public function unlock(TeamerWorkMonth $workMonth, Request $request): Response
|
||||||
|
{
|
||||||
|
$this->synchronizer->unlock($workMonth);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Der Monat wurde wieder geöffnet und muss neu unterschrieben werden');
|
||||||
|
|
||||||
|
$this->logger->info('Unlock work month', [
|
||||||
|
'work_month' => $workMonth->getUuid(),
|
||||||
|
'teamer' => $workMonth->getTeamer()?->getFullName(),
|
||||||
|
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 489 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user