Files
FHC-Core/application/libraries/PruefungsverlaufLib.php
T
2026-09-15 10:59:09 +02:00

838 lines
29 KiB
PHP

<?php
/**
* Copyright (C) 2026 fhcomplete.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
if (! defined('BASEPATH')) exit('No direct script access allowed');
/**
* The exam history of one student in one course: the order of the attempts, the attempt count
* and the limits. This class is the only source of the examination rules. The controller and the
* client only read the result.
*/
class PruefungsverlaufLib
{
/** The first attempt: the original assessment, which is the transcript grade itself. */
const ROLLE_ERSTANTRITT = 'erstantritt';
/** Each attempt after the first one. */
const ROLLE_PRUEFUNG = 'pruefung';
/** The last attempt. The examination rules require a commission for it. */
const ROLLE_KOMMISSIONELL = 'kommissionell';
/** Fallback for PRUEFUNG_TYP_JE_ANTRITT. */
private static $_typJeAntrittVorgabe = [1 => 'Termin1', 2 => 'Termin2', 3 => 'Termin3'];
private $_ci;
private $_noteCache = [];
private $_specialNotes = null;
private $_typen = null;
public function __construct()
{
$this->_ci =& get_instance();
$this->_ci->load->model('education/LePruefung_model', 'LePruefungModel');
$this->_ci->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel');
$this->_ci->load->model('education/Note_model', 'NoteModel');
$this->_ci->load->model('education/Pruefungstyp_model', 'PruefungstypModel');
$this->_ci->load->config('noten');
}
/** Builds the history. Each exam gets position, zaehlt, antritt_nr and kommissionell. @return stdClass */
public function buildVerlauf($pruefungen, $lvNote = null, $zeugnisNote = null)
{
$pruefungen = $this->sortPruefungen($pruefungen);
$antrittCount = 0;
$terminal = false;
$eintraege = [];
foreach ($pruefungen as $i => $p) {
$zaehlt = $this->zaehltAlsAntritt($p->note, $p->pruefungstyp_kurzbz);
if ($zaehlt) $antrittCount++;
// A kommissionelle exam closes the chain, also if its grade uses no attempt. A student
// with a kommissionelle Prüfung gets no further exam from this tool.
$kommissionell = $this->istKommissionell($p->pruefungstyp_kurzbz);
if ($kommissionell) $terminal = true;
$eintrag = clone $p;
$eintrag->position = $i + 1;
$eintrag->zaehlt = $zaehlt;
$eintrag->antritt_nr = $zaehlt ? $antrittCount : null;
$eintrag->kommissionell = $kommissionell;
$eintraege[] = $eintrag;
}
// if no exam counts, the course grade itself is the first attempt
$implizit = ($antrittCount === 0 && $lvNote !== null && $this->zaehltAlsAntritt($lvNote));
if ($implizit) $antrittCount = 1;
// deciding grade: last counting attempt, else the course grade
$aktuelleNote = null;
foreach ($eintraege as $eintrag) {
if ($eintrag->zaehlt) $aktuelleNote = $eintrag->note;
}
if ($aktuelleNote === null && $implizit) $aktuelleNote = $lvNote;
// a pass closes the chain: a final grade always, any other positive one unless
// CIS_GESAMTNOTE_NOTENVERBESSERUNG is on
$bestanden = $this->istAbschliessendeNote($aktuelleNote)
|| (!$this->darfVerbessern() && $this->istPositiveNote($aktuelleNote));
if ($bestanden) $terminal = true;
$maxAntritte = $this->getMaxAntritte();
// credited: the row stays visible, but you cannot enter anything
$angerechnet = $this->istAnrechnungsnote($zeugnisNote);
$verlauf = new stdClass();
$verlauf->pruefungen = $eintraege;
$verlauf->antrittCount = $antrittCount;
$verlauf->maxAntritte = $maxAntritte;
$verlauf->terminal = $terminal;
// closed by a pass, not by the attempt limit
$verlauf->bestanden = $bestanden;
$verlauf->angerechnet = $angerechnet;
$verlauf->impliziterErstantritt = $implizit;
$verlauf->canAdd = !$terminal && !$angerechnet && $antrittCount < $maxAntritte;
// after the first exam each new entry is a repeat attempt
$verlauf->erstantrittMoeglich = !$angerechnet && (count($eintraege) === 0);
// a repeat exists: a second exam, or one exam that is no attempt 1 (next to an implicit attempt 1)
$verlauf->hatWiederholung = count($eintraege) > 1
|| (count($eintraege) === 1 && $eintraege[0]->pruefungstyp_kurzbz !== $this->legacyTypFuerAntritt(1));
// The last attempt of the chain is kommissionell (§17 Abs 1). The role therefore follows
// from the position, and the user interface offers no type to select.
if (count($eintraege) === 0 && !$implizit) {
$verlauf->naechsteRolle = self::ROLLE_ERSTANTRITT;
} else {
// an exam exists -> the next is a repeat at least, even if none counted yet
$verlauf->naechsteRolle = $this->rolleFuerAntritt(max(2, $antrittCount + 1));
}
// The next attempt is the kommissionelle one, but this tool may not create it.
$verlauf->kommPruefGesperrt = $verlauf->canAdd
&& $verlauf->naechsteRolle === self::ROLLE_KOMMISSIONELL
&& !$this->darfKommPruefAnlegen();
if ($verlauf->kommPruefGesperrt) $verlauf->canAdd = false;
return $verlauf;
}
/** May this tool CREATE the kommissionelle Prüfung? An existing one is always shown. @return bool */
public function darfKommPruefAnlegen()
{
$erlaubt = $this->_ci->config->item('CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF');
return $erlaubt === null ? true : (bool) $erlaubt;
}
/** May a student attempt again after a positive grade? @return bool */
public function darfVerbessern()
{
return $this->configBool('CIS_GESAMTNOTE_NOTENVERBESSERUNG', false);
}
/**
* Attempt from which the exam is kommissionell, or null if none is.
* Config takes a number, 0 for never, or 'letzter'.
*
* @return int|null
*/
public function getKommissionellAbAntritt()
{
$wert = $this->_ci->config->item('CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT');
if ($wert === null || $wert === 'letzter') {
$max = $this->getMaxAntritte();
// a chain of one has nothing to close
return $max > 1 ? $max : null;
}
return (is_numeric($wert) && (int) $wert >= 1) ? (int) $wert : null;
}
/** The exam types held before a commission. @return array */
public function getKommissionellTypen()
{
$typen = $this->_ci->config->item('PRUEFUNG_KOMMISSIONELL_TYPEN');
return is_array($typen) ? $typen : ['kommPruef', 'zusKommPruef'];
}
/** Attempts that count, kommissionell included. Config wins, else the old flags. @return int */
public function getMaxAntritte()
{
$max = $this->_ci->config->item('CIS_GESAMTNOTE_MAX_ANTRITTE');
if (is_numeric($max) && (int) $max > 0) return (int) $max;
return $this->maxAntritteAusAltFlags();
}
/** Grade an empty entry falls back to, named by config. @return mixed|null */
public function getNoteNichtEingetragen()
{
return $this->getSpecialNotes()['nichtEingetragen'];
}
/** All exams of one student in one course, in chronological order. @return array */
public function getPruefungen($student_uid, $lv_id, $sem_kurzbz)
{
$result = $this->_ci->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lv_id, $sem_kurzbz);
if (isError($result) || !hasData($result)) return [];
return $this->sortPruefungen(getData($result));
}
/**
* Special grades, resolved from config names to tbl_note keys. A PK means something else in
* every installation, so no config holds one. An unknown name is dropped.
*
* @return array{entschuldigt: mixed, ohneAntritt: array, anrechnung: array, limitMap: array}
*/
public function getSpecialNotes()
{
if ($this->_specialNotes !== null) return $this->_specialNotes;
$entschuldigt = $this->getNoteByBezeichnung($this->_ci->config->item('NOTE_ENTSCHULDIGT_BEZEICHNUNG'));
$ohneAntritt = $this->resolveNoten('NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN');
if ($entschuldigt !== null && !in_array($entschuldigt, $ohneAntritt)) $ohneAntritt[] = $entschuldigt;
// the limit names a grade, the rules work on its key
$limitMap = [];
foreach ($this->configArray('NOTEN_OCCURANCE_LIMIT_MAP') as $bezeichnung => $limit) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null) $limitMap[$note] = $limit;
}
$this->_specialNotes = [
'entschuldigt' => $entschuldigt,
'ohneAntritt' => array_values($ohneAntritt),
'anrechnung' => $this->resolveNoten('NOTEN_ANRECHNUNG_BEZEICHNUNGEN'),
'abschliessend' => $this->resolveNoten('NOTEN_ABSCHLIESSEND_BEZEICHNUNGEN'),
'nichtEingetragen' => $this->getNoteByBezeichnung($this->_ci->config->item('NOTE_NICHT_EINGETRAGEN_BEZEICHNUNG')),
// order matters here: best grade first
'rangfolge' => $this->resolveNoten('NOTEN_RANGFOLGE_BEZEICHNUNGEN'),
'limitMap' => $limitMap
];
return $this->_specialNotes;
}
/** The exam types that never use an attempt. @return array */
public function getTypenOhneAntritt()
{
$typen = $this->_ci->config->item('PRUEFUNG_TYPEN_OHNE_ANTRITT');
return is_array($typen) ? $typen : ['zusKommPruef'];
}
/**
* @param mixed $lvNote counts as the implicit first attempt while no exam counts
* @param mixed $zeugnisNote a credited grade makes all exams impossible
* @return stdClass
*/
public function getVerlauf($student_uid, $lv_id, $sem_kurzbz, $lvNote = null, $zeugnisNote = null)
{
return $this->buildVerlauf($this->getPruefungen($student_uid, $lv_id, $sem_kurzbz), $lvNote, $zeugnisNote);
}
/** An exam with a later date locks the grade. You can still correct the date. @return bool */
public function hatSpaeterenTermin($verlauf, $pruefung_id)
{
$current = null;
foreach ($verlauf->pruefungen as $p) {
if ($p->pruefung_id == $pruefung_id) { $current = $p; break; }
}
if ($current === null) return false;
foreach ($verlauf->pruefungen as $p) {
if ($p->pruefung_id == $current->pruefung_id) continue;
if ($p->position > $current->position) return true;
}
return false;
}
/**
* Does a repeat exist? See buildVerlauf.
*
* Attempt 1 and the LV-Note are the same performance, so the takeover path may still write it.
* From the first repeat on the grade belongs to the attempt.
*
* @return bool
*/
public function hatWiederholung($pruefungen)
{
return $this->buildVerlauf($pruefungen)->hatWiederholung;
}
/** Grade with no better one: closes the chain for good. @return bool */
public function istAbschliessendeNote($note)
{
if ($note === null || $note === '') return false;
return in_array($note, $this->getSpecialNotes()['abschliessend']);
}
/** 'angerechnet' or 'intern angerechnet'. The TRANSCRIPT grade decides. @return bool */
public function istAnrechnungsnote($note)
{
if ($note === null || $note === '') return false;
return in_array($note, $this->getSpecialNotes()['anrechnung']);
}
/** Tells you if an exam takes place before a commission. @return bool */
public function istKommissionell($typ)
{
return in_array($typ, $this->getKommissionellTypen());
}
/**
* Is this grade a pass? tbl_note.positiv also sits on non-achievements ('entschuldigt',
* 'Teilgenommen'), so ask this only about a grade that uses an attempt.
*
* @return bool
*/
public function istPositiveNote($note)
{
if ($note === null || $note === '') return false;
$noteRow = $this->getNote($note);
return $noteRow ? (bool) $noteRow->positiv : false;
}
/** Is the new grade worse? An unknown grade is never worse. @return bool */
public function istSchlechter($neu, $alt)
{
$vergleich = $this->vergleicheNoten($neu, $alt);
return $vergleich !== null && $vergleich > 0;
}
/** Legacy pruefungstyp for an attempt number; Stv still reads that column. @return string */
public function legacyTypFuerAntritt($antrittNr)
{
$antrittNr = max(1, (int) $antrittNr);
$typen = $this->getTypen();
$jeAntritt = $this->getTypJeAntritt();
$kommissionell = $this->_ci->config->item('PRUEFUNG_TYP_KOMMISSIONELL');
if (!is_string($kommissionell) || $kommissionell === '') $kommissionell = 'kommPruef';
// the kommissionell role owns its type and beats the position
if ($this->rolleFuerAntritt($antrittNr) === self::ROLLE_KOMMISSIONELL && isset($typen[$kommissionell])) {
return $kommissionell;
}
// a type missing from tbl_pruefungstyp breaks the FK, so step down
for ($n = $antrittNr; $n >= 1; $n--) {
if (isset($jeAntritt[$n]) && isset($typen[$jeAntritt[$n]])) return $jeAntritt[$n];
}
return isset($jeAntritt[1]) ? $jeAntritt[1] : self::$_typJeAntrittVorgabe[1];
}
/** Legacy type for a new repeat; never below Termin2. @return string */
public function legacyTypFuerWiederholung($verlauf)
{
return $this->legacyTypFuerAntritt(max(2, $verlauf->antrittCount + 1));
}
/**
* The course grade after one exam write: the grade of the last exam that uses an attempt.
* Without one, an implicit attempt 1 stays, else nothing is entered. The course grade is never
* 'entschuldigt': an excused date uses no attempt.
*
* @param mixed $pruefung_id set = edit of that row, empty = new exam after all others
* @return array [note, punkte]
*/
public function lvNoteNachTermin($pruefungen, $pruefung_id, $note, $punkte, $lvNote, $lvPunkte)
{
$neu = $pruefung_id === null || $pruefung_id === '';
$ergebnis = null;
foreach ($this->sortPruefungen($pruefungen) as $p) {
$zeile = (!$neu && $p->pruefung_id == $pruefung_id) ? [$note, $punkte] : [$p->note, $p->punkte];
if ($this->zaehltAlsAntritt($zeile[0], $p->pruefungstyp_kurzbz)) $ergebnis = $zeile;
}
if ($neu && $this->zaehltAlsAntritt($note)) $ergebnis = [$note, $punkte];
if ($ergebnis !== null) return $ergebnis;
if ($this->buildVerlauf($pruefungen, $lvNote)->impliziterErstantritt) return [$lvNote, $lvPunkte];
return [$this->getNoteNichtEingetragen(), null];
}
/** Role of one attempt: 1 = erstantritt, later = pruefung, from the commission on = kommissionell. @return string */
public function rolleFuerAntritt($antrittNr)
{
$nr = max(1, (int) $antrittNr);
$abKommissionell = $this->getKommissionellAbAntritt();
if ($abKommissionell !== null && $nr >= $abKommissionell) return self::ROLLE_KOMMISSIONELL;
return $nr === 1 ? self::ROLLE_ERSTANTRITT : self::ROLLE_PRUEFUNG;
}
/** Sort keys: datum, then tbl_pruefungstyp.sort (old rows without a date), then pruefung_id. */
public function sortPruefungen($pruefungen)
{
$pruefungen = array_values($pruefungen);
$typen = $this->getTypen();
usort($pruefungen, function ($a, $b) use ($typen) {
$da = substr((string) $a->datum, 0, 10);
$db = substr((string) $b->datum, 0, 10);
if ($da !== $db) {
if ($da === '') return -1;
if ($db === '') return 1;
return ($da < $db) ? -1 : 1;
}
$sa = isset($typen[$a->pruefungstyp_kurzbz]) ? (int) $typen[$a->pruefungstyp_kurzbz]->sort : 0;
$sb = isset($typen[$b->pruefungstyp_kurzbz]) ? (int) $typen[$b->pruefungstyp_kurzbz]->sort : 0;
if ($sa !== $sb) return ($sa < $sb) ? -1 : 1;
return ((int) $a->pruefung_id < (int) $b->pruefung_id) ? -1 : 1;
});
return $pruefungen;
}
/** Occurrence limit per NOTEN_OCCURANCE_LIMIT_MAP; $excludePruefungId skips the edited row. @return bool */
public function ueberschreitetNotenLimit($pruefungen, $note, $excludePruefungId = null)
{
$limitMap = $this->getSpecialNotes()['limitMap'];
$limit = null;
foreach ($limitMap as $limitNote => $limitVal) {
if ($limitNote == $note) { $limit = $limitVal; break; }
}
if ($limit === null) return false;
$count = 0;
foreach ($pruefungen as $p) {
if ($excludePruefungId !== null && $p->pruefung_id == $excludePruefungId) continue;
if ($p->note == $note) $count++;
}
return ($count + 1) > $limit;
}
/**
* Writes attempt 1 as its own exam row. A course grade without any exam IS attempt 1; while
* that row is missing the next exam becomes attempt 2 and the whole legacy chain shifts.
*
* Idempotent: nothing is written once a repeat exists. Nothing is written either if the only
* exam documents a result of its own (see darfErstantrittUeberschreiben).
*
* @param bool $mitDatum also write $datum into an existing attempt 1. Only the grader
* picks that date - a release must not move it.
* @param mixed $lehreinheit_id null = look it up
* @return stdClass|null the exam row, or null if nothing was written
*/
public function upsertErstantritt($student_uid, $lv_id, $sem_kurzbz, $note, $punkte, $datum, $mitarbeiter_uid = null, $mitDatum = false, $lehreinheit_id = null)
{
$pruefungen = $this->getPruefungen($student_uid, $lv_id, $sem_kurzbz);
if ($this->hatWiederholung($pruefungen)) return null;
$jetzt = date('Y-m-d H:i:s');
$tag = substr((string) $datum, 0, 10);
if (count($pruefungen) === 1) {
// The course grade belongs to attempt 1 only. An excused Termin keeps its own grade.
if (!$this->darfErstantrittUeberschreiben($pruefungen[0])) return null;
$daten = [
'note' => $note,
'punkte' => $punkte,
'updateamum' => $jetzt,
'updatevon' => getAuthUID()
];
if ($mitDatum) $daten['datum'] = $tag;
$this->_ci->LePruefungModel->update($pruefungen[0]->pruefung_id, $daten);
return $this->ladePruefung($pruefungen[0]->pruefung_id);
}
// the server finds the Lehreinheit; it does not use a value from the client
if ($lehreinheit_id === null) {
$resLe = $this->_ci->LehrveranstaltungModel->getLeByStudent($student_uid, $sem_kurzbz, $lv_id);
if (isError($resLe) || !hasData($resLe)) return null;
$lehreinheit_id = current(getData($resLe))->lehreinheit_id;
}
$id = $this->_ci->LePruefungModel->insert([
'lehreinheit_id' => $lehreinheit_id,
'student_uid' => $student_uid,
'mitarbeiter_uid' => $mitarbeiter_uid,
'note' => $note,
'punkte' => $punkte,
'pruefungstyp_kurzbz' => $this->legacyTypFuerAntritt(1),
'datum' => $tag,
'anmerkung' => '',
'insertamum' => $jetzt,
'insertvon' => getAuthUID(),
'updateamum' => null,
'updatevon' => null,
'ext_id' => null
]);
return $id ? $this->ladePruefung($id->retval) : null;
}
/**
* Guards a NEW attempt: A limit, B chronology, C occurrence limit, D waiting period.
*
* @return array|null [phraseKey, extraParams] or null if permitted
*/
public function validateAdd($pruefungen, $note, $datum, $lvNote = null, $zeugnisNote = null)
{
if ($this->istAnrechnungsnote($zeugnisNote)) return ['c4angerechnetKeinePruefung', []];
$verlauf = $this->buildVerlauf($pruefungen, $lvNote, $zeugnisNote);
// the next attempt is kommissionell, and this tool may not create it
if ($verlauf->kommPruefGesperrt) return ['kommPruefNichtErlaubt', []];
// A: the first attempt only materialises the course grade, so it adds no attempt
if ($verlauf->naechsteRolle !== self::ROLLE_ERSTANTRITT && !$verlauf->canAdd) {
// a pass and a used-up limit are different answers
if ($verlauf->bestanden) return ['pruefungNachBestandenerNote', []];
return ['maxAntritteReached', [$verlauf->maxAntritte]];
}
// only one exam without a result per history; more of them break the order of the attempts
if ($this->istOffen($note) && $this->hatOffenenTermin($pruefungen)) return ['pruefungOhneErgebnis', []];
// B: no attempt before an existing exam; same day counts as too early unless configured
$gleicherTag = $this->configBool('CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG', false);
$newDate = substr((string) $datum, 0, 10);
foreach ($pruefungen as $p) {
$d = substr((string) $p->datum, 0, 10);
if ($d === '') continue;
if ($gleicherTag ? ($d > $newDate) : ($d >= $newDate)) {
return ['pruefungDatumBeforeExisting', []];
}
}
// C: occurrence limit, e.g. one 'entschuldigt' only
if ($this->ueberschreitetNotenLimit($pruefungen, $note, null)) {
return ['noteOccuranceLimitReached', []];
}
// D: the waiting period between two attempts
return $this->pruefeAntrittsabstand($verlauf, $newDate);
}
/**
* Guards an EDIT. Date must stay between the neighbouring exams; a later exam locks the grade.
*
* Returns a phrase key, not a text - the controller adds student_uid and translates.
*
* @return array|null [phraseKey, extraParams] or null if permitted
*/
public function validateEdit($pruefungen, $pruefung_id, $newNote, $newDatum, $lvNote = null, $zeugnisNote = null)
{
if ($pruefung_id === null || $pruefung_id === '') return null; // add, not an edit
if ($this->istAnrechnungsnote($zeugnisNote)) return ['c4angerechnetKeinePruefung', []];
$verlauf = $this->buildVerlauf($pruefungen, $lvNote, $zeugnisNote);
// only an exam of this student in this course; Stv owns zusKommPruef
$current = null;
foreach ($verlauf->pruefungen as $p) {
if ($p->pruefung_id == $pruefung_id) { $current = $p; break; }
}
if ($current === null || in_array($current->pruefungstyp_kurzbz, $this->getTypenOhneAntritt())) {
return ['c4pruefungNichtGespeichert', []];
}
// only one exam without a result per history
if ($this->istOffen($newNote) && $this->hatOffenenTermin($pruefungen, $pruefung_id)) return ['pruefungOhneErgebnis', []];
$currentDate = substr((string) $current->datum, 0, 10);
$new = substr((string) $newDatum, 0, 10);
// bounds: the exams directly before and after this one
$lower = null; $upper = null;
foreach ($verlauf->pruefungen as $p) {
if ($p->pruefung_id == $current->pruefung_id) continue;
$d = substr((string) $p->datum, 0, 10);
if ($d === '') continue;
if ($d < $currentDate) { if ($lower === null || $d > $lower) $lower = $d; }
elseif ($d > $currentDate) { if ($upper === null || $d < $upper) $upper = $d; }
}
// grade is locked once a later attempt exists
if ($this->configBool('CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN', true)
&& $this->hatSpaeterenTermin($verlauf, $current->pruefung_id) && $newNote != $current->note) {
return ['pruefungNoteLocked', []];
}
// datum must stay strictly between the neighbouring exam dates
if (($lower !== null && $new <= $lower) || ($upper !== null && $new >= $upper)) {
return ['pruefungDatumOutOfRange', []];
}
// an edit may change the note too -> re-check the occurrence limit
if ($this->ueberschreitetNotenLimit($verlauf->pruefungen, $newNote, $current->pruefung_id)) {
return ['noteOccuranceLimitReached', []];
}
return null;
}
/**
* Compares two grades via NOTEN_RANGFOLGE_BEZEICHNUNGEN (tbl_note.notenwert is NULL everywhere).
*
* @return int|null <0 if $a is better, 0 equal, null if one grade is not in the list
*/
public function vergleicheNoten($a, $b)
{
$rang = $this->getSpecialNotes()['rangfolge'];
$ia = array_search($a, $rang);
$ib = array_search($b, $rang);
if ($ia === false || $ib === false) return null;
return $ia - $ib;
}
/**
* Does this Termin use an attempt? The NOTE decides; the type only via
* PRUEFUNG_TYPEN_OHNE_ANTRITT, which never counts.
*
* @return bool
*/
public function zaehltAlsAntritt($note, $typ = null)
{
if ($typ !== null && in_array($typ, $this->getTypenOhneAntritt())) return false;
if ($note === null || $note === '') return false;
if (in_array($note, $this->getSpecialNotes()['ohneAntritt'])) return false;
// a credited grade is not an assessment, therefore it uses no attempt
if ($this->istAnrechnungsnote($note)) return false;
$noteRow = $this->getNote($note);
return $noteRow ? (bool) $noteRow->lehre : false;
}
/** One configuration value that must be a list. @return array */
private function configArray($key)
{
$wert = $this->_ci->config->item($key);
return is_array($wert) ? $wert : [];
}
/** One switch; an absent key keeps the default. @return bool */
private function configBool($key, $default)
{
$wert = $this->_ci->config->item($key);
return $wert === null ? (bool) $default : (bool) $wert;
}
/** Days, or null if the rule is off. @return int|null */
private function configTage($key)
{
$wert = $this->_ci->config->item($key);
if (!is_numeric($wert)) return null;
return ((int) $wert >= 0) ? (int) $wert : null;
}
/**
* May the course grade go into this existing exam row? Attempt 1 and the course grade are the
* same performance, therefore that row takes the grade. An empty row takes it too: it waits for
* the result.
*
* A row with a result of its own does NOT take it. 'entschuldigt' and 'Nicht beurteilt' use no
* attempt, but they document a dated event. The course grade then stays the implicit first
* attempt, which buildVerlauf already counts.
*
* @return bool
*/
private function darfErstantrittUeberschreiben($pruefung)
{
// the student administration owns the types that never use an attempt (zusKommPruef)
if (in_array($pruefung->pruefungstyp_kurzbz, $this->getTypenOhneAntritt())) return false;
if ($this->zaehltAlsAntritt($pruefung->note, $pruefung->pruefungstyp_kurzbz)) return true;
// the row carries no result yet
$offen = $this->getSpecialNotes()['nichtEingetragen'];
return $offen !== null && $pruefung->note == $offen;
}
/**
* @return stdClass|null
*/
private function getNote($note)
{
if (array_key_exists($note, $this->_noteCache)) return $this->_noteCache[$note];
$result = $this->_ci->NoteModel->load($note);
$this->_noteCache[$note] = (!isError($result) && hasData($result)) ? getData($result)[0] : null;
return $this->_noteCache[$note];
}
/**
* @return mixed|null the key of the grade, or null if no grade carries the name
*/
private function getNoteByBezeichnung($bezeichnung)
{
if (!is_string($bezeichnung) || trim($bezeichnung) === '') return null;
$result = $this->_ci->NoteModel->loadWhere(['bezeichnung' => $bezeichnung]);
return (!isError($result) && hasData($result)) ? getData($result)[0]->note : null;
}
/**
* tbl_pruefungstyp as a map: sorts old rows and limits the legacy type to types that exist.
* A missing type breaks the FK (Termin3 is absent by default).
*
* @return array
*/
private function getTypen()
{
if ($this->_typen !== null) return $this->_typen;
$this->_typen = [];
$result = $this->_ci->PruefungstypModel->load();
if (!isError($result) && hasData($result)) {
foreach (getData($result) as $typ) {
$this->_typen[$typ->pruefungstyp_kurzbz] = $typ;
}
}
return $this->_typen;
}
/** @return array attempt number => legacy type */
public function getTypJeAntritt()
{
$typen = $this->configArray('PRUEFUNG_TYP_JE_ANTRITT');
return count($typen) > 0 ? $typen : self::$_typJeAntrittVorgabe;
}
/** Does an exam other than $ausser still wait for its result? @return bool */
private function hatOffenenTermin($pruefungen, $ausser = null)
{
foreach ($pruefungen as $p) {
if ($p->pruefung_id != $ausser && $this->istOffen($p->note)) return true;
}
return false;
}
/** "Noch nicht eingetragen": the exam waits for its result. @return bool */
private function istOffen($note)
{
$offen = $this->getNoteNichtEingetragen();
return $offen !== null && $note !== null && $note !== '' && $note == $offen;
}
/** @return stdClass|null */
private function ladePruefung($pruefung_id)
{
$result = $this->_ci->LePruefungModel->load($pruefung_id);
return (!isError($result) && hasData($result)) ? getData($result)[0] : null;
}
/** 1 (the original assessment) + TERMIN2 + TERMIN3 + KOMMPRUEF. @return int */
private function maxAntritteAusAltFlags()
{
$max = 1;
if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++;
if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++;
if (defined('CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF') && CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) $max++;
return $max;
}
/**
* Waiting period between the last attempt and a new one.
*
* Runs between ATTEMPTS, not dates: an excused Termin must not push the next attempt away.
*
* @return array|null [phraseKey, extraParams] or null if permitted
*/
private function pruefeAntrittsabstand($verlauf, $newDate)
{
$min = $this->configTage('CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE');
$max = $this->configTage('CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE');
if (($min === null && $max === null) || $newDate === '') return null;
// last Termin that uses an attempt
$letzter = null;
foreach ($verlauf->pruefungen as $p) {
if (!$p->zaehlt) continue;
$d = substr((string) $p->datum, 0, 10);
if ($d !== '' && ($letzter === null || $d > $letzter)) $letzter = $d;
}
if ($letzter === null) return null;
$abstand = $this->tageZwischen($letzter, $newDate);
if ($abstand === null) return null;
if ($min !== null && $abstand < $min) return ['pruefungAbstandZuKurz', [$min]];
if ($max !== null && $abstand > $max) return ['pruefungAbstandZuLang', [$max]];
return null;
}
/** The grades of one configuration key, found by their name. @return array */
private function resolveNoten($bezeichnungKey)
{
$noten = [];
foreach ($this->configArray($bezeichnungKey) as $bezeichnung) {
$note = $this->getNoteByBezeichnung($bezeichnung);
if ($note !== null && !in_array($note, $noten)) $noten[] = $note;
}
return array_values($noten);
}
/** Whole days between two Y-m-d days. @return int|null */
private function tageZwischen($von, $bis)
{
$a = DateTime::createFromFormat('Y-m-d|', $von);
$b = DateTime::createFromFormat('Y-m-d|', $bis);
if ($a === false || $b === false) return null;
$diff = $a->diff($b);
return $diff->invert ? -((int) $diff->days) : (int) $diff->days;
}
}