implemented scoring system
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-08 19:10:23 +02:00
parent 4589be119c
commit 63adef79df
19 changed files with 2873 additions and 36 deletions

View File

@ -101,9 +101,77 @@ function qdb_analytics_overview(PDO $pdo, array $tokenRec): array {
'submissionsLast30d' => $submissions30d,
'questionnaires' => $perQuestionnaire,
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
'scoringProfiles' => qdb_analytics_scoring_profiles($pdo, $tokenRec),
];
}
/**
* Band distribution and averages per active scoring profile (RBAC-scoped).
*
* @return list<array<string, mixed>>
*/
function qdb_analytics_scoring_profiles(PDO $pdo, array $tokenRec): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$profiles = $pdo->query(
"SELECT profileID, name, description,
greenMin, greenMax, yellowMin, yellowMax, redMin, isActive
FROM scoring_profile WHERE isActive = 1 ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($profiles as $p) {
$profileID = $p['profileID'];
$stmt = $pdo->prepare(
"SELECT r.band, r.coachBand, r.weightedTotal
FROM client_scoring_profile_result r
INNER JOIN client cl ON cl.clientCode = r.clientCode
WHERE r.profileID = :pid AND ($rbacClause)"
);
$stmt->execute(array_merge([':pid' => $profileID], $rbacParams));
$computedBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
$coachBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
$pendingReview = 0;
$totalSum = 0.0;
$count = 0;
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$calcBand = $row['band'] ?? '';
if (isset($computedBands[$calcBand])) {
$computedBands[$calcBand]++;
}
$coachBand = trim((string)($row['coachBand'] ?? ''));
if ($coachBand === '') {
$pendingReview++;
} elseif (isset($coachBands[$coachBand])) {
$coachBands[$coachBand]++;
}
$totalSum += (float)$row['weightedTotal'];
$count++;
}
$members = qdb_scoring_profile_members($pdo, $profileID);
$bandRanges = qdb_normalize_scoring_bands($p);
$out[] = [
'profileID' => $profileID,
'name' => $p['name'],
'description' => $p['description'] ?? '',
'greenMin' => $bandRanges['greenMin'],
'greenMax' => $bandRanges['greenMax'],
'yellowMin' => $bandRanges['yellowMin'],
'yellowMax' => $bandRanges['yellowMax'],
'redMin' => $bandRanges['redMin'],
'questionnaires' => $members,
'clientCount' => $count,
'bands' => $computedBands,
'computedBands' => $computedBands,
'coachBands' => $coachBands,
'pendingReview' => $pendingReview,
'averageTotal' => $count > 0 ? round($totalSum / $count, 2) : null,
];
}
return $out;
}
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$now = time();

757
lib/scoring.php Normal file
View File

