Skip to content

BACKEND ISSUE 15 — [FEATURE] Implement Quartile Performance Distribution API With Project-Based Filters #33

Description

@LeDuyCoder

🎯 Goal

Implement API GET /analytics/journals/quartiles để trả về dữ liệu phân bố tạp chí theo nhóm Scimago Quartile.

API này phục vụ biểu đồ vòng khuyên Quartile Distribution trong màn hình:

Scientia: Journal Metrics & Performance (V6)

Mục tiêu là cung cấp:

  • Tổng số journal trong phạm vi project
  • Tỷ lệ phân bố journal theo các nhóm Q1, Q2, Q3, Q4
  • Dữ liệu sẵn sàng để FE render donut chart

🖼️ UI Mapping

Screen

Màn hình 4: Scientia: Journal Metrics & Performance (V6)

UI Block

Quartile Distribution

Chart Type

Donut chart / Ring chart

Description

Biểu đồ vòng khuyên hiển thị tỷ lệ tạp chí theo các nhóm phân hạng:

Q1 - High Impact
Q2 - Moderate
Q3 - Standard
Q4 - Developing

Ví dụ UI:

2.4k TOTAL JOURNALS

Q1 (High Impact)    42%
Q2 (Moderate)       35%
Q3 (Standard)       18%
Q4 (Developing)     5%

📡 Endpoint

GET /analytics/journals/quartiles

📥 Query Parameters

Param Type Required Description
project_id string / number Yes ID của project cần lấy quartile distribution
subject_area string No Lọc hẹp thêm theo subject area cụ thể
keywords string[] / comma-separated No Lọc hẹp thêm theo danh sách keyword
from_year number No Năm bắt đầu lọc dữ liệu bài báo
to_year number No Năm kết thúc lọc dữ liệu bài báo

✅ Example Requests

1. Fetch quartile distribution by project only

GET /analytics/journals/quartiles?project_id=PROJECT_ID

2. Fetch quartile distribution with subject area filter

GET /analytics/journals/quartiles?project_id=PROJECT_ID&subject_area=Computer Science

3. Fetch quartile distribution with keywords filter

GET /analytics/journals/quartiles?project_id=PROJECT_ID&keywords=AI,Machine Learning,RAG

4. Fetch quartile distribution with year range

GET /analytics/journals/quartiles?project_id=PROJECT_ID&from_year=2021&to_year=2026

🧾 Response Contract

{
  "code": 200,
  "message": "Fetch quartile distribution successfully",
  "data": {
    "totalJournals": 2400,
    "distribution": [
      {
        "group": "Q1 (High Impact)",
        "percentage": 42
      },
      {
        "group": "Q2 (Moderate)",
        "percentage": 35
      }
    ]
  }
}

📌 Response Field Explanation

Field Type Description
totalJournals number Tổng số journal duy nhất trong filtered dataset
distribution array Danh sách tỷ lệ phân bố theo quartile
distribution[].group string Tên nhóm quartile hiển thị trên UI
distribution[].percentage number Tỷ lệ journal thuộc nhóm đó, tính theo phần trăm

✅ Expected Full Distribution Format

API nên trả đủ 4 nhóm Q1-Q4 để FE render ổn định:

{
  "code": 200,
  "message": "Fetch quartile distribution successfully",
  "data": {
    "totalJournals": 2400,
    "distribution": [
      {
        "group": "Q1 (High Impact)",
        "percentage": 42
      },
      {
        "group": "Q2 (Moderate)",
        "percentage": 35
      },
      {
        "group": "Q3 (Standard)",
        "percentage": 18
      },
      {
        "group": "Q4 (Developing)",
        "percentage": 5
      }
    ]
  }
}

🧠 Business Logic

1. Project-based filtering

API bắt buộc nhận project_id.

Từ project_id, hệ thống cần lấy scope nghiên cứu của project:

  • Subject area của project
  • Subject categories thuộc subject area đó
  • Keywords project đang theo dõi

Sau đó dùng scope này để lọc tập bài báo trước khi tính phân bố journal quartile.

Ví dụ project đang theo dõi:

{
  "project_id": 1,
  "subject_area": "Artificial Intelligence",
  "subject_categories": ["Computer Science Applications", "Artificial Intelligence"],
  "keywords": ["LLM", "RAG", "Machine Learning"]
}

