M17 — Dự án tổng kết
← 16 - Python kết nối Database | Quay lại Bắt đầu
Mục tiêu module
Xây dựng một ứng dụng hoàn chỉnh từ đầu tới cuối, tổng hợp toàn bộ 16 module đã học. Đây là bằng chứng cho thấy bạn đã nắm được nền tảng lập trình.
Thời lượng: ~8 giờ | Tuần: 26
1. Đề bài: Sổ Thu Chi Cá Nhân
1.1. Mô tả
Xây dựng ứng dụng dòng lệnh quản lý thu chi cá nhân, lưu dữ liệu vào SQLite.
1.2. Yêu cầu chức năng
| # | Chức năng | Mô tả |
|---|---|---|
| 1 | Thêm giao dịch | Ngày, loại (Thu/Chi), danh mục, số tiền, ghi chú |
| 2 | Xem danh sách | Có phân trang, lọc theo tháng / loại / danh mục |
| 3 | Sửa giao dịch | Sửa theo ID |
| 4 | Xóa giao dịch | Có xác nhận trước khi xóa |
| 5 | Quản lý danh mục | Thêm/sửa/xóa danh mục thu, chi |
| 6 | Báo cáo tháng | Tổng thu, tổng chi, số dư, chi tiết theo danh mục |
| 7 | Báo cáo năm | Bảng 12 tháng, biểu đồ cột bằng ký tự |
| 8 | Xuất CSV | Xuất dữ liệu ra file Excel đọc được |
| 9 | Sao lưu / phục hồi | Xuất/nhập JSON |
1.3. Yêu cầu kỹ thuật
- Chia thành ít nhất 4 module (
.pyriêng) - Dùng class cho ít nhất 2 thành phần (
@dataclasscho model, class cho tầng lưu trữ) - Dùng SQLite với parameterized query 100%
- Dùng
Decimalcho mọi số tiền - Dùng
date/datetimecho ngày tháng - Xử lý lỗi đầy đủ — chương trình không bao giờ crash
- Mọi hàm có docstring
- Tên biến/hàm theo PEP 8
- Có
requirements.txt,.gitignore,README.md - Chạy trong môi trường ảo
2. Cấu trúc dự án đề xuất
so_thu_chi/
├── .venv/
├── .gitignore
├── README.md
├── requirements.txt
├── main.py <- Điểm khởi chạy, menu
├── cau_hinh.py <- Hằng số, đường dẫn
├── models.py <- @dataclass: GiaoDich, DanhMuc
├── kho_du_lieu.py <- Tầng database: CRUD
├── nghiep_vu.py <- Logic tính toán, báo cáo
├── giao_dien.py <- Hàm in ấn, nhập liệu
├── tien_ich.py <- Hàm dùng chung
└── du_lieu/
└── so_thu_chi.db
Sơ đồ phụ thuộc giữa các module
graph TD A[main.py] --> B[giao_dien.py] A --> C[nghiep_vu.py] B --> D[tien_ich.py] C --> E[kho_du_lieu.py] C --> F[models.py] E --> F E --> G[cau_hinh.py] C --> G B --> G
Nguyên tắc phân tầng
Mũi tên chỉ chiều phụ thuộc.
kho_du_lieu.pykhông được importgiao_dien.py— tầng dưới không biết gì về tầng trên. Đây là nguyên tắc kiến trúc phần mềm cơ bản, bạn đã gặp trong các hệ thống ERP: tầng Data Access, tầng Business Logic, tầng Presentation.
3. Code khởi đầu
Dưới đây là bộ khung để bạn bắt đầu. Không copy toàn bộ — hãy gõ lại và tự hoàn thiện các phần TODO.
cau_hinh.py
"""Cấu hình và hằng số toàn hệ thống."""
from decimal import Decimal
from pathlib import Path
# Đường dẫn
THU_MUC_GOC = Path(__file__).parent
THU_MUC_DU_LIEU = THU_MUC_GOC / "du_lieu"
DUONG_DAN_DB = THU_MUC_DU_LIEU / "so_thu_chi.db"
THU_MUC_XUAT = THU_MUC_GOC / "xuat"
# Đảm bảo thư mục tồn tại
THU_MUC_DU_LIEU.mkdir(exist_ok=True)
THU_MUC_XUAT.mkdir(exist_ok=True)
# Hiển thị
DO_RONG_BANG = 80
SO_DONG_MOI_TRANG = 15
# Nghiệp vụ
LOAI_THU = "THU"
LOAI_CHI = "CHI"
CAC_LOAI = (LOAI_THU, LOAI_CHI)
SO_TIEN_TOI_DA = Decimal("999999999999")
DANH_MUC_MAC_DINH = [
(LOAI_THU, "Luong"),
(LOAI_THU, "Thuong"),
(LOAI_THU, "Ban hang"),
(LOAI_THU, "Khac"),
(LOAI_CHI, "An uong"),
(LOAI_CHI, "Di lai"),
(LOAI_CHI, "Nha o"),
(LOAI_CHI, "Y te"),
(LOAI_CHI, "Giao duc"),
(LOAI_CHI, "Giai tri"),
(LOAI_CHI, "Khac"),
]
TEN_THANG = {
1: "Thang 01", 2: "Thang 02", 3: "Thang 03", 4: "Thang 04",
5: "Thang 05", 6: "Thang 06", 7: "Thang 07", 8: "Thang 08",
9: "Thang 09", 10: "Thang 10", 11: "Thang 11", 12: "Thang 12",
}models.py
"""Các model dữ liệu."""
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
import cau_hinh
@dataclass
class GiaoDich:
"""Một giao dịch thu hoặc chi."""
ngay: date
loai: str
danh_muc: str
so_tien: Decimal
ghi_chu: str = ""
id: int | None = None
def __post_init__(self):
"""Kiểm tra dữ liệu ngay sau khi khởi tạo."""
if self.loai not in cau_hinh.CAC_LOAI:
raise ValueError(
f"Loai phai la mot trong {cau_hinh.CAC_LOAI}, nhan duoc '{self.loai}'"
)
if self.so_tien <= 0:
raise ValueError(f"So tien phai lon hon 0, nhan duoc {self.so_tien}")
if self.so_tien > cau_hinh.SO_TIEN_TOI_DA:
raise ValueError(f"So tien vuot gioi han cho phep")
if not self.danh_muc or not self.danh_muc.strip():
raise ValueError("Danh muc khong duoc de trong")
@property
def la_thu(self):
"""Giao dịch này có phải khoản thu không."""
return self.loai == cau_hinh.LOAI_THU
@property
def so_tien_co_dau(self):
"""Số tiền có dấu: dương nếu thu, âm nếu chi."""
return self.so_tien if self.la_thu else -self.so_tien
def __str__(self):
dau = "+" if self.la_thu else "-"
return f"[{self.ngay:%d/%m/%Y}] {dau}{self.so_tien:,.0f} - {self.danh_muc}"
@dataclass(frozen=True)
class DanhMuc:
"""Danh mục thu/chi."""
loai: str
ten: str
def __str__(self):
return f"{self.ten} ({self.loai})"
@dataclass
class BaoCaoThang:
"""Kết quả báo cáo một tháng."""
nam: int
thang: int
tong_thu: Decimal = Decimal("0")
tong_chi: Decimal = Decimal("0")
theo_danh_muc: dict = field(default_factory=dict)
so_giao_dich: int = 0
@property
def so_du(self):
"""Chênh lệch thu - chi."""
return self.tong_thu - self.tong_chi
@property
def ty_le_tiet_kiem(self):
"""Tỷ lệ tiết kiệm trên tổng thu (%)."""
if self.tong_thu == 0:
return Decimal("0")
return self.so_du / self.tong_thu * 100kho_du_lieu.py (khung — bạn hoàn thiện)
"""Tầng truy cập dữ liệu — mọi thao tác với database nằm ở đây."""
import sqlite3
from contextlib import contextmanager
from datetime import date
from decimal import Decimal
import cau_hinh
from models import DanhMuc, GiaoDich
@contextmanager
def ket_noi():
"""Context manager quản lý kết nối SQLite."""
conn = sqlite3.connect(cau_hinh.DUONG_DAN_DB)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def khoi_tao():
"""Tạo bảng và nạp danh mục mặc định."""
with ket_noi() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS DanhMuc (
Loai TEXT NOT NULL,
Ten TEXT NOT NULL,
PRIMARY KEY (Loai, Ten)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS GiaoDich (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Ngay TEXT NOT NULL,
Loai TEXT NOT NULL,
DanhMuc TEXT NOT NULL,
SoTien TEXT NOT NULL,
GhiChu TEXT DEFAULT '',
FOREIGN KEY (Loai, DanhMuc) REFERENCES DanhMuc(Loai, Ten)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_gd_ngay ON GiaoDich(Ngay)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_gd_loai ON GiaoDich(Loai)")
# Nạp danh mục mặc định nếu bảng rỗng
co = conn.execute("SELECT COUNT(*) FROM DanhMuc").fetchone()[0]
if co == 0:
conn.executemany(
"INSERT INTO DanhMuc (Loai, Ten) VALUES (?, ?)",
cau_hinh.DANH_MUC_MAC_DINH
)
def _tu_row(row):
"""Chuyển sqlite3.Row thành GiaoDich."""
return GiaoDich(
id=row["Id"],
ngay=date.fromisoformat(row["Ngay"]),
loai=row["Loai"],
danh_muc=row["DanhMuc"],
so_tien=Decimal(row["SoTien"]),
ghi_chu=row["GhiChu"] or "",
)
# ---------- GIAO DỊCH ----------
def them(gd: GiaoDich) -> int:
"""Thêm giao dịch, trả về Id vừa tạo."""
with ket_noi() as conn:
cur = conn.execute(
"INSERT INTO GiaoDich (Ngay, Loai, DanhMuc, SoTien, GhiChu) "
"VALUES (?, ?, ?, ?, ?)",
(gd.ngay.isoformat(), gd.loai, gd.danh_muc, str(gd.so_tien), gd.ghi_chu)
)
return cur.lastrowid
def lay_theo_id(id_gd: int) -> GiaoDich | None:
"""Lấy một giao dịch theo Id."""
with ket_noi() as conn:
row = conn.execute("SELECT * FROM GiaoDich WHERE Id = ?", (id_gd,)).fetchone()
return _tu_row(row) if row else None
def loc(nam=None, thang=None, loai=None, danh_muc=None) -> list[GiaoDich]:
"""
Lọc giao dịch theo nhiều tiêu chí.
Mọi tham số là tùy chọn — bỏ qua nghĩa là không lọc theo tiêu chí đó.
"""
sql = "SELECT * FROM GiaoDich WHERE 1=1"
tham_so = []
if nam is not None:
sql += " AND strftime('%Y', Ngay) = ?"
tham_so.append(f"{nam:04d}")
if thang is not None:
sql += " AND strftime('%m', Ngay) = ?"
tham_so.append(f"{thang:02d}")
if loai is not None:
sql += " AND Loai = ?"
tham_so.append(loai)
if danh_muc is not None:
sql += " AND DanhMuc = ?"
tham_so.append(danh_muc)
sql += " ORDER BY Ngay DESC, Id DESC"
with ket_noi() as conn:
return [_tu_row(r) for r in conn.execute(sql, tham_so).fetchall()]
def cap_nhat(gd: GiaoDich) -> bool:
"""Cập nhật giao dịch. TODO: bạn tự hoàn thiện."""
# TODO
raise NotImplementedError
def xoa(id_gd: int) -> bool:
"""Xóa giao dịch theo Id. TODO: bạn tự hoàn thiện."""
# TODO
raise NotImplementedError
# ---------- DANH MỤC ----------
def lay_danh_muc(loai=None) -> list[DanhMuc]:
"""Lấy danh sách danh mục, lọc theo loại nếu có."""
sql = "SELECT Loai, Ten FROM DanhMuc"
tham_so = ()
if loai:
sql += " WHERE Loai = ?"
tham_so = (loai,)
sql += " ORDER BY Loai, Ten"
with ket_noi() as conn:
return [DanhMuc(r["Loai"], r["Ten"]) for r in conn.execute(sql, tham_so)]
def them_danh_muc(dm: DanhMuc):
"""Thêm danh mục mới. TODO: xử lý trùng lặp."""
# TODO
raise NotImplementedErrormain.py (khung)
"""Sổ thu chi cá nhân — chương trình chính."""
import sqlite3
import cau_hinh
import giao_dien
import kho_du_lieu
import nghiep_vu
def menu_chinh():
"""Vòng lặp menu chính."""
while True:
giao_dien.in_menu_chinh()
chon = input("Chon chuc nang: ").strip()
try:
if chon == "1":
giao_dien.man_hinh_them_giao_dich()
elif chon == "2":
giao_dien.man_hinh_danh_sach()
elif chon == "3":
giao_dien.man_hinh_sua()
elif chon == "4":
giao_dien.man_hinh_xoa()
elif chon == "5":
giao_dien.man_hinh_danh_muc()
elif chon == "6":
giao_dien.man_hinh_bao_cao_thang()
elif chon == "7":
giao_dien.man_hinh_bao_cao_nam()
elif chon == "8":
giao_dien.man_hinh_xuat_csv()
elif chon == "9":
giao_dien.man_hinh_sao_luu()
elif chon == "0":
print("\nTam biet!")
break
else:
print("Lua chon khong hop le")
except ValueError as e:
print(f"\n ! Loi du lieu: {e}")
except sqlite3.Error as e:
print(f"\n ! Loi database: {e}")
except KeyboardInterrupt:
print("\n ! Da huy thao tac")
except Exception as e:
print(f"\n ! Loi khong xac dinh: {type(e).__name__}: {e}")
def main():
"""Điểm khởi chạy."""
print("Dang khoi tao...")
kho_du_lieu.khoi_tao()
menu_chinh()
if __name__ == "__main__":
main()4. Gợi ý cho các phần khó
4.1. Biểu đồ cột bằng ký tự
def ve_bieu_do_cot(du_lieu, do_rong_toi_da=40):
"""
Vẽ biểu đồ cột ngang bằng ký tự.
Tham số:
du_lieu (dict): {nhãn: giá trị}
do_rong_toi_da (int): Số ký tự tối đa của cột dài nhất.
"""
if not du_lieu:
print(" (Khong co du lieu)")
return
lon_nhat = max(du_lieu.values())
if lon_nhat == 0:
print(" (Tat ca gia tri bang 0)")
return
do_dai_nhan = max(len(str(k)) for k in du_lieu)
for nhan, gia_tri in du_lieu.items():
do_dai = int(gia_tri / lon_nhat * do_rong_toi_da)
cot = "█" * do_dai
print(f" {str(nhan):<{do_dai_nhan}} │{cot} {gia_tri:,.0f}")
# Sử dụng
ve_bieu_do_cot({
"An uong": Decimal("4500000"),
"Di lai": Decimal("1200000"),
"Nha o": Decimal("8000000"),
"Giai tri": Decimal("2000000"),
}) An uong │██████████████████████ 4,500,000
Di lai │██████ 1,200,000
Nha o │████████████████████████████████████████ 8,000,000
Giai tri │██████████ 2,000,000
4.2. Phân trang
def in_co_phan_trang(danh_sach, so_dong_moi_trang=15, ham_in_dong=None):
"""In danh sách theo trang, chờ người dùng nhấn Enter."""
tong = len(danh_sach)
if tong == 0:
print(" (Khong co du lieu)")
return
so_trang = (tong + so_dong_moi_trang - 1) // so_dong_moi_trang
for trang in range(so_trang):
dau = trang * so_dong_moi_trang
cuoi = min(dau + so_dong_moi_trang, tong)
for muc in danh_sach[dau:cuoi]:
if ham_in_dong:
ham_in_dong(muc)
else:
print(f" {muc}")
print(f"\n --- Trang {trang + 1}/{so_trang} "
f"(dong {dau + 1}-{cuoi}/{tong}) ---")
if trang < so_trang - 1:
tiep = input(" Enter de xem tiep, 'q' de thoat: ").strip().lower()
if tiep == "q":
break
print()4.3. Nhập ngày an toàn
from datetime import date, datetime
def nhap_ngay(thong_bao, mac_dinh_hom_nay=True):
"""
Nhập ngày theo định dạng dd/mm/yyyy.
Nhấn Enter để dùng ngày hôm nay (nếu mac_dinh_hom_nay=True).
"""
goi_y = " (Enter = hom nay)" if mac_dinh_hom_nay else ""
while True:
nhap = input(f"{thong_bao} [dd/mm/yyyy]{goi_y}: ").strip()
if not nhap and mac_dinh_hom_nay:
return date.today()
for mau in ("%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%y"):
try:
return datetime.strptime(nhap, mau).date()
except ValueError:
continue
print(" ! Dinh dang khong hop le. Vi du: 08/08/2026")4.4. Nhập số tiền an toàn
from decimal import Decimal, InvalidOperation
def nhap_so_tien(thong_bao, toi_thieu=Decimal("0.01")):
"""Nhập số tiền, chấp nhận dấu chấm/phẩy phân cách hàng nghìn."""
while True:
nhap = input(f"{thong_bao}: ").strip()
if not nhap:
print(" ! Khong duoc de trong")
continue
# Bỏ dấu phân cách hàng nghìn
sach = nhap.replace(",", "").replace(".", "").replace(" ", "")
try:
gia_tri = Decimal(sach)
except InvalidOperation:
print(" ! Vui long nhap so. Vi du: 1500000 hoac 1.500.000")
continue
if gia_tri < toi_thieu:
print(f" ! So tien phai tu {toi_thieu} tro len")
continue
return gia_tri4.5. Chọn từ danh sách
def chon_tu_danh_sach(thong_bao, cac_muc, hien_thi=str):
"""
Cho người dùng chọn một mục từ danh sách bằng số thứ tự.
Trả về mục được chọn, hoặc None nếu người dùng hủy.
"""
if not cac_muc:
print(" ! Danh sach rong")
return None
print(f"\n{thong_bao}")
for i, muc in enumerate(cac_muc, start=1):
print(f" {i:>2}. {hien_thi(muc)}")
print(" 0. Huy")
while True:
nhap = input("Chon: ").strip()
if not nhap.isdigit():
print(" ! Vui long nhap so")
continue
chon = int(nhap)
if chon == 0:
return None
if 1 <= chon <= len(cac_muc):
return cac_muc[chon - 1]
print(f" ! Vui long chon tu 0 den {len(cac_muc)}")5. Lộ trình 8 giờ
| Giờ | Việc |
|---|---|
| 1 | Dựng cấu trúc thư mục, .venv, cau_hinh.py, models.py |
| 2 | kho_du_lieu.py — khởi tạo DB, hàm them, loc, lay_theo_id |
| 3 | kho_du_lieu.py — hoàn thiện cap_nhat, xoa, phần danh mục |
| 4 | tien_ich.py + giao_dien.py — các hàm nhập liệu an toàn |
| 5 | main.py + menu + chức năng 1, 2 (thêm, xem) |
| 6 | Chức năng 3, 4, 5 (sửa, xóa, danh mục) |
| 7 | nghiep_vu.py — báo cáo tháng, năm, biểu đồ |
| 8 | Xuất CSV, sao lưu JSON, README.md, kiểm thử toàn diện |
6. Checklist nghiệm thu
Chức năng
- Thêm được giao dịch thu và chi
- Xem được danh sách, có phân trang
- Lọc được theo tháng, loại, danh mục
- Sửa được giao dịch
- Xóa được (có hỏi xác nhận)
- Quản lý được danh mục
- Báo cáo tháng hiển thị đúng tổng thu, chi, số dư
- Báo cáo năm hiển thị 12 tháng
- Có biểu đồ cột
- Xuất CSV mở được bằng Excel, tiếng Việt hiển thị đúng
- Sao lưu/phục hồi JSON hoạt động
Kỹ thuật
- Chia thành ít nhất 4 module
- Có ít nhất 2 class/dataclass
- Mọi câu SQL dùng parameterized query
- Mọi số tiền dùng
Decimal - Mọi hàm có docstring
- Tên biến/hàm theo
snake_case - Có
requirements.txt,.gitignore,README.md
Kiểm thử phá hoại (BẮT BUỘC làm)
Thử làm hỏng chương trình bằng mọi cách. Nó phải không sập:
- Nhập chữ vào ô số tiền
- Nhập số âm
- Nhập số cực lớn (999999999999999)
- Nhập ngày
32/13/2026 - Để trống mọi ô, nhấn Enter liên tục
- Nhấn
Ctrl + Cgiữa chừng - Nhập ID không tồn tại khi sửa/xóa
- Nhập ký tự đặc biệt
'; DROP TABLE GiaoDich; -- - Xóa file database khi chương trình đang chạy
- Nhập tiếng Việt có dấu vào mọi ô văn bản
7. Đề bài thay thế
Nếu sổ thu chi không hấp dẫn, chọn một trong các đề sau — yêu cầu kỹ thuật giữ nguyên:
| Đề | Mô tả |
|---|---|
| Quản lý kho hàng | Nhập/xuất kho, tồn kho, cảnh báo hết hàng, báo cáo xuất nhập tồn |
| Sổ công nợ | Khách hàng, hóa đơn, thanh toán, báo cáo tuổi nợ |
| Quản lý tài sản cố định | Tài sản, khấu hao theo đường thẳng, báo cáo giá trị còn lại |
| Chấm công và tính lương | Nhân viên, chấm công, tính lương, bảo hiểm, thuế TNCN |
| Đối chiếu dữ liệu | Đọc 2 file CSV, đối chiếu, báo cáo chênh lệch (dùng set ở 09 - Dict và Set) |
8. Sau khi hoàn thành
8.1. Tự đánh giá
Viết vào Nhật ký học tập - Template:
- Phần nào khó nhất? Tại sao?
- Kiến thức module nào bạn dùng nhiều nhất?
- Kiến thức module nào bạn thấy chưa vững, cần ôn lại?
- Nếu làm lại từ đầu, bạn sẽ làm khác gì?
- Bạn mất bao lâu thực tế so với 8 giờ dự kiến?
8.2. Bước tiếp theo
Bạn đã hoàn thành nền tảng lập trình Python. Giờ chọn một hướng đi:
| Hướng | Nội dung cần học | Phù hợp nếu |
|---|---|---|
| Xử lý dữ liệu | pandas, openpyxl, numpy | Muốn tự động hóa báo cáo Excel |
| Tích hợp hệ thống | requests, REST API, SQLAlchemy | Muốn nối ERP với hệ thống khác |
| Chất lượng code | pytest, type hints, ruff, Git | Muốn làm phần mềm nghiêm túc |
| Web | FastAPI, HTML/CSS cơ bản | Muốn làm ứng dụng web |
| Giao diện desktop | tkinter, PySide6 | Muốn làm tool có giao diện |
Lời khuyên
Chọn một hướng, không làm nhiều thứ cùng lúc. Với quỹ thời gian 5 giờ/tuần, dàn trải sẽ khiến bạn không sâu ở đâu cả.
Với công việc ERP-VN của bạn, tôi gợi ý thứ tự: Git → pandas/openpyxl → SQLAlchemy → API. Git trước vì nó là công cụ nền, dùng cho mọi thứ về sau.
8.3. Duy trì
Kiến thức lập trình mai một nhanh nếu không dùng. Cách duy trì:
- Mỗi tuần viết một script nhỏ giải quyết một việc lặp lại trong công việc
- Đọc lại notebook này mỗi 3 tháng, cập nhật bổ sung những gì học thêm
- Tham gia cộng đồng: đọc câu hỏi người khác, thử tự trả lời
9. Tài liệu
| Nguồn | Nội dung | Link |
|---|---|---|
| The Python Tutorial | Toàn bộ hướng dẫn chính thức | https://docs.python.org/3/tutorial/ |
| Python HOWTOs | Các hướng dẫn chuyên đề | https://docs.python.org/3/howto/ |
| Python Standard Library | Thư viện chuẩn | https://docs.python.org/3/library/ |
| PEP Index | Toàn bộ đề xuất cải tiến Python | https://peps.python.org/ |
| Python Glossary | Từ điển thuật ngữ chính thức | https://docs.python.org/3/glossary.html |
Chúc mừng bạn đã hoàn thành lộ trình 26 tuần!
← Quay lại Bắt đầu