酒店PMS系统设计:房型房价管理与动态价格优先级实现
一、核心设计理念:房价跟着房型走
在酒店PMS(Property Management System)中,房价管理的核心原则是:房价绑定房型,而非绑定具体房间。
这样做的好处:
- 房价维护成本低:改一个房型价格,所有该房型房间同步生效
- 库存灵活:只要该房型有空房就能卖,不用逐间定价
- 符合酒店运营习惯:前台/OTA上架的都是房型维度
二、价格动态性:每天的价格都不一样
酒店房价不是一成不变的,受多种因素影响:
| 影响因素 | 示例 | 典型场景 |
|---|---|---|
| 基础价 | 工作日 ¥400 / 周末 ¥500 | 常态化定价 |
| 特定时间段 | 暑假 ¥600/晚(7.1-8.31) | 寒暑假、会展期 |
| 特定节假日 | 国庆 ¥800/晚 | 春节、五一、国庆 |
| 特定日期 | 12月31日 ¥1200/晚 | 跨年夜、店庆、本地大型活动 |
三、价格优先级规则设计
当多个价格规则在同一天同时命中时,必须有一个明确的优先级来决出"最终生效价格"。
优先级体系(从高到低)
特定日期价格 > 特定节假日价格 > 特定时间段价格 > 基础价
设计逻辑
| 层级 | 说明 |
|---|---|
| 特定日期(最高优先级) | 酒店偶尔会有特殊活动日或临时调价,需要最高优先级覆盖一切 |
| 特定节假日 | 法定节假日通常比普通时间段贵,但可能被某天的特殊活动覆盖 |
| 特定时间段 | 寒暑假、展会周等中期价格策略 |
| 基础价(最低优先级) | 兜底价格,工作日/周末的常态价格 |
四、数据库表结构设计(MySQL)
4.1 房型表(room_type)
CREATE TABLE room_type (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '房型ID',
type_code VARCHAR(50) NOT NULL COMMENT '房型编码,如 DELUXE_DBL',
type_name VARCHAR(100) NOT NULL COMMENT '房型名称,如 豪华大床房',
bed_type VARCHAR(30) DEFAULT NULL COMMENT '床型',
max_occupancy INT NOT NULL DEFAULT 2 COMMENT '最大入住人数',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1-有效 0-无效',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_type_code (type_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房型表';
4.2 基础价表(rate_base)
存储工作日/周末的常态化价格:
CREATE TABLE rate_base (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_type_id BIGINT NOT NULL COMMENT '房型ID',
weekday_type TINYINT NOT NULL COMMENT '1-工作日(周一至周五) 2-周末(周六周日) 3-全天统一',
base_price DECIMAL(10,2) NOT NULL COMMENT '基础价格',
effective_from DATE DEFAULT NULL COMMENT '生效开始日期,NULL表示不限制',
effective_to DATE DEFAULT NULL COMMENT '生效结束日期,NULL表示不限制',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-停用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_roomtype_weekday (room_type_id, weekday_type),
CONSTRAINT fk_rate_base_roomtype FOREIGN KEY (room_type_id) REFERENCES room_type(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='基础房价表(工作日/周末)';
4.3 特定时间段价格表(rate_period)
CREATE TABLE rate_period (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_type_id BIGINT NOT NULL COMMENT '房型ID',
period_name VARCHAR(100) NOT NULL COMMENT '时间段名称,如 暑假/寒假/展会期',
start_date DATE NOT NULL COMMENT '开始日期',
end_date DATE NOT NULL COMMENT '结束日期',
price DECIMAL(10,2) NOT NULL COMMENT '该时间段价格',
priority INT NOT NULL DEFAULT 20 COMMENT '优先级数值,越小越高(预留扩展)',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-停用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_daterange (start_date, end_date),
INDEX idx_roomtype (room_type_id),
CONSTRAINT fk_rate_period_roomtype FOREIGN KEY (room_type_id) REFERENCES room_type(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='特定时间段房价表(寒暑假/展会等)';
4.4 特定节假日价格表(rate_holiday)
CREATE TABLE rate_holiday (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_type_id BIGINT NOT NULL COMMENT '房型ID',
holiday_name VARCHAR(50) NOT NULL COMMENT '节假日名称,如 春节/国庆节/端午节',
holiday_date DATE NOT NULL COMMENT '节假日日期(精确到天)',
price DECIMAL(10,2) NOT NULL COMMENT '该节假日价格',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-停用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_roomtype_holiday (room_type_id, holiday_date),
INDEX idx_holiday_date (holiday_date),
CONSTRAINT fk_rate_holiday_roomtype FOREIGN KEY (room_type_id) REFERENCES room_type(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='特定节假日房价表';
4.5 特定日期价格表(rate_specific_date)
最高优先级的价格规则:
CREATE TABLE rate_specific_date (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_type_id BIGINT NOT NULL COMMENT '房型ID',
specific_date DATE NOT NULL COMMENT '特定日期',
price DECIMAL(10,2) NOT NULL COMMENT '该日价格',
reason VARCHAR(200) DEFAULT NULL COMMENT '调价原因,如 跨年夜/店庆/大型活动',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-停用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_roomtype_date (room_type_id, specific_date),
CONSTRAINT fk_rate_specific_roomtype FOREIGN KEY (room_type_id) REFERENCES room_type(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='特定日期房价表(最高优先级)';
4.6 每日房型最终价格表(room_type_daily_price)
最终生效价格落地表,通过定时任务或实时计算后写入,供预订、下单时直接读取:
CREATE TABLE room_type_daily_price (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_type_id BIGINT NOT NULL COMMENT '房型ID',
price_date DATE NOT NULL COMMENT '日期',
final_price DECIMAL(10,2) NOT NULL COMMENT '最终生效价格',
source_type TINYINT DEFAULT NULL COMMENT '1-基础价 2-时间段 3-节假日 4-特定日期',
source_id BIGINT DEFAULT NULL COMMENT '来源规则ID',
calculated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '计算时间',
UNIQUE KEY uk_roomtype_date (room_type_id, price_date),
INDEX idx_price_date (price_date),
INDEX idx_roomtype (room_type_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日房型最终价格表(房价日历)';
五、Java 实体类
// ========== 房型 ==========
@Data
@TableName("room_type")
public class RoomType {
private Long id;
private String typeCode;
private String typeName;
private String bedType;
private Integer maxOccupancy;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// ========== 基础价 ==========
@Data
@TableName("rate_base")
public class RateBase {
private Long id;
private Long roomTypeId;
/** 1-工作日 2-周末 3-全天统一 */
private Integer weekdayType;
private BigDecimal basePrice;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// ========== 时间段价格 ==========
@Data
@TableName("rate_period")
public class RatePeriod {
private Long id;
private Long roomTypeId;
private String periodName;
private LocalDate startDate;
private LocalDate endDate;
private BigDecimal price;
private Integer priority;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// ========== 节假日价格 ==========
@Data
@TableName("rate_holiday")
public class RateHoliday {
private Long id;
private Long roomTypeId;
private String holidayName;
private LocalDate holidayDate;
private BigDecimal price;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// ========== 特定日期价格 ==========
@Data
@TableName("rate_specific_date")
public class RateSpecificDate {
private Long id;
private Long roomTypeId;
private LocalDate specificDate;
private BigDecimal price;
private String reason;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// ========== 每日最终价格 ==========
@Data
@TableName("room_type_daily_price")
public class RoomTypeDailyPrice {
private Long id;
private Long roomTypeId;
private LocalDate priceDate;
private BigDecimal finalPrice;
/** 1-基础价 2-时间段 3-节假日 4-特定日期 */
private Integer sourceType;
private Long sourceId;
private LocalDateTime calculatedAt;
}
// ========== 价格计算结果DTO ==========
@Data
@AllArgsConstructor
public class PriceResultDTO {
/** 最终价格 */
private BigDecimal finalPrice;
/** 来源类型 */
private Integer sourceType;
/** 来源规则ID */
private Long sourceId;
}
六、Mapper 接口(MyBatis-Plus)
@Mapper
public interface RoomTypeDailyPriceMapper extends BaseMapper<RoomTypeDailyPrice> {
/**
* 批量插入或更新每日价格
*/
int batchInsertOrUpdate(@Param("list") List<RoomTypeDailyPrice> list);
}
@Mapper
public interface RateSpecificDateMapper extends BaseMapper<RateSpecificDate> {
RateSpecificDate selectByRoomTypeAndDate(@Param("roomTypeId") Long roomTypeId,
@Param("date") LocalDate date);
}
@Mapper
public interface RateHolidayMapper extends BaseMapper<RateHoliday> {
RateHoliday selectByRoomTypeAndDate(@Param("roomTypeId") Long roomTypeId,
@Param("date") LocalDate date);
}
@Mapper
public interface RatePeriodMapper extends BaseMapper<RatePeriod> {
RatePeriod selectByRoomTypeAndDate(@Param("roomTypeId") Long roomTypeId,
@Param("date") LocalDate date);
}
@Mapper
public interface RateBaseMapper extends BaseMapper<RateBase> {
RateBase selectByRoomTypeAndWeekday(@Param("roomTypeId") Long roomTypeId,
@Param("weekdayType") Integer weekdayType);
}
七、价格计算核心逻辑
7.1 单日价格计算服务
@Service
@RequiredArgsConstructor
public class PriceCalculationService {
private final RateSpecificDateMapper specificDateMapper;
private final RateHolidayMapper holidayMapper;
private final RatePeriodMapper periodMapper;
private final RateBaseMapper baseMapper;
/**
* 计算指定房型在指定日期的最终价格
* 优先级:特定日期(4) > 节假日(3) > 时间段(2) > 基础价(1)
*/
public PriceResultDTO calculateDailyPrice(Long roomTypeId, LocalDate targetDate) {
// 优先级4:特定日期价格(最高)
RateSpecificDate specific = specificDateMapper.selectByRoomTypeAndDate(roomTypeId, targetDate);
if (specific != null && specific.getStatus() == 1) {
return new PriceResultDTO(specific.getPrice(), 4, specific.getId());
}
// 优先级3:特定节假日价格
RateHoliday holiday = holidayMapper.selectByRoomTypeAndDate(roomTypeId, targetDate);
if (holiday != null && holiday.getStatus() == 1) {
return new PriceResultDTO(holiday.getPrice(), 3, holiday.getId());
}
// 优先级2:特定时间段价格
RatePeriod period = periodMapper.selectByRoomTypeAndDate(roomTypeId, targetDate);
if (period != null && period.getStatus() == 1) {
return new PriceResultDTO(period.getPrice(), 2, period.getId());
}
// 优先级1:基础价(区分工作日/周末)
int dayOfWeek = targetDate.getDayOfWeek().getValue(); // 1=周一 ... 7=周日
Integer weekdayType = (dayOfWeek <= 5) ? 1 : 2;
RateBase base = baseMapper.selectByRoomTypeAndWeekday(roomTypeId, weekdayType);
if (base != null && base.getStatus() == 1) {
return new PriceResultDTO(base.getBasePrice(), 1, base.getId());
}
// 兜底:尝试全天统一的基础价
RateBase unified = baseMapper.selectByRoomTypeAndWeekday(roomTypeId, 3);
if (unified != null && unified.getStatus() == 1) {
return new PriceResultDTO(unified.getBasePrice(), 1, unified.getId());
}
throw new PriceNotFoundException(
String.format("未找到房型[%d]在日期[%s]的价格配置", roomTypeId, targetDate));
}
}
7.2 批量生成房价日历服务
@Service
@RequiredArgsConstructor
public class DailyPriceGenerateService {
private final PriceCalculationService priceCalculationService;
private final RoomTypeDailyPriceMapper dailyPriceMapper;
private final RoomTypeMapper roomTypeMapper;
/**
* 为指定房型生成未来N天的每日价格
*/
@Transactional
public int generateDailyPrices(Long roomTypeId, int daysAhead) {
LocalDate startDate = LocalDate.now();
List<RoomTypeDailyPrice> batchList = new ArrayList<>();
for (int i = 0; i < daysAhead; i++) {
LocalDate targetDate = startDate.plusDays(i);
PriceResultDTO result = priceCalculationService.calculateDailyPrice(roomTypeId, targetDate);
RoomTypeDailyPrice dailyPrice = new RoomTypeDailyPrice();
dailyPrice.setRoomTypeId(roomTypeId);
dailyPrice.setPriceDate(targetDate);
dailyPrice.setFinalPrice(result.getFinalPrice());
dailyPrice.setSourceType(result.getSourceType());
dailyPrice.setSourceId(result.getSourceId());
dailyPrice.setCalculatedAt(LocalDateTime.now());
batchList.add(dailyPrice);
}
return dailyPriceMapper.batchInsertOrUpdate(batchList);
}
/**
* 为所有有效房型生成每日价格
*/
@Transactional
public void generateAllRoomTypeDailyPrices(int daysAhead) {
List<RoomType> roomTypes = roomTypeMapper.selectList(
Wrappers.<RoomType>lambdaQuery().eq(RoomType::getStatus, 1)
);
for (RoomType rt : roomTypes) {
generateDailyPrices(rt.getId(), daysAhead);
}
}
}
7.3 批量生成SQL(纯SQL方案,可选)
INSERT INTO room_type_daily_price
(room_type_id, price_date, final_price, source_type, source_id, calculated_at)
SELECT
v_room_type_id,
date_series.dt,
CASE
WHEN rsd.price IS NOT NULL THEN rsd.price
WHEN rh.price IS NOT NULL THEN rh.price
WHEN rp.price IS NOT NULL THEN rp.price
WHEN rb.price IS NOT NULL THEN rb.price
ELSE v_default_price
END AS final_price,
CASE
WHEN rsd.price IS NOT NULL THEN 4
WHEN rh.price IS NOT NULL THEN 3
WHEN rp.price IS NOT NULL THEN 2
WHEN rb.price IS NOT NULL THEN 1
ELSE 0
END AS source_type,
CASE
WHEN rsd.price IS NOT NULL THEN rsd.id
WHEN rh.price IS NOT NULL THEN rh.id
WHEN rp.price IS NOT NULL THEN rp.id
WHEN rb.price IS NOT NULL THEN rb.id
ELSE NULL
END AS source_id,
NOW()
FROM (
SELECT DATE_ADD(CURDATE(), INTERVAL seq DAY) AS dt
FROM seq_0_to_89
) date_series
LEFT JOIN rate_specific_date rsd
ON rsd.room_type_id = v_room_type_id
AND rsd.specific_date = date_series.dt
AND rsd.status = 1
LEFT JOIN rate_holiday rh
ON rh.room_type_id = v_room_type_id
AND rh.holiday_date = date_series.dt
AND rh.status = 1
LEFT JOIN rate_period rp
ON rp.room_type_id = v_room_type_id
AND date_series.dt BETWEEN rp.start_date AND rp.end_date
AND rp.status = 1
LEFT JOIN rate_base rb
ON rb.room_type_id = v_room_type_id
AND rb.status = 1
AND (
(DAYOFWEEK(date_series.dt) IN (2,3,4,5,6) AND rb.weekday_type = 1)
OR (DAYOFWEEK(date_series.dt) IN (1,7) AND rb.weekday_type = 2)
OR rb.weekday_type = 3
)
ON DUPLICATE KEY UPDATE
final_price = VALUES(final_price),
source_type = VALUES(source_type),
source_id = VALUES(source_id),
calculated_at = VALUES(calculated_at);
八、Mapper XML(批量插入/更新)
<!-- RoomTypeDailyPriceMapper.xml -->
<mapper namespace="com.example.pms.mapper.RoomTypeDailyPriceMapper">
<insert id="batchInsertOrUpdate" parameterType="java.util.List">
INSERT INTO room_type_daily_price
(room_type_id, price_date, final_price, source_type, source_id, calculated_at)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.roomTypeId}, #{item.priceDate}, #{item.finalPrice},
#{item.sourceType}, #{item.sourceId}, #{item.calculatedAt})
</foreach>
ON DUPLICATE KEY UPDATE
final_price = VALUES(final_price),
source_type = VALUES(source_type),
source_id = VALUES(source_id),
calculated_at = VALUES(calculated_at)
</insert>
</mapper>
九、定时任务触发(可选)
@Component
@RequiredArgsConstructor
public class PriceGenerateScheduler {
private final DailyPriceGenerateService generateService;
/**
* 每天凌晨2点生成未来90天的价格日历
*/
@Scheduled(cron = "0 0 2 * * ?")
public void scheduledGenerate() {
generateService.generateAllRoomTypeDailyPrices(90);
}
}
十、设计要点回顾
| 要点 | 说明 |
|---|---|
| 价格与房间解耦 | 房价绑定房型,所有同房型房间共享价格 |
| 优先级链 | 特定日期 > 节假日 > 时间段 > 基础价,通过代码判断顺序实现 |
| 规则与落地分离 | 规则表负责配置,daily_price表负责落地查询,避免实时计算 |
| 可追溯 | source_type + source_id 记录价格来源,方便对账 |
| MySQL适配 | 使用 ON DUPLICATE KEY UPDATE 实现幂等写入,适合定时任务反复执行 |
| 扩展性强 | 新增价格类型(如渠道价、会员等级价)只需新增规则表并在计算逻辑中插入对应层级 |
声明:本文为个人技术总结,表结构、代码均为通用行业示例,非任何真实生产系统。