API chỉ tính quartile distribution trên các journal có bài báo thuộc scope này.


2. Filter priority

Nếu client chỉ truyền:

project_id

API tự động lấy toàn bộ subject area, subject categories và keywords của project để lọc.

Nếu client truyền thêm:

subject_area
keywords

API sẽ lọc hẹp hơn trong phạm vi project.

Ví dụ:

GET /analytics/journals/quartiles?project_id=1&subject_area=AI&keywords=LLM,RAG

Kết quả chỉ tính quartile distribution trên các journal:

  • Có bài báo thuộc project scope
  • Có bài báo thuộc subject area AI
  • Có bài báo có keyword liên quan đến LLM hoặc RAG

3. Journal collection logic

API cần lấy danh sách journal duy nhất từ filtered articles.

Suggested logic:

1. Filter related articles by project scope
2. Join Article -> Issue -> Journal
3. Distinct by Journal.journal_id
4. Join Journal -> Journal_Ranking
5. Get latest quartile data
6. Count journals by quartile
7. Calculate percentages

4. Quartile source

Ưu tiên lấy quartile từ bảng ranking hoặc metrics của journal.

Suggested sources:

Journal_Ranking.quartile
Journal_Ranking.scimago_quartile
Journal.quartile

Tuỳ schema thực tế.

Nếu có nhiều bản ghi ranking theo năm:

Use latest available ranking year

Nếu client truyền to_year:

Use latest ranking year <= to_year

5. Quartile group mapping

API cần chuẩn hóa label quartile như sau:

Raw Quartile Response group
Q1 Q1 (High Impact)
Q2 Q2 (Moderate)
Q3 Q3 (Standard)
Q4 Q4 (Developing)

Nếu raw quartile không hợp lệ hoặc null:

Ignore journal đó khỏi quartile percentage calculation

Suggested phase 1:

Only count Q1-Q4.
Unknown quartile is excluded.

6. Percentage calculation

Công thức:

percentage = round((journalCountInQuartile / totalJournalsWithValidQuartile) * 100)

Trong đó:

totalJournalsWithValidQuartile = tổng số journal có quartile Q1-Q4

Ví dụ:

Q1 = 42 journals
Q2 = 35 journals
Q3 = 18 journals
Q4 = 5 journals
total = 100

Q1 percentage = 42%
Q2 percentage = 35%
Q3 percentage = 18%
Q4 percentage = 5%

7. Total journals rule

totalJournals nên là số lượng journal duy nhất trong filtered dataset.

Suggested:

totalJournals = count(distinct journal_id)

Nếu có journal không có quartile, totalJournals vẫn có thể bao gồm chúng.

Tuy nhiên, percentage nên tính trên các journal có quartile hợp lệ để tổng Q1-Q4 gần bằng 100%.

Alternative:

totalJournals = totalJournalsWithValidQuartile

Suggested phase 1:

totalJournals = totalJournalsWithValidQuartile

Để UI donut chart nhất quán.


8. Missing quartile group

API nên luôn trả đủ 4 nhóm Q1-Q4.

Nếu một nhóm không có journal:

{
  "group": "Q4 (Developing)",
  "percentage": 0
}

Rule:

distribution.length = 4

Order cố định:

Q1 -> Q2 -> Q3 -> Q4

9. Rounding rule

Suggested phase 1:

percentage = round to integer

Nếu tổng sau rounding không đúng 100 chính xác, có thể chấp nhận sai số nhỏ.

Optional improvement:

Adjust largest group by rounding difference to make total exactly 100.

📦 Data Source

API có thể lấy dữ liệu từ các bảng hiện tại:

Project
Project_Keyword
Subject_Area
Subject_Category
Topic
Sub_Topic
Keyword
Keyword_Article
Article
Issue
Journal
Journal_Ranking

Relevant schema flow:

Project.subject_area -> Subject_Area.subject_area_id
Subject_Category.subject_area_id -> Subject_Area.subject_area_id
Article.primary_topic -> Topic.topic_id
Topic.subject_category_id -> Subject_Category.subject_category_id
Sub_Topic.article_id -> Article.article_id
Sub_Topic.topic_id -> Topic.topic_id
Keyword_Article.article_id -> Article.article_id
Keyword_Article.keyword_id -> Keyword.keyword_id
Project_Keyword.keyword_id -> Keyword.keyword_id
Article.issue_id -> Issue.issue_id
Issue.journal_id -> Journal.journal_id
Journal.journal_id -> Journal_Ranking.journal_id