@ -0,0 +1,757 @@
<?php
/**
* Questionnaire scoring engine and scoring-profile aggregation.
*/
/** Default glass-scale level keys and implicit points (04). */
function qdb_glass_scale_level_keys(): array {
return ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
}
function qdb_default_glass_level_points(): array {
return [
'never_glass' => 0,
'little_glass' => 1,
'moderate_glass' => 2,
'much_glass' => 3,
'extreme_glass' => 4,
];
}
function qdb_make_score_rule_id(string $questionID, string $scopeKey, string $levelKey): string {
return substr(hash('sha256', $questionID . "\0" . $scopeKey . "\0" . $levelKey), 0, 32);
}
/**
* @return list<array{scopeKey: string, levelKey: string, points: int}>
*/
function qdb_get_score_rules_for_question(PDO $pdo, string $questionID): array {
$stmt = $pdo->prepare(
'SELECT scopeKey, levelKey, points FROM question_score_rule WHERE questionID = :qid ORDER BY scopeKey, levelKey'
);
$stmt->execute([':qid' => $questionID]);
$rows = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$rows[] = [
'scopeKey' => (string)($r['scopeKey'] ?? ''),
'levelKey' => (string)($r['levelKey'] ?? ''),
'points' => (int)($r['points'] ?? 0),
];
}
return $rows;
}
/**
* @param list<array{scopeKey?: string, levelKey: string, points: int}> $rules
*/
function qdb_sync_question_score_rules(PDO $pdo, string $questionID, array $rules): void {
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :qid')
->execute([':qid' => $questionID]);
if ($rules === []) {
return;
}
$ins = $pdo->prepare(
'INSERT INTO question_score_rule (ruleID, questionID, scopeKey, levelKey, points)
VALUES (:rid, :qid, :sk, :lk, :pts)'
);
foreach ($rules as $rule) {
$scopeKey = trim((string)($rule['scopeKey'] ?? ''));
$levelKey = trim((string)($rule['levelKey'] ?? ''));
if ($levelKey === '') {
continue;
}
$ins->execute([
':rid' => qdb_make_score_rule_id($questionID, $scopeKey, $levelKey),
':qid' => $questionID,
':sk' => $scopeKey,
':lk' => $levelKey,
':pts' => (int)($rule['points'] ?? 0),
]);
}
}
/**
* Build nested map: scopeKey -> levelKey -> points.
*
* @return array<string, array<string, int>>
*/
function qdb_score_rule_map_for_question(PDO $pdo, string $questionID): array {
$map = [];
foreach (qdb_get_score_rules_for_question($pdo, $questionID) as $rule) {
$map[$rule['scopeKey']][$rule['levelKey']] = $rule['points'];
}
return $map;
}
/**
* Attach scoreRules to glass symptom rows for editor API.
*
* @return list<array{key: string, labelDe: string, scoreLevels: array<string, int>}>
*/
function qdb_glass_symptoms_with_score_rules(PDO $pdo, string $questionID, array $config): array {
$ruleMap = $questionID !== '' ? qdb_score_rule_map_for_question($pdo, $questionID) : [];
$defaults = qdb_default_glass_level_points();
$rows = [];
foreach (qdb_glass_symptoms_with_labels($pdo, $config) as $row) {
$key = $row['key'];
$levels = [];
foreach (qdb_glass_scale_level_keys() as $levelKey) {
$levels[$levelKey] = $ruleMap[$key][$levelKey]
?? $ruleMap[''][$levelKey]
?? $defaults[$levelKey]
?? 0;
}
$rows[] = [
'key' => $key,
'labelDe' => $row['labelDe'],
'scoreLevels' => $levels,
];
}
return $rows;
}
/**
* Parse scoreRules from question save body.
*
* @return list<array{scopeKey: string, levelKey: string, points: int}>
*/
function qdb_parse_score_rules_body(array $body, string $questionType, array $config): array {
if (!isset($body['scoreRules']) || !is_array($body['scoreRules'])) {
if ($questionType === 'glass_scale_question' && isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) {
return qdb_score_rules_from_glass_symptoms($body['glassSymptoms']);
}
return [];
}
$rules = [];
foreach ($body['scoreRules'] as $row) {
if (!is_array($row)) {
continue;
}
$levelKey = trim((string)($row['levelKey'] ?? ''));
if ($levelKey === '') {
continue;
}
$rules[] = [
'scopeKey' => trim((string)($row['scopeKey'] ?? '')),
'levelKey' => $levelKey,
'points' => (int)($row['points'] ?? 0),
];
}
return $rules;
}
/**
* @param list<array{key?: string, scoreLevels?: array<string, int>}> $glassSymptoms
* @return list<array{scopeKey: string, levelKey: string, points: int}>
*/
function qdb_score_rules_from_glass_symptoms(array $glassSymptoms): array {
$rules = [];
$defaults = qdb_default_glass_level_points();
foreach ($glassSymptoms as $row) {
if (!is_array($row)) {
continue;
}
$scopeKey = trim((string)($row['key'] ?? ''));
if ($scopeKey === '') {
continue;
}
$levels = is_array($row['scoreLevels'] ?? null) ? $row['scoreLevels'] : $defaults;
foreach (qdb_glass_scale_level_keys() as $levelKey) {
$rules[] = [
'scopeKey' => $scopeKey,
'levelKey' => $levelKey,
'points' => (int)($levels[$levelKey] ?? $defaults[$levelKey] ?? 0),
];
}
}
return $rules;
}
function qdb_normalize_scoring_bands(array $row): array {
$greenMax = (int)($row['greenMax'] ?? 12);
$yellowMax = (int)($row['yellowMax'] ?? 36);
return [
'greenMin' => (int)($row['greenMin'] ?? 0),
'greenMax' => $greenMax,
'yellowMin' => (int)($row['yellowMin'] ?? ($greenMax + 1)),
'yellowMax' => $yellowMax,
'redMin' => (int)($row['redMin'] ?? ($yellowMax + 1)),
];
}
/**
* @return string|null Error message, or null if valid.
*/
function qdb_validate_scoring_bands(array $bands): ?string {
if ($bands['greenMin'] > $bands['greenMax']) {
return 'Green min must not exceed green max';
}
if ($bands['yellowMin'] > $bands['yellowMax']) {
return 'Yellow min must not exceed yellow max';
}
if ($bands['greenMax'] >= $bands['yellowMin']) {
return 'Green range must end before yellow range starts';
}
if ($bands['yellowMax'] >= $bands['redMin']) {
return 'Yellow range must end before red range starts';
}
return null;
}
/**
* @param array<string, int>|int $bandsOrGreenMax Band map or legacy greenMax
*/
function qdb_band_for_total(float $total, array|int $bandsOrGreenMax, ?int $yellowMax = null): string {
$bands = is_array($bandsOrGreenMax)
? qdb_normalize_scoring_bands($bandsOrGreenMax)
: qdb_normalize_scoring_bands(['greenMax' => $bandsOrGreenMax, 'yellowMax' => $yellowMax ?? 36]);
if ($total >= $bands['greenMin'] && $total <= $bands['greenMax']) {
return 'green';
}
if ($total >= $bands['yellowMin'] && $total <= $bands['yellowMax']) {
return 'yellow';
}
if ($total >= $bands['redMin']) {
return 'red';
}
if ($total < $bands['greenMin']) {
return 'green';
}
if ($total < $bands['yellowMin']) {
return 'yellow';
}
return 'red';
}
function qdb_parse_scoring_bands_body(array $body): array {
$greenMax = (int)($body['greenMax'] ?? 12);
$yellowMax = (int)($body['yellowMax'] ?? 36);
return qdb_normalize_scoring_bands([
'greenMin' => (int)($body['greenMin'] ?? 0),
'greenMax' => $greenMax,
'yellowMin' => (int)($body['yellowMin'] ?? ($greenMax + 1)),
'yellowMax' => $yellowMax,
'redMin' => (int)($body['redMin'] ?? ($yellowMax + 1)),
]);
}
function qdb_scoring_bands_to_api(array $row): array {
$bands = qdb_normalize_scoring_bands($row);
return $bands;
}
function qdb_compute_questionnaire_score(PDO $pdo, string $clientCode, string $questionnaireID): int {
$qStmt = $pdo->prepare(
'SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
);
$qStmt->execute([':qn' => $questionnaireID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$aoStmt = $pdo->prepare(
'SELECT ao.questionID, ao.defaultText, ao.points
FROM answer_option ao
JOIN question q ON q.questionID = ao.questionID
WHERE q.questionnaireID = :qn'
);
$aoStmt->execute([':qn' => $questionnaireID]);
$optionPoints = [];
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optionPoints[$ao['questionID']][$ao['defaultText']] = (int)$ao['points'];
}
$aStmt = $pdo->prepare(
'SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
ao.defaultText AS optionKey
FROM client_answer ca
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
JOIN question q ON q.questionID = ca.questionID
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn'
);
$aStmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
$answersByQuestion = [];
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
$answersByQuestion[$a['questionID']] = $a;
}
$total = 0;
foreach ($questions as $qRow) {
$total += qdb_score_points_for_question(
$pdo,
$qRow,
$answersByQuestion[$qRow['questionID']] ?? null,
$optionPoints[$qRow['questionID']] ?? []
);
}
return $total;
}
function qdb_score_points_for_question(
PDO $pdo,
array $questionRow,
?array $answerRow,
array $optionPointsMap,
): int {
if ($answerRow === null) {
return 0;
}
$type = (string)($questionRow['type'] ?? '');
$questionID = (string)($questionRow['questionID'] ?? '');
$config = json_decode($questionRow['configJson'] ?? '{}', true) ?: [];
$ruleMap = qdb_score_rule_map_for_question($pdo, $questionID);
switch ($type) {
case 'radio_question':
case 'string_spinner':
$key = trim((string)($answerRow['optionKey'] ?? $answerRow['freeTextValue'] ?? ''));
return $key !== '' ? (int)($optionPointsMap[$key] ?? 0) : 0;
case 'multi_check_box_question':
$raw = trim((string)($answerRow['freeTextValue'] ?? ''));
if ($raw === '') {
return 0;
}
$sum = 0;
foreach (qdb_decode_multi_check_values($raw) as $mk) {
$sum += (int)($optionPointsMap[$mk] ?? 0);
}
return $sum;
case 'glass_scale_question':
$json = trim((string)($answerRow['freeTextValue'] ?? ''));
if ($json === '') {
return 0;
}
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return 0;
}
$defaults = qdb_default_glass_level_points();
$sum = 0;
foreach ($decoded as $symptomKey => $levelKey) {
$sk = trim((string)$symptomKey);
$lk = trim((string)$levelKey);
if ($sk === '' || $lk === '') {
continue;
}
$sum += (int)($ruleMap[$sk][$lk]
?? $ruleMap[''][$lk]
?? $defaults[$lk]
?? 0);
}
return $sum;
case 'slider_question':
case 'value_spinner':
$value = $answerRow['numericValue'];
if ($value === null || $value === '') {
$value = $answerRow['freeTextValue'];
}
if ($value === null || $value === '') {
return 0;
}
$levelKey = is_numeric($value)
? (string)(int)round((float)$value)
: trim((string)$value);
return (int)($ruleMap[''][$levelKey] ?? 0);
default:
return 0;
}
}
function qdb_recompute_profile_scores_for_client(PDO $pdo, string $clientCode, ?string $triggerQuestionnaireID = null): void {
$profileSql = 'SELECT profileID, name, greenMin, greenMax, yellowMin, yellowMax, redMin FROM scoring_profile WHERE isActive = 1';
$params = [];
if ($triggerQuestionnaireID !== null && $triggerQuestionnaireID !== '') {
$profileSql = '
SELECT sp.profileID, sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
FROM scoring_profile sp
JOIN scoring_profile_questionnaire spq ON spq.profileID = sp.profileID
WHERE sp.isActive = 1 AND spq.questionnaireID = :qn';
$params[':qn'] = $triggerQuestionnaireID;
}
$pStmt = $pdo->prepare($profileSql);
$pStmt->execute($params);
$profiles = $pStmt->fetchAll(PDO::FETCH_ASSOC);
$deleteOrphan = $pdo->prepare(
'DELETE FROM client_scoring_profile_result WHERE clientCode = :cc AND profileID = :pid'
);
$upsert = $pdo->prepare(
'INSERT INTO client_scoring_profile_result
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot)
VALUES (:cc, :pid, :wt, :band, :at, :snap)
ON CONFLICT(clientCode, profileID) DO UPDATE SET
weightedTotal = excluded.weightedTotal,
band = excluded.band,
computedAt = excluded.computedAt,
questionnaireSnapshot = excluded.questionnaireSnapshot'
);
foreach ($profiles as $profile) {
$profileID = $profile['profileID'];
$members = qdb_scoring_profile_members($pdo, $profileID);
if ($members === []) {
continue;
}
$snapshot = [];
$weightedTotal = 0.0;
$allComplete = true;
foreach ($members as $member) {
$qnID = $member['questionnaireID'];
$weight = (float)$member['weight'];
$cq = $pdo->prepare(
"SELECT status, sumPoints FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qn"
);
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
$row = $cq->fetch(PDO::FETCH_ASSOC);
if (!$row || ($row['status'] ?? '') !== 'completed') {
$allComplete = false;
break;
}
$sumPoints = (int)($row['sumPoints'] ?? 0);
$contribution = $sumPoints * $weight;
$weightedTotal += $contribution;
$snapshot[$qnID] = [
'sumPoints' => $sumPoints,
'weight' => $weight,
'contribution' => round($contribution, 4),
];
}
if (!$allComplete) {
$deleteOrphan->execute([':cc' => $clientCode, ':pid' => $profileID]);
continue;
}
$band = qdb_band_for_total($weightedTotal, qdb_normalize_scoring_bands($profile));
$upsert->execute([
':cc' => $clientCode,
':pid' => $profileID,
':wt' => round($weightedTotal, 4),
':band' => $band,
':at' => time(),
':snap' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
]);
}
}
/**
* @return list<array{questionnaireID: string, weight: float, orderIndex: int, name?: string}>
*/
function qdb_scoring_profile_members(PDO $pdo, string $profileID): array {
$stmt = $pdo->prepare(
'SELECT spq.questionnaireID, spq.weight, spq.orderIndex, q.name
FROM scoring_profile_questionnaire spq
JOIN questionnaire q ON q.questionnaireID = spq.questionnaireID
WHERE spq.profileID = :pid
ORDER BY spq.orderIndex, spq.questionnaireID'
);
$stmt->execute([':pid' => $profileID]);
$rows = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$rows[] = [
'questionnaireID' => $r['questionnaireID'],
'weight' => (float)$r['weight'],
'orderIndex' => (int)$r['orderIndex'],
'name' => $r['name'] ?? '',
];
}
return $rows;
}
/**
* @return list<array<string, mixed>>
*/
function qdb_list_scoring_profiles(PDO $pdo): array {
$stmt = $pdo->query(
'SELECT profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin,
createdAt, updatedAt
FROM scoring_profile ORDER BY name'
);
$profiles = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$row['isActive'] = (int)$row['isActive'];
$bands = qdb_normalize_scoring_bands($row);
$row = array_merge($row, $bands);
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']);
$profiles[] = $row;
}
return $profiles;
}
function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array {
$stmt = $pdo->prepare(
'SELECT profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin,
createdAt, updatedAt
FROM scoring_profile WHERE profileID = :pid'
);
$stmt->execute([':pid' => $profileID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$row['isActive'] = (int)$row['isActive'];
$row = array_merge($row, qdb_normalize_scoring_bands($row));
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID);
return $row;
}
/**
* Backfill default glass score rules (04) for all glass questions missing rules.
*/
function qdb_backfill_glass_score_rules(PDO $pdo): int {
$stmt = $pdo->query("SELECT questionID, configJson FROM question WHERE type = 'glass_scale_question'");
$count = 0;
$defaults = qdb_default_glass_level_points();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$existing = qdb_get_score_rules_for_question($pdo, $row['questionID']);
if ($existing !== []) {
continue;
}
$config = json_decode($row['configJson'] ?? '{}', true) ?: [];
$rules = [];
foreach ($config['symptoms'] ?? [] as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
foreach ($defaults as $levelKey => $pts) {
$rules[] = ['scopeKey' => $sk, 'levelKey' => $levelKey, 'points' => $pts];
}
}
if ($rules !== []) {
qdb_sync_question_score_rules($pdo, $row['questionID'], $rules);
$count++;
}
}
return $count;
}
function qdb_recompute_all_questionnaire_scores(PDO $pdo): int {
$stmt = $pdo->query(
"SELECT clientCode, questionnaireID FROM completed_questionnaire WHERE status = 'completed'"
);
$n = 0;
$upd = $pdo->prepare(
'UPDATE completed_questionnaire SET sumPoints = :sp WHERE clientCode = :cc AND questionnaireID = :qn'
);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$score = qdb_compute_questionnaire_score($pdo, $row['clientCode'], $row['questionnaireID']);
$upd->execute([
':sp' => $score,
':cc' => $row['clientCode'],
':qn' => $row['questionnaireID'],
]);
qdb_recompute_profile_scores_for_client($pdo, $row['clientCode']);
$n++;
}
return $n;
}
function qdb_seed_default_rhs_scoring_profile(PDO $pdo): ?string {
foreach (qdb_list_scoring_profiles($pdo) as $p) {
if ($p['name'] === 'RHS Index') {
return $p['profileID'];
}
}
$rhsId = 'questionnaire_2_rhs';
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
$chk->execute([':id' => $rhsId]);
if (!$chk->fetch()) {
return null;
}
$profileID = bin2hex(random_bytes(16));
$now = time();
$pdo->prepare(
'INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :ca, :ua)'
)->execute([
':id' => $profileID,
':name' => 'RHS Index',
':desc' => 'Legacy RHS thresholds (green 012, yellow 1336, red 37+)',
':ca' => $now,
':ua' => $now,
]);
$pdo->prepare(
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES (:pid, :qn, 1.0, 0)'
)->execute([':pid' => $profileID, ':qn' => $rhsId]);
return $profileID;
}
function qdb_is_valid_scoring_band(?string $band): bool {
return in_array($band, ['green', 'yellow', 'red'], true);
}
/**
* Coach decision when set; otherwise the server-computed band.
*/
function qdb_effective_scoring_band(array $row): string {
$coach = trim((string)($row['coachBand'] ?? ''));
if ($coach !== '' && qdb_is_valid_scoring_band($coach)) {
return $coach;
}
return (string)($row['band'] ?? '');
}
function qdb_scoring_review_row_to_api(array $row, bool $includeComputed = true): array {
$coachBand = trim((string)($row['coachBand'] ?? ''));
$out = [
'profileID' => (string)($row['profileID'] ?? ''),
'name' => (string)($row['name'] ?? ''),
'coachBand' => $coachBand !== '' ? $coachBand : null,
'pendingReview' => $coachBand === '',
];
if ($includeComputed) {
$out['weightedTotal'] = (float)($row['weightedTotal'] ?? 0);
$out['calculatedBand'] = (string)($row['band'] ?? '');
$out['computedAt'] = !empty($row['computedAt']) ? date('Y-m-d H:i', (int)$row['computedAt']) : '';
}
return $out;
}
/**
* @param list<string> $clientCodes
* @return list<array{clientCode: string, profiles: list<array<string, mixed>>}>
*/
function qdb_app_scoring_review_for_clients(PDO $pdo, array $clientCodes): array {
if ($clientCodes === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
$stmt = $pdo->prepare(
"SELECT r.clientCode, r.profileID, r.coachBand, sp.name
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
WHERE r.clientCode IN ($placeholders)
ORDER BY r.clientCode, sp.name"
);
$stmt->execute(array_values($clientCodes));
$byClient = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cc = (string)$row['clientCode'];
$byClient[$cc][] = qdb_scoring_review_row_to_api($row, false);
}
$out = [];
foreach ($clientCodes as $code) {
$out[] = [
'clientCode' => $code,
'profiles' => $byClient[$code] ?? [],
];
}
return $out;
}
/**
* @return array<string, mixed>
*/
/**
* Store coach band; create a result row from app-local computed values when none exists yet.
*
* @return array<string, mixed>
*/
function qdb_save_coach_scoring_review(
PDO $pdo,
string $clientCode,
string $profileID,
string $coachBand,
string $userID,
?float $weightedTotal = null,
?string $calculatedBand = null,
): array {
if (!qdb_is_valid_scoring_band($coachBand)) {
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
}
$stmt = $pdo->prepare(
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc AND r.profileID = :pid'
);
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
if ($weightedTotal === null || $calculatedBand === null || !qdb_is_valid_scoring_band($calculatedBand)) {
throw new RuntimeException('Scoring profile result not found; calculatedBand and weightedTotal required');
}
$profile = qdb_get_scoring_profile($pdo, $profileID);
if (!$profile || (int)($profile['isActive'] ?? 0) !== 1) {
throw new RuntimeException('Scoring profile not found');
}
$now = time();
$pdo->prepare(
'INSERT INTO client_scoring_profile_result
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot,
coachBand, coachReviewedAt, coachReviewedByUserID)
VALUES (:cc, :pid, :wt, :band, :at, :snap, :cb, :rat, :uid)'
)->execute([
':cc' => $clientCode,
':pid' => $profileID,
':wt' => round($weightedTotal, 4),
':band' => $calculatedBand,
':at' => $now,
':snap' => '{}',
':cb' => $coachBand,
':rat' => $now,
':uid' => $userID,
]);
return qdb_scoring_review_row_to_api([
'profileID' => $profileID,
'name' => $profile['name'],
'weightedTotal' => $weightedTotal,
'band' => $calculatedBand,
'coachBand' => $coachBand,
'computedAt' => $now,
]);
}
return qdb_set_coach_scoring_band($pdo, $clientCode, $profileID, $coachBand, $userID);
}
function qdb_set_coach_scoring_band(
PDO $pdo,
string $clientCode,
string $profileID,
string $coachBand,
string $userID
): array {
if (!qdb_is_valid_scoring_band($coachBand)) {
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
}
$stmt = $pdo->prepare(
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc AND r.profileID = :pid'
);
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new RuntimeException('Scoring profile result not found or profile incomplete');
}
$now = time();
$pdo->prepare(
'UPDATE client_scoring_profile_result
SET coachBand = :cb, coachReviewedAt = :at, coachReviewedByUserID = :uid
WHERE clientCode = :cc AND profileID = :pid'
)->execute([
':cb' => $coachBand,
':at' => $now,
':uid' => $userID,
':cc' => $clientCode,
':pid' => $profileID,
]);
$row['coachBand'] = $coachBand;
$row['profileID'] = $profileID;
return qdb_scoring_review_row_to_api($row);
}

