Python+PyQt5+MySQL酒店管理系统课程设计实战指南
2026/9/16 8:59:05 网站建设 项目流程

简介:面向数据库系统课程设计的酒店管理系统完整项目,基于Python+PyQt5+MySQL搭建,包含可直接运行的源代码、数据库脚本与课程设计报告,适合计算机及相关专业学生参考、复用或进行功能扩展。系统围绕酒店日常运营,实现员工管理(含权限分级与增删改查)、客房管理(个人/团队/预订入住、退房、房间维护)和客户管理(入住记录存档与分析)等核心业务,界面全部采用Qt Designer绘制,业务逻辑与界面分离,便于深入学习GUI编程和MySQL数据库交互。资源包共61个文件,涵盖18个Python源码文件、8个UI界面文件、3个SQL数据库脚本、2份PDF课程设计文档,另附E-R图、功能结构图等设计素材,整体大小仅8.28MB,目录结构清晰易检索。已有209人学习下载,凭借完整的设计文档和可直接运行的代码,可显著节省课设开发时间,同时为理解软件分层架构与数据库落地应用提供真实案例。

1. 课设标题里藏着三重门槛:GUI、数据库、可运行

把“基于 Python+PyQt5+MySQL 的酒店管理系统”这个课设标题拆开看,真正卡住人的从来不是 Python 语法,而是三件事要同时交差:界面能点、数据能查、交上去的压缩包解压后能跑。前两件靠一个下午就能打通,第三件才是课程设计的高分分水岭——开发机上能启动的程序,换到机房电脑往往崩在 MySQL 连接、PyQt5 插件或编码上。下面按课设交付顺序写:先用 MySQL 把实体和存储过程钉死,再用 PyQt5 搭出前台窗口,最后补上“可直接运行”的两个落地细节,顺带覆盖课程设计报告里最常被追问的事务、时序图和界面截图从哪来。适合正在做课设的学生,也适合帮人审课设代码的工程师快速定位问题。

2. 选型定了先别写代码,把 MySQL 表结构与存储过程钉死

2.1 房间、客户、订单三张核心表的实体关系

酒店管理系统做课程设计,规模不需要上微服务,但关系型数据库该有的味道要出来。常见做法是围绕“房间-客户-订单”三条主线建表,不要一上来就建十几张表把外键绕成蜘蛛网。核心关系是:一个客户可以有多张订单,一张订单对着一间房,房间状态由订单驱动变化。会员信息可以单独拆 guest 表,也可以并入客户表,课设建议拆开,因为结账、报表都要按客户维度统计。

这里有个建模选择:房间的状态是“存在性字段”还是“派生字段”。把rooms.status做成独立字段,查询房态最方便,但必须靠代码或存储过程保证它与订单状态一致;如果完全由订单表实时计算房态,就少一个字段,但每次刷新房态都要 JOIN。课设阶段选前者,配合事务把两处更新绑在同一提交里,实现上最直观,答辩时也能讲清楚状态一致性怎么保证。

2.2 可直接执行的建库建表 SQL:约束与索引一次写对

下面是这套系统最常见的一套建库脚本,按课程设计默认场景设计:单机开发,root 账号,InnoDB 引擎。注意字符集,MySQL 8.0 默认 utf8mb4,但建库时显式指定更稳,避免 Windows 下控制台导入 SQL 文件时注释中文变乱码。

