DHDataHọc
QuizLesson 30/306 min read+10 XP

Đồ án nhỏ: báo cáo doanh thu theo tháng

Bạn sẽ học được

  • Ghép toàn bộ chương 5 vào một chương trình hoàn chỉnh.
  • Đọc CSV, parse ngày, nhóm theo tháng, tổng hợp doanh thu.
  • Xử lý dòng lỗi mà không làm dừng cả báo cáo.
  • Xuất kết quả dạng bảng và JSON.
  • Tự kiểm tra bằng bộ câu hỏi cuối bài.

Đề bài

Cho file sales.csv ghi các đơn hàng:

code
ngay,mat_hang,so_luong,don_gia
2026-01-05,Áo thun,2,150000
2026-01-12,Quần jean,1,450000
2026-02-03,Áo thun,3,150000
2026-02-14,Áo khoác,2,350000
2026-02-20,Quần jean,1,190000
2026-02-21,Mũ,,90000

Viết chương trình:

  1. Đọc từng dòng, bỏ qua dòng lỗi và đếm lại.
  2. Tính doanh thu từng dòng = so_luong × don_gia.
  3. Nhóm doanh thu theo tháng (YYYY-MM).
  4. In bảng tổng hợp và tổng cộng.
  5. Xuất dict doanh thu theo tháng ra JSON.

Kết quả mong đợi:

code
Số dòng hợp lệ: 5 | dòng lỗi: 1
2026-01      750,000
2026-02    1,340,000
Tổng       2,090,000
{"2026-01": 750000, "2026-02": 1340000}

Dòng cuối (Mũ) thiếu so_luong nên int("") lỗi — đây là dòng bị bỏ qua.

Bước 1 — Đọc và kiểm tra từng dòng

Điểm quyết định chất lượng báo cáo: một dòng hỏng không được làm hỏng cả file. Đọc từng dòng, parse ngay khi đọc, và gom ngày + tháng vào bản ghi:

code
import csv
import io
from datetime import datetime

def doc_ban_ghi(reader):
    rows, loi = [], 0
    for row in reader:
        try:
            ngay = datetime.strptime(row["ngay"], "%Y-%m-%d").date()
            so_luong = int(row["so_luong"])
            don_gia = int(row["don_gia"])
        except (ValueError, KeyError, TypeError):
            loi += 1
            continue
        rows.append({
            "ngay": ngay,
            "thang": ngay.strftime("%Y-%m"),
            "mat_hang": row["mat_hang"],
            "doanh_thu": so_luong * don_gia,
        })
    return rows, loi

Vì sao bắt cả ba loại:

  • ValueError: ô rỗng hoặc không phải số (int("")).
  • KeyError: file thiếu cột so với dự kiến.
  • TypeError: hiếm hơn, nhưng giữ lại để hàm không vỡ nếu đầu vào khác kiểu.

continue sau khi đếm lỗi là mấu chốt: báo cáo đi tiếp, còn số dòng lỗi được trả về để người dùng biết dữ liệu không sạch.

Trong file thật:

code
with open("sales.csv", newline="", encoding="utf-8") as f:
    rows, loi = doc_ban_ghi(csv.DictReader(f))

Với file xuất từ Excel, đổi encoding="utf-8" thành "utf-8-sig".

Bước 2 — Nhóm theo tháng

rows đã có sẵn trường thang, nên chỉ cần cộng dồn vào dict. Dùng get(..., 0) + là mẫu gom nhóm cơ bản:

code
theo_thang = {}
for r in rows:
    theo_thang[r["thang"]] = theo_thang.get(r["thang"], 0) + r["doanh_thu"]

tong = sum(theo_thang.values())

Dict giữ thứ tự chèn, nên tháng xuất hiện trước in trước — với dữ liệu đã sắp theo ngày thì báo cáo tự nhiên đúng thứ tự. Nếu nguồn lộn xộn, sắp lại:

code
theo_thang = dict(sorted(theo_thang.items()))

Nếu chỉ cần một dòng cho mỗi tháng, có thể thay toàn bộ vòng lặp bằng defaultdict(int) hoặc Counter:

code
from collections import defaultdict

theo_thang = defaultdict(int)
for r in rows:
    theo_thang[r["thang"]] += r["doanh_thu"]

Bước 3 — In bảng

Canh cột bằng format spec trong f-string: < canh trái cho nhãn, > canh phải cho số, , thêm dấu phân cách hàng nghìn:

code
for thang, tien in theo_thang.items():
    print(f"{thang:<10}{tien:>10,}")
print(f"{'Tổng':<10}{tong:>10,}")
code
2026-01      750,000
2026-02    1,340,000
Tổng       2,090,000

Kiểm tra bằng tay để chắc số đúng:

ThángCác dòngDoanh thu
2026-012 × 150.000; 1 × 450.000300.000 + 450.000 = 750.000
2026-023 × 150.000; 2 × 350.000; 1 × 190.000450.000 + 700.000 + 190.000 = 1.340.000
Tổng2.090.000

Nên in cả số dòng đã bỏ qua. Một báo cáo không nói gì về dòng lỗi là báo cáo không đáng tin:

code
print("Số dòng hợp lệ:", len(rows), "| dòng lỗi:", loi)
code
Số dòng hợp lệ: 5 | dòng lỗi: 1

Bước 4 — Xuất JSON

Sau khi đã có bảng trong bộ nhớ, xuất JSON rất ngắn vì theo_thang đã đúng cấu trúc dict → object:

code
import json