🧮 Suggested Query Logic

1. Resolve project scope

project_id
-> subject_area
-> subject_category_ids
-> keyword_ids

2. Filter related articles

Article match nếu thỏa mãn ít nhất một trong các điều kiện:

Article.primary_topic thuộc subject_category_ids
OR Article có Sub_Topic thuộc subject_category_ids
OR Article có Keyword_Article thuộc keyword_ids

3. Map articles to journals

Filtered Article
-> Issue
-> Journal

4. Get latest quartile for each journal

Journal
-> latest Journal_Ranking
-> quartile

5. Aggregate distribution

count Q1
count Q2
count Q3
count Q4
calculate percentage

⚠️ Edge Cases

1. Project not found

Nếu project_id không tồn tại:

{
  "code": 404,
  "message": "Project not found",
  "data": null
}

2. Project has no scope

Nếu project chưa có subject area hoặc keywords:

{
  "code": 200,
  "message": "Fetch quartile distribution successfully",
  "data": {
    "totalJournals": 0,
    "distribution": [
      {
        "group": "Q1 (High Impact)",
        "percentage": 0
      },
      {
        "group": "Q2 (Moderate)",
        "percentage": 0
      },
      {
        "group": "Q3 (Standard)",
        "percentage": 0
      },
      {
        "group": "Q4 (Developing)",
        "percentage": 0
      }
    ]
  }
}

3. Empty filtered dataset

Nếu filter xong không có bài báo phù hợp:

{
  "code": 200,
  "message": "Fetch quartile distribution successfully",
  "data": {
    "totalJournals": 0,
    "distribution": [
      {
        "group": "Q1 (High Impact)",
        "percentage": 0
      },
      {
        "group": "Q2 (Moderate)",
        "percentage": 0
      },
      {
        "group": "Q3 (Standard)",
        "percentage": 0
      },
      {
        "group": "Q4 (Developing)",
        "percentage": 0
      }
    ]
  }
}

4. Article has no journal

Nếu bài báo không map được journal:

Ignore article đó trong quartile calculation

5. Journal has no quartile

Nếu journal không có quartile:

Ignore journal đó khỏi distribution calculation

Nếu tất cả journal đều không có quartile:

{
  "code": 200,
  "message": "Fetch quartile distribution successfully",
  "data": {
    "totalJournals": 0,
    "distribution": [
      {
        "group": "Q1 (High Impact)",
        "percentage": 0
      },
      {
        "group": "Q2 (Moderate)",
        "percentage": 0
      },
      {
        "group": "Q3 (Standard)",
        "percentage": 0
      },
      {
        "group": "Q4 (Developing)",
        "percentage": 0
      }
    ]
  }
}

6. Invalid year range

Nếu from_year > to_year:

{
  "code": 400,
  "message": "Invalid year range",
  "data": null
}

🧪 Acceptance Criteria

  • API nhận được project_id
  • API tự lấy được subject area / subject categories / keywords của project
  • API hỗ trợ filter thêm bằng subject_area
  • API hỗ trợ filter thêm bằng keywords
  • API hỗ trợ filter theo from_yearto_year
  • Response có object data
  • data.totalJournals luôn là number
  • data.distribution luôn là array
  • distribution luôn có đủ 4 nhóm Q1-Q4
  • Group order cố định là Q1, Q2, Q3, Q4
  • Mỗi item có đủ group, percentage
  • percentage luôn là number
  • percentage nằm trong khoảng 0 đến 100
  • Không có NaN trong response
  • Không có duplicate quartile group
  • Empty dataset vẫn trả đủ Q1-Q4 với percentage 0
  • FE có thể render donut chart trực tiếp không cần transform thêm
  • Response time với dữ liệu nhỏ dưới 100ms

🧪 Test Cases

TC-01: Fetch quartile distribution by project_id only

Request

GET /analytics/journals/quartiles?project_id=1

Expected

  • API lấy project scope
  • Filter bài báo theo project scope
  • Map article sang journal
  • Lấy quartile của journal
  • Tính percentage Q1-Q4
  • Response đúng contract

TC-02: Fetch quartile distribution with subject_area filter

Request

GET /analytics/journals/quartiles?project_id=1&subject_area=Artificial Intelligence

