简介:在门店管理系统中,计费规则与账务处理往往是比界面更核心的工程难点。无论是餐饮、零售还是服务行业,按次、按时长、按过夜等多变的计费口径,以及跨天结算、并发抢占等场景,都对数据库设计和事务一致性提出较高要求。洗浴中心管理系统正是这类问题的典型缩影:顾客手牌作为业务主键,所有消费挂账、离店统一结算,浴资超时计算、凌晨封账、技师提成归集均需在数据层面精确定义。本文从通用计费模型与事务处理切入,结合洗浴业态的手牌状态管理、计费规则表、结算事务、日结脚本等实践,拆解一套可落地的数据模型与账务流程,帮助后端开发与实施人员快速理解此类系统的核心设计要点,避免在并发、对账和报表口径上反复踩坑。
1. 洗浴中心管理系统.doc 背后要做的是哪一套账
在门店系统这个序列里,洗浴中心管理系统.doc 往往不是源码,而是一份需求说明书或者立项方案。真正接触过这类项目的开发都知道,洗浴中心和餐饮、零售差别很大:顾客进门先领手牌,所有消费都记在手牌上,离店才统一结账。因此这套系统的难点不在界面,而在手牌账户、计费规则和凌晨封账三条主线。解读文档时重点看浴资怎么算、中途加单怎么记、跨天账单归到哪一天,三条线理顺了,会员卡和库存都是常规模块。适合正在接门店系统、又没做过洗浴业态的开发和实施人员。
2. 先把数据模型画对:洗浴中心管理系统的会员、手牌与计费表
2.1 手牌号是业务主键,不是自增 ID
洗浴中心里,每个顾客进门领一个手牌,对应一把更衣柜钥匙。后续的浴资、搓澡、饮料、过夜全部挂在这个手牌上。设计时手牌要单独建表,手牌号建议直接用柜号或连续号段,例如 001 到 500,打印成手环上的条码,既能扫码也能人工输入。常见做法是建一张locker_wristband表记录手牌状态,字段定义如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| wristband_no | varchar(10) | 手牌号,主键,对应更衣柜 |
| status | tinyint | 0 空闲 / 1 占用 / 2 挂失 / 3 停用 |
| checkin_id | bigint | 当前占用记录的 ID,空闲时可空 |
| member_id | bigint | 绑定的会员 ID,可空 |
| broken_at | datetime | 挂失或停用时间 |
这里的关键是status和checkin_id要分开存。很多第一版设计只存状态,结果顾客换柜、手牌损坏重新发牌时,历史账单就找不回来了。锁柜和锁账单是两件事,前台看到的"这个柜子有人"和后台的"这张账单有效"必须解耦。
2.2 计费项目和卡项分开建表,改价不动表结构
浴资、助浴、足疗的价格会随季节调整,会员折扣又和项目组合绑定。把价格直接写死在订单表里是初期最顺手、后期最痛苦的方案。我一般拆成service_item和member_card两张表,中间再挂套餐明细关系。service_item里除了价格,还必须带billing_type,因为浴资按次、足疗按时长、过夜按封顶价,结算口径完全不同。
CREATE TABLE service_item ( item_id INT PRIMARY KEY, item_name VARCHAR(50) NOT NULL, billing_type TINYINT NOT NULL COMMENT '1按次 2按时长 3按过夜', price DECIMAL(10,2) NOT NULL, overtime_rate DECIMAL(10,2) DEFAULT 0 COMMENT '按时长项目超时单价', unit_minutes INT DEFAULT 60 COMMENT '一个计费周期的分钟数', status TINYINT DEFAULT 1 );unit_minutes和overtime_rate是给按时长项目用的。比如足疗 90 分钟 128 元,超时每 30 分钟加 30 元,那么unit_minutes=30、overtime_rate=30,计费时按分钟差计算,而不是让代码里写死 128 和 30。价格变动只改表记录,不用重新发版,连锁门店还能用一套代码支撑不同分店的差异化定价。
2.3 技师分成和库存消耗要预留扩展位
洗浴行业和餐饮不一样,服务由技师完成,提成按项目金额的区间比例走。如果提成比例存进订单表,后面调整一次就要改一遍历史数据,对账时还会和当月的实际发放口径冲突。正确做法是消费明细里只存technician_id和service_item_id,提成比例在日结时由计提规则统一计算。同理,毛巾和一次性消耗品按项目展开,项目表里留consume_stock_items字段,日结时一并扣库存,避免月底盘点才发现毛巾少了三百条却不知道哪个环节透的。
注意:手牌、项目、技师三张表是洗浴中心管理系统的骨架。骨架不歪,后续的计费和报表才能接得住。订单表里永远不要直接存"浴资 38 元"这种写死文本。
3. 浴资计费是洗浴中心管理系统最容易翻车的环节:规则与实现
3.1 先定计费口径:按分钟算,展示层再决定要不要按小时
浴资是洗浴中心最主要的收入,也是最容易产生客诉的部分。常见口径有三种:按次、按小时超时补差、按过夜。按次最简单,进门 38 元不限时;按小时需要记录started_at和settled_at,超过基础时长后按周期补收。我建议系统内统一按分钟计算,展示层再决定显示成"小时"或"半小时一组"。原因是跨天结算时分钟口径最好对账,小时口径经常出现 59 分钟和 60 分钟差一位小数的情况,客诉起来说不清。
计费规则我习惯单独存一张参数表,比写在代码里灵活:
| 规则项 | 典型值 | 说明 |
|---|---|---|
| base_minutes | 180 | 基础时长,超过后开始计超时 |
| base_price | 38 | 基础时长内的费用 |
| cycle_minutes | 30 | 超时计费周期 |
| cycle_price | 15 | 每个超时周期的费用 |
| night_start | 00:00 | 过夜计费开始时间 |
| night_price | 58 | 过夜一口价 |
3.2 用一段最小函数把超时和过夜算出来
下面的函数按分钟口径计算浴资,适合放在结算接口里复用:
def calc_bath_fee(started_at, settled_at, rule): total_min = int((settled_at - started_at).total_seconds() // 60) if total_min <= rule.base_minutes: return rule.base_price # 过夜:跨天且结算时间在凌晨5点前,按过夜一口价封顶 if started_at.date() != settled_at.date() and settled_at.hour < 5: return rule.night_price extra_min = total_min - rule.base_minutes cycles = (extra_min + rule.cycle_minutes - 1) // rule.cycle_minutes # 向上取整 return rule.base_price + cycles * rule.cycle_price参数含义:base_minutes是免超时的时间窗口,cycle_minutes是超时计费周期,向上取整表示超时 31 分钟按 2 个周期收,而不是 1.03 个周期。过夜判断放在超时判断之后,因为过夜是封顶价,一旦命中就不走超时累加。注意settled_at.hour < 5这个边界值,凌晨 4 点结账和早上 6 点结账在规则里是两个收法,门店不特殊,但系统要支持参数配置。
3.3 凌晨封账:日结和夜审不能用同一个脚本
洗浴中心很少零点关门,营业往往会持续到凌晨,所以日结时间点不能是自然日 24 点,通常是早上 5 点到 7 点之间。封账脚本要做两件事:把未结账手牌的当前时间写入账单快照,再按技师、项目、会员卡汇总生成当日营业表。这里有个高频坑:跨天过夜的账单,消费时间跨了两个自然日,但收款只发生在离店那一天。如果按created_at汇总,这笔钱会归到离店日,和当天的营业日报对不上,财务会天天来问。
提示:日报的归属日建议用手牌的结账时间,而不是消费时间。夜审跑批时,先把前一天 5 点到当天 5 点的账单标记为已封账,再去重算技师提成。顺序错了,报表必错。
4. 业务链路落库:洗浴中心管理系统里的开牌、加单与离店结算
4.1 开牌接口:手牌状态和账单必须同时落库
顾客进店取手牌,系统要做两个原子操作:把手牌改为占用,创建一条待结算的主账单。两步必须在一个事务里完成,否则会出现手牌发出去了、账单却没建,顾客消费完无法结账的尴尬局面。
def checkin(wristband_no, member_id=None): with db.transaction(): band = db.execute( "SELECT status FROM locker_wristband WHERE wristband_no=%s FOR UPDATE", wristband_no) if band.status != 0: raise LockerOccupied(wristband_no) db.execute( "UPDATE locker_wristband SET status=1 WHERE wristband_no=%s", wristband_no) bill_id = db.insert( "INSERT INTO bill(main_flag, wristband_no, member_id, started_at)" " VALUES (1,%s,%s,NOW())", wristband_no, member_id) return bill_idFOR UPDATE的作用是锁住手牌行,防止两个收银员同时把同一个手牌发给两位顾客。洗浴中心高峰期的开牌是典型的并发写操作,这一行不加,后续所有对账问题都会从这里冒出来。main_flag标记主账单,后面所有加单都挂在主账单下,离店时一次结算,避免拆成多笔导致收银员漏单。
4.2 加单采用明细记账,不维护主单实时总额
顾客中途点一瓶水、加一个足疗,这些都属于bill_item明细。主账单上不要维护实时总额,总额由明细汇总算出。理由很直接:任何一笔加单都要可追溯,如果直接改主单金额,日结时无法区分是手动调价还是正常消费。
INSERT INTO bill_item(bill_id, item_id, technician_id, qty, amount, created_at) SELECT 1024, item_id, %s, 1, price, NOW() FROM service_item WHERE item_id=%s;注意amount是从service_item现查出来的,不是前端传过来的数字。前端传价格的收银系统是常见漏洞,价格篡改成本太低。服务端按item_id重新取价,同时记下当时生效的会员折扣规则编号。技师 ID 也在这里落库,日结算提成时直接按bill_item分组,不用回去翻操作日志。
4.3 离店结算:确认、支付、冲正必须是一个事务
洗浴中心的结算比餐饮复杂在三个地方:顾客可能同时结多人账单,会员余额不足时要拆成部分划卡加部分现金,支付平台回调还可能超时。完整流程是汇总主账单下所有明细、扣除折扣、生成应收、写支付流水、把手牌置回空闲。这几步不拆开,任何一步失败都会出现"钱收了柜子没还"或者"柜子还了账没消"。
| 场景 | 处理方式 |
|---|---|
| 会员余额不足 | 余额全扣,差额生成现金支付单 |
| 多人合并结账 | 只结算主账单,各明细归并到同一支付单 |
| 支付平台回调超时 | 支付单标记为待确认,收银台二次查询而不是直接入账 |
payment_record里必须有status(待支付、已支付、已冲正)和channel(现金、会员卡、微信、支付宝)。冲正不是删流水,而是写一条负数流水,保证流水总和始终等于收银现金盘点数。多支付方式混合时,把每笔拆分记录都写成独立流水,而不是一个总金额,这样日结对账时定位到具体某一笔就快得多。
注意:支付回调超时最忌讳的是"先改状态再对账"。生产环境的标准做法是保持待支付,由收银端主动向支付平台查询结果,查询成功后才落已支付。被动等回调的单据,丢单率比想象中高。
5. 洗浴中心管理系统上线后重点盯的并发冲突、对账口径与日结脚本
5.1 手牌并发不要靠应用锁,要靠数据库行锁
很多第一版用全局锁或 Redis 分布式锁防止手牌重复发放,单机门店够用,一旦做到多门店连锁就失效。数据库行锁永远比应用锁可靠,事务提交后锁自动释放。注意FOR UPDATE必须放在事务里,且查询和更新要使用同一连接,否则锁不会生效。用 ORM 时尤其要检查连接复用配置,连接池换连接会导致锁在另一个会话里形同虚设。
5.2 对不上账时先查三个位置
日结对账不平,九成是三类原因:跨天账单归属日不对、冲正流水直接改了原记录、退柜时没有清空checkin_id导致重复关联。排查顺序建议是:先按手牌找当天未闭合的账单,再按支付渠道加总对现金,最后检查bill_item里有没有technician_id为空的项目。这里给一个能直接跑的核对脚本,日结后看输出是否为空:
mysql -uops -p洗浴系统 -e \ "SELECT wristband_no,bill_id FROM locker_wristband WHERE status=1 AND checkin_id IS NULL;"输出为空,说明手牌状态与账单关联一致;有记录说明存在开了牌但没建账单的异常,要补账单而不是直接改手牌状态。这类检查脚本建议写进 cron,每天封账后自动跑一遍,异常单独发到运维群。
5.3 一个可以直接抄的日结脚本骨架
#!/bin/bash BILL_DATE=$(date -d "yesterday 05:00" +%F) mysql -uops -p"$DB_PASS" -e " UPDATE bill SET settled_flag=1 WHERE settled_at >= '$BILL_DATE 05:00' AND settled_flag=0; INSERT INTO daily_report(biz_date, item_id, amount) SELECT '$BILL_DATE', item_id, SUM(amount) FROM bill_item WHERE settled_flag=1 GROUP BY item_id;"这段脚本的核心是把"账单封存"和"报表生成"分成两条语句执行:先标记settled_flag,再根据标记生成报表。这样同一天重跑脚本不会产生重复汇总,封账和生成报表之间断电也不至于把报表跑成半截。生产环境建议把两步放进存储过程并加事务,cron只负责触发,判断逻辑留在数据库里。日结脚本要保留每次执行的时间戳和影响行数,门店隔周来查一笔账时,靠的就是这份执行痕迹定位数据是哪个批次产生的。
本文还有配套的精品资源,点击获取