EzCode# Bài 20 — Thống kê và báo cáo
Học xong bài này bạn sẽ:
- Dùng
DATE_FORMATđể gom dữ liệu theo tháng- Viết truy vấn có nhiều subquery cùng lúc và sắp xếp theo cột tính toán
- Hoàn thiện Model
Coursesvới đủ 13 phương thứcCần học trước: Bài 19
1. Vấn đề đặt ra
Giảng viên cần trả lời ba câu hỏi kinh doanh:
- Tôi kiếm được bao nhiêu? — tổng doanh thu
- Tháng nào bán chạy? — doanh thu theo từng tháng
- Khóa nào ra tiền nhất? — xếp hạng khóa học
Bạn có thể lấy hết dữ liệu về PHP rồi tính bằng foreach. Nhưng đó là cách sai:
- Với 10.000 dòng ghi danh, bạn kéo cả 10.000 dòng qua mạng chỉ để cộng một con số
- MySQL được thiết kế để làm việc này nhanh gấp nhiều lần PHP
- Code PHP tính toán dài dòng và dễ sai
Nguyên tắc: tính toán trên dữ liệu thì để database làm; PHP chỉ nhận kết quả và hiển thị.
Bài này dạy bạn các công cụ để làm điều đó.
2. Kiến thức mới
2.1. Hàm tổng hợp không có GROUP BY
Bài 19 dùng hàm tổng hợp kèm GROUP BY để có nhiều dòng kết quả. Nhưng nếu không có GROUP BY, chúng gom toàn bộ kết quả thành đúng một dòng:
SELECT SUM(e.price) as total FROM enrollments e
INNER JOIN courses c ON e.course_id = c.id
WHERE c.teacher_id = 35 AND e.role = 'student'
Kết quả luôn là một dòng, một cột:
| total |
|---|
| 155000 |
Vì vậy dùng queryOne() chứ không phải query(), và đọc bằng $result['total'].
2.2. SUM() trả về NULL khi không có dòng nào
Đây là cái bẫy quan trọng nhất của bài.
| Hàm | Không có dòng nào thì trả về |
|---|---|
COUNT(*) |
0 |
SUM(cot) |
NULL |
MAX(cot) |
NULL |
AVG(cot) |
NULL |
Chỉ COUNT là trả về 0. Ba hàm còn lại trả NULL, vì "tổng của không có gì" về mặt toán học là không xác định.
Hậu quả: nếu giảng viên chưa bán được khóa nào, getTotalRevenueByTeacher() sẽ trả về null. Rồi number_format(null) sinh cảnh báo trên PHP 8.1 trở lên:
Deprecated: number_format(): Passing null to parameter #1 ($num) of type float is deprecated
Đó là lý do mọi phương thức thống kê đều kết thúc bằng:
return $result['total'] ?? 0;
Toán tử ?? biến null thành 0.
Chú ý một điểm tinh tế:
??chỉ chặn đượcnull, không chặn được chuỗi rỗng hayfalse. Ở đây đủ dùng vìSUMchỉ trả vềnullhoặc một số.
2.3. DATE_FORMAT() — cắt ngày để gom nhóm
Đây là kỹ thuật hay nhất của bài.
Bảng enrollments lưu thời điểm chính xác tới giây:
| user_id | price | enrolled_at |
|---|---|---|
| 2 | 30000 | 2025-08-01 09:15:22 |
| 3 | 30000 | 2025-08-14 16:42:01 |
| 4 | 25000 | 2025-09-02 11:03:55 |
Muốn gom theo tháng, bạn không thể GROUP BY enrolled_at — mỗi dòng một giây khác nhau nên mỗi dòng thành một nhóm riêng.
DATE_FORMAT() cắt bớt phần chi tiết:
DATE_FORMAT(e.enrolled_at, '%Y-%m')
2025-08-01 09:15:22 biến thành chuỗi 2025-08. Hai dòng đầu giờ có cùng giá trị, nên gom được thành một nhóm.
Bảng ký hiệu định dạng:
| Ký hiệu | Nghĩa | Ví dụ |
|---|---|---|
%Y |
Năm 4 chữ số | 2025 |
%y |
Năm 2 chữ số | 25 |
%m |
Tháng 2 chữ số | 08 |
%d |
Ngày 2 chữ số | 01 |
%H |
Giờ 24 giờ | 09 |
Muốn thống kê theo ngày thì dùng '%Y-%m-%d'; theo năm thì '%Y'. Cùng một kỹ thuật.
Vì sao đặt %Y trước %m? Vì chuỗi 2025-08 sắp xếp đúng theo thứ tự thời gian khi so sánh chữ. Nếu viết '%m-%Y' cho ra 08-2025, thì tháng 1 năm 2026 (01-2026) sẽ đứng trước tháng 8 năm 2025 khi sắp xếp — sai hoàn toàn.
2.4. GROUP BY trên một biểu thức
SELECT DATE_FORMAT(e.enrolled_at, '%Y-%m') as month, SUM(e.price) as revenue
FROM enrollments e
...
GROUP BY DATE_FORMAT(e.enrolled_at, '%Y-%m')
ORDER BY month DESC
LIMIT 12
Chú ý biểu thức DATE_FORMAT(...) được viết hai lần: một ở SELECT, một ở GROUP BY.
MySQL cho phép viết tắt bằng bí danh:
GROUP BY month
Nhưng viết đầy đủ thì chuẩn hơn và chạy được trên mọi hệ quản trị cơ sở dữ liệu. Tác giả chọn cách đầy đủ.
Còn ORDER BY month DESC thì dùng bí danh — MySQL cho phép trong ORDER BY vì mệnh đề này chạy sau khi SELECT đã tính xong.
LIMIT 12 giới hạn 12 tháng gần nhất. Kết hợp với ORDER BY month DESC, nó lấy 12 tháng mới nhất chứ không phải 12 tháng đầu tiên.
2.5. Nhiều subquery trong một câu
SELECT c.*,
(SELECT COUNT(*) FROM enrollments WHERE course_id = c.id AND role = 'student') as student_count,
(SELECT SUM(price) FROM enrollments WHERE course_id = c.id AND role = 'student') as revenue
FROM courses c
WHERE c.teacher_id = ?
ORDER BY student_count DESC
LIMIT 5
Hai subquery, mỗi cái trả về một cột. Kỹ thuật này bạn đã học ở Bài 17 mục 2.3, giờ dùng hai lần cùng lúc.
Cái giá phải trả: với 10 khóa học, MySQL chạy 10 × 2 = 20 subquery. Với 1000 khóa học thì 2000. Đây là lúc nên chuyển sang LEFT JOIN cộng GROUP BY.
2.6. ORDER BY theo cột tính toán
ORDER BY student_count DESC
student_count không phải cột thật trong bảng — nó là kết quả của subquery.
MySQL vẫn sắp xếp được, vì ORDER BY chạy sau khi mọi cột đã tính xong.
Nhưng WHERE thì không. Câu này báo lỗi:
WHERE student_count > 5 -- Unknown column 'student_count' in 'where clause'
Vì WHERE chạy trước khi các cột trong SELECT được tính.
Muốn lọc theo cột tính toán thì dùng HAVING:
HAVING student_count > 5
Thứ tự thực hiện một câu SQL, ghi nhớ để hiểu vì sao:
FROMvàJOIN— lấy dữ liệuWHERE— lọc từng dòngGROUP BY— gom nhómHAVING— lọc từng nhómSELECT— tính các cột và bí danhORDER BY— sắp xếpLIMIT— cắt bớt
Bí danh chỉ tồn tại từ bước 5, nên chỉ bước 6 và 7 dùng được.
2.7. Hiển thị lại ngày đã gom
<?= date('m/Y', strtotime($stat['month'] . '-01')) ?>
Cột month chứa chuỗi 2025-08 — không phải một ngày hợp lệ, nên strtotime('2025-08') cho kết quả không đoán được.
Ghép thêm -01 biến nó thành 2025-08-01, một ngày hợp lệ. Sau đó date('m/Y', ...) cho ra 08/2025.
Đây là mẹo nhỏ nhưng cần thiết. Cách khác là tách chuỗi bằng explode('-', $stat['month']) rồi ghép ngược lại.
3. Áp dụng vào EzCode
3.1. Thêm năm phương thức thống kê vào Model
Chèn vào Models/Courses.php trước dấu } cuối. Sau bài này, Model Courses hoàn chỉnh với 13 phương thức — nhiều nhất project.
📁
Models/Courses.php— sửa
function countByTeacher($teacher_id)
{
$sql = "SELECT COUNT(*) as total FROM courses WHERE teacher_id=?";
$result = $this->db->queryOne($sql, $teacher_id);
return $result['total'] ?? 0;
}
function getTotalRevenueByTeacher($teacher_id)
{
$sql = "SELECT SUM(e.price) as total FROM enrollments e
INNER JOIN courses c ON e.course_id = c.id
WHERE c.teacher_id = ? AND e.role = 'student'";
$result = $this->db->queryOne($sql, $teacher_id);
return $result['total'] ?? 0;
}
function getTotalStudentsByTeacher($teacher_id)
{
$sql = "SELECT COUNT(DISTINCT e.user_id) as total FROM enrollments e
INNER JOIN courses c ON e.course_id = c.id
WHERE c.teacher_id = ? AND e.role = 'student'";
$result = $this->db->queryOne($sql, $teacher_id);
return $result['total'] ?? 0;
}
function getMonthlyStatsByTeacher($teacher_id)
{
$sql = "SELECT
DATE_FORMAT(e.enrolled_at, '%Y-%m') as month,
COUNT(DISTINCT e.user_id) as students,
SUM(e.price) as revenue
FROM enrollments e
INNER JOIN courses c ON e.course_id = c.id
WHERE c.teacher_id = ? AND e.role = 'student'
GROUP BY DATE_FORMAT(e.enrolled_at, '%Y-%m')
ORDER BY month DESC
LIMIT 12";
return $this->db->query($sql, $teacher_id);
}
function getTopCoursesByTeacher($teacher_id)
{
$sql = "SELECT c.*,
(SELECT COUNT(*) FROM enrollments WHERE course_id = c.id AND role = 'student') as student_count,
(SELECT SUM(price) FROM enrollments WHERE course_id = c.id AND role = 'student') as revenue
FROM courses c
WHERE c.teacher_id = ?
ORDER BY student_count DESC
LIMIT 5";
return $this->db->query($sql, $teacher_id);
}
Ba phương thức đầu dùng queryOne(), hai phương thức sau dùng query(). Lý do đã giải thích ở mục 2.1: có GROUP BY hoặc lấy nhiều dòng thì dùng query().
countByTeacher() không cần JOIN vì nó chỉ đếm trong bảng courses. Ba phương thức còn lại đều phải nối với courses để lọc theo teacher_id.
getTotalStudentsByTeacher() dùng COUNT(DISTINCT e.user_id) chứ không phải COUNT(*).
Sự khác biệt rất quan trọng: nếu một học viên mua 3 khóa của cùng giảng viên, COUNT(*) đếm thành 3 người, còn COUNT(DISTINCT) đếm đúng 1 người. Tác giả làm đúng.
⚠️ Lưu ý về nguồn dữ liệu doanh thu. Cả năm phương thức đều tính trên bảng
enrollments, không phải bảngpayments.Nghĩa là "doanh thu" ở đây là tổng giá của các lượt ghi danh. Với luồng thanh toán ở Bài 21, dòng
enrollmentschỉ được tạo khi VNPay báo thành công, nên con số này đúng.Nhưng nếu quản trị viên thêm tay một dòng vào
enrollments— như bạn đã làm để thử nghiệm ở Bài 19 — thì doanh thu tăng lên mà không có giao dịch thật nào.Đây không phải lỗi, chỉ là điều bạn cần biết khi đọc số liệu.
3.2. Thêm analytics vào Controller
📁
Controllers/TeacherController.php— sửa
public function analytics()
{
include_once "./Models/Courses.php";
$coursesModel = new Courses();
// Thống kê tổng quan
$totalCourses = $coursesModel->countByTeacher($_SESSION['user_id']);
$totalRevenue = $coursesModel->getTotalRevenueByTeacher($_SESSION['user_id']);
$totalStudents = $coursesModel->getTotalStudentsByTeacher($_SESSION['user_id']);
// Thống kê theo tháng
$monthlyStats = $coursesModel->getMonthlyStatsByTeacher($_SESSION['user_id']);
// Top khóa học
$topCourses = $coursesModel->getTopCoursesByTeacher($_SESSION['user_id']);
include_once "./Views/teacher_analytics.php";
}
}
Khối này kết thúc bằng } đóng class — TeacherController đã hoàn chỉnh với 10 phương thức.
Phương thức analytics() là ví dụ điển hình của Controller làm đúng việc: chỉ gọi Model và giao dữ liệu cho View. Không có câu SQL nào, không có tính toán nào, không có HTML nào.
Nó gọi Model 5 lần với cùng một object $coursesModel. Tốt hơn cách tạo 5 object riêng, vì chỉ mở một kết nối database.
3.3. Tạo View thống kê
📁
Views/teacher_analytics.php— tạo mới
<main class="dashboard-page">
<?php include_once "./Views/layout_teacher_sidebar.php"; ?>
<div class="dashboard-content">
<h2>Thống kê và Báo cáo</h2>
<!-- Thống kê tổng quan -->
<div class="stats">
<div class="stat-box">
<h3><?= $totalCourses ?></h3>
<p>Tổng khóa học</p>
</div>
<div class="stat-box">
<h3><?= $totalStudents ?></h3>
<p>Tổng học viên</p>
</div>
<div class="stat-box">
<h3><?= number_format($totalRevenue) ?>đ</h3>
<p>Tổng doanh thu</p>
</div>
</div>
<!-- Top khóa học -->
<h3>Top khóa học bán chạy</h3>
<?php if (empty($topCourses)): ?>
<div style="text-align: center; padding: 30px; color: #666;">
<p>Chưa có dữ liệu thống kê</p>
</div>
<?php else: ?>
<table class="course-table">
<thead>
<tr>
<th>Tên khóa học</th>
<th>Số học viên</th>
<th>Doanh thu</th>
<th>Giá khóa học</th>
</tr>
</thead>
<tbody>
<?php foreach ($topCourses as $course): ?>
<tr>
<td><?= htmlspecialchars($course['title']) ?></td>
<td><?= $course['student_count'] ?? 0 ?></td>
<td><?= number_format($course['revenue'] ?? 0) ?>đ</td>
<td><?= number_format($course['price']) ?>đ</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<!-- Thống kê theo tháng -->
<h3>Thống kê theo tháng</h3>
<?php if (empty($monthlyStats)): ?>
<div style="text-align: center; padding: 30px; color: #666;">
<p>Chưa có dữ liệu thống kê theo tháng</p>
</div>
<?php else: ?>
<table class="course-table">
<thead>
<tr>
<th>Tháng</th>
<th>Số học viên mới</th>
<th>Doanh thu</th>
</tr>
</thead>
<tbody>
<?php foreach ($monthlyStats as $stat): ?>
<tr>
<td><?= date('m/Y', strtotime($stat['month'] . '-01')) ?></td>
<td><?= $stat['students'] ?></td>
<td><?= number_format($stat['revenue']) ?>đ</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</main>
Đây là View sạch nhất của cả project. Nó không có khối flash message nào — vì trang này chỉ đọc dữ liệu, không có thao tác nào để báo thành công hay thất bại.
Dòng <?= number_format($course['revenue'] ?? 0) ?>đ có ?? 0 vì subquery SUM(price) trả về null khi khóa học chưa ai mua — đúng cái bẫy ở mục 2.2.
So sánh ô "Tổng doanh thu" ở đây với ô "Tổng giá trị" ở dashboard Bài 17:
| Dashboard (Bài 17) | Analytics (Bài 20) | |
|---|---|---|
| Nhãn | "Tổng giá trị" | "Tổng doanh thu" |
| Công thức | Cộng price của các khóa học |
SUM cột price của enrollments |
| Chưa bán được gì | Vẫn hiện số dương | Hiện 0 |
| Ý nghĩa | Giá niêm yết cộng lại | Tiền thật đã thu |
Trang này mới là con số đúng. Dashboard chỉ hiển thị một phép cộng gây hiểu nhầm, như hộp cảnh báo ở Bài 17 mục 3.4 đã nêu.
4. Chạy thử
Đăng nhập giảng viên, mở ?ctrl=teacher&act=analytics hoặc bấm mục "Thống kê" trên sidebar.
Thử 1 — Khi chưa có dữ liệu. Với tài khoản mẫu và bảng enrollments nguyên bản, bạn thấy:
- Tổng khóa học: 2
- Tổng học viên: 0
- Tổng doanh thu: 0đ
- Bảng "Top khóa học bán chạy": có 2 dòng, cột Số học viên và Doanh thu đều là 0
- Bảng "Thống kê theo tháng": "Chưa có dữ liệu thống kê theo tháng"
Đây là hành vi đúng. Chú ý bảng Top vẫn có dòng dù chưa ai mua — vì nó lấy từ bảng courses, không phụ thuộc enrollments.
Thử 2 — Thêm dữ liệu để thấy thống kê hoạt động.
Vào phpMyAdmin, tab SQL, chạy câu sau. Nhớ đổi 20 và 21 thành id khóa học thật của bạn:
INSERT INTO enrollments (user_id, course_id, role, price, enrolled_at) VALUES
(2, 20, 'student', 30000, '2025-06-15 10:00:00'),
(3, 20, 'student', 30000, '2025-07-02 14:30:00'),
(4, 20, 'student', 30000, '2025-07-20 09:15:00'),
(6, 21, 'student', 25000, '2025-08-05 16:00:00'),
(7, 21, 'student', 25000, '2025-08-18 11:20:00'),
(2, 21, 'student', 25000, '2025-08-25 08:45:00');
Sáu dòng, nhưng chỉ 5 người khác nhau — user 2 mua cả hai khóa.
Tải lại trang thống kê:
- Tổng khóa học: 2
- Tổng học viên: 5 — không phải 6, vì
COUNT(DISTINCT)ở mục 3.1 - Tổng doanh thu: 165.000đ
Bảng "Top khóa học bán chạy":
| Tên khóa học | Số học viên | Doanh thu | Giá khóa học |
|---|---|---|---|
| Lập trình Python | 3 | 90.000đ | 30.000đ |
| Internet of Things | 3 | 75.000đ | 25.000đ |
Bảng "Thống kê theo tháng":
| Tháng | Số học viên mới | Doanh thu |
|---|---|---|
| 08/2025 | 3 | 75.000đ |
| 07/2025 | 2 | 60.000đ |
| 06/2025 | 1 | 30.000đ |
Đây là lúc DATE_FORMAT cho thấy sức mạnh. Sáu dòng dữ liệu với sáu thời điểm khác nhau được gom thành ba nhóm tháng, mỗi nhóm có tổng riêng.
Thử 3 — Kiểm chứng COUNT(DISTINCT). So sánh:
- Tổng học viên trên trang: 5
- Số dòng bạn vừa chèn: 6
Chênh lệch 1 chính là user 2 được đếm một lần thay vì hai.
Bây giờ chạy thử trong phpMyAdmin câu không có DISTINCT:
SELECT COUNT(e.user_id) as total FROM enrollments e
INNER JOIN courses c ON e.course_id = c.id
WHERE c.teacher_id = 35 AND e.role = 'student';
Kết quả là 6 — sai. Đó là lý do DISTINCT cần thiết.
Thử 4 — Kiểm chứng thứ tự thực hiện SQL. Trong phpMyAdmin, thử chạy:
SELECT c.*,
(SELECT COUNT(*) FROM enrollments WHERE course_id = c.id AND role = 'student') as student_count
FROM courses c
WHERE c.teacher_id = 35 AND student_count > 2;
Bạn nhận lỗi:
#1054 - Unknown column 'student_count' in 'where clause'
Đúng như mục 2.6 đã giải thích. Đổi WHERE ... AND student_count > 2 thành HAVING student_count > 2 thì chạy được.
Thử 5 — Xóa dữ liệu thử nghiệm nếu bạn muốn quay về trạng thái ban đầu:
DELETE FROM enrollments WHERE enrolled_at >= '2025-06-01';
5. Bài tập
Bài tập 1
Thêm phương thức getRevenueByCategory($teacher_id) thống kê doanh thu theo danh mục.
Kết quả gồm ba cột: tên danh mục, số khóa học, tổng doanh thu.
Hiển thị nó thành một bảng thứ ba trên trang thống kê.
Gợi ý: cần nối ba bảng
enrollments,courses,categories, rồiGROUP BYtheocategories.id.
Bài tập 2
Sửa getMonthlyStatsByTeacher() để:
- Sắp xếp tăng dần theo tháng (tháng cũ lên đầu)
- Bỏ
LIMIT 12
Sau đó trả lời: hai thay đổi này ảnh hưởng thế nào tới trang, và có nên giữ không?
Bài tập 3
Viết câu SQL trong phpMyAdmin tìm học viên chi nhiều tiền nhất cho một giảng viên cụ thể.
Kết quả gồm: tên học viên, email, tổng tiền đã chi, số khóa đã mua.
Chỉ lấy đúng một dòng đứng đầu.
6. Đáp án
Đáp án bài tập 1
📄 Models/Courses.php
function getRevenueByCategory($teacher_id)
{
$sql = "SELECT
cat.name as category_name,
COUNT(DISTINCT c.id) as course_count,
SUM(e.price) as revenue
FROM enrollments e
INNER JOIN courses c ON e.course_id = c.id
INNER JOIN categories cat ON c.category_id = cat.id
WHERE c.teacher_id = ? AND e.role = 'student'
GROUP BY cat.id
ORDER BY revenue DESC";
return $this->db->query($sql, $teacher_id);
}
📄 Controllers/TeacherController.php — thêm vào analytics()
$revenueByCategory = $coursesModel->getRevenueByCategory($_SESSION['user_id']);
📄 Views/teacher_analytics.php — thêm bảng thứ ba
<h3>Doanh thu theo danh mục</h3>
<?php if (empty($revenueByCategory)): ?>
<div style="text-align: center; padding: 30px; color: #666;">
<p>Chưa có dữ liệu</p>
</div>
<?php else: ?>
<table class="course-table">
<thead>
<tr>
<th>Danh mục</th>
<th>Số khóa học</th>
<th>Doanh thu</th>
</tr>
</thead>
<tbody>
<?php foreach ($revenueByCategory as $row): ?>
<tr>
<td><?= htmlspecialchars($row['category_name']) ?></td>
<td><?= $row['course_count'] ?></td>
<td><?= number_format($row['revenue'] ?? 0) ?>đ</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
Ba điểm đáng chú ý:
Thứ nhất, COUNT(DISTINCT c.id) chứ không phải COUNT(*). Vì sau khi nối với enrollments, một khóa học có 3 học viên sẽ xuất hiện 3 dòng. COUNT(*) sẽ đếm thành 3 khóa học. DISTINCT sửa được.
Đây đúng là tình huống mà Bài 19 mục 2.3 đã cảnh báo: có JOIN thì phải dùng COUNT(DISTINCT).
Thứ hai, GROUP BY cat.id chứ không phải GROUP BY cat.name. Nếu hai danh mục trùng tên, gom theo tên sẽ gộp nhầm chúng. Gom theo khóa chính luôn an toàn.
Thứ ba, bắt đầu từ enrollments chứ không phải courses. Nghĩa là danh mục chưa bán được khóa nào sẽ không xuất hiện. Muốn chúng xuất hiện với doanh thu 0 thì phải bắt đầu từ categories và dùng LEFT JOIN.
Đáp án bài tập 2
📄 Models/Courses.php — bản sửa
GROUP BY DATE_FORMAT(e.enrolled_at, '%Y-%m')
ORDER BY month ASC";
Ảnh hưởng của việc đổi sang ASC:
Bảng hiện tháng cũ ở trên, tháng mới ở dưới. Với dữ liệu thử nghiệm: 06/2025, 07/2025, 08/2025.
Nên hay không? Tùy mục đích:
DESC(bản gốc) tốt hơn cho bảng báo cáo — người dùng quan tâm tháng gần nhất, và nó nằm ngay dòng đầu không cần cuộn.ASCtốt hơn nếu bạn vẽ biểu đồ đường — trục thời gian phải đi từ trái sang phải theo chiều tăng.
Vì đây là bảng nên DESC hợp lý hơn. Nên giữ bản gốc.
Ảnh hưởng của việc bỏ LIMIT 12:
Bảng sẽ hiện mọi tháng từ khi website ra đời. Sau 3 năm hoạt động là 36 dòng, sau 10 năm là 120 dòng.
Ba vấn đề:
- Trang dài lê thê, người dùng phải cuộn nhiều
- Dữ liệu cũ ít giá trị — doanh thu tháng 8 năm ngoái hiếm khi cần xem cùng lúc với tháng này
- Truy vấn nặng dần theo thời gian
Nên giữ LIMIT 12. Con số 12 rất hợp lý: đúng một năm, đủ để so sánh cùng kỳ năm trước.
Nếu cần xem xa hơn, cách đúng là thêm bộ lọc khoảng thời gian, không phải bỏ giới hạn.
Bài học rút ra: LIMIT trong truy vấn báo cáo không phải là cắt xén dữ liệu — nó là quyết định thiết kế có chủ đích. Nhưng nó phải được nói rõ cho người dùng biết, ví dụ đổi tiêu đề thành "Thống kê 12 tháng gần nhất".
Bản gốc của EzCode chỉ ghi "Thống kê theo tháng" — người dùng không biết mình đang xem dữ liệu bị giới hạn. Đây là một điểm nhỏ nên cải thiện.
Đáp án bài tập 3
SELECT
u.name,
u.email,
SUM(e.price) AS tong_chi,
COUNT(DISTINCT e.course_id) AS so_khoa
FROM users u
INNER JOIN enrollments e ON u.id = e.user_id
INNER JOIN courses c ON e.course_id = c.id
WHERE c.teacher_id = 35 AND e.role = 'student'
GROUP BY u.id
ORDER BY tong_chi DESC
LIMIT 1;
Với dữ liệu thử nghiệm ở mục 4, kết quả là:
| name | tong_chi | so_khoa | |
|---|---|---|---|
| Trần Thị B | student1@example.com | 55000 | 2 |
Câu này gói gọn mọi kỹ thuật của Bài 19 và Bài 20:
| Thành phần | Kiến thức từ |
|---|---|
| Nối ba bảng | Bài 19 mục 2.4 |
GROUP BY u.id |
Bài 19 mục 2.2 |
SUM() và COUNT(DISTINCT) |
Bài 19 mục 2.3 |
ORDER BY theo bí danh |
Bài 20 mục 2.6 |
LIMIT 1 |
Bài 20 mục 2.4 |
Một biến thể hữu ích — tìm top 5 thay vì top 1, và chỉ lấy những người chi trên 50.000:
...
GROUP BY u.id
HAVING tong_chi > 50000
ORDER BY tong_chi DESC
LIMIT 5;
Chú ý dùng HAVING chứ không phải WHERE, vì tong_chi là cột tính toán — đúng thứ tự thực hiện ở mục 2.6.
Nếu bạn muốn đưa câu này vào project, hãy thêm nó thành một phương thức getTopSpendersByTeacher($teacher_id, $limit = 5) trong Models/User.php — vì nó trả về thông tin về người dùng, không phải khóa học.
Việc chọn đúng Model để đặt phương thức cũng là một quyết định thiết kế: đặt theo dữ liệu chính mà nó trả về, không phải theo nơi bạn tình cờ nghĩ ra nó.
➡️ Bài tiếp theo: Bài 21 — Tích hợp thanh toán VNPay
⬅️ Về mục lục · Bài trước
All rights reserved