$validator->setLang($testLang)); function appointmentTypeExpect(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } $payload = [ 'id' => 1, 'patient_id' => 101, 'doctor_id' => 202, 'appointment_date' => '2026-09-10', 'appointment_time' => '09:30', 'period' => 'morning', 'channel_source' => 'test', 'status' => 1, ]; foreach (['video' => '视频问诊', 'text' => '图文问诊'] as $type => $label) { foreach (['create', 'adminEdit'] as $scene) { $validator = (new AppointmentValidate())->scene($scene); appointmentTypeExpect($validator->check($payload + ['appointment_type' => $type]), "$scene accepts $type"); } appointmentTypeExpect(AppointmentTypeEnum::description($type) === $label, "$type label round trip"); appointmentTypeExpect(AppointmentTypeEnum::withDefault(['appointment_type' => $type])['appointment_type'] === $type, 'default does not override an explicit choice'); } $invalidValues = ['', ' ', 'phone', 'offline', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']]; foreach ($invalidValues as $value) { foreach (['create', 'adminEdit'] as $scene) { appointmentTypeExpect(!(new AppointmentValidate())->scene($scene)->check($payload + ['appointment_type' => $value]), "$scene rejects " . json_encode($value)); } // Invalid requests must be rejected before touching a database, even if called outside the controller. appointmentTypeExpect(AppointmentLogic::create(['appointment_type' => $value]) === false, 'create rejects invalid type before DB'); appointmentTypeExpect(AppointmentLogic::adminEdit(['appointment_type' => $value], 0, []) === false, 'edit rejects invalid type before DB'); } appointmentTypeExpect((new AppointmentValidate())->scene('create')->check($payload), 'legacy create request may omit type'); appointmentTypeExpect(AppointmentTypeEnum::withDefault([])['appointment_type'] === 'video', 'omitted create type persists as video'); appointmentTypeExpect(!(new AppointmentValidate())->scene('adminEdit')->check($payload), 'edit cannot silently reset an existing text selection'); appointmentTypeExpect(AppointmentLogic::adminEdit([], 0, []) === false, 'internal edit also requires explicit type'); $model = (new ReflectionClass(Appointment::class))->newInstanceWithoutConstructor(); foreach ([null, '', ' '] as $legacyEmpty) { appointmentTypeExpect($model->getAppointmentTypeAttr($legacyEmpty) === 'video', 'legacy empty model value uses video'); appointmentTypeExpect($model->getAppointmentTypeDescAttr(null, ['appointment_type' => $legacyEmpty]) === '视频问诊', 'legacy empty model label uses video'); } appointmentTypeExpect(AppointmentTypeEnum::description('phone') === '电话问诊', 'historical phone records retain accurate labels'); $filter = (new ReflectionClass(AppointmentLogic::class))->getMethod('filterAppointmentRowByExistingColumns'); appointmentTypeExpect($filter->invoke(null, ['appointment_type' => 'text'], ['appointment_type']) === ['appointment_type' => 'text'], 'text is retained in database write payload'); try { $filter->invoke(null, ['appointment_type' => 'text'], ['id']); throw new RuntimeException('missing appointment_type column must not silently lose the selected type'); } catch (RuntimeException $exception) { appointmentTypeExpect(str_contains($exception->getMessage(), '挂号表缺少 appointment_type'), 'missing schema yields an actionable error'); } $summary = (new ReflectionClass(DiagnosisLists::class))->getMethod('appendLatestAppointmentSummary'); $lists = (new ReflectionClass(DiagnosisLists::class))->newInstanceWithoutConstructor(); $row = []; $summary->invokeArgs($lists, [&$row, ['id' => 8, 'appointment_type' => 'text']]); appointmentTypeExpect($row['latest_appointment_id'] === 8 && $row['latest_appointment_type'] === 'text' && $row['latest_appointment_type_desc'] === '图文问诊', 'latest appointment summary keeps its own type'); $summary->invokeArgs($lists, [&$row, ['id' => 9, 'appointment_type' => null]]); appointmentTypeExpect($row['latest_appointment_type'] === 'video', 'next legacy appointment does not inherit previous text type'); $myPatientReflection = new ReflectionClass(MyPatientLists::class); $myPatientLists = $myPatientReflection->newInstanceWithoutConstructor(); $pickPrimary = $myPatientReflection->getMethod('pickPrimaryAppointment'); $primarySummary = $myPatientReflection->getMethod('appendPrimaryAppointmentSummary'); $appointments = [ ['id' => 101, 'doctor_id' => 201, 'appointment_date' => '2026-09-09', 'appointment_time' => '08:30:00', 'status' => 3, 'appointment_type' => 'video'], ['id' => 102, 'doctor_id' => 202, 'appointment_date' => '2026-09-10', 'appointment_time' => '09:30:00', 'status' => 1, 'appointment_type' => 'text'], ['id' => 103, 'doctor_id' => 203, 'appointment_date' => '2026-09-11', 'appointment_time' => '10:30:00', 'status' => 1, 'appointment_type' => 'video'], ]; $adminNames = [201 => '医生甲', 202 => '医生乙', 203 => '医生丙']; $row = []; $primary = $pickPrimary->invoke($myPatientLists, $appointments, '', '', '2026-09-10'); $primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]); appointmentTypeExpect( $row['appointment_id'] === 102 && $row['appointment_doctor_id'] === 202 && $row['appointment_time_text'] === '2026-09-10 09:30' && $row['appointment_type'] === 'text' && $row['appointment_type_desc'] === '图文问诊', 'my patient row uses the upcoming primary appointment type instead of the latest appointment type' ); $primary = $pickPrimary->invoke($myPatientLists, $appointments, '2026-09-11', '2026-09-11', '2026-09-10'); $primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]); appointmentTypeExpect($row['appointment_id'] === 103 && $row['appointment_type'] === 'video', 'date filter updates both the primary appointment and its type'); $primary = $pickPrimary->invoke($myPatientLists, $appointments, '', '', '2026-09-10', [3]); $primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]); appointmentTypeExpect($row['appointment_id'] === 101 && $row['appointment_type'] === 'video', 'status filter updates both the primary appointment and its type'); foreach ([null, '', ' ', 'phone', 'unknown'] as $storedType) { $primary = $appointments[1]; $primary['appointment_type'] = $storedType; $primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]); appointmentTypeExpect($row['appointment_type'] === AppointmentTypeEnum::normalizeStored($storedType), 'my patient row normalizes only legacy empty types'); appointmentTypeExpect($row['appointment_type_desc'] === AppointmentTypeEnum::description($storedType), 'my patient row preserves historical and unknown labels'); } $primary = $pickPrimary->invoke($myPatientLists, [], '', '', '2026-09-10'); $primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]); appointmentTypeExpect( $row['has_appointment'] === 0 && $row['appointment_id'] === 0 && $row['appointment_type'] === null && $row['appointment_type_desc'] === '', 'a patient without an appointment does not inherit a previous type or gain a default video appointment' ); $progressReflection = new ReflectionClass(MyPatientProgressLists::class); $progressLists = $progressReflection->newInstanceWithoutConstructor(); $progressTypeText = $progressReflection->getMethod('appointmentTypeText'); foreach (['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊', '' => '视频问诊', ' ' => '视频问诊', 'unknown' => '未知'] as $type => $label) { appointmentTypeExpect($progressTypeText->invoke($progressLists, $type) === $label, 'progress labels agree with stored appointment types'); } echo "Appointment type validation, defaults, legacy labels and primary summaries: OK\n";