CREATE DATABASE IF NOT EXISTS hotel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE hotel_db; CREATE TABLE rooms ( room_id INT AUTO_INCREMENT PRIMARY KEY, room_no VARCHAR(10) NOT NULL UNIQUE COMMENT '房号', room_type ENUM('单人间','双人间','家庭房') NOT NULL, price_per_night DECIMAL(10,2) NOT NULL DEFAULT 0, status ENUM('空闲','入住','维修') NOT NULL DEFAULT '空闲', remark VARCHAR(255) DEFAULT '' ) ENGINE=InnoDB; CREATE TABLE guests ( guest_id INT AUTO_INCREMENT PRIMARY KEY, guest_name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL UNIQUE, phone VARCHAR(20) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE orders ( order_id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, room_id INT NOT NULL, guest_id INT NOT NULL, check_in DATETIME NOT NULL, check_out DATETIME DEFAULT NULL, nights INT NOT NULL DEFAULT 1, total_amount DECIMAL(10,2) DEFAULT 0, status ENUM('入住','退房','取消') NOT NULL DEFAULT '入住', KEY idx_status (status), CONSTRAINT fk_order_room FOREIGN KEY (room_id) REFERENCES rooms(room_id), CONSTRAINT fk_order_guest FOREIGN KEY (guest_id) REFERENCES guests(guest_id) ) ENGINE=InnoDB;

这段 SQL 有三个值得在报告里展开的设计点。第一,rooms.status用 ENUM 限死取值范围,界面下拉框可以照着枚举填,查询统计时不会出现“可住”“空房”“空闲”三种写法并存。第二,orders.check_out允许 NULL,退房前不填,这是“当前在住订单”的自然表达;如果建表就 NOT NULL,入住登记时反而要先编一个假的退房时间。第三,外键写在订单表上,约束住“订单必须关联存在的房间和客户”,虽然代码里照样要判断,但数据库这层兜底能拦住批量导入带来的脏数据。

字段设计要点课设常见错误
rooms.status用 ENUM 限死枚举值手输“可住/空房/满房”,统计口径混乱
orders.check_out允许 NULL,退房前不填NOT NULL 导致入住登记要先编造退房时间
orders.order_id自增主键,与业务无关拿 room_id 当订单主键,一间房只能住一次
guests.id_card建 UNIQUE 唯一约束同一身份证重复开单无法拦截

字段设计的常见误区顺带提一下:拿 room_id 当订单主键是最典型的错,一间房多次入住时主键冲突直接崩掉;电话号码只做普通索引即可,身份证号才值得建 UNIQUE。order_no留成字符串是给“显示用单号”留余地,比如20250601-001这种格式,方便前台对单。

2.3 用 MySQL 存储过程收拢“退房结算”业务规则

退房结算是酒店管理系统的核心业务,课设报告里最常画的就是这张图。结算规则一句话:房费等于实际住宿天数乘以房价,不足一天按一天算。把这句规则写进 MySQL 存储过程,而不是散落在按钮的点击事件里,答辩时被问“业务规则在哪一层”就能直接回答。

DELIMITER // CREATE PROCEDURE checkout_room( IN p_order_id INT, IN p_leave_time DATETIME, OUT p_amount DECIMAL(10,2) ) BEGIN DECLARE v_room_id INT; DECLARE v_check_in DATETIME; DECLARE v_price DECIMAL(10,2); SELECT room_id, check_in, price_per_night INTO v_room_id, v_check_in, v_price FROM orders JOIN rooms USING (room_id) WHERE order_id = p_order_id AND status = '入住' FOR UPDATE; IF v_room_id IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order not found or already checked out'; END IF; SET p_amount = GREATEST(DATEDIFF(p_leave_time, v_check_in), 1) * v_price; UPDATE orders SET status='退房', check_out=p_leave_time, total_amount=p_amount WHERE order_id = p_order_id; UPDATE rooms SET status='空闲' WHERE room_id = v_room_id; END // DELIMITER ;

参数设计上,把退房时间p_leave_time作为入参而不是直接调 NOW(),好处是补单、改单时能传历史时间,单纯演示退房结算时传NOW()也行。FOR UPDATE锁住订单行,防止两个窗口同时对这个订单点退房。GREATEST(..., 1)处理“当天入住当天退”的边界,否则DATEDIFF会算出 0 元账单。存储过程的验证方式很简单:导完脚本后执行CALL checkout_room(1, NOW(), @amt); SELECT @amt;,看到房间状态同时被改掉,就说明逻辑通。

3. PyQt5 GUI 编程骨架:连接层、主窗口与信号槽

3.1 为什么用 pymysql 封装单例连接,而不是每个按钮各连一次

Python 连 MySQL 的方案很多,SQLAlchemy 功能强但课设没必要;pymysql 是纯 Python 实现,装上就能用,不用像 mysql-connector 那样配一堆驱动。常见做法是不引入 ORM,直接用 SQL,理由很现实:答辩抽问存储过程、事务、索引时,用裸 SQL 回答起来最不心虚。连接不建议每个按钮创建一个,演示时点一次卡一次。用一个类级别的单例包住连接,项目里所有窗口共享这一条连接即可。

# db.py —— 课程设计级的数据库连接封装 import pymysql class DB: _conn = None @classmethod def connect(cls, host='localhost', port=3306, user='root', password='123456', database='hotel_db'): cls._conn = pymysql.connect( host=host, port=port, user=user, password=password, database=database, charset='utf8mb4', autocommit=False, cursorclass=pymysql.cursors.DictCursor ) return cls._conn @classmethod def query(cls, sql, args=None): cur = cls._conn.cursor() cur.execute(sql, args or ()) rows = cur.fetchall() cur.close() return rows @classmethod def execute(cls, sql, args=None): cur = cls._conn.cursor() affected = cur.execute(sql, args or ()) cur.close() cls._conn.commit() return affected

cursorclassDictCursor,查出来的行是字典,row['room_no']row[0]可读性强得多,也避免 SELECT 字段顺序一变代码就跟着改。autocommit=False是刻意的:入住登记这类操作需要两个 UPDATE 同生共死,靠手动 COMMIT 控制事务边界。查询走query,写操作用execute,事务内需要手写 COMMIT/ROLLBACK 时不走execute,直接拿DB._conn操作,这也是课设代码里最常见的分层习惯。

3.2 主窗口沿用 QMainWindow,房态视图用 QTableWidget

主窗口布局课设常见做法是:左边 QListWidget 做功能导航,中间 QTableWidget 放房态或订单明细,底部 QStatusBar 显示数据库连接状态和提示。QTableWidget 比 QTableView 适合课设,因为数据量小,不需要自定义 Model/View 就有现成的填格能力;要注意它默认允许双击编辑,管理员界面里最好关掉,避免演示时手滑把单价改了。

控件适合场景课设建议
QTableWidget数据量小、需要直接填格房态列表、订单列表首选用它
QTableView + QSqlTableModel数据量大、需要排序过滤课设没必要引入,徒增复杂度
QTreeWidget树形导航、分类展示放左侧功能菜单顺手
# main_window.py —— 主窗口骨架 from PyQt5.QtWidgets import (QMainWindow, QWidget, QTableWidget, QTableWidgetItem, QPushButton, QVBoxLayout) from db import DB class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle('酒店管理系统 - 前台') self.resize(860, 540) self.table = QTableWidget(0, 4) self.table.setHorizontalHeaderLabels(['房号', '类型', '单价', '状态']) self.table.setEditTriggers(QTableWidget.NoEditTriggers) btn = QPushButton('刷新房态') btn.clicked.connect(self.refresh_rooms) box = QVBoxLayout() box.addWidget(self.table) box.addWidget(btn) center = QWidget(self) center.setLayout(box) self.setCentralWidget(center) def refresh_rooms(self): rows = DB.query( 'SELECT room_no, room_type, price_per_night, status ' 'FROM rooms ORDER BY room_no') self.table.setRowCount(len(rows)) for i, row in enumerate(rows): values = [row['room_no'], row['room_type'], f"{row['price_per_night']:.2f}", row['status']] for j, value in enumerate(values): self.table.setItem(i, j, QTableWidgetItem(value))

setRowCount(len(rows))配合循环setItem是性价比最高的填表方式;另一种insertRow逐行插入会在数据量上来后明显卡顿,而且行数多了容易漏删。NoEditTriggers保证表格只读,所有修改都走弹窗表单,数据校验集中在同一处。单价转成f"{...:.2f}"再显示,能顺手解决浮点数显示成100.0的问题。

3.3 信号槽驱动的“查询-填充”最小闭环

PyQt5 里按钮事件、窗口关闭、下拉选择都是信号,槽函数就是普通 Python 方法。上面的btn.clicked.connect(self.refresh_rooms)就是最小闭环:点击按钮触发查询,查询结果填回表格。这个模式后面所有功能都在复用,区别只是 SQL 和控件不同。运行时先连数据库再弹主界面,是另一个值得养成的顺序习惯——MySQL 没启动时,程序要有明确报错而不是黑框闪退。

if __name__ == '__main__': import sys from PyQt5.QtWidgets import QApplication, QMessageBox app = QApplication(sys.argv) try: DB.connect() except Exception as exc: QMessageBox.critical(None, '连接失败', f'请检查 MySQL 是否启动:\n{exc}') sys.exit(1) win = MainWindow() win.show() sys.exit(app.exec_())

这段启动代码把“数据库不可用”从“程序崩溃”变成了“弹窗提示”,答辩演示时即使忘开 MySQL 也不会当场翻车。QMessageBox.critical属于模态对话框,把str(exc)接进去后,机房电脑连不上库时不用开调试器就能判断是密码错还是服务没启。

4. 入住登记与退房结算:PyQt5 界面 + MySQL 事务的联动实现

4.1 入住登记用 FOR UPDATE 锁行,房间与订单同事务提交

入住登记是酒店管理系统的并发敏感点:同一间房的最后一个空闲状态,可能被两个前台窗口同时抢到。课设虽然不会真有并发压力,但事务写法必须正确,报告里的 E-R 图、时序图、事务说明都指望这段代码。实现上,先查房间状态并加排他锁,确认空闲后插入订单、改房间状态,最后一次性提交。

from datetime import datetime def check_in(self, room_id, guest_id, nights): if nights <= 0: raise ValueError('入住天数必须大于 0') conn = DB.connect() cur = conn.cursor() try: cur.execute('SELECT status FROM rooms WHERE room_id=%s FOR UPDATE', (room_id,)) row = cur.fetchone() if row is None or row['status'] != '空闲': raise RuntimeError('该房间不可入住') order_no = f"{datetime.now():%Y%m%d%H%M%S}-{room_id}" cur.execute( "INSERT INTO orders (order_no, room_id, guest_id," " check_in, nights, status) VALUES (%s, %s, %s, NOW(), %s, '入住')", (order_no, room_id, guest_id, nights)) cur.execute('UPDATE rooms SET status=%s WHERE room_id=%s', ('入住', room_id)) conn.commit() except Exception: conn.rollback() raise finally: cur.close()

FOR UPDATE的意义是:在这事务提交前,其他连接执行同一条 SELECT 会被阻塞,等到提交后才能读到新状态。order_no拼上时间戳和房间号,保证同一秒对同一房间重复提交也不会撞唯一索引。异常时rollback是关键,否则会出现“房间状态已改、订单没插入”的中间态;这也是课程设计报告里“事务一致性”一节最直接的证据。

4.2 退房结算:用 callproc 调用存储过程并回读金额

退房按钮要做的事:从当前订单列表选中一条在住订单,调用checkout_room,把返回金额显示出来,同时刷新房态表。点按钮之前要把订单状态筛选好,只让“入住”状态的订单进入退房流程,避免重复退房。

def checkout_selected(self, order_id): conn = DB.connect() with conn.cursor() as cur: cur.callproc('checkout_room', (order_id, datetime.now(), -1)) cur.execute('SELECT @_checkout_room_2 AS amount') row = cur.fetchone() conn.commit() if row: QMessageBox.information(self, '退房成功', f'应收金额:{row["amount"]} 元') self.refresh_rooms() self.refresh_order_table()

pymysql 调用存储过程时,OUT 参数不直接返回,而是存到用户变量里,命名规则是@_存储过程名_参数序号。这里checkout_room的第三个参数序号是 2,所以读出@_checkout_room_2。先SELECTcommit,确保金额是在事务内读的。如果存储过程走的是SIGNAL报错,Python 这边会抛异常,界面上用 try 包一层弹窗即可,不必让程序退出。

4.3 QComboBox 联动过滤房型,刷新动作收拢成一个方法

房态界面光有“刷新”按钮不够,课设演示时评审大概率会点一下房型下拉框,看筛选是否生效。常见做法是:房型下拉框的选项写死三类房型加上“全部”,选择变化时重新查房间表并按类型过滤。

def on_type_changed(self): room_type = self.combo_type.currentText() if room_type == '全部': rows = DB.query('SELECT room_no, room_type, price_per_night, status' ' FROM rooms ORDER BY room_no') else: rows = DB.query('SELECT room_no, room_type, price_per_night, status' ' FROM rooms WHERE room_type=%s ORDER BY room_no', (room_type,)) self.fill_table(rows)

下拉框的currentTextChanged信号连接这个函数,每次选择变化自动刷新表格,这就是 GUI 编程里典型的联动。容易漏掉的一点:入住、退房成功后要同时刷新房间表和订单表,而不是只刷其中一个。房态统计区如果显示“当前在住 X 间”,也应在这个时间点一起重算。把三个刷新动作收进一个refresh_all()方法,是避免“界面和数据库状态不一致”最笨也最有效的办法。

动作订单状态房间状态需要刷新
入住成功入住入住房间表、订单表、统计
退房成功退房空闲房间表、订单表、统计
取消订单取消空闲房间表、订单表

5. 报表与可视化:把查询结果渲染进 PyQt5 界面

5.1 用 QTextBrowser 的 setHtml 直接渲染营收报表

课程设计报告要截图,如果报表做成打印样式的 HTML,截图效果远好于 QTableView 里挤成一行行的数据。QTextBrowser 支持富文本和网页样式,不需要引入 WebEngine 就能显示表格。热门检索里总有人问 PyQt5 显示 HTML 怎么处理,实际上setHtml对课设报表够用,且打包体积远小于 QWebEngineView。

def show_income_report(self): rows = DB.query( "SELECT DATE(check_out) AS d, COUNT(*) AS cnt," " SUM(total_amount) AS amt" " FROM orders WHERE status='退房'" " GROUP BY DATE(check_out) ORDER BY d DESC LIMIT 30") html_parts = ['''<h3>近 30 日退房营收</h3> <table border="1" cellspacing="0" cellpadding="6" width="100%"> <tr><th>日期</th><th>退房单数</th><th>营收(元)</th></tr>'''] for row in rows: html_parts.append( f"<tr><td>{row['d']}</td><td>{row['cnt']}</td>" f"<td>{float(row['amt']):,.2f}</td></tr>") html_parts.append('</table>') self.text_report.setHtml(''.join(html_parts))

GROUP BY DATE(check_out)把退房时间按天聚合,注意营收按退房时间而不是入住时间统计,这是业务口径,报告里可以专门写一句。金额格式化用:,.2f会带千分位逗号,比裸数字更像正式报表。QTextBrowser的默认字体在 Windows 下偏小,可以加一行self.text_report.document().setDefaultStyleSheet('body{font-size:14px}')统一调。

5.2 把 matplotlib 图表嵌进 PyQt5 画布,房态占比一眼看清

GUI 编程的加分项是带图。matplotlib 有专门的 Qt5 后端,把 Figure 塞进FigureCanvasQTAgg就能和 QWidget 一起布局。房态占比饼图是最不容易出错的选型,因为只查一个分组统计。

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure def refresh_pie(self): rows = DB.query('SELECT status, COUNT(*) AS c FROM rooms GROUP BY status') if not rows: return fig = Figure(figsize=(4, 3), dpi=100) ax = fig.add_subplot(111) labels = [r['status'] for r in rows] values = [r['c'] for r in rows] ax.pie(values, labels=labels, autopct='%1.1f%%') self._pie_canvas = FigureCanvas(fig) self.layout_pie.addWidget(self._pie_canvas)

FigureCanvas是 QWidget 子类,能直接 addWidget;刷新前要把旧画布从布局里摘掉,否则多张饼图叠在一起。dpi=100是清晰度和打包体积的平衡点。这里不用每次重建 Figure 也行,但要清空坐标轴重画;课设代码怎么直接怎么来,重建 Figure 反而简单。

5.3 报表导出 CSV 与打印的一条可行路径

评审要“导出报表”功能时,别用 xlwt 老库,直接用csv标准库最稳。编码指定utf-8-sig,Windows 上用 Excel 打开才不会乱码。

import csv with open('income.csv', 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['日期', '退房单数', '营收']) for row in rows: writer.writerow([row['d'], row['cnt'], float(row['amt'])])

utf-8-sig会写 BOM 头,Excel 才能识别 UTF-8;newline=''防止 Windows 下每行多一个空行。如果还要打印,QTextBrowser 从 QTextEdit 继承了print_(),接一个 QPrinter 就能直接走系统打印对话框,报表截图和打印可以共用一份 HTML 数据。

6. 让“可直接运行”成立:PyInstaller 打包与验证清单

6.1 打包参数、资源路径与 PyQt5 插件坑

“可直接运行”五个字在标题里是最重的一条承诺。开发机python main.py能跑不算数,交付物要么是main.py + requirements.txt,要么是 exe。课设通常两者都交,源码包给老师查,exe 用于演示。打包用 PyInstaller,Windows 上命令是:

pip install pyinstaller pymysql PyQt5 pyinstaller -F -w -n hotel_system main.py

-F打单文件,-w去掉控制台黑窗,-n指定程序名。资源文件要用--add-data带进包,Windows 分隔符是分号,Linux 是冒号。程序里读资源路径时,用sys._MEIPASS判断是否在打包环境:

import sys, os def resource_path(name): base = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, name)

