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
195 lines
7.3 KiB
PHP
195 lines
7.3 KiB
PHP
<?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;
|
|
}
|
|
}
|