Files
heala-backend/src/modules/submissions/providers/answer/calculate-result.provider.ts
T

254 lines
7.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import {
EntityManager,
In,
LessThanOrEqual,
MoreThanOrEqual,
Repository,
} from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { SubmissionResult } from '../../entities/submission-result.entity';
import { Submission } from '../../entities/submission.entity';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { SubmissionAnswer } from '../../entities/submission-answer.entity';
import { Question } from '@/modules/questions/entities/question.entity';
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
@Injectable()
export class CalculateResultProvider {
constructor(
/**
* Inject Question Repository
*/
@InjectRepository(Question)
private readonly questionRepository: Repository<Question>,
/**
* Inject Result Range Repository
*/
@InjectRepository(ResultRange)
private readonly resultRangeRepository: Repository<ResultRange>,
) {}
public async calculateAuthenticated(
submission: Submission,
manager: EntityManager,
) {
let submissionAnswers: SubmissionAnswer[] = [];
try {
submissionAnswers = await manager.find(SubmissionAnswer, {
where: { submission: { id: submission.id } },
relations: {
question: {
rangeRules: true,
},
choice: true,
choices: true,
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
const result = await this.calculateFromAnswers(
submissionAnswers.map((answer) => ({
questionId: answer.question.id,
question: answer.question,
choiceId: answer.choice?.id,
choice: answer.choice,
choiceIds: answer.choices?.map((choice) => choice.id),
choices: answer.choices,
numericValue: answer.numericValue,
})),
async (questionIds) =>
manager.find(Question, {
where: { id: In(questionIds) },
relations: {
choices: true,
rangeRules: true,
},
}),
async (probability) =>
manager.find(ResultRange, {
where: {
test: { id: submission.test.id },
minProbability: LessThanOrEqual(probability),
maxProbability: MoreThanOrEqual(probability),
},
}),
);
const submissionResult = manager.create(SubmissionResult, {
submission,
score: result.score,
probability: result.probability,
resultRange: result.resultRange,
});
try {
await manager.save(submissionResult);
} catch (err) {
throw new AppTimeoutException(err);
}
return {
score: result.score,
probability: result.probability,
title: result.resultRange?.title,
description: result.resultRange?.description,
};
}
public async calculateGuest(submission: GuestSubmissionSession) {
const result = await this.calculateFromAnswers(
submission.answers.map((answer) => ({
questionId: answer.questionId,
choiceId: answer.choiceId,
choiceIds: answer.choiceIds,
numericValue: answer.numericValue,
})),
async (questionIds) =>
this.questionRepository.find({
where: { id: In(questionIds) },
relations: {
choices: true,
rangeRules: true,
},
}),
async (probability) =>
this.resultRangeRepository.find({
where: {
test: { id: submission.testId },
minProbability: LessThanOrEqual(probability),
maxProbability: MoreThanOrEqual(probability),
},
}),
);
submission.result = {
score: result.score,
probability: result.probability,
title: result.resultRange?.title || '',
description: result.resultRange?.description || '',
};
return submission.result;
}
private async calculateFromAnswers(
answers: Array<{
questionId: number;
question?: Question;
choiceId?: number;
choice?: { id: number; score: number };
choiceIds?: Array<number>;
choices?: Array<{ id: number; score: number }>;
numericValue?: number;
}>,
loadQuestions: (questionIds: number[]) => Promise<Question[]>,
loadResultRanges: (probability: number) => Promise<ResultRange[]>,
) {
let score = 0;
const questionIds = answers.map((a) => a.questionId);
const questions = await loadQuestions(questionIds);
const questionsMap = new Map<number, Question>();
questions.forEach((q) => questionsMap.set(q.id, q));
for (const answer of answers) {
const question = answer.question ?? questionsMap.get(answer.questionId);
if (!question) continue;
if (
question.type === QuestionType.SINGLE_CHOICE &&
answer.choiceId !== undefined
) {
const choice =
answer.choice ??
question.choices.find((c) => c.id === answer.choiceId);
if (choice) {
score += question.weight * choice.score;
}
} else if (
question.type === QuestionType.MULTI_CHOICE &&
answer.choiceIds !== undefined
) {
const selectedChoiceIds = new Set(answer.choiceIds);
const selectedChoices =
answer.choices ??
question.choices.filter((choice) => selectedChoiceIds.has(choice.id));
const multiChoiceScore = selectedChoices.reduce(
(sum, choice) => sum + choice.score,
0,
);
score += question.weight * multiChoiceScore;
} else if (
question.type === QuestionType.NUMERIC &&
answer.numericValue !== undefined
) {
question.rangeRules?.forEach((rule) => {
if (
answer.numericValue! >= rule.min &&
answer.numericValue! <= rule.max
) {
score += question.weight * rule.score;
}
});
}
}
let totalScores = 0;
questions.forEach((question) => {
if (question.type === QuestionType.SINGLE_CHOICE) {
const maxScore =
question.choices.length > 0
? Math.max(...question.choices.map((choice) => choice.score))
: 0;
totalScores += question.weight * maxScore;
} else if (question.type === QuestionType.MULTI_CHOICE) {
const maxScore = question.choices.reduce(
(sum, choice) => sum + choice.score,
0,
);
totalScores += question.weight * maxScore;
} else if (
question.type === QuestionType.NUMERIC &&
question.rangeRules &&
question.rangeRules.length > 0
) {
const maxScore = Math.max(
...question.rangeRules.map((rule) => rule.score),
);
totalScores += question.weight * maxScore;
}
});
const probability =
totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0;
const resultRanges = await loadResultRanges(probability);
return {
score,
probability,
resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
};
}
}