简介这份文档资料面向酒店管理专业学生、酒店一线员工及希望系统了解PMS的从业者围绕酒店管理系统Property Management System系列课程展开帮助读者建立从信息化理念到前台业务操作的完整认知。内容涵盖酒店信息化概论、PMS系统概览、客户资料与预订、接待与收银、房务管理与夜审、团队基础等模块并以肯德基电子优惠券、文华东方酒店客户记忆等案例说明信息化对降低成本、提升服务体验的价值同时强调跨部门信息流通与避免重复客户资料、误操作等实际问题。资源包共1个doc文件约240KB以课程讲义形式呈现结构按章目与课时编号组织便于按模块查阅与自学。目前已有187人学习适合作为酒店PMS入门培训、岗位轮训或教学备课的参考材料帮助读者理解系统操作原则与部门协作要点。1. 酒店管理系统PMS系列课程从文档到可跑通的房态与订单模块很多同行第一次接触酒店管理系统PMS系列课程.doc 这类资料时都会卡在同一个地方文档里把房态、订单、夜审、渠道对接讲得头头是道但真到自己动手搭一套能跑的环境却发现连“一间房从可售变成已占用”这条链路都串不起来。PMS 不是简单的增删改查它的核心是围绕“房态”这个实时状态机做并发控制再叠加订单、价格、渠道、账务四条线。这份系列课程文档的价值在于它把酒店业务拆成了可教学的模块但文档不会告诉你的是房态表怎么设计才不会超卖、订单状态机怎么和夜审时间点对齐、渠道订单进来时怎么防止重复占用。这篇笔记面向的是想照着文档把 PMS 核心模块真正跑起来的开发者不管你是刚入行的新手还是接过酒店项目但被夜审和超卖坑过的熟手下面这套从表结构到接口再到排查的路径都能直接抄作业。2. 房态与订单的数据模型先定状态机再建表2.1 为什么房态不能只用一张 rooms 表酒店 PMS 最容易被低估的就是房态模型。新手常见做法是建一张 rooms 表字段放 room_no、type、status订单来了就把 status 改成“已占用”。这套做法在单机演示里能跑一旦遇到“今天退房明天入住”“钟点房按小时释放”“维修房临时锁房”就会翻车。房态的本质是“某间房在某段时间区间内的可用性”它是一个时间维度上的排他约束不是房间的一个静态属性。我一般会把房态拆成三层物理房room、房型room_type、库存日历inventory_calendar。物理房管房号、楼层、朝向、维修状态房型管可售数量、基础价、加床策略库存日历按日期存每个房型的可售数、已售数、锁房数。订单不直接改房间状态而是去扣减对应日期的库存。这样超卖的判断就变成一句带条件的 UPDATE而不是靠应用层查了再改。提示库存日历的粒度建议按“房型 日期”存不要按“物理房 日期”否则房型升级、换房场景会非常难处理。2.2 订单状态机的五个核心状态与迁移条件订单状态机是 PMS 的第二根支柱。文档里通常会列“预订、入住、离店、取消、NoShow”这几个词但不会告诉你状态之间怎么迁移、谁触发、失败怎么办。我落地时一般收敛成五个状态PENDING待确认、CONFIRMED已确认、CHECKED_IN已入住、CHECKED_OUT已离店、CANCELLED已取消。NoShow 不单独做状态而是用“确认后未入住且过了夜审时间”这个条件标记避免状态爆炸。迁移规则要写死在服务层不能散落在各个接口里。比如 PENDING 只能到 CONFIRMED 或 CANCELLEDCONFIRMED 可以到 CHECKED_IN 或 CANCELLEDCHECKED_IN 只能到 CHECKED_OUT。每次迁移都要带操作人、时间戳、原因码写一条 order_status_log。这样后面查“这单为什么被取消”才有后悔药可吃。2.3 建表 SQL 与关键索引下面这套表结构是我在多个中小型酒店项目里反复用过的精简版字段做了裁剪但约束和索引是完整的。可以直接在 MySQL 8 里跑。-- 房型表管可售数量和基础价 CREATE TABLE room_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL UNIQUE COMMENT 房型编码如 DLX, name VARCHAR(64) NOT NULL, base_price DECIMAL(10,2) NOT NULL DEFAULT 0, total_rooms INT NOT NULL DEFAULT 0 COMMENT 该房型物理房总数, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存日历按房型日期扣减防超卖的核心表 CREATE TABLE inventory_calendar ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_type_id BIGINT NOT NULL, stay_date DATE NOT NULL, total_qty INT NOT NULL DEFAULT 0 COMMENT 当日可售总数, sold_qty INT NOT NULL DEFAULT 0 COMMENT 已售数, locked_qty INT NOT NULL DEFAULT 0 COMMENT 锁房数如渠道预留, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_type_date (room_type_id, stay_date), CONSTRAINT fk_inv_type FOREIGN KEY (room_type_id) REFERENCES room_type(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单主表 CREATE TABLE hotel_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, room_type_id BIGINT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, room_count INT NOT NULL DEFAULT 1, status VARCHAR(16) NOT NULL DEFAULT PENDING, guest_name VARCHAR(64) NOT NULL, guest_phone VARCHAR(20), total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, channel VARCHAR(16) NOT NULL DEFAULT DIRECT COMMENT DIRECT/OTA/WALKIN, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_checkin (status, check_in), KEY idx_phone (guest_phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单状态日志每次迁移都写一条 CREATE TABLE order_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, from_status VARCHAR(16), to_status VARCHAR(16) NOT NULL, operator VARCHAR(64), reason VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明inventory_calendar 的 UNIQUE KEY 保证同一房型同一天只有一行扣减时用UPDATE ... SET sold_qty sold_qty ? WHERE room_type_id ? AND stay_date ? AND total_qty - sold_qty - locked_qty ?靠数据库行锁和 WHERE 条件保证不超卖。version 字段留给需要乐观锁的场景比如渠道批量同步。order_status_log 是排查问题的黑匣子别省。参数说明total_qty 是当日该房型可售总数不是物理房总数因为可能有维修房sold_qty 只统计有效订单占用的间夜locked_qty 给渠道预留或团队锁房用。check_in 和 check_out 是日期不是时间PMS 里入住日当天 14:00 后入住、次日 12:00 前离店是行业惯例时间点放在业务层判断。3. 用 Python 跑通预订与入住的最小闭环3.1 预订接口扣库存与写订单必须在同一事务预订是 PMS 里并发最高的写操作OTA 渠道、前台、小程序可能同时下单。下面这段 Python 用 FastAPI SQLAlchemy 演示核心逻辑重点看事务边界和扣减顺序。from fastapi import FastAPI, HTTPException from sqlalchemy import text from sqlalchemy.orm import Session from datetime import date, timedelta import uuid app FastAPI() def date_range(check_in: date, check_out: date): 生成入住期间每一晚的日期离店日不占库存 d check_in while d check_out: yield d d timedelta(days1) app.post(/orders) def create_order(payload: dict, db: Session): room_type_id payload[room_type_id] check_in date.fromisoformat(payload[check_in]) check_out date.fromisoformat(payload[check_out]) room_count payload.get(room_count, 1) if check_out check_in: raise HTTPException(400, 离店日期必须晚于入住日期) order_no H uuid.uuid4().hex[:16].upper() try: # 1. 逐晚扣减库存WHERE 条件保证不超卖 for stay_date in date_range(check_in, check_out): result db.execute(text( UPDATE inventory_calendar SET sold_qty sold_qty :cnt WHERE room_type_id :rt AND stay_date :d AND total_qty - sold_qty - locked_qty :cnt ), {cnt: room_count, rt: room_type_id, d: stay_date}) if result.rowcount 0: raise HTTPException(409, f{stay_date} 库存不足) # 2. 写订单主表 db.execute(text( INSERT INTO hotel_order (order_no, room_type_id, check_in, check_out, room_count, status, guest_name, guest_phone, total_amount, channel) VALUES (:no, :rt, :ci, :co, :cnt, PENDING, :name, :phone, :amt, :ch) ), {no: order_no, rt: room_type_id, ci: check_in, co: check_out, cnt: room_count, name: payload[guest_name], phone: payload.get(guest_phone), amt: payload.get(total_amount, 0), ch: payload.get(channel, DIRECT)}) # 3. 写状态日志 db.execute(text( INSERT INTO order_status_log (order_id, from_status, to_status, operator, reason) SELECT id, NULL, PENDING, :op, 创建订单 FROM hotel_order WHERE order_no :no ), {op: payload.get(operator, system), no: order_no}) db.commit() except HTTPException: db.rollback() raise except Exception as e: db.rollback() raise HTTPException(500, f下单失败: {e}) return {order_no: order_no, status: PENDING}逻辑说明扣库存放在写订单之前任何一晚扣失败就抛异常回滚保证不会出现“订单写了但库存没扣”的脏数据。date_range 用生成器逐晚处理离店日不占库存这是酒店和机票最大的区别。状态日志用 INSERT ... SELECT 拿到刚插入的 order_id避免再查一次。参数说明room_count 是间数连住三晚订两间就是每晚扣 2channel 区分 DIRECT、OTA、WALKIN后面做渠道对账和佣金计算要用total_amount 在预订阶段可以先存预估价入住时再按实际价格重算。3.2 入住与离店状态迁移要带操作人和时间入住和离店是状态迁移不是简单改字段。下面这段代码把迁移规则和日志写在一起。ALLOWED_TRANSITIONS { PENDING: [CONFIRMED, CANCELLED], CONFIRMED: [CHECKED_IN, CANCELLED], CHECKED_IN: [CHECKED_OUT], CHECKED_OUT: [], CANCELLED: [], } app.post(/orders/{order_no}/transition) def transition(order_no: str, payload: dict, db: Session): to_status payload[to_status] operator payload.get(operator, front_desk) row db.execute(text( SELECT id, status FROM hotel_order WHERE order_no :no FOR UPDATE ), {no: order_no}).fetchone() if not row: raise HTTPException(404, 订单不存在) from_status row.status if to_status not in ALLOWED_TRANSITIONS.get(from_status, []): raise HTTPException(409, f不允许从 {from_status} 迁移到 {to_status}) db.execute(text( UPDATE hotel_order SET status :to WHERE id :id ), {to: to_status, id: row.id}) db.execute(text( INSERT INTO order_status_log (order_id, from_status, to_status, operator, reason) VALUES (:oid, :f, :t, :op, :reason) ), {oid: row.id, f: from_status, t: to_status, op: operator, reason: payload.get(reason, )}) # 取消订单要回滚库存 if to_status CANCELLED: order db.execute(text( SELECT room_type_id, check_in, check_out, room_count FROM hotel_order WHERE id :id ), {id: row.id}).fetchone() for stay_date in date_range(order.check_in, order.check_out): db.execute(text( UPDATE inventory_calendar SET sold_qty sold_qty - :cnt WHERE room_type_id :rt AND stay_date :d ), {cnt: order.room_count, rt: order.room_type_id, d: stay_date}) db.commit() return {order_no: order_no, status: to_status}逻辑说明SELECT ... FOR UPDATE 锁住订单行防止两个前台同时操作同一单。迁移规则用字典集中管理新增状态只改一处。取消时回滚库存注意回滚也要逐晚处理且要防止重复取消导致库存多回。参数说明operator 记录操作人前台工号或系统账号reason 在取消和 NoShow 时必填方便后面做原因分析。CHECKED_OUT 之后不允许再迁移要改只能走冲账流程这是财务合规的底线。3.3 夜审把“今天”推进到“明天”的定时任务夜审是 PMS 最容易被忽略但最不能出错的部分。它做三件事把 NoShow 订单标记出来、生成当日营业报表、把系统营业日推进一天。下面是一个最小夜审脚本。from datetime import date, datetime, timedelta def night_audit(db: Session, business_date: date): # 1. 标记 NoShow确认后未入住且入住日已过 db.execute(text( UPDATE hotel_order SET status CANCELLED WHERE status CONFIRMED AND check_in :bd AND id NOT IN (SELECT order_id FROM order_status_log WHERE to_status CHECKED_IN) ), {bd: business_date}) # 2. 生成营业日报简化版 report db.execute(text( SELECT channel, COUNT(*) AS cnt, SUM(total_amount) AS amt FROM hotel_order WHERE check_in :bd AND status ! CANCELLED GROUP BY channel ), {bd: business_date}).fetchall() # 3. 推进营业日 db.execute(text( UPDATE hotel_config SET business_date :next WHERE id 1 ), {next: business_date timedelta(days1)}) db.commit() return report逻辑说明NoShow 判断用“确认后没有入住日志”这个条件比单独加状态更干净。营业日报按渠道分组方便和 OTA 对账。营业日存在 hotel_config 表里所有涉及“今天”的业务都读这个值不要直接用系统时间否则跨零点操作会乱。参数说明business_date 是当前营业日通常凌晨 2 点到 6 点之间跑夜审如果酒店有凌晨入住营业日要延后到 6 点再切。这个时间点每个酒店不一样做成配置项。4. 渠道订单接入与防重OTA 订单为什么总重复占用4.1 渠道订单的三种接入方式与选型酒店渠道订单常见三种接入OTA 主动推送如携程、美团的订单推送、PMS 定时拉取、中间件转发。中小酒店我一般推荐推送 落库去重因为拉取有延迟中间件多一层故障点。推送接口要暴露一个带签名的 webhook收到后先落 channel_order_raw 表再异步转成内部订单。选型理由推送实时性好但要求公网可达和签名校验拉取适合没有公网条件的单体酒店但要做增量游标中间件适合连锁但运维成本高。不管哪种都要在 channel_order_raw 里存渠道订单号 渠道编码的唯一索引这是防重的第一道闸。4.2 用唯一索引和幂等键防重复占用渠道重复推送是血泪经验里最常见的一类。同一个 OTA 订单号推两次如果直接走预订逻辑库存就被扣两次。解决办法是两层数据库唯一索引 应用层幂等键。CREATE TABLE channel_order_raw ( id BIGINT PRIMARY KEY AUTO_INCREMENT, channel VARCHAR(16) NOT NULL, channel_order_no VARCHAR(64) NOT NULL, payload JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT NEW COMMENT NEW/PROCESSED/FAILED, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_channel_order (channel, channel_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;def handle_channel_order(db: Session, channel: str, channel_order_no: str, payload: dict): # 第一层唯一索引防重重复插入直接忽略 try: db.execute(text( INSERT INTO channel_order_raw (channel, channel_order_no, payload) VALUES (:ch, :no, :pl) ), {ch: channel, no: channel_order_no, pl: json.dumps(payload)}) db.commit() except IntegrityError: db.rollback() return {result: duplicate_ignored} # 第二层转内部订单用渠道单号做幂等键 existing db.execute(text( SELECT order_no FROM hotel_order WHERE channel :ch AND order_no LIKE :prefix ), {ch: channel, prefix: f{channel}-{channel_order_no}%}).fetchone() if existing: return {result: already_converted, order_no: existing.order_no} # 走正常预订逻辑order_no 用渠道单号派生 return create_order({**payload, channel: channel, order_no: f{channel}-{channel_order_no}}, db)逻辑说明唯一索引是最后一道防线即使应用层判断漏了数据库也会拦住。第二层用渠道单号派生内部订单号保证同一渠道单只会生成一个内部订单。payload 存原始 JSON出问题可以回溯渠道到底推了什么。参数说明channel 用短编码如 CTRIP、MEITUANchannel_order_no 是渠道侧单号长度可能到 64 位status 字段用于标记处理结果FAILED 的要有人工介入队列。4.3 渠道价格与库存同步的边界渠道接入不只是收订单还要同步价格和库存。常见做法是 PMS 每 5 到 15 分钟把 inventory_calendar 的可售数推给渠道价格按房型 日期推。边界在于渠道侧可能有自己的预留库存PMS 推的可用数要减去 locked_qty渠道卖超时 PMS 要能拒单并回传失败原因。这块没有银弹只能靠对账每天夜审后拉渠道订单和 PMS 订单做比对差异进人工队列。5. 避坑与排查PMS 上线后最容易翻车的五件事5.1 现象凌晨订单库存扣错日期原因业务代码直接用date.today()判断入住日夜审还没跑系统日期已经跨天导致凌晨 1 点下的单被算到第二天。解决所有涉及“今天”的判断统一读 hotel_config.business_date夜审推进营业日之前凌晨订单仍算前一个营业日。5.2 现象取消订单后库存没回来原因取消逻辑只改了订单状态忘了回滚 inventory_calendar或者回滚时没按逐晚处理。解决把回滚库存和状态迁移放在同一事务回滚前先查 order_status_log 确认这单确实占过库存避免重复回滚。5.3 现象OTA 订单重复占用同一单号扣了两次库存原因渠道重复推送应用层查重有并发窗口两个请求同时查到“不存在”然后都插入。解决channel_order_raw 加 (channel, channel_order_no) 唯一索引插入冲突就忽略内部订单号用渠道单号派生保证幂等。5.4 现象夜审跑完后报表金额和实际对不上原因夜审统计用了订单的 total_amount但入住期间可能有加床、迷你吧消费没计入或者取消订单的退款没冲减。解决营业日报要基于账务流水folio而不是订单主表每笔消费和退款都写流水报表按流水汇总。5.5 现象前台同时操作同一订单状态被覆盖原因两个请求同时读到 CONFIRMED一个改 CHECKED_IN一个改 CANCELLED后写的覆盖先写的。解决状态迁移用 SELECT ... FOR UPDATE 锁行迁移前校验当前状态是否在允许列表里不在就拒绝。6. 用对账脚本验证 PMS 数据一致性上线后最值钱的一个习惯是每天跑一遍对账脚本。它不解决业务问题但能让你在客人投诉之前发现库存和订单对不上。下面这个脚本检查三件事库存日历的 sold_qty 是否等于有效订单占用的间夜数、订单状态日志是否有断链、渠道原始订单是否都转成了内部订单。def reconcile(db: Session, biz_date: date): issues [] # 1. 库存对账sold_qty 应等于有效订单间夜数 rows db.execute(text( SELECT ic.room_type_id, ic.stay_date, ic.sold_qty, COALESCE(SUM(o.room_count), 0) AS order_qty FROM inventory_calendar ic LEFT JOIN hotel_order o ON o.room_type_id ic.room_type_id AND ic.stay_date o.check_in AND ic.stay_date o.check_out AND o.status IN (PENDING,CONFIRMED,CHECKED_IN,CHECKED_OUT) WHERE ic.stay_date :bd GROUP BY ic.room_type_id, ic.stay_date, ic.sold_qty ), {bd: biz_date}).fetchall() for r in rows: if r.sold_qty ! r.order_qty: issues.append(f库存不符 type{r.room_type_id} date{r.stay_date} f日历{r.sold_qty} 订单{r.order_qty}) # 2. 状态日志断链检查每条订单最后一条日志的 to_status 应等于当前状态 broken db.execute(text( SELECT o.order_no, o.status, l.to_status FROM hotel_order o JOIN ( SELECT order_id, to_status, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY id DESC) AS rn FROM order_status_log ) l ON l.order_id o.id AND l.rn 1 WHERE l.to_status ! o.status )).fetchall() for b in broken: issues.append(f状态断链 order{b.order_no} 主表{b.status} 日志{b.to_status}) # 3. 渠道原始订单未转换检查 pending db.execute(text( SELECT channel, channel_order_no FROM channel_order_raw WHERE status NEW AND created_at NOW() - INTERVAL 30 MINUTE )).fetchall() for p in pending: issues.append(f渠道单未转换 {p.channel}/{p.channel_order_no}) return issues逻辑说明第一段用 LEFT JOIN 把库存日历和订单间夜对齐注意 join 条件是stay_date check_in AND stay_date check_out离店日不占库存。第二段用窗口函数取每条订单最后一条日志和主表状态比对断链说明有代码绕过了状态迁移接口。第三段找超过 30 分钟还没转换的渠道单通常是转换逻辑抛异常没被捕获。参数说明biz_date 传当前营业日30 分钟是渠道单转换的容忍窗口可按渠道调整issues 列表为空才算通过非空要进人工排查队列。这个脚本我一般挂在夜审之后跑结果发到运维群。我自己的习惯是PMS 上线第一周每天跑对账第二周隔天跑稳定后每周跑两次。别等客人到前台发现没房才去查那时候库存已经乱了补数据比重跑脚本痛苦十倍。这套从表结构到对账的路径覆盖了 PMS 最核心的房态和订单闭环渠道和账务可以在此基础上扩展。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?