Files
unsupervised-scheduler/src/Registration/IntakeRecording.php
T
thatguygriffandClaude Opus 5 8c21a3fa9d
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
Let the studio book lessons and record intake collected elsewhere
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
2026-08-24 14:06:16 -03:00

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;
}
}