--student-id --form-id [--dry-run] [--overwrite] [--complete]'; protected $arguments = [ 'file' => 'Path to the DOCX containing the seeded questions and their answers.', ]; protected $options = [ '--student-id' => 'Required database ID of the student.', '--form-id' => 'Required assessment form ID.', '--dry-run' => 'Parse and validate without changing the database.', '--overwrite' => 'Allow replacement of existing non-empty answers.', '--complete' => 'Mark the assessment completed; requires all form questions to have answers.', ]; public function run(array $params) { $path = trim((string) ($params[0] ?? '')); $studentId = $this->positiveOption('student-id'); $formId = $this->positiveOption('form-id'); $dryRun = CLI::getOption('dry-run') !== null; $overwrite = CLI::getOption('overwrite') !== null; $complete = CLI::getOption('complete') !== null; if ($path === '' || $studentId === null || $formId === null) { CLI::error('File, --student-id, and --form-id are required.'); CLI::write($this->usage); return EXIT_ERROR; } try { $realPath = realpath($path); if ($realPath === false) { throw new \RuntimeException("DOCX file does not exist: {$path}"); } $db = Database::connect(); $this->assertTables($db); $student = $db->table('students')->select('id, school_id, firstname, lastname')->where('id', $studentId)->get()->getRowArray(); if ($student === null) { throw new \RuntimeException("Student {$studentId} was not found."); } $form = $db->table('assessment_forms f') ->select('f.id, f.name, f.pool_id, f.status, f.school_year, p.name AS pool_name') ->join('question_pools p', 'p.id = f.pool_id') ->where('f.id', $formId)->get()->getRowArray(); if ($form === null) { throw new \RuntimeException("Assessment form {$formId} was not found."); } if ((string) $form['pool_name'] !== 'New Student Assessment') { throw new \RuntimeException('The selected form does not belong to the New Student Assessment pool.'); } $questions = $db->table('assessment_form_questions fq') ->select('q.id, q.text, fq.order_index') ->join('assessment_questions q', 'q.id = fq.question_id') ->where('fq.form_id', $formId) ->orderBy('fq.order_index', 'ASC')->get()->getResultArray(); if ($questions === []) { throw new \RuntimeException('The selected assessment form has no questions.'); } $parsed = (new AssessmentDocxReader())->read($realPath, $questions); CLI::write(sprintf( 'Student: #%d %s %s (%s)', $studentId, (string) $student['firstname'], (string) $student['lastname'], (string) ($student['school_id'] ?? 'no school ID') )); CLI::write(sprintf('Form: #%d %s [%s]', $formId, (string) $form['name'], (string) ($form['school_year'] ?? ''))); CLI::write(sprintf('Parsed %d document blocks; matched %d of %d questions.', $parsed['block_count'], count($parsed['answers']), count($questions))); foreach ($questions as $question) { $questionId = (int) $question['id']; $length = mb_strlen($parsed['answers'][$questionId] ?? ''); CLI::write(sprintf(' [%s] question_id=%d answer_chars=%d', $length > 0 ? 'matched' : 'missing', $questionId, $length)); } if ($parsed['answers'] === []) { throw new \RuntimeException('No answers were matched. Check that the DOCX contains the exact seeded question text.'); } if ($complete && $parsed['missing'] !== []) { throw new \RuntimeException('--complete cannot be used while one or more form questions are missing answers.'); } $assessment = $db->table('student_assessments')->where(['student_id' => $studentId, 'form_id' => $formId])->get()->getRowArray(); $existingAnswers = []; if ($assessment !== null) { foreach ($db->table('student_answers')->where('student_assessment_id', (int) $assessment['id'])->get()->getResultArray() as $row) { $existingAnswers[(int) $row['question_id']] = $row; } } $conflicts = []; foreach ($parsed['answers'] as $questionId => $answer) { $old = trim((string) ($existingAnswers[$questionId]['answer_value'] ?? '')); if ($old !== '' && $old !== $answer) { $conflicts[] = $questionId; } } if ($conflicts !== [] && $dryRun) { CLI::write('Dry run found ' . count($conflicts) . ' existing answer(s) that differ; an actual import will require --overwrite.', 'yellow'); } elseif ($conflicts !== [] && ! $overwrite) { throw new \RuntimeException('Existing answers differ for question ID(s) ' . implode(', ', $conflicts) . '; rerun with --overwrite after reviewing the dry run.'); } if ($dryRun) { CLI::write('Dry run complete; no database rows were changed.', 'yellow'); return EXIT_SUCCESS; } $now = date('Y-m-d H:i:s'); $db->transBegin(); try { if ($assessment === null) { $db->table('student_assessments')->insert([ 'form_id' => $formId, 'student_id' => $studentId, 'status' => $complete ? 'completed' : 'in_progress', 'assigned_at' => $now, 'started_at' => $now, 'submitted_at' => $complete ? $now : null, 'created_at' => $now, 'updated_at' => $now, ]); $assessmentId = (int) $db->insertID(); } else { $assessmentId = (int) $assessment['id']; } foreach ($parsed['answers'] as $questionId => $answer) { $payload = ['answer_value' => $answer, 'updated_at' => $now]; if (isset($existingAnswers[$questionId])) { if (trim((string) $existingAnswers[$questionId]['answer_value']) !== $answer) { $db->table('student_answers')->where('id', (int) $existingAnswers[$questionId]['id'])->update($payload); } } else { $db->table('student_answers')->insert($payload + [ 'student_assessment_id' => $assessmentId, 'question_id' => $questionId, 'created_at' => $now, ]); } } if ($assessment !== null) { $statusUpdate = ['updated_at' => $now]; if ($complete && (string) $assessment['status'] !== 'graded') { $statusUpdate += ['status' => 'completed', 'submitted_at' => $now]; } elseif ((string) $assessment['status'] === 'not_started') { $statusUpdate += ['status' => 'in_progress', 'started_at' => $now]; } $db->table('student_assessments')->where('id', $assessmentId)->update($statusUpdate); } if ($db->transStatus() === false) { throw new \RuntimeException('The database rejected one or more imported rows.'); } $db->transCommit(); } catch (Throwable $e) { $db->transRollback(); throw $e; } CLI::write(sprintf('Imported %d answer(s) into student assessment #%d.', count($parsed['answers']), $assessmentId), 'green'); if ($parsed['missing'] !== []) { CLI::write(count($parsed['missing']) . ' unanswered question(s) were left unchanged.', 'yellow'); } return EXIT_SUCCESS; } catch (Throwable $e) { CLI::error($e->getMessage()); return EXIT_ERROR; } } private function positiveOption(string $name): ?int { $rawValue = CLI::getOption($name); // CodeIgniter 4.7 does not split --option=value. Support that common // spelling as well as its native --option value form. if ($rawValue === null || $rawValue === true) { $prefix = '--' . $name . '='; foreach ($_SERVER['argv'] ?? [] as $argument) { if (is_string($argument) && str_starts_with($argument, $prefix)) { $rawValue = substr($argument, strlen($prefix)); break; } } } $value = filter_var($rawValue, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); return $value === false ? null : (int) $value; } private function assertTables($db): void { foreach (['students', 'question_pools', 'assessment_questions', 'assessment_forms', 'assessment_form_questions', 'student_assessments', 'student_answers'] as $table) { if (! $db->tableExists($table)) { throw new \RuntimeException("Required table {$table} does not exist; run the migrations first."); } } } }