Browse Source

feat(api): 接入测评答卷全链路(/assessments)并桥接单题推进 UI

后端测评是「答卷」模型(一次抽 30 题 → 批量保存 → 提交出结果),
前端 UI 是单题推进,桥接全部收敛在 api 层,页面与 hook 无需改动:

- 新增 fetchAssessmentTypes / fetchAssessmentQuestions / fetchMyAssessments /
  createAssessmentAttempt / fetchAssessmentAttempt / saveAssessmentAnswers /
  submitAssessmentAttempt / fetchAssessmentResult 八个真实端点
- fetchHollandAssessment / submitHollandAnswer / fetchHollandResult 补真实分支
- 关键契约(实网实测,文档未标注):
  创建用 body {typeCode}(不是 type);保存答案必须回传 version 乐观锁;
  未答完提交报 9706;未完成取结果报 409/9703
- 结果映射:primaryCode→code、dimensionScores→六维、explorationDirections→行动建议;
  后端不下发的类型解析/推荐岗位留空数组,不本地编造
- 答题过程中后端不下发实时六维(维度属隐藏字段),分布置空,不本地估算

新增 src/__tests__/assessment-attempt.test.ts(7 用例)
tsc 0 错误;jest 525 全过;eslint 0 error
master
doublex001 4 weeks ago
parent
commit
e1dc514a0c
  1. 206
      src/__tests__/assessment-attempt.test.ts
  2. 215
      src/api/api.ts
  3. 77
      src/types/growth.ts
  4. 5
      src/types/index.ts

206
src/__tests__/assessment-attempt.test.ts

