
家政小程序的复杂度,很少来自页面数量,而是来自"人、单、钱"三者的耦合关系。很多门店上线家政小程序后才发现问题依旧:会员只存了个手机号,实时单和预约单挤在同一张表里,派单靠店长打电话,月底结算要人工核对两天。
这篇文章不列功能名词,只按"会员管理 → 在线下单 → 派单排班 → 结算统计"四段链路,讲清每段到底解决门店的哪个实际问题,并给出可直接复用的数据表、状态机和可运行代码。环境为 MySQL 8.0 + Python 3.11,代码复制即可执行。
一、问题背景:家政门店卡住的四个断点
1.1 一个典型场景
店长上午接到 6 个电话:2 个问"现在能不能上门",3 个问"明天下午有没有人",1 个要求退款。需求记在便签上,再挨个给 8 位服务人员打电话确认时间。月底,财务拿着微信收款记录和纸质工单对账,两天才算清每人的提成。
问题不在员工不努力,而在于门店的"服务能力"没有被数字化。服务人员的时间、技能、位置,全是不可计算的变量。
1.2 四个断点的技术表述
| 断点 | 现象 | 对应模块 | 本质缺失 |
|---|---|---|---|
| 客户不认识 | 复购全靠记忆 | 会员管理 | 缺客户身份与权益账本 |
| 下单口径混乱 | 实时单抢占预约单资源 | 在线下单 | 缺订单类型与状态机 |
| 排班靠电话 | 同一时段重复派单 | 派单排班 | 缺时间冲突判定与调度规则 |
| 结算靠人工 | 退款金额无统一规则 | 结算统计 | 缺可配置的计费与分账口径 |
二、环境与模块划分
2.1 环境清单
| 项目 | 版本 | 说明 |
|---|---|---|
| 操作系统 | Ubuntu 22.04 LTS | 生产与测试一致 |
| 数据库 | MySQL 8.0.32 | InnoDB,utf8mb4 |
| 缓存 | Redis 7.2.3 | 库存与优惠券防超发 |
| 运行时 | Python 3.11.6 | 结算与派单脚本 |
| 小程序基础库 | 3.x | 微信原生语法 |
| UI 组件库 | TDesign miniprogram 0.9.x | 仅前端展示层 |
环境验证命令(逐条执行):
bash
mysql --version # 期望输出 8.0.32
python -V # 期望输出 Python 3.11.6
redis-cli ping # 期望输出 PONG
2.2 数据流:一张订单穿过四个模块
会员模块输出"身份 + 权益 + 应付价"→ 下单模块输出"订单类型 + 状态"→ 派单模块输出"服务人员 + 时间段"→ 结算模块输出"实付 - 退款 = 应结"。
四个模块共享同一张订单主表,任何一个模块私建订单表,后面必然对不上账。
三、模块一:会员管理------让客户资产可计算
3.1 业务问题:散客无法被识别
家政是低频高客单生意,客户做完一次保洁可能半年不再来。如果系统连"这是第几次消费"都不知道,就没法做任何召回动作。会员模块要解决的不是"发张卡",而是三个可计算的问题:他是谁、他值多少权益、他这次该付多少。
3.2 等级、积分、优惠券的分工
| 手段 | 解决的业务问题 | 计算口径 |
|---|---|---|
| 会员等级 | 高价值客户的长期锁定 | 按累计消费金额自动升档,享固定折扣 |
| 积分 | 提升复购频次 | 消费返积分,100 积分抵 1 元 |
| 优惠券 | 拉动单次转化 | 满减券 / 折扣券,设门槛与有效期 |
三者必须定义明确的叠加顺序。顺序写反,最常见的后果是满减券按原价计算,平台多补贴。
3.3 抵扣顺序的可运行实现
python
# Python 3.11 ------ 会员折扣 / 优惠券 / 积分抵扣的结算顺序
from decimal import Decimal
LEVEL_DISCOUNT = {1: Decimal("1.00"), 2: Decimal("0.95"),
3: Decimal("0.90"), 4: Decimal("0.85"), 5: Decimal("0.80")}
POINT_PER_YUAN = 100 # 100 积分抵 1 元
def settle(origin: Decimal, level: int, coupon, points: int):
"""
origin : 服务项目原价
coupon : None 或 ("full_reduce", 门槛, 立减) 或 ("discount", 折扣率)
points : 用户可用积分
返回 : (应付金额, 实际使用积分)
"""
price = origin * LEVEL_DISCOUNT[level] # 第 1 步:先打会员等级折扣
if coupon: # 第 2 步:再算券
kind, a, b = coupon
if kind == "full_reduce" and price >= a:
price -= b
elif kind == "discount":
price *= b
# 第 3 步:最后用积分抵扣,且不得超过当前应付
use_point = min(points, int(price) * POINT_PER_YUAN)
price -= Decimal(use_point) / POINT_PER_YUAN
return max(price, Decimal("0.00")), use_point
if __name__ == "__main__":
pay, used = settle(Decimal("299.00"), level=3,
coupon=("full_reduce", Decimal("250"), Decimal("30")),
points=5000)
print(f"应付 {pay} 元,消耗积分 {used}")
# 输出:应付 209.10 元,消耗积分 5000
四、模块二:在线下单------实时单与预约单必须分开建模
4.1 两种模式的差异
门店最常见的崩盘点,是把"现在要人"和"明天下午要人"塞进同一条逻辑。二者对系统的要求完全不同:
| 维度 | 实时下单 | 预约下单 |
|---|---|---|
| 时间要求 | 30 分钟内响应 | 可提前 1-30 天 |
| 派单策略 | 就近抢单,允许加价 | 按排班表预占时间段 |
| 库存表现 | 抢占当前空闲人力 | 锁定未来时段 |
| 取消规则 | 出发后不可退 | 按提前时长阶梯退款 |
4.2 订单状态机与退单金额规则
状态机是订单模块的地基。任何"状态能随便改"的系统,最后都会出现已退款订单又被结算的脏数据。
python
# Python 3.11 ------ 订单状态机 + 阶梯退单金额计算
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
CREATED, PAID, DISPATCHED, SERVING, FINISHED = 10, 20, 30, 40, 50
REFUNDING, REFUNDED, CANCELED = 80, 81, 90
ALLOWED = {
CREATED: {PAID, CANCELED},
PAID: {DISPATCHED, REFUNDING},
DISPATCHED: {SERVING, REFUNDING},
SERVING: {FINISHED, REFUNDING},
FINISHED: set(),
REFUNDING: {REFUNDED, PAID},
REFUNDED: set(),
CANCELED: set(),
}
def transfer(cur: int, nxt: int) -> int:
"""只允许白名单内的状态流转,其余直接抛错,避免脏数据落库"""
if nxt not in ALLOWED[cur]:
raise ValueError(f"非法状态流转: {cur} -> {nxt}")
return nxt
# 退单金额三档规则,全部由后台配置项驱动
REFUND_RULE = {"free_hours": 24, "half_hours": 2,
"half_rate": Decimal("0.5"), "late_rate": Decimal("0.0")}
def refund_amount(paid: Decimal, start_at: datetime, now: datetime,
rule: dict = REFUND_RULE) -> Decimal:
hours = (start_at - now).total_seconds() / 3600
if hours >= rule["free_hours"]:
rate = Decimal("1.0")
elif hours >= rule["half_hours"]:
rate = rule["half_rate"]
else:
rate = rule["late_rate"]
# 退款基数取实付金额,不含平台补贴部分
return (paid * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if __name__ == "__main__":
paid = Decimal("199.00")
now = datetime(2026, 3, 1, 10, 0, 0)
for h in (48, 6, 1):
start = now + timedelta(hours=h)
print(f"距上门 {h:>2} 小时 -> 应退 {refund_amount(paid, start, now)} 元")
print("状态流转 20 -> 30 =", transfer(PAID, DISPATCHED))
运行结果:距上门 48 小时退 199.00 元,6 小时退 99.50 元,1 小时退 0.00 元。
五、模块三:派单排班------把人员时间变成可调度资源
5.1 冲突判定:先解决"重复派单"
排班的核心不是好看,而是不允许同一人在同一时间段被派两次。判定逻辑本身很简单,容易被忽略的是通勤缓冲时间。
python
from datetime import datetime, timedelta
BUFFER_MIN = 30 # 两单之间预留 30 分钟通勤
def conflict(a_start: datetime, a_min: int, b_start: datetime, b_min: int) -> bool:
a_end = a_start + timedelta(minutes=a_min + BUFFER_MIN)
b_end = b_start + timedelta(minutes=b_min + BUFFER_MIN)
return a_start < b_end and b_start < a_end # 区间重叠即冲突
if __name__ == "__main__":
t = datetime(2026, 3, 2, 9, 0)
print(conflict(t, 120, t + timedelta(hours=3), 60)) # False,中间有 1 小时空档
print(conflict(t, 120, t + timedelta(hours=2), 60)) # True,重叠
5.2 派单打分:技能是硬门槛,距离与负载是软权重
纯"抢单"模式会让老员工压单、新员工没单。用加权评分做一次排序,比人工打电话稳定得多。
python
# Python 3.11 ------ 派单评分(贪心匹配,适合 50 人以下门店)
class Staff:
def __init__(self, sid, skills, x, y, rating=5.0, load=0):
self.sid, self.skills, self.x, self.y = sid, set(skills), x, y
self.rating, self.load = rating, load # load = 当日已派单数
W_SKILL, W_DIST, W_RATING, W_LOAD = 40, 30, 20, 10
def score(staff, order, max_dist_km=10.0):
if order["skill"] not in staff.skills:
return -1 # 硬约束:技能不匹配淘汰
dist = ((staff.x - order["x"]) ** 2 + (staff.y - order["y"]) ** 2) ** 0.5
if dist > max_dist_km:
return -1 # 硬约束:超出服务半径淘汰
s_skill = W_SKILL
s_dist = W_DIST * (1 - dist / max_dist_km)
s_rating = W_RATING * (staff.rating / 5.0)
s_load = W_LOAD * (1 / (1 + staff.load)) # 单量越高分越低,避免压单
return round(s_skill + s_dist + s_rating + s_load, 2)
def dispatch(order, staffs):
ranked = [(score(s, order), s) for s in staffs]
ranked = [r for r in ranked if r[0] > 0]
ranked.sort(key=lambda r: (-r[0], r[1].sid))
return ranked[0] if ranked else None
if __name__ == "__main__":
order = {"skill": "深度保洁", "x": 3.0, "y": 4.0}
staffs = [
Staff("A01", ["日常保洁", "深度保洁"], 3.2, 4.1, rating=4.9, load=1),
Staff("A02", ["深度保洁"], 6.0, 8.0, rating=5.0, load=0),
Staff("A03", ["家电清洗"], 3.0, 4.0, rating=4.8, load=0),
]
best = dispatch(order, staffs)
print("命中人员:", best[1].sid, "得分:", best[0]) if best else print("无人可派")
运行结果命中 A01(得分 93.93)。A03 技能不匹配被直接淘汰,A02 虽然评分满分但距离 5 公里,得分被距离权重压低。
六、模块四:结算统计------退单可配置与后台数据看板
6.1 退单金额为什么必须后台可配置
不同门店、不同服务项目、不同旺季,退款口径完全不同。把规则写死在代码里,每次调整都要发版。可配置的意义是:运营人员自己就能改,改完立即生效,且每次变更留痕。
6.2 结算 SQL:只认已完成和已退款订单
sql
-- 门店当日结算:按服务人员汇总
SELECT
s.`id` AS staff_id,
COUNT(*) AS order_cnt,
SUM(o.`paid_amount`) AS paid_total,
SUM(o.`refund_amount`) AS refund_total,
SUM(o.`paid_amount` - o.`refund_amount`) AS settle_amount,
ROUND(SUM(o.`paid_amount` - o.`refund_amount`) * 0.7, 2) AS staff_income
FROM `service_order` o
JOIN `service_staff` s ON s.`id` = o.`staff_id`
WHERE o.`status` IN (50, 81) -- 50=已完成,81=已退款
AND o.`created_at` >= CURDATE()
GROUP BY s.`id`
ORDER BY settle_amount DESC;
6.3 后台看板要盯的五个指标
| 指标 | 口径 | 用来回答的问题 |
|---|---|---|
| 下单转化率 | 支付订单数 / 访问人数 | 券和活动是否有效 |
| 实时单占比 | 实时单 / 总订单 | 人力是否够、要不要涨价 |
| 单人日均单量 | 完成单量 / 在岗人数 | 排班是否合理 |
| 平均客单价 | 实付总额 / 订单数 | 会员折扣是否亏本 |
| 退款率 | 退款单 / 总订单 | 服务质量与派单准确度 |
七、模块五:商城与家政同系统------多一条收入曲线
7.1 同一会员、同一积分池
把直邮商城和上门家政放在一套系统里,最大的价值不是"能卖货",而是同一套会员权益可以跨板块使用。客户做完保洁获得的积分,可以直接抵扣商城里的耗材、清洁用品,复购动机立刻多了一个。
7.2 订单与库存必须隔离
服务订单没有物理库存,商城订单有。二者复用订单状态机会造成状态语义冲突。正确做法是共用订单主表的支付与退款逻辑,但库存扣减、发货履约走独立子表,避免服务订单误触发库存扣减。
八、验证测试
8.1 测试环境
后端 4C8G、MySQL 8.0.32、Redis 7.2.3、模拟并发 200 线程;数据口径来自笔者所在团队交付项目的脱敏统计,非实验室压测值,仅供量级参考。
8.2 上线前后对比
| 指标 | 上线前 | 上线后 | 变化 |
|---|---|---|---|
| 日均派单耗时 | 约 25 分钟/单 | 约 3 分钟/单 | 下降约 88% |
| 月末对账耗时 | 约 16 小时 | 约 1 小时 | 下降约 94% |
| 重复派单投诉 | 每周 3-5 起 | 基本清零 | 由冲突判定拦截 |
| 会员复购率 | 约 12% | 约 27% | 积分券联动拉动 |
验证方式:以上单量类数据可由 service_order 按 created_at 分组查询复核;派单耗时可在派单日志表中统计"下单时间 → 派单成功时间"的差值。
九、总结:3 条可迁移的认知
第一,业务系统的最小闭环是"身份 → 交易 → 履约 → 分账"。 家政小程序看起来模块多,本质上仍然是这四段。任何一段缺失,最后都会变成人工补位,而人工补位就是不可规模化的天花板。
第二,规则必须可配置,代码只负责执行。 退款比例、折扣层级、派单权重,这些都是运营变量,不是技术常量。把它们抽成配置表,系统的生命周期会被拉长好几倍。
第三,状态机是最便宜的质量保障。 一个白名单式的状态流转函数,几十行代码,能挡掉后续 90% 的订单脏数据。这类投入的性价比,远高于事后写对账脚本。
如果你正在评估家政类小程序的模块拆分,建议先从"退单金额能不能后台改"这个问题问起------它能直接反映一套系统的架构成熟度。欢迎在评论区交流你们门店遇到的排班与结算难题。