1. 从Excel表格到系统化:这家小酒店的痛点逼我写了这套系统
做酒店客房管理系统之前,我一直觉得这活儿不难——无非是登记、开房、退房、算钱。直到一个做民宿老板的朋友拿着一个塞满公式和合并单元格的Excel把我堵在咖啡厅,我才意识到,排房不是“登记”那么简单。那几天前台三班倒,每个班次都要在共享表格里改房态,结果经常出现同一间房被两个渠道订出去,或者明明标了“已入住”却没人填客人信息。她问我:能不能用Python给我做一套简单的系统?要有源码,要能自己改,最好连数据库脚本和文档一起交付,不然以后接手的员工根本看不懂。
我答应的时候还没想到,这套“基于Python的酒店客房管理系统”会从一个课程设计级别的Demo,慢慢演化成交付给真实场景使用的项目。本文要聊的,就是这套系统的完整拆解:包括我怎么选型、怎么设计数据库、怎么把预订到退房的整个状态机撸顺,以及那些不跑一遍根本发现不了的坑。适合正在做课设、想接私活、或者单纯想搞明白“酒店管理系统到底怎么实现”的朋友参考。文章里给出的思路和关键代码段都是可复用的,源码结构、数据库脚本和文档的整理思路也会一并讲透。
系统的定位很明确:不碰供应链、不碰员工考勤,只把“客房”这一条核心链路做穿。具体包括房间档案管理、客户信息登记、预订锁房、入住办理、换房、退房结算、入住率统计这几个模块。技术栈锁定Python + Flask + MySQL,数据库设计上耗尽心思做了状态字段和订单流水双轨制,最终交付物包含完整Python源码、可直接导入的SQL脚本、以及一份前台操作指南加上开发文档。下面就从需求开始讲。
2. 技术选型的取舍:Python + Flask + MySQL是怎么凑到一起的
2.1 为什么放弃Django选了Flask
很多人在社区问,酒店管理系统用Django不是现成的有admin后台吗?确实,Django自带Admin、模型、迁移、认证,一套组合拳打下来似乎更快。但我最后选了Flask,原因是这个系统本质上是一个高度定制的中小型业务系统,不是内容型网站。Django的ORM在使用上确实顺手,但它的App结构和中间件机制对于一个小团队来说有点重,尤其当我想在路线上写一些简单的状态机判断时,Flask的“微框架”属性让我能更自由地控制请求的生命周期。另一个现实因素是从部署角度考虑,Flask应用用一个app.py就能跑起来,配合waitress或gunicorn都很轻,不像Django要配一堆WSGI中间件。对于酒店前台那种只有一两台Windows收银机的环境,Flask明显更友好。
还要考虑一个关键点:这套系统的使用者是非技术背景的前台人员。Flask + Jinja2模板能让我把整个页面逻辑集中在服务端渲染里,不需要单独起一个前端Node服务。反观Django,即使不写前端,也默认带了不少用不上的能力,数据库迁移逻辑在项目初期频繁改表时也会拖慢节奏。所以,如果你的项目偏重业务逻辑的快速迭代、需要贴合前台操作习惯的后台界面,Flask可以用,而且能让你少踩很多“框架替你做了决定”的坑。
2.2 SQLite还是MySQL,以及连接池的必要性
这是踩过的很实在的一个问题。开发阶段我用了SQLite,因为单文件、免安装,跑起来零负担。但项目需要交付给真实酒店跑,就不得不换成MySQL。为什么?第一,SQLite不支持并发写:前台三台收银机同时操作时,SQLite的数据库锁会让排房和结算出现明显的卡顿,甚至“database is locked”报错。第二,SQLite在数据量上去后备份和权限管理很弱。MySQL有独立的账户体系,binlog日志方便恢复,更重要的是它支持SELECT ... FOR UPDATE这样的行级锁,这在处理“同一间房并发预订”时几乎是救命特性。
既然用了MySQL,连接池就必须提上日程。说实话,很多Python项目根本不配连接池,每次请求都pymysql.connect一次,短连接在高并发下很容易把数据库的线程数打满。我用的方案是SQLAlchemy自带的核心连接池,配置了pool_size=5、max_overflow=10,并在Flask应用启动时初始化引擎。这里有个细节:连接池不是越大越好。对于酒店前台这种并发峰值也就几十个人的场景,连接池设个5到10就够了,省得数据库被一堆空闲连接拖垮。如果你不想引SQLAlchemy太重,用DBUtils.PooledDB配合PyMySQL也能达到类似效果,但我在项目里是直接用SQLAlchemy Core做连接管理,和ORM模型共用一套引擎,少一层中间件。
2.3 ORM与原生SQL的边界:哪些查询必须手写
关于ORM和原生SQL的争论,在这里的结论:建模查询用ORM,统计报表用原生SQL。为什么?ORM在对象关系映射上确实省心,特别像房间表、订单表、客户表这种典型的CRUD,SQLAlchemy的模型写出来非常直观。但有几种查询必须手写,否则要么性能崩,要么逻辑绕到你怀疑人生。第一个是复杂的日期交叉判断:例如查某个时间段内的可用房间,用ORM表式join会写得很痛苦,但原生SQL用NOT EXISTS子查询就能轻松解决。第二个是入住率聚合,后台要按日统计出租率、按房型统计收入,这些窗口函数(如ROW_NUMBER、SUM OVER)在ORM里要拼半天,直接用text()片段嵌进SQL更清爽。
我在系统中维护了一个orders表,统计当日入住率时先查出当天所有在住的订单记录,然后按房型分组,再关联房间总数算百分比。这类带窗口函数的语句用ORM写,不仅可读性差,还会多出不少隐式查询,导致前台页面转圈半天。项目中凡涉及统计报表的地方,我都用了db.session.execute(text(...))原生SQL,确保数据库一次算完返回结果。这个边界划清楚后,维护成本低很多。
3. 数据库设计:把房间、订单、账单变成可检索的表格
3.1 房间表与状态机:从“空闲/入住/脏房”扩展到六态
酒店房态不能拍脑袋设计成“空闲、占用、脏”三个字段就完事。真实前台面临的房态至少包括:空闲(可预订)、预占(已被预订但客人未到)、已入住(在住)、已退房(房间已退,还没清洁)、清洁中(保洁正在打扫)、维修中(排查维修问题)。其中“已退房”和“清洁中”必须分开,因为退房后不一定立刻有人打扫,而前台的排房操作在“清洁中”结束时才能释放房间。我建了一张rooms表,包含room_id、room_number、room_type、floor、status、price_per_night、capacity等字段,其中status用TINYINT存枚举值:0空闲、1预占、2入住、3已退、4清洁中、5维修。
这张表最核心的设计不是字段多,而是状态流有方向性。举例:预占的房间不能直接变空闲,必须经过“取消预订”或者“办理入住”;已入住的房间不能直接变空闲,必须经过“退房”变成已退房,再由清洁完成变成空闲。为了把这个规则落到数据库层,我除了在应用层写状态机校验,还在rooms表加了一个status_updated_at字段记录状态变更时间,方便做超时提醒。比如预占状态超过24小时客人没到,前台列表里就要高亮显示。
3.2 订单主表+子表:预订、入住、换房、加床的归一化处理
订单结构是这套系统的灵魂。我采用“订单主表(orders)+ 订单明细表(order_items)”的做法。主表存的是客户主键、总金额、状态(待入住/在住/已退/已取消)、check_in_date、check_out_date、created_at。明细表存的是每晚的房费、加床费、减免费用和备注。为什么要拆分?因为换房场景下,客户可能在一个订单里住了A房两天,又换了B房一晚,费用明细不同,但订单整体还是一个连续履约过程。如果把换房信息全堆在一个表里,退房结算时很难分清哪天多少钱。
这里有个更关键的设计:订单状态使用“step”字段而不是用一系列时间字段去推算。例如预订阶段的订单step=1,入住登记后step=2,退房后step=3,取消step=0。这样在列表筛选时直接where step in (1,2)就能找到所有“需要今天接待”的客人,不需要去比较入住时间。换房操作实际上是“把原房间的剩余晚数拆出来建一个新订单明细”,同时把原明细停止日期修改为换房日。所有明细都外键关联order_id,删除主订单时级联删除,保证数据不会出现脏孤儿记录。
3.3 金额字段为什么要用Decimal列而不是Float列
这个坑特别典型,而且很多课设代码里都是错的。数据库里凡是涉及钱的字段,一律用DECIMAL(10,2),不能图省事用FLOAT。为什么?因为浮点数在二进制里无法精确表示0.1。比如119.9乘以3,在Python的float计算里得到359.69999999999994,直接入库再Reselect,账单就会对不上。我第一版就踩了这坑,晚上对账时发现差了一分钱,查了半天才想起来是浮点精度问题。
除了金额类型,订单的计费日期字段我统一用DATETIME,不用DATE,因为前台退房往往发生在12:00左右,延时退房会额外收费,需要存精确时间点。而像统计“某天入住率”时,只需要比较DATETIME的日期部分即可。这里有个小技巧:数据库连接字符串里加上use_timezone=False,统一用服务器本地时间,不要引入UTC偏移,否则凌晨退房的订单日期可能错位。对于单店应用,保存本地时间就够了,别给自己添乱。
4. 核心功能落地:登录、排房、退房结算的代码实现
4.1 用Flask-Login还是JWT?我最后选了JWT的完整理由
酒店管理系统的登录模块看着简单,但涉及角色权限。系统里有两类角色:店长(能看报表、调房价、操作所有房间)、前台(只能办理入住和退房)。一开始我想省事用Flask-Login,session在服务端存状态,非常经典。但我发现一个问题:前台的浏览器是那种老Windows上的Chrome,动不动就清Cookie,session一丢前台就要重新登录。另外当我需要给之后的小程序端预留接口时,session方案天生不适合移动端。
所以最终用了JWT。实现方式很简单:登录接口验证账密后,生成一个带角色信息的JWT放在cookie里,请求进来时通过Flask的before_request钩子做解析,再把用户身份存进g对象。JWT的好处是服务端不用存会话状态,重启应用也不掉线,而且payload里带角色标识,权限判断就是一个装饰器的事儿。坏处是如果要强制退出不容易,但酒店内部系统无所谓,token过期时间设成6小时,前台上班登录一次就行。这边附一个关键装饰器代码:
from functools import wraps from flask import request, jsonify, g import jwt def login_required(role=None): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): token = request.cookies.get('token') if not token: return jsonify({"code": 401, "msg": "未登录"}), 401 try: payload = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256']) g.user_id = payload['uid'] g.user_role = payload['role'] except jwt.ExpiredSignatureError: return jsonify({"code": 401, "msg": "登录已过期"}), 401 except jwt.InvalidTokenError: return jsonify({"code": 401, "msg": "无效凭证"}), 401 if role and g.user_role != role: return jsonify({"code": 403, "msg": "权限不足"}), 403 return f(*args, **kwargs) return wrapper return decorator实际使用时,在房态操作的路由上加@login_required(role='admin')就能限制只有店长能调价,前台角色则被拦截。这套逻辑简单清晰,没引入额外扩展包,调试也方便。
4.2 预订和入住的房间锁定:事务与行锁的正确姿势
“同一间房同时被两个人抢着预订”是并发问题的典型场景。方案是在事务里用SELECT ... FOR UPDATE锁定rooms表的行,然后检查状态,如果状态不是空闲,立刻回滚。关键是必须把“检查状态”和“更新状态”放在同一个事务里,中间不能有任何让出事务的IO操作。很多课设代码是分开的两个request,先查一次房态,再点提交,中间隔了几分钟,那肯定有并发漏洞。
我当时的实现是在MySQL事务里执行类似这样的SQL:
SELECT status FROM rooms WHERE room_id = ? FOR UPDATE; -- 应用层判断status == 0(空闲)后继续 UPDATE rooms SET status = 1, pre_customer_id = ?, pre_check_in_date = ? WHERE room_id = ?; INSERT INTO orders ...; COMMIT;用SQLAlchemy时,注意session.begin()之后的查询会默认自动使用当前事务,但只有执行了FOR UPDATE子句才能真正锁行。SQLAlchemy里直接支持db.session.execute(text(...)),所以我把这个核心操作写成了原生SQL,避免ORM把锁语义吞掉。顺带提一句,innodb的行锁是作用在索引上的,所以必须保证WHERE条件room_id是主键或唯一索引,否则会退化成表锁,一锁就锁一排。
入住办理的逻辑和预订类似,但多了一步“把预订状态改成在住”。这里要注意,如果客人是先预订后退房(比如线上渠道)再现场入住,订单记录可能已经存在status=1的状态,那么办理入住时的状态流应该是:锁房间行 -> 校验订单状态必须为1 -> 更新房间状态为2 -> 更新订单状态为2。这个状态机务必写在应用层的统一接口里,不要让前端业务逻辑绕过接口直接改状态。
4.3 退房结算:夜间房价、延时费与折扣的算法流程
退房结算涉及最复杂的计算逻辑。一个订单的实际应付金额并不简单等于房晚单价乘以晚数。举例:客人预订2晚,实际住了2晚,但第二天早上10点退房,这算正常;如果14点退房,就要看酒店规则——有些酒店收取半天房费,有些则收取全天。还有各种折扣,比如连住优惠、协议客户优惠。
我在结算模块里把流程拆成四步:
- 计算实际入住晚数:根据orders表的check_in_date(DATETIME)和实际退房时刻(实际退房时间单独存一个actual_check_out字段),按酒店规则计算。规则是:当天中午12点前退房不额外算晚数,12点后加收半天,18点后加收全天。这些规则全部做成常量配置,方便店长在后台调整。
- 遍历order_items明细,把每晚房费累加到总价。
- 添加额外的消费项(minibar,加床等),这些不是房费,单独立表或者存json字段。
- 应用折扣:会员95折、协议客户9折,这些折扣作用在房价总额上,不影响minibar。
这四步放在一个事务里,最后写orders表的bill_amount字段和actual_check_out时间,然后把房间状态从“在住”更新为“已退房”。钱的计算全程使用Python的decimal.Decimal,不用浮点。核心一个函数如下:
from decimal import Decimal, ROUND_HALF_UP def calc_bill(order_items, extra_consumes, discount_rate=Decimal('1.00')): total_room = sum(item.amount for item in order_items) # item.amount是Decimal total_extra = sum(item.amount for item in extra_consumes) total = (total_room * discount_rate + total_extra).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) return total这里注意,Decimal在MySQL里取出后,SQLAlchemy会转为Decimal类型,不需要额外转换。可是如果你用PyMySQL原生的fetchall,拿到的是字符串,容易误操作,建议还是保持SQLAlchemy的类型转换。
5. 踩坑实录:并发订同一间房、跨天日期计算与账单精度
5.1 CASE 1:同一秒两个人订了同一间大床房
这问题是在测试环境用两个浏览器隐身窗口同时操作暴露出来的。一个窗口开在A房点击“预订”,另一个窗口也开在A房点击“预订”,两个请求几乎同时到服务端。最初的代码是这样写的:先查rooms表状态,如果是空闲,就生成订单,再更新房间状态。结果两个请求都查到了状态是空闲,都生成了订单,虽然最后房间状态还是被置为预占,但产生了两个预订单,都对应同一间房。如果客人实际到店,其中一个订单就成“幽灵订单”。
修复过程就是前面说的行锁。但还有一个细节:即使加了FOR UPDATE,也有可能因为事务隔离级别的问题出现幻读——两个事务同时执行FOR UPDATE,第一个拿到锁后更新状态并提交,第二个等待后恢复执行,应该能看到最新状态。MySQL默认的REPEATABLE READ级别下,FOR UPDATE本身就是“当前读”,会读到最新已提交数据,所以没问题。关键是千万不要把状态校验放在锁外面。后来我在订单生成接口还加了唯一约束:rooms表预留一个pre_order_id字段,预定后把订单id写进去,在MySQL层面再设一个UNIQUE索引,双保险。
5.2 CASE 2:“住了一晚”到底是23:00-次日12:00还是24小时
这个坑极具迷惑性。前台的订单如果按标准计算,客人23:00入住,次日12:00退房,住了一晚;但如果客人23:00入住,次日晚20:00退房,按实际时间差就是21小时,可是酒店规则是按“天数”收费,这应该算两天吗?我最初用datetime.timedelta换算小时数,然后除以24算出晚数,结果发现这家民宿的老板压根不认这个算法。真实规则是:无论几点入住,只要在次日12:00前退房都算一晚;12:00-18:00退房算一晚加半天;18:00以后退房算两晚。
所以要建立一个独立的计费函数,不能简单用时间差除以每天24小时。
import datetime def calc_nights(check_in, check_out, rule_time=datetime.time(12, 0)): # 确保退房日期大于入住日期 days = (datetime.datetime.combine(check_out.date(), rule_time) - check_in).days if days <= 0: return 1 # 不足一天按一天算 hours_over = check_out - datetime.datetime.combine(check_out.date(), rule_time) if hours_over <= datetime.timedelta(hours=6): return days + Decimal('0.5') else: return days + Decimal('1.0')这个算法就是先把“基准时间”定在每天中午12点,然后看离店时刻超出了多久。为了确保前端输入合理,我在页面用的是日期选择器加“到店时间”“离店时间”,后端单独解析,不让前台自由填写时间。这个教训告诉我们:酒店业务规则永远要按行业习惯建模,不要按数学直觉建模。
5.3 CASE 3:Float算房费出现的0.01元差额
有一次对账时发现总营业额比订单明细总额少了0.01元。排查到最后,发现是从订单明细到总金额的汇总过程用了Python原生的float相加。例如订单三笔明细价格分别为99.90元、99.90元、99.80元,在float里的存储误差累加后,round到2位小数就变成了299.60元,但实际应为299.60元,当时出现的是299.59。单笔差一分,几十笔订单汇总后差值就被放大。
解决方法只有一条:所有涉及钱的变量从数据库读出来后,立即转为Decimal,计算完成后入库还是Decimal。我在SQLAlchemy模型里把Numeric(10,2)字段映射为Decimal,查询结果天然是Decimal,所以唯一要防的是在Python业务代码里不要使用float()去转换。另外在页面展示时,使用flask模板的format_price过滤器,内部调Decimal的quantize,绝对不会出现浮点误差。这也是一个非常容易忽略但又极其重要的坑。
6. 交付一个能直接跑的项目:源码、SQL脚本、文档三件套
6.1 项目目录结构与核心文件的职责边界
拿到标题“源码+数据库+文档”的交付要求,就要把项目组织得像样,不然接手的开发或者老板根本不知道从哪看起。我的最终目录是这样的:
hotel_manager/ ├── app.py # Flask应用入口,蓝图注册、初始化 ├── config.py # 配置文件(数据库地址、密钥、规则常量) ├── models/ # SQLAlchemy模型 │ ├── __init__.py │ ├── room.py │ ├── order.py │ └── user.py ├── blueprints/ │ ├── auth.py # 登录/登出 │ ├── room.py # 房态管理 │ ├── order.py # 预订/入住/退房 │ └── stats.py # 统计报表 ├── templates/ # Jinja2模板 │ ├── base.html │ ├── dashboard.html │ ├── room_list.html │ └── order_checkout.html ├── static/ # 静态资源 js/css ├── db/ │ ├── init.sql # 初始化表结构 │ ├── seed.sql # 演示数据 │ └── update_001.sql # 迭代版本增量脚本 ├── docs/ │ ├── 需求说明书.md │ ├── 数据库设计.md │ └── 部署文档.md └── requirements.txt核心逻辑尽量塞在models和blueprints里,app.py只做初始化,避免一个文件写到2000行。models/下每个表一个文件,order.py里包含订单状态机常量和结算函数,这样代码结构清晰,找问题也快。deliver时把requirements.txt固定好版本,像Flask==2.3.0、SQLAlchemy==1.4.46、PyMySQL==1.0.2,这能省去后续大量的环境兼容问题。
6.2 数据库初始化脚本的多版本管理与演示数据
数据库脚本不是一份就够。我在db目录下放了init.sql建表,seed.sql填充少量演示数据,并且坚持用增量脚本记录每次表结构变更。比如第一次上线后,发现orders表缺少actual_check_out字段,就写一个update_001.sql:
ALTER TABLE orders ADD COLUMN actual_check_out DATETIME NULL COMMENT '实际退房时间'; ALTER TABLE orders ADD INDEX idx_status_checkout (status, actual_check_out);这样好处是,后续你去维护老客户的项目时,只要按版本顺序跑增量脚本,就能把数据库升到最新。千万别在已有项目的init.sql里直接改字段,那样新环境跑没问题,老环境补不上。
seed.sql也很重要,给一套能直接演示的数据:5种房型、20个房间、几条不同状态的订单。演示数据要刻意包含“在住”“已退”“预占”三种状态,方便打开页面立刻看到效果。注意演示数据里的密码不能是明文,我用Flask自带generate_password_hash生成哈希值,存到user表里,避免文档里出现真实密码。
6.3 文档怎么写才能让接手的同学不骂人
很多源码包里的文档就是README写两句话,太糊弄。我坚持写三份:需求说明书、数据库设计文档、部署文档。
需求说明书重点第1-2页写出系统边界:哪些功能不做,哪些功能做,前台操作流程是什么。业务规则要写清,比如退房延时规则、折扣计算规则。数据库设计文档必须画清楚实体关系,用的是Markdown表格加文字说明,列出每张表的字段名、类型、是否允许空值、含义、枚举值。特别要把状态枚举表列出来,例如room.status=0空闲、1预占等。部署文档要写两种环境:开发环境(直接python app.py跑)和生产环境(用waitress启动Windows服务),然后写清楚如何初始化数据库、修改config.py里的连接串。
我见过很多源码包写“部署时请修改数据库配置”,然后没有下文。我的习惯是直接在部署文档里贴出完整的config示例,把cursorclass=dict参数都提前配好。做到这份上,交付才算真的完成。
7. 上线后还能怎么扩展:从半自动化到真正无人值守
系统上线稳定跑了一个季度后,朋友又提出了几个新需求:第一,要能在小程序上查剩余房量;第二,前台的大屏上要滚动显示当天入住率;第三,需要把订单通过API推给OTA平台。这就意味着原来纯服务端渲染的Flask模板工程,要开始接REST接口了。
我的做法是保留现有页面不动,额外增加一个/api前缀的蓝图,把关键查询和操作封装成JSON接口。接口的鉴权同样走JWT,但和后台cookie登录分开,小程序端用Bearer token头部携带。这一步改动没有颠覆架构,因为模型的业务方法都是复用的。另外,我引入了简单的缓存层,把当天的房态统计结果缓存到内存里,设置30秒过期,避免小程序端高频轮询把MySQL打疼。
如果你的项目未来可能往“多渠道售卖”方向走,这里有一个重要建议:把房间的“库存”概念从订单里剥离出来。也就是说,orders表是客户履约记录,rooms_stock表用于渠道路由,每个渠道有自己的可售库存,前台手动排房占用的库存和OTA渠道锁定的库存分开管理。我当时没有这么做,因为初期只是单店直售,但如果你预计要接携程、美团,这步迟早要做。
坦白说,酒店客房管理系统功能不难,难点全在业务语义的边界和数据一致性上。如果你想拿这套系统练手或者应付验收,建议从抄我的设计开始,但一定要把“状态机”和“事务边界”想清楚。这两个东西想透了,就算你最后写出来的代码一行都不像我,这个项目也算真正吃透了。