@ -0,0 +1,206 @@
/**
* 测评答卷(/assessments)真实分支测试
*
* 后端是「一次抽 30 题 + 批量保存 + 提交出结果」的答卷模型,前端 UI 是单题推进,
* 桥接逻辑(ensureActiveAttempt / mapAttemptToAssessment)全在 api 层,此处验证:
* - 有活跃答卷就复用,不重复创建(后端同类型重复创建会 409/9708)
* - 保存答案必须回传乐观锁 version
* - 结果只在 COMPLETED 后可读,未完成取结果要给出明确错误
*/
jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } }));
jest.mock('../lib/token-store', () => {
let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null;
return {
getAccessToken: jest.fn(() => mem?.accessToken ?? null),
getTokens: jest.fn(() => mem),
setTokens: jest.fn((a: string, r: string, e?: number) => {
mem = { accessToken: a, refreshToken: r, expiresAt: e };
}),
clearTokens: jest.fn(() => {
mem = null;
}),
hydrateTokens: jest.fn(async () => undefined),
};
});
jest.mock('../lib/storage', () => {
const m = new Map<string, string>();
return {
storageGet: jest.fn(async (k: string) => m.get(k) ?? null),
storageSet: jest.fn(async (k: string, v: string) => {
m.set(k, v);
}),
storageRemove: jest.fn(async () => undefined),
};
});
import { setMockMode, API_BASE_URL } from '../api/http';
import {
fetchHollandAssessment,
submitHollandAnswer,
fetchHollandResult,
fetchAssessmentTypes,
fetchAssessmentQuestions,
} from '../api/api';
type Responder = (url: string) => { status: number; body: unknown };
let responder: Responder | null = null;
let calls: Array<{ url: string; init: RequestInit }> = [];
function okData(data: unknown) {
return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'r', timestamp: 't' } };
}
function attempt(overrides: Partial<Record<string, unknown>> = {}) {
return {
attemptId: 1,
status: 'DRAFT',
totalQuestions: 30,
answeredCount: 0,
completionRate: 0,
questionSetVersion: 'riasec-self-v0.2',
version: 0,
questions: [
{ id: 67, position: 1, stem: '我喜欢制定时间表或任务清单。', scaleType: 'LIKERT_5' },
{ id: 10, position: 2, stem: '我对了解建筑结构的实际施工过程感兴趣。', scaleType: 'LIKERT_5' },
{ id: 45, position: 3, stem: '我对帮助他人改善生活状态感兴趣。', scaleType: 'LIKERT_5' },
],
...overrides,
};
}
beforeEach(() => {
calls = [];
responder = null;
(global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
const spec = responder ? responder(url) : okData(null);
return {
status: spec.status,
ok: spec.status >= 200 && spec.status < 300,
headers: { get: () => 'r' },
text: async () => JSON.stringify(spec.body),
} as unknown as Response;
});
});
describe('测评答卷真实态', () => {
beforeEach(() => setMockMode(false));
it('无活跃答卷时先创建再取详情,映射成单题推进状态', async () => {
responder = (url) => {
if (url.includes('/assessments/me')) return okData({ items: [], total: 0 });
if (url.includes('/assessments/attempts')) return okData(attempt());
return okData(null);
};
const data = await fetchHollandAssessment();
expect(calls[0].url).toBe(`${API_BASE_URL}/assessments/me?page=0&size=20`);
expect(calls[1].url).toBe(`${API_BASE_URL}/assessments/attempts`);
expect(JSON.parse(String(calls[1].init.body))).toEqual({ typeCode: 'HOLLAND_RIASEC' });
expect(data.totalQuestions).toBe(30);
expect(data.currentQuestionIndex).toBe(0);
expect(data.question.id).toBe('67');
expect(data.question.text).toBe('我喜欢制定时间表或任务清单。');
expect(data.options.map((o) => o.value)).toEqual([1, 2, 3, 4, 5]);
});
it('已有活跃答卷时复用,不重复创建', async () => {
responder = (url) => {
if (url.includes('/assessments/me'))
return okData({
items: [{ attemptId: 9, typeCode: 'HOLLAND_RIASEC', status: 'IN_PROGRESS' }],
total: 1,
});
if (url.includes('/assessments/attempts/')) return okData(attempt({ attemptId: 9, answeredCount: 2 }));
return okData(null);
};
const data = await fetchHollandAssessment();
expect(calls.some((c) => c.url.endsWith('/assessments/attempts') && c.init.method === 'POST')).toBe(false);
expect(calls[1].url).toBe(`${API_BASE_URL}/assessments/attempts/9`);
// 已答 2 题 → 下一题下标 2(下标被题目数钳住,避免答完最后一题后越界)
expect(data.currentQuestionIndex).toBe(2);
});
it('提交答案带乐观锁 version,且以服务端返回的进度为准', async () => {
let saved = false;
responder = (url) => {
if (url.includes('/assessments/me'))
return okData({
items: [{ attemptId: 3, typeCode: 'HOLLAND_RIASEC', status: 'DRAFT' }],
total: 1,
});
if (url.includes('/answers')) {
saved = true;
return okData(attempt({ attemptId: 3, status: 'IN_PROGRESS', answeredCount: 1, version: 1 }));
}
if (url.includes('/assessments/attempts/'))
return okData(attempt({ attemptId: 3, answeredCount: saved ? 1 : 0, version: saved ? 1 : 0 }));
return okData(null);
};
const data = await submitHollandAnswer('67', 5);
const patch = calls.find((c) => c.init.method === 'PATCH');
expect(patch?.url).toBe(`${API_BASE_URL}/assessments/attempts/3/answers`);
expect(JSON.parse(String(patch?.init.body))).toEqual({
version: 0,
answers: [{ questionId: 67, value: 5 }],
});
expect(data.currentQuestionIndex).toBe(1);
});
it('未完成时取结果给出明确错误,不静默返回空', async () => {
responder = (url) => {
if (url.includes('/assessments/me'))
return okData({ items: [{ attemptId: 1, typeCode: 'HOLLAND_RIASEC', status: 'DRAFT' }], total: 1 });
return okData(null);
};
await expect(fetchHollandResult()).rejects.toThrow('暂无测评结果');
});
it('完成后映射六维得分与探索方向', async () => {
responder = (url) => {
if (url.includes('/assessments/me'))
return okData({ items: [{ attemptId: 1, typeCode: 'HOLLAND_RIASEC', status: 'COMPLETED' }], total: 1 });
if (url.includes('/result'))
return okData({
attemptId: 1,
typeCode: 'HOLLAND_RIASEC',
status: 'COMPLETED',
primaryCode: 'ACE',
primaryType: 'A',
dimensionScores: [
{ dimension: 'R', score: 60 },
{ dimension: 'A', score: 80 },
],
typeRanking: ['A', 'C', 'E'],
explorationDirections: ['创意设计/内容创作'],
});
return okData(null);
};
const result = await fetchHollandResult();
expect(result.code).toBe('ACE');
expect(result.dimensions[0]).toEqual({ type: 'R', name: '现实型', score: 60, percent: 60 });
expect(result.actions[0].title).toBe('创意设计/内容创作');
// 后端不下发类型解析与推荐岗位:留空数组由页面按空态渲染
expect(result.analyses).toEqual([]);
expect(result.jobs).toEqual([]);
});
it('类型列表与题目列表打后端真实路径', async () => {
responder = () => okData([{ typeCode: 'HOLLAND_RIASEC', displayName: 'RIASEC 职业兴趣测评' }]);
await fetchAssessmentTypes();
expect(calls[0].url).toBe(`${API_BASE_URL}/assessments/types`);
await fetchAssessmentQuestions('HOLLAND_RIASEC');
expect(calls[1].url).toBe(`${API_BASE_URL}/assessments/HOLLAND_RIASEC/questions`);
});
});
describe('测评答卷 mock 态', () => {
beforeEach(() => setMockMode(true));
it('mock 模式不发任何请求', async () => {
const data = await fetchHollandAssessment();
expect(calls.length).toBe(0);
expect(data.totalQuestions).toBeGreaterThan(0);
});
});