print(json.dumps(theo_thang, ensure_ascii=False))
code
{"2026-01": 750000, "2026-02": 1340000}

Ghi ra file:

code
with open("bao_cao.json", "w", encoding="utf-8") as f:
    json.dump(theo_thang, f, ensure_ascii=False, indent=2)

Trong đồ án này mọi kiểu đều serialize được (str, int), nên không gặp TypeError: Object of type ... is not JSON serializable. Nhưng nếu bạn thêm trường ngay kiểu date vào JSON, nhớ .isoformat() trước.

Bước 5 — Ghép lại

Toàn bộ chương trình, dùng chuỗi mẫu thay cho file:

code
import csv
import io
import json
from datetime import datetime

raw = """ngay,mat_hang,so_luong,don_gia
2026-01-05,Áo thun,2,150000
2026-01-12,Quần jean,1,450000
2026-02-03,Áo thun,3,150000
2026-02-14,Áo khoác,2,350000
2026-02-20,Quần jean,1,190000
2026-02-21,Mũ,,90000
"""

def doc_ban_ghi(reader):
    rows, loi = [], 0
    for row in reader:
        try:
            ngay = datetime.strptime(row["ngay"], "%Y-%m-%d").date()
            so_luong = int(row["so_luong"])
            don_gia = int(row["don_gia"])
        except (ValueError, KeyError, TypeError):
            loi += 1
            continue
        rows.append({
            "ngay": ngay,
            "thang": ngay.strftime("%Y-%m"),
            "mat_hang": row["mat_hang"],
            "doanh_thu": so_luong * don_gia,
        })
    return rows, loi

rows, loi = doc_ban_ghi(csv.DictReader(io.StringIO(raw)))
print("Số dòng hợp lệ:", len(rows), "| dòng lỗi:", loi)

theo_thang = {}
for r in rows:
    theo_thang[r["thang"]] = theo_thang.get(r["thang"], 0) + r["doanh_thu"]

tong = sum(theo_thang.values())
for thang, tien in theo_thang.items():
    print(f"{thang:<10}{tien:>10,}")
print(f"{'Tổng':<10}{tong:>10,}")

print(json.dumps(theo_thang, ensure_ascii=False))

Chạy đúng cho kết quả ở phần đề bài. Đổi io.StringIO(raw) thành open("sales.csv", newline="", encoding="utf-8") là dùng được với file thật.

Mở rộng

  • Thêm cột số đơn mỗi tháng: so_don[thang] = so_don.get(thang, 0) + 1.
  • Tính giá trị trung bình mỗi đơn từng tháng.
  • Đọc thêm cột khu_vuc và nhóm hai tầng thang → khu_vuc.
  • Ghi báo cáo ra CSV bằng DictWriter thay vì in màn hình.
  • Kiểm tra don_gia âm và đếm riêng loại lỗi đó thay vì gộp chung.

Tự kiểm tra

1. Tổng doanh thu tháng 2 là bao nhiêu? 450.000 + 700.000 + 190.000 = 1.340.000.

2. Vì sao dòng Mũ bị bỏ qua? Cột so_luong rỗng; int("") ném ValueError. except bắt lỗi, loi += 1, continue.

3. Nếu xóa continue thì chuyện gì xảy ra? Vòng lặp vẫn chạy tiếp (vì except đã bắt lỗi), nhưng rows.append vẫn được gọi với so_luong/don_gia chưa gán — gây UnboundLocalError ở lần lặp đầu tiên bị lỗi.

4. Vì sao datetime.strptime cần "%Y-%m-%d"? Chuỗi dạng 2026-01-05; nếu dùng %d/%m/%Y sẽ lỗi hoặc đọc sai. Định dạng phải khớp nguồn.

5. Tổng tính từ theo_thang hay từ rows — có khác nhau không? Không, sum(theo_thang.values()) bằng tổng doanh_thu của mọi dòng hợp lệ. Tính từ theo_thang gọn hơn và tự khớp với bảng đã in.

6. Muốn báo cáo luôn bắt đầu bằng 2026-01 dù tháng đó không có đơn thì làm sao? Không thể suy ra từ dữ liệu. Duyệt danh sách tháng cố định và dùng theo_thang.get(thang, 0) để in 0 cho tháng thiếu.

Lỗi thường gặp

Triệu chứngNguyên nhânCách sửa
Cả chương trình dừng ở dòng hỏngKhông có try/except quanh parseBắt (ValueError, KeyError, TypeError)
Báo cáo thiếu số mà không hayNuốt lỗi bằng passĐếm loi và in ra
UnboundLocalErrorcontinue bị bỏ sótThêm continue trong except
Tháng bị đảo thứ tựNguồn lộn xộnsorted(theo_thang.items())
Số dính liềnThiếu , trong format specf"{tien:,}"
Tiếng Việt thành \uensure_ascii mặc địnhensure_ascii=False
strptime lỗiSai định dạng ngàyKhớp %Y-%m-%d với nguồn
Tổng lệchCộng trước khi ép kiểuÉp int rồi mới nhân

Tóm tắt

  • Đọc CSV → parse ngay → bỏ qua dòng lỗi có đếm.
  • Nhóm bằng dict.get(..., 0) +, tổng bằng sum(values()).
  • In bảng bằng format spec <, >, ,.
  • Xuất JSON bằng dumps(..., ensure_ascii=False).
  • Một đồ án dữ liệu tốt luôn nói rõ bao nhiêu dòng bị bỏ — im lặng là lỗi.

Use ← → to move between lessons