打包常见的坑:多进程或多线程下-F单文件会导致临时目录被提前清理,课设单窗口场景基本不受影响;matplotlib 打包体积会多十几 MB,报告里写明即可。PyQt5 偶发启动时报 Qt platform plugin 找不到,常见做法是把PyQt5-Qt5固定回 5.15.2。

6.2 交付前对照清单自查:三行命令验证数据库与打包

作为帮人审课设代码的人,最常看到的问题是:zip 解压后,README 说“导入 hotel_db.sql”,但源文件里根本没有 sql;或者密码写死成自己机器上的 root 密码,换台机器连不上。交付前至少跑一遍下面三条命令:

mysql -u root -p < sql/init.sql python -c "from db import DB; DB.connect(); print(DB.query('SELECT COUNT(*) c FROM rooms'))" pyinstaller -F -w --add-data "resources;resources" main.py

第一条重建数据库,第二条验证 pymysql 连接和查询,第三条出 exe。DB.connect()的密码参数在课设里写死可以理解,但要把密码提成config.py里的常量,README 里用一行话说清怎么改。

提示:exe 打包好后,在干净的 Windows 虚拟机上验证一次,比在开发机上点十次都有说服力。

最后在无 Python 环境的干净机器上双击 exe,能看到主窗口和房态数据,才算真正达成“可直接运行”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询