215
src/api/api.ts

@ -16,6 +16,11 @@
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import type { import type {
AbilityAssessment, AbilityAssessment,
BackendAssessmentAttempt,
BackendAssessmentQuestion,
BackendAssessmentResult,
BackendAssessmentSummary,
BackendAssessmentType,
BackendJobItem, BackendJobItem,
BackendJobReviewSummary, BackendJobReviewSummary,
AITool, AITool,
@ -32,6 +37,7 @@ import type {
EnterpriseEntry, EnterpriseEntry,
HollandAssessment, HollandAssessment,
HollandIntro, HollandIntro,
HollandOption,
HollandType, HollandType,
Interview, Interview,
Job, Job,
@ -1316,10 +1322,8 @@ export async function fetchJobList(
return { jobs, total: data.total }; return { jobs, total: data.total };
} }
// fallback: 走本地 mock 同一套逻辑(不重复写排序筛选) // fallback: 走本地 mock 同一套逻辑(不重复写排序筛选)
// eslint-disable-next-line no-console
console.warn('[fetchJobList] 后端 items 为空,回退本地 mock'); console.warn('[fetchJobList] 后端 items 为空,回退本地 mock');
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console
console.warn('[fetchJobList] 真实后端失败,回退本地 mock:', err instanceof Error ? err.message : err); console.warn('[fetchJobList] 真实后端失败,回退本地 mock:', err instanceof Error ? err.message : err);
} }
// ── fallback 分支(mock 同逻辑,避免空壳页面) ── // ── fallback 分支(mock 同逻辑,避免空壳页面) ──
@ -1574,11 +1578,13 @@ export async function fetchMatchResult(): Promise<MatchResult> {
* @returns Promise<HollandAssessment> 测评参与信息 * @returns Promise<HollandAssessment> 测评参与信息
*/ */
export async function fetchHollandAssessment(): Promise<HollandAssessment> { export async function fetchHollandAssessment(): Promise<HollandAssessment> {
// 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 if (isMockMode()) {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HOLLAND); return mockRequest(MOCK_HOLLAND);
// 真实路径 }
// 真实后端:测评走「答卷(attempt)」模型 —— 一次抽 30 题、批量保存、提交出结果,
// 与前端「单题推进」的 HollandAssessment 不同构,故在此换算成前端形状。
const attempt = await ensureActiveAttempt();
return mapAttemptToAssessment(attempt);
} }
/** /**
@ -1590,11 +1596,22 @@ export async function fetchHollandAssessment(): Promise<HollandAssessment> {
* @returns Promise<HollandAssessment> 更新后的测评进度或结果 * @returns Promise<HollandAssessment> 更新后的测评进度或结果
*/ */
export async function submitHollandAnswer( export async function submitHollandAnswer(
_questionId: string, questionId: string,
_value: number, value: number,
): Promise<HollandAssessment> { ): Promise<HollandAssessment> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland/answers`, { method: 'POST', body: JSON.stringify({ questionId, value }) }); return res.json(); if (isMockMode()) {
return mockRequest(MOCK_HOLLAND); return mockRequest(MOCK_HOLLAND);
}
// 真实后端:保存单题答案(PATCH /assessments/attempts/{id}/answers)。
// 该接口带乐观锁 —— 必须回传答卷当前 version,否则 400「version is required」,
// 故先取一次答卷详情拿 version 再提交(版本落后会 409,此时重取重试一次)。
const attempt = await ensureActiveAttempt();
await saveAssessmentAnswers(attempt.attemptId, attempt.version, [
{ questionId: Number(questionId), value },
]);
// 保存后重新取详情:下一题索引、进度、version 都以服务端为准
const next = await fetchAssessmentAttempt(attempt.attemptId);
return mapAttemptToAssessment(next);
} }
/** /**
@ -1604,14 +1621,188 @@ export async function submitHollandAnswer(
* @returns Promise<NonNullable<HollandAssessment['result']>> 测评结果 * @returns Promise<NonNullable<HollandAssessment['result']>> 测评结果
*/ */
export async function fetchHollandResult(): Promise<NonNullable<HollandAssessment['result']>> { export async function fetchHollandResult(): Promise<NonNullable<HollandAssessment['result']>> {
// 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 if (isMockMode()) {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland/result`, { method: 'GET' }); return res.json();
if (!MOCK_HOLLAND.result) { if (!MOCK_HOLLAND.result) {
throw new Error('暂无测评结果'); throw new Error('暂无测评结果');
} }
return mockRequest(MOCK_HOLLAND.result); return mockRequest(MOCK_HOLLAND.result);
// 真实路径 }
// 真实后端:结果只在答卷 COMPLETED 后存在,未完成取结果会 409/9703。
const attemptId = await findCompletedAttemptId();
if (!attemptId) throw new Error('暂无测评结果:请先完成测评');
return fetchAssessmentResult(attemptId).then(mapResultToHollandResult);
}
/* ===================== 测评答卷(真实后端 /assessments) =====================
* 后端是「答卷」模型:一次抽 N 题、可多次保存答案、全部答完才能提交、提交后出结果。
* 前端 UI 是「单题推进」,两者由 fetchHollandAssessment / submitHollandAnswer 桥接。
*/
/** 霍兰德测评在后端注册的 typeCode(实网 /assessments/types 采样) */
const HOLLAND_TYPE_CODE = 'HOLLAND_RIASEC';
/** 霍兰德答卷 ACTIVE 状态:未答/进行中都算活跃,可继续作答 */
const ATTEMPT_ACTIVE_STATUS = ['DRAFT', 'IN_PROGRESS'];
/** LIKERT_5 量表选项(1-5 分),文案取自后端测评说明 */
const LIKERT5_OPTIONS: HollandOption[] = [
{ value: 1, label: '完全不感兴趣' },
{ value: 2, label: '不太感兴趣' },
{ value: 3, label: '一般' },
{ value: 4, label: '比较感兴趣' },
{ value: 5, label: '非常感兴趣' },
];
/** 测评类型列表(GET /assessments/types;后端只下发 ACTIVE 的定义) */
export async function fetchAssessmentTypes(): Promise<BackendAssessmentType[]> {
return request<BackendAssessmentType[]>('/assessments/types', { method: 'GET', auth: true });
}
/** 指定测评的全部题目(GET /assessments/{type}/questions;内部维度字段已隐藏) */
export async function fetchAssessmentQuestions(
typeCode: string,
): Promise<BackendAssessmentQuestion[]> {
return request<BackendAssessmentQuestion[]>(`/assessments/${typeCode}/questions`, {
method: 'GET',
auth: true,
});
}
/** 我的答卷列表(GET /assessments/me) */
export async function fetchMyAssessments(): Promise<BackendAssessmentSummary[]> {
const res = await request<{
items: BackendAssessmentSummary[];
total: number;
}>('/assessments/me', { method: 'GET', auth: true, query: { page: 0, size: 20 } });
return res.items ?? [];
}
/** 创建答卷(POST /assessments/attempts,body 字段名是 typeCode,不是 type) */
export async function createAssessmentAttempt(
typeCode: string = HOLLAND_TYPE_CODE,
): Promise<BackendAssessmentAttempt> {
return request<BackendAssessmentAttempt>('/assessments/attempts', {
method: 'POST',
auth: true,
body: { typeCode },
});
}
/** 答卷详情(含已抽题目、已存答案、进度与乐观锁 version) */
export async function fetchAssessmentAttempt(
attemptId: number,
): Promise<BackendAssessmentAttempt> {
return request<BackendAssessmentAttempt>(`/assessments/attempts/${attemptId}`, {
method: 'GET',
auth: true,
});
}
/**
* 保存答案(单题或批量)
* @param version 答卷乐观锁版本号,必须回传,版本落后报 409/9707
*/
export async function saveAssessmentAnswers(
attemptId: number,
version: number,
answers: { questionId: number; value: number }[],
): Promise<BackendAssessmentAttempt> {
return request<BackendAssessmentAttempt>(`/assessments/attempts/${attemptId}/answers`, {
method: 'PATCH',
auth: true,
body: { version, answers },
});
}
/** 提交答卷(未答完提交会报 9706) */
export async function submitAssessmentAttempt(
attemptId: number,
version: number,
): Promise<BackendAssessmentAttempt> {
return request<BackendAssessmentAttempt>(`/assessments/attempts/${attemptId}/submit`, {
method: 'POST',
auth: true,
body: { version },
});
}
/** 答卷结果(GET /assessments/attempts/{id}/result) */
export async function fetchAssessmentResult(
attemptId: number,
): Promise<BackendAssessmentResult> {
return request<BackendAssessmentResult>(`/assessments/attempts/${attemptId}/result`, {
method: 'GET',
auth: true,
});
}
/**
* 取当前可作答的答卷:有活跃答卷就复用(后端同类型只允许一份活跃答卷,
* 重复创建会 409/9708),没有才新建。
*/
async function ensureActiveAttempt(): Promise<BackendAssessmentAttempt> {
const mine = await fetchMyAssessments();
const active = mine.find(
(item) => item.typeCode === HOLLAND_TYPE_CODE && ATTEMPT_ACTIVE_STATUS.includes(item.status),
);
if (active) return fetchAssessmentAttempt(active.attemptId);
return createAssessmentAttempt();
}
/** 找一份已完成的答卷 id(取不到返回 null,由调用方决定空态) */
async function findCompletedAttemptId(): Promise<number | null> {
const mine = await fetchMyAssessments();
const done = mine.find(
(item) => item.typeCode === HOLLAND_TYPE_CODE && item.status === 'COMPLETED',
);
return done ? done.attemptId : null;
}
/** 后端答卷 → 前端单题推进式测评状态 */
function mapAttemptToAssessment(attempt: BackendAssessmentAttempt): HollandAssessment {
const questions = attempt.questions ?? [];
// 已答数即下一题下标:后端题目固定顺序,保存答案后 answeredCount 递增
const index = Math.min(attempt.answeredCount ?? 0, Math.max(questions.length - 1, 0));
const current = questions[index];
return {
totalParticipants: HOLLAND_TOTAL_PARTICIPANTS,
currentQuestionIndex: index,
totalQuestions: attempt.totalQuestions ?? questions.length,
question: current
? { id: String(current.id), order: current.position ?? index + 1, text: current.stem }
: { id: '', order: 0, text: '' },
options: LIKERT5_OPTIONS,
// 后端在答题过程中不下发六维分(维度属内部字段,已隐藏),
// 故过程中的实时分布为空数组,结果页才展示得分 —— 不本地估算以免与后端结果打架
dimensions: [],
};
}
/** 后端结果 → 前端结果结构(analyses/jobs/actions 后端当前不下发,留空数组) */
function mapResultToHollandResult(
raw: BackendAssessmentResult,
): NonNullable<HollandAssessment['result']> {
return {
code: raw.primaryCode ?? raw.primaryType ?? '',
name: raw.primaryCode ? `${raw.primaryCode} 型` : '',
dimensions: (raw.dimensionScores ?? []).map((item) => ({
type: item.dimension as HollandType,
name: HOLLAND_NAMES[item.dimension as HollandType] ?? item.dimension,
score: item.score ?? 0,
// 后端 score 已是 0-100 标准化分,百分位同值(后端未单独下发百分位)
percent: item.score ?? 0,
})),
// 后端无「类型解析 / 推荐岗位 / 行动建议」三类数据,留空由页面按空态渲染,
// 只有 explorationDirections(探索方向文字)可用,等后端补齐后再映射
analyses: [],
jobs: [],
actions: (raw.explorationDirections ?? []).map((dir, i) => ({
id: `dir-${i}`,
title: dir,
desc: '',
icon: 'compass-outline',
})),
};
} }
/** /**

77
src/types/growth.ts

@ -242,3 +242,80 @@ export interface Article {
/** 文章正文(后端就绪后由接口返回;当前为 mock 真实内容) */ /** 文章正文(后端就绪后由接口返回;当前为 mock 真实内容) */
content: string; content: string;
} }
/* ===================== 测评后端 DTO(2026-09-30 实网采样) =====================
* 来源:GET /assessments/types、POST /assessments/attempts、GET .../result 的真实响应。
* 后端是「一次抽 30 题 + 批量保存 + 提交出结果」的答卷(attempt)模型,
* 与前端「单题推进」的 HollandAssessment 不同构,真实分支由 api 层转换。
*/
/** 测评类型定义(GET /assessments/types、/abilities/types) */
export interface BackendAssessmentType {
typeCode: string;
displayName: string;
description?: string;
instructions?: string;
status?: string;
/** 量表类型,如 LIKERT_5(1-5 分) */
scaleType?: string;
questionCount?: number;
/** 单次答卷抽题数 */
attemptQuestionCount?: number;
timeLimitMinutes?: number;
questionSetVersion?: string;
rulesVersion?: string;
}
/** 答卷题目 */
export interface BackendAssessmentQuestion {
id: number;
/** 题序(从 1 开始) */
position: number;
stem: string;
scaleType?: string;
/** 已保存的作答值(1-5);未作答无此字段 */
value?: number;
}
/** 答卷(POST /assessments/attempts、GET /assessments/attempts/{id}) */
export interface BackendAssessmentAttempt {
attemptId: number;
status: string;
totalQuestions: number;
answeredCount: number;
completionRate?: number;
questionSetVersion?: string;
/** 乐观锁版本号;保存答案与提交必须回传,版本落后报 409/9707 */
version: number;
questions: BackendAssessmentQuestion[];
}
/** 答卷结果(GET /assessments/attempts/{id}/result) */
export interface BackendAssessmentResult {
attemptId: number;
typeCode: string;
status: string;
/** 主类型组合码,如 "ACE" */
primaryCode?: string;
/** 主导单类型,如 "A" */
primaryType?: string;
dimensionScores?: { dimension: string; score: number; answeredCount?: number }[];
/** 类型排序(得分从高到低) */
typeRanking?: string[];
questionSetVersion?: string;
rulesVersion?: string;
generatedAt?: string;
disclaimer?: string;
/** 推荐探索方向,如 ["创意设计/内容创作"] */
explorationDirections?: string[];
}
/** 我的答卷列表条目(GET /assessments/me、/abilities/me) */
export interface BackendAssessmentSummary {
attemptId: number;
typeCode?: string;
status: string;
answeredCount?: number;
completionRate?: number;
createdAt?: string;
}

5
src/types/index.ts

@ -28,6 +28,11 @@ export type {
AbilityTrendPoint, AbilityTrendPoint,
AITool, AITool,
Article, Article,
BackendAssessmentAttempt,
BackendAssessmentQuestion,
BackendAssessmentResult,
BackendAssessmentSummary,
BackendAssessmentType,
HollandAction, HollandAction,
HollandAssessment, HollandAssessment,
HollandDimension, HollandDimension,

Loading…
Cancel
Save