View File

@ -732,5 +732,116 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
'supervisorUsername' => $client['supervisorUsername'] ?? '',
],
'questionnaires' => $questionnaires,
'scoringProfiles' => qdb_client_scoring_profile_results($pdo, $clientCode),
];
}
function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array {
require_once __DIR__ . '/scoring.php';
$stmt = $pdo->prepare(
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc
ORDER BY sp.name'
);
$stmt->execute([':cc' => $clientCode]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bands = qdb_normalize_scoring_bands($row);
$coachBand = trim((string)($row['coachBand'] ?? ''));
$out[] = [
'profileID' => $row['profileID'],
'name' => $row['name'],
'weightedTotal' => (float)$row['weightedTotal'],
'band' => $row['band'],
'calculatedBand' => $row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'greenMin' => $bands['greenMin'],
'greenMax' => $bands['greenMax'],
'yellowMin' => $bands['yellowMin'],
'yellowMax' => $bands['yellowMax'],
'redMin' => $bands['redMin'],
'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
'coachReviewedAt' => !empty($row['coachReviewedAt'])
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [],
];
}
return $out;
}
/**
* Active scoring profiles + per-client results for the clients list (RBAC-scoped).
*
* @return array{profiles: list<array{profileID: string, name: string}>, byClient: array<string, array<string, array{band: string, weightedTotal: float, name: string}>>}
*/
function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$profiles = $pdo->query(
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
if ($profiles === []) {
return ['profiles' => [], 'byClient' => []];
}
$stmt = $pdo->prepare(
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
FROM client_scoring_profile_result r
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
INNER JOIN client cl ON cl.clientCode = r.clientCode
WHERE ($rbacClause)"
);
$stmt->execute($rbacParams);
$byClient = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cc = (string)$row['clientCode'];
$coachBand = trim((string)($row['coachBand'] ?? ''));
$byClient[$cc][$row['profileID']] = [
'band' => (string)$row['band'],
'calculatedBand' => (string)$row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'weightedTotal' => (float)$row['weightedTotal'],
'name' => (string)$row['name'],
];
}
return [
'profiles' => array_map(static fn(array $p) => [
'profileID' => (string)$p['profileID'],
'name' => (string)$p['name'],
], $profiles),
'byClient' => $byClient,
];
}
/**
* @return list<array{profileID: string, name: string, band: ?string, weightedTotal: ?float}>
*/
function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): array {
$out = [];
foreach ($summary['profiles'] as $profile) {
$result = $summary['byClient'][$clientCode][$profile['profileID']] ?? null;
$out[] = [
'profileID' => $profile['profileID'],
'name' => $profile['name'],
'band' => $result['calculatedBand'] ?? null,
'calculatedBand' => $result['calculatedBand'] ?? null,
'coachBand' => $result['coachBand'] ?? null,
'effectiveBand' => $result['effectiveBand'] ?? null,
'pendingReview' => $result['pendingReview'] ?? false,
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
];
}
return $out;
}