Let the studio book lessons and record intake collected elsewhere
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
Two related gaps, closed together because the second is created by the first. A private lesson could only be booked by the student or their guardian, so a booking taken over the phone had no way in — where group classes have had "Add students directly" all along. "Book a lesson for a student" is now a panel on Scheduler and My Lessons: student, open time, lesson type, with weekly term reservations and a no-charge option for make-up lessons. The booking core is extracted to Booking\LessonBooker and shared with POST /bookings, so the two paths cannot drift on offering rules, slot claiming, or billing. That leaves a registration with no intake answers and no policy acceptances, because nobody was at a keyboard to give them — already true of every directly added group-class student. Ticking the boxes on a student's behalf would be an audit trail that says something untrue, so instead the answers are collected another way and recorded afterwards, from a lesson's or an enrolment's detail page. Every recording must say how it was collected, which is stamped on each row along with who typed it and shown in a new "How it was given" column: a policy ticked online and one transcribed from paper must never look alike. Only staff-made registrations qualify (us_lessons.booked_by, us_group_enrollments.enrolled_by) — one the student made already holds their own answers. Only what is still missing can be recorded, re-checked at write time, so a stale or double-posted form cannot duplicate or overwrite. No IP is stored for a transcription, and accepted_by stays the student while recorded_by names the staff member. Intake is now generic over Registration\IntakeSubject, which Lesson and Enrollment both implement; LessonDetail became Registration\IntakeAudit and is shared by both detail views rather than duplicated. Closes #182 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
This commit is contained in:
@@ -25,6 +25,15 @@ class Answer {
|
||||
public readonly int $registrationId,
|
||||
public readonly int $studentId,
|
||||
public readonly ?string $answerValue = null,
|
||||
/**
|
||||
* How this answer reached the studio when it did not come from the booking
|
||||
* form — see {@see IntakeProvenance}. Null is the ordinary case: the student
|
||||
* typed it in themselves.
|
||||
*/
|
||||
public readonly ?string $collectedVia = null,
|
||||
public readonly ?string $collectedNote = null,
|
||||
/** The staff member who typed it in, when somebody did. */
|
||||
public readonly int $recordedBy = 0,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
@@ -35,6 +44,9 @@ class Answer {
|
||||
registrationId: Val::int( $row->registration_id ),
|
||||
studentId: Val::int( $row->student_id ),
|
||||
answerValue: Val::stringOrNull( $row->answer_value ),
|
||||
collectedVia: Val::stringOrNull( $row->collected_via ?? null ),
|
||||
collectedNote: Val::stringOrNull( $row->collected_note ?? null ),
|
||||
recordedBy: Val::int( $row->recorded_by ?? 0 ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -52,6 +64,9 @@ class Answer {
|
||||
'registration_id' => $this->registrationId,
|
||||
'student_id' => $this->studentId,
|
||||
'answer_value' => $this->answerValue,
|
||||
'collected_via' => $this->collectedVia,
|
||||
'collected_note' => $this->collectedNote,
|
||||
'recorded_by' => $this->recordedBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ class AnswerRepository {
|
||||
'registration_id' => $answer->registrationId,
|
||||
'student_id' => $answer->studentId,
|
||||
'answer_value' => $answer->answerValue,
|
||||
'collected_via' => $answer->collectedVia,
|
||||
'collected_note' => $answer->collectedNote,
|
||||
'recorded_by' => $answer->recordedBy,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%s', '%d', '%d', '%s', '%s' ]
|
||||
[ '%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
|
||||
/**
|
||||
* Builds the display rows for one registration's audit trail: the intake answers
|
||||
* the student gave and the policy versions they accepted. Used by the admin
|
||||
* lesson detail view and the group-class enrolment detail view alike, mirroring
|
||||
* the per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
||||
*
|
||||
* Which rows belong to the registration is the subject's own business
|
||||
* ({@see IntakeSubject::intakeRegistrationId()}) — notably, a weekly lesson
|
||||
* series is answered for and agreed to once, against its anchor, so every
|
||||
* occurrence reads the same trail rather than only the first looking answered.
|
||||
*/
|
||||
class IntakeAudit {
|
||||
|
||||
public function __construct(
|
||||
private AnswerRepository $answers,
|
||||
private QuestionRepository $questions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The intake-question answers recorded for this registration, in submission
|
||||
* order.
|
||||
*
|
||||
* @return list<array{question: string, answer: string, source: string}>
|
||||
*/
|
||||
public function answers( IntakeSubject $subject ): array {
|
||||
return array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
$value = $answer->answerValue ?? '';
|
||||
|
||||
return [
|
||||
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
|
||||
'answer' => '' === $value ? '—' : $value,
|
||||
'source' => $this->source( $answer->collectedVia, $answer->collectedNote, $answer->recordedBy ),
|
||||
];
|
||||
},
|
||||
$this->answers->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a recorded row came from: given online by the student, or collected
|
||||
* some other way and typed in — in which case who typed it is named, since an
|
||||
* unattributed transcription is worth much less than an attributed one.
|
||||
*/
|
||||
private function source( ?string $collectedVia, ?string $collectedNote, int $recordedBy ): string {
|
||||
$described = IntakeProvenance::describe( $collectedVia, $collectedNote );
|
||||
|
||||
if ( null === $collectedVia || '' === $collectedVia || $recordedBy <= 0 ) {
|
||||
return $described;
|
||||
}
|
||||
|
||||
$user = get_userdata( $recordedBy );
|
||||
|
||||
return sprintf(
|
||||
/* translators: 1: how the answer was collected, 2: name of the staff member who recorded it. */
|
||||
__( '%1$s — recorded by %2$s', 'unsupervised-schedular' ),
|
||||
$described,
|
||||
UserName::format( $user instanceof \WP_User ? $user : null, $recordedBy )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy versions the student accepted for this registration, with the
|
||||
* captured acceptance time and IP for the audit trail.
|
||||
*
|
||||
* @return list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}>
|
||||
*/
|
||||
public function acceptances( IntakeSubject $subject ): array {
|
||||
return array_map(
|
||||
function ( PolicyAcceptance $acceptance ): array {
|
||||
$version = $this->versions->findById( $acceptance->policyVersionId );
|
||||
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
|
||||
|
||||
return [
|
||||
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
|
||||
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
|
||||
'accepted_at' => $acceptance->acceptedAt ?? '',
|
||||
'ip' => $acceptance->ipAddress ?? '',
|
||||
'source' => $this->source( $acceptance->collectedVia, $acceptance->collectedNote, $acceptance->recordedBy ),
|
||||
];
|
||||
},
|
||||
$this->acceptances->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
/**
|
||||
* Where an intake answer or policy acceptance came from, when it did not come
|
||||
* from the student filling in the booking form.
|
||||
*
|
||||
* A lesson the studio booked on someone's behalf has no answers and no
|
||||
* acceptances — nobody was at a keyboard to give them — so they are collected
|
||||
* some other way and typed in afterwards. What makes that record worth keeping
|
||||
* is knowing *how*: "accepted on 24 Aug" means one thing when a student ticked a
|
||||
* box and quite another when a staff member read it off a signed form, and an
|
||||
* audit trail that cannot tell them apart is worse than no audit trail, because
|
||||
* it looks like one.
|
||||
*
|
||||
* Absent (null) provenance is therefore meaningful in its own right: it is the
|
||||
* ordinary case of the student answering online.
|
||||
*/
|
||||
class IntakeProvenance {
|
||||
|
||||
public const VIA_PAPER = 'paper';
|
||||
public const VIA_IN_PERSON = 'in_person';
|
||||
public const VIA_PHONE = 'phone';
|
||||
public const VIA_EMAIL = 'email';
|
||||
public const VIA_OTHER = 'other';
|
||||
|
||||
/**
|
||||
* How the answers can have reached the studio. `other` exists so the list
|
||||
* never forces a lie, and is the one option that must be explained.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_METHODS = [ self::VIA_PAPER, self::VIA_IN_PERSON, self::VIA_PHONE, self::VIA_EMAIL, self::VIA_OTHER ];
|
||||
|
||||
/** Longest note the `collected_note` VARCHAR(191) column holds. */
|
||||
public const MAX_NOTE_LENGTH = 191;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $collectedVia,
|
||||
public readonly ?string $collectedNote = null,
|
||||
public readonly int $recordedBy = 0,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build from submitted values, or explain what is wrong with them. A method
|
||||
* outside the vocabulary is rejected rather than stored: a column that can say
|
||||
* anything says nothing. `other` requires the note, since "other" on its own
|
||||
* answers the question with the question.
|
||||
*/
|
||||
public static function fromInput( string $collectedVia, string $collectedNote, int $recordedBy ): self|\WP_Error {
|
||||
if ( ! in_array( $collectedVia, self::VALID_METHODS, true ) ) {
|
||||
return new \WP_Error( 'invalid_collection_method', __( 'Choose how these were collected.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$note = trim( $collectedNote );
|
||||
|
||||
if ( self::VIA_OTHER === $collectedVia && '' === $note ) {
|
||||
return new \WP_Error( 'collection_note_required', __( 'Say how these were collected.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
return new self(
|
||||
collectedVia: $collectedVia,
|
||||
collectedNote: '' !== $note ? mb_substr( $note, 0, self::MAX_NOTE_LENGTH ) : null,
|
||||
recordedBy: $recordedBy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The methods as `value => label`, for the form's picker and for reading a
|
||||
* stored value back on screen.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function choices(): array {
|
||||
return [
|
||||
self::VIA_PAPER => __( 'On a signed paper form', 'unsupervised-schedular' ),
|
||||
self::VIA_IN_PERSON => __( 'In person', 'unsupervised-schedular' ),
|
||||
self::VIA_PHONE => __( 'Over the phone', 'unsupervised-schedular' ),
|
||||
self::VIA_EMAIL => __( 'By email', 'unsupervised-schedular' ),
|
||||
self::VIA_OTHER => __( 'Some other way', 'unsupervised-schedular' ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a stored row reads on screen: the method's label, plus its note. An
|
||||
* empty method is the ordinary case — the student answered online — and says
|
||||
* so rather than showing a blank cell.
|
||||
*/
|
||||
public static function describe( ?string $collectedVia, ?string $collectedNote = null ): string {
|
||||
if ( null === $collectedVia || '' === $collectedVia ) {
|
||||
return __( 'Given online when booking', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$label = self::choices()[ $collectedVia ] ?? $collectedVia;
|
||||
$note = null !== $collectedNote ? trim( $collectedNote ) : '';
|
||||
|
||||
return '' !== $note ? $label . ' — ' . $note : $label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
|
||||
/**
|
||||
* Recording, after the fact, the intake answers and policy acceptances of a
|
||||
* registration the studio made on a student's behalf — a lesson booked from the
|
||||
* Scheduler, a student added straight into a group class.
|
||||
*
|
||||
* Such a registration has neither: nobody was at a keyboard to answer the
|
||||
* questions or tick the boxes, and staff doing it *for* the student at the time
|
||||
* would be an audit trail that says something untrue. The answers are instead
|
||||
* collected some other way — a paper form, a phone call — and typed in here, each
|
||||
* row stamped with how it was obtained ({@see IntakeProvenance}), so a reader can
|
||||
* always tell a student's own click from a studio's transcription.
|
||||
*
|
||||
* Two rules hold this honest:
|
||||
*
|
||||
* 1. **Only a staff-made registration qualifies**
|
||||
* ({@see IntakeSubject::isStaffRegistered()}). One the student made already
|
||||
* has their real answers, and letting staff add more would let the record be
|
||||
* edited after the fact.
|
||||
* 2. **Only what is still missing can be recorded.** Answers and acceptances are
|
||||
* written once and never overwritten, so a second submission cannot quietly
|
||||
* replace what a student actually said.
|
||||
*/
|
||||
class IntakeRecording {
|
||||
|
||||
public function __construct(
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private RegistrationGate $gate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* What is still unrecorded for this registration: the intake questions with no
|
||||
* answer, and the current policy versions with no acceptance. An empty pair
|
||||
* means there is nothing left to collect and the form has nothing to show.
|
||||
*
|
||||
* @return array{questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>}
|
||||
*/
|
||||
public function pending( IntakeSubject $subject ): array {
|
||||
$type = $subject->intakeRegistrationType();
|
||||
$registrationId = $subject->intakeRegistrationId();
|
||||
|
||||
$answered = array_map(
|
||||
static fn( Answer $a ): int => $a->questionId,
|
||||
$this->answers->findByRegistration( $type, $registrationId )
|
||||
);
|
||||
|
||||
$accepted = array_map(
|
||||
static fn( PolicyAcceptance $a ): int => $a->policyVersionId,
|
||||
$this->acceptances->findByRegistration( $type, $registrationId )
|
||||
);
|
||||
|
||||
$questions = [];
|
||||
foreach ( $this->questions->findByOffering( $subject->intakeOfferingId(), true ) as $question ) {
|
||||
if ( in_array( (int) $question->id, $answered, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$questions[] = [
|
||||
'id' => (int) $question->id,
|
||||
'label' => $question->label,
|
||||
'required' => $this->isRequiredOf( $question ),
|
||||
];
|
||||
}
|
||||
|
||||
$policies = [];
|
||||
foreach ( $this->gate->requiredPolicyVersionIds() as $versionId ) {
|
||||
if ( in_array( $versionId, $accepted, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->versions->findById( $versionId );
|
||||
$policy = null !== $version ? $this->policies->findById( $version->policyId ) : null;
|
||||
|
||||
$policies[] = [
|
||||
'version_id' => $versionId,
|
||||
'policy' => null !== $policy ? $policy->title : sprintf( '#%d', $versionId ),
|
||||
'version' => null !== $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'questions' => $questions,
|
||||
'policies' => $policies,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the studio collected elsewhere, returning the notice to show.
|
||||
*
|
||||
* Submitted answers and acceptances are narrowed to what is actually still
|
||||
* pending before anything is written, so a stale form — reloaded, or posted
|
||||
* twice — can neither duplicate a row nor overwrite one.
|
||||
*
|
||||
* @param array<int, string> $answers question_id => answer value
|
||||
* @param list<int> $versionIds Policy version ids being accepted
|
||||
*
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
public function record( IntakeSubject $subject, array $answers, array $versionIds, string $collectedVia, string $collectedNote, int $recordedBy ): string|\WP_Error {
|
||||
if ( ! $subject->isStaffRegistered() ) {
|
||||
return new \WP_Error(
|
||||
'not_recordable',
|
||||
__( 'Intake can only be recorded for a registration the studio made on the student\'s behalf.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
$provenance = IntakeProvenance::fromInput( $collectedVia, $collectedNote, $recordedBy );
|
||||
if ( $provenance instanceof \WP_Error ) {
|
||||
return $provenance;
|
||||
}
|
||||
|
||||
$pending = $this->pending( $subject );
|
||||
|
||||
$pendingQuestionIds = array_map( static fn( array $q ): int => $q['id'], $pending['questions'] );
|
||||
$pendingVersionIds = array_map( static fn( array $p ): int => $p['version_id'], $pending['policies'] );
|
||||
|
||||
$newAnswers = [];
|
||||
foreach ( $answers as $questionId => $value ) {
|
||||
$value = trim( $value );
|
||||
if ( '' !== $value && in_array( (int) $questionId, $pendingQuestionIds, true ) ) {
|
||||
$newAnswers[ (int) $questionId ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$newVersionIds = array_values( array_intersect( $versionIds, $pendingVersionIds ) );
|
||||
|
||||
if ( [] === $newAnswers && [] === $newVersionIds ) {
|
||||
return new \WP_Error( 'nothing_to_record', __( 'Nothing was filled in to record.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
// No IP address is passed: the student was not at a browser, and borrowing
|
||||
// the staff member's would put a false location in the audit trail. Nor is
|
||||
// an acceptor — the student agreed, on paper or over the phone; who typed it
|
||||
// in is `recorded_by`, which the provenance carries.
|
||||
$this->gate->record(
|
||||
$subject->intakeRegistrationType(),
|
||||
$subject->intakeRegistrationId(),
|
||||
$subject->intakeStudentId(),
|
||||
$subject->intakeOfferingId(),
|
||||
$newAnswers,
|
||||
$newVersionIds,
|
||||
null,
|
||||
0,
|
||||
$provenance
|
||||
);
|
||||
|
||||
return $this->notice( count( $newAnswers ), count( $newVersionIds ), $provenance );
|
||||
}
|
||||
|
||||
/** What was written, and how it was said to have been collected. */
|
||||
private function notice( int $answers, int $acceptances, IntakeProvenance $provenance ): string {
|
||||
$parts = [];
|
||||
|
||||
if ( $answers > 0 ) {
|
||||
/* translators: %d: number of intake answers recorded. */
|
||||
$parts[] = sprintf( _n( '%d answer', '%d answers', $answers, 'unsupervised-schedular' ), $answers );
|
||||
}
|
||||
|
||||
if ( $acceptances > 0 ) {
|
||||
/* translators: %d: number of policy acceptances recorded. */
|
||||
$parts[] = sprintf( _n( '%d policy acceptance', '%d policy acceptances', $acceptances, 'unsupervised-schedular' ), $acceptances );
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
/* translators: 1: what was recorded, e.g. "2 answers and 1 policy acceptance", 2: how they were collected. */
|
||||
__( 'Recorded %1$s, collected: %2$s', 'unsupervised-schedular' ),
|
||||
implode( __( ' and ', 'unsupervised-schedular' ), $parts ),
|
||||
IntakeProvenance::describe( $provenance->collectedVia, $provenance->collectedNote )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the question is one the booking form would have insisted on, of
|
||||
* either audience. It is shown as a hint only — a studio that has half the
|
||||
* answers should be able to record the half it has, rather than being made to
|
||||
* invent the rest to get the form to submit.
|
||||
*/
|
||||
private function isRequiredOf( Question $question ): bool {
|
||||
return $question->isRequired || $question->isRequiredChild;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
/**
|
||||
* A registration that intake answers and policy acceptances hang off: a booked
|
||||
* lesson, or a group-class enrolment.
|
||||
*
|
||||
* The two are different enough to keep their own tables and their own booking
|
||||
* flows, but identical in this one respect — somebody registered, questions were
|
||||
* (or were not) answered, policies were (or were not) agreed to — and the rules
|
||||
* for reading and recording that are not worth writing twice. Everything about
|
||||
* intake works against this interface rather than either model.
|
||||
*
|
||||
* The `intake` prefix is not decoration: both implementations already carry
|
||||
* `$studentId` and `$offeringId` properties, and PHP 8.1 has no way to declare a
|
||||
* property on an interface, so the accessors need names of their own.
|
||||
*/
|
||||
interface IntakeSubject {
|
||||
|
||||
/**
|
||||
* Which polymorphic registration table this is — `Answer::REG_LESSON` or
|
||||
* `Answer::REG_ENROLLMENT`, matching the acceptance constants of the same
|
||||
* names.
|
||||
*/
|
||||
public function intakeRegistrationType(): string;
|
||||
|
||||
/**
|
||||
* The id answers and acceptances are stored against. Not always the row's own
|
||||
* id: a weekly lesson series is answered for once, against its anchor, so
|
||||
* every occurrence reads and writes the same registration.
|
||||
*/
|
||||
public function intakeRegistrationId(): int;
|
||||
|
||||
/** The offering whose questions apply. */
|
||||
public function intakeOfferingId(): int;
|
||||
|
||||
/** Who the answers and acceptances belong to. */
|
||||
public function intakeStudentId(): int;
|
||||
|
||||
/**
|
||||
* Whether the studio registered this on the student's behalf, rather than the
|
||||
* student (or their guardian) doing it themselves. Only these can have their
|
||||
* intake recorded after the fact: one the student made already holds their own
|
||||
* answers, and adding to those would make the record editable after the event.
|
||||
*/
|
||||
public function isStaffRegistered(): bool;
|
||||
}
|
||||
@@ -62,10 +62,14 @@ class RegistrationGate {
|
||||
* guardian booking for a child. It defaults to 0, read back as "the student
|
||||
* agreed for themselves".
|
||||
*
|
||||
* `$provenance` marks answers the studio collected some other way and typed in
|
||||
* afterwards; null (the default) is the ordinary case of the student giving
|
||||
* them online. See {@see IntakeProvenance}.
|
||||
*
|
||||
* @param array<int, string> $answers question_id => answer value
|
||||
* @param list<int> $acceptedVersionIds Accepted policy version IDs
|
||||
*/
|
||||
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0 ): void {
|
||||
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0, ?IntakeProvenance $provenance = null ): void {
|
||||
foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) {
|
||||
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
|
||||
if ( '' === $value ) {
|
||||
@@ -79,6 +83,9 @@ class RegistrationGate {
|
||||
registrationId: $registrationId,
|
||||
studentId: $studentId,
|
||||
answerValue: $value,
|
||||
collectedVia: $provenance?->collectedVia,
|
||||
collectedNote: $provenance?->collectedNote,
|
||||
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -96,17 +103,22 @@ class RegistrationGate {
|
||||
registrationId: $registrationId,
|
||||
acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId,
|
||||
ipAddress: $ipAddress,
|
||||
collectedVia: $provenance?->collectedVia,
|
||||
collectedNote: $provenance?->collectedNote,
|
||||
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current published version IDs of every booking-scoped policy.
|
||||
* Current published version IDs of every booking-scoped policy — what a
|
||||
* booking must accept, and so also what a late, collected-elsewhere recording
|
||||
* has to offer.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function requiredPolicyVersionIds(): array {
|
||||
public function requiredPolicyVersionIds(): array {
|
||||
$ids = [];
|
||||
|
||||
foreach ( $this->policies->findForScope( Policy::SCOPE_BOOKING ) as $policy ) {
|
||||
|
||||
Reference in New Issue
Block a user