Expected

  • Chỉ lấy dữ liệu thuộc subject area được truyền vào
  • Không trả dữ liệu ngoài project scope
  • Distribution vẫn đủ Q1-Q4

TC-03: Fetch quartile distribution with keywords filter

Request

GET /analytics/journals/quartiles?project_id=1&keywords=LLM,RAG

Expected

  • Chỉ lấy dữ liệu có keyword match với LLM hoặc RAG
  • Không có item null
  • Không có percentage null hoặc NaN

TC-04: Fetch quartile distribution with year range

Request

GET /analytics/journals/quartiles?project_id=1&from_year=2021&to_year=2026

Expected

  • Chỉ lấy bài báo trong khoảng năm 2021 đến 2026
  • Nếu có ranking theo năm, dùng quartile mới nhất <= to_year
  • Response đúng contract

TC-05: Missing quartile group

Input

Filtered dataset chỉ có journal thuộc Q1 và Q2.

Expected

API vẫn trả đủ Q1-Q4:

{
  "totalJournals": 100,
  "distribution": [
    {
      "group": "Q1 (High Impact)",
      "percentage": 70
    },
    {
      "group": "Q2 (Moderate)",
      "percentage": 30
    },
    {
      "group": "Q3 (Standard)",
      "percentage": 0
    },
    {
      "group": "Q4 (Developing)",
      "percentage": 0
    }
  ]
}

TC-06: Journal has no quartile

Input

Journal có bài báo phù hợp nhưng không có quartile.

Expected

  • Journal đó bị bỏ khỏi distribution calculation
  • API không crash
  • Response vẫn đúng contract

TC-07: Project not found

Request

GET /analytics/journals/quartiles?project_id=999

Expected

{
  "code": 404,
  "message": "Project not found",
  "data": null
}

TC-08: Empty filtered dataset

Request

GET /analytics/journals/quartiles?project_id=1&keywords=unknown-keyword

Expected

{
  "code": 200,
  "message": "Fetch quartile distribution successfully",
  "data": {
    "totalJournals": 0,
    "distribution": [
      {
        "group": "Q1 (High Impact)",
        "percentage": 0
      },
      {
        "group": "Q2 (Moderate)",
        "percentage": 0
      },
      {
        "group": "Q3 (Standard)",
        "percentage": 0
      },
      {
        "group": "Q4 (Developing)",
        "percentage": 0
      }
    ]
  }
}

TC-09: Invalid year range

Request

GET /analytics/journals/quartiles?project_id=1&from_year=2026&to_year=2021

Expected

{
  "code": 400,
  "message": "Invalid year range",
  "data": null
}

📌 Implementation Notes

Nên tách logic thành các phần riêng:

Controller
  -> validate query params
Service
  -> get project tracking scope
  -> filter related articles
  -> map articles to journals
  -> get latest journal quartile
  -> count quartile groups
  -> calculate percentages
  -> build response contract

Gợi ý function:

getProjectTrackingScope(projectId)
buildArticleScopeFilter(scope, queryParams)
getFilteredJournalQuartiles(filters)
getLatestJournalQuartile(journalId, toYear)
countQuartileGroups(items)
calculateQuartilePercentages(counts)
buildQuartileDistributionResponse(totalJournals, distribution)

📦 Suggested Mock Data

const mockQuartileDistribution = {
  totalJournals: 2400,
  distribution: [
    {
      group: "Q1 (High Impact)",
      percentage: 42
    },
    {
      group: "Q2 (Moderate)",
      percentage: 35
    },
    {
      group: "Q3 (Standard)",
      percentage: 18
    },
    {
      group: "Q4 (Developing)",
      percentage: 5
    }
  ]
};

🚀 Future Improvements

  • Add raw count per quartile:
{
  "group": "Q1 (High Impact)",
  "count": 1008,
  "percentage": 42
}
  • Add quartile field separately from display label:
{
  "quartile": "Q1",
  "label": "High Impact",
  "percentage": 42
}
  • Add rankingYear
  • Add unknownQuartileCount
  • Add metric_type=scimago|jcr
  • Add Redis caching:
analytics:journals:quartiles:{project_id}:{subject_area}:{keywords}:{from_year}:{to_year}
  • Optimize query with indexes on:
    • project_id
    • publication_year
    • article_id
    • issue_id
    • journal_id
    • keyword_id
    • subject_category_id
    • quartile

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions