写 CRM 的时候,订单状态是一个特别容易被低估的东西。
一开始你可能只想在表里放个字段:
text
pending / completed / cancelled
然后页面上渲染一下 badge,好像就结束了。
但家政业务不是这样。一个订单从客户需求转过来以后,可能要确认、派工、上门服务、完工、结算;中间还可能取消、改时间、换阿姨。状态一旦写散,最后就会变成这种代码:
rust
// 伪代码:别这么写
if order.status != "completed" && order.status != "cancelled" {
order.status = payload.status;
}
短期能跑,长期很难维护。因为你根本不知道某个接口到底允许从哪个状态跳到哪个状态。
所以我在 Pico-CRM 里把订单状态机放进了领域模型。
一、订单只有 6 个状态,但不是随便跳
项目里的订单状态定义在 backend/src/domain/crm/order/model.rs:
rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderStatus {
Pending,
Confirmed,
Dispatching,
InService,
Completed,
Cancelled,
}
对应业务含义大概是:
text
Pending 待处理,通常由服务需求转成订单
Confirmed 已确认,客户和服务内容基本确认
Dispatching 派工中,已经进入排班/派单阶段
InService 服务中,家政人员已开始服务
Completed 已完成,服务结束
Cancelled 已取消,终态
这里看起来只是枚举,真正关键的是 can_transition:
rust
pub fn can_transition(current: OrderStatus, next: OrderStatus) -> bool {
current == next
|| matches!(
(current, next),
(OrderStatus::Pending, OrderStatus::Confirmed)
| (OrderStatus::Pending, OrderStatus::Dispatching)
| (OrderStatus::Pending, OrderStatus::InService)
| (OrderStatus::Confirmed, OrderStatus::Dispatching)
| (OrderStatus::Confirmed, OrderStatus::InService)
| (OrderStatus::Dispatching, OrderStatus::Confirmed)
| (OrderStatus::Dispatching, OrderStatus::InService)
| (OrderStatus::InService, OrderStatus::Completed)
)
}
这段代码表达了几个真实取舍。
第一,Pending -> Completed 不允许。一个刚创建的订单不能直接完工,至少要进入服务过程。
第二,Dispatching -> Confirmed 是允许的。家政业务里派工过程中可能发现时间、人员或客户信息要回退确认,状态机不能只按理想流程往前走。
第三,Pending -> InService 也允许。MVP 阶段有些线下订单已经安排好了,系统补录时没必要强迫用户点一遍"确认"和"派工"。
这就是业务系统里状态机最有意思的地方:它不是画一条最漂亮的流程线,而是把真实世界允许发生的路径收敛到代码里。
二、为什么 Cancelled 不放进普通流转
注意上面的 can_transition 里没有任何状态可以跳到 Cancelled。
不是忘了写,而是我故意把取消拆成了独立流程。
普通状态更新走的是 update_status:
rust
pub async fn update_status(
&self,
uuid: String,
payload: UpdateOrderStatus,
operator_uuid: Option<String>,
) -> Result<SharedOrder, String> {
if payload.status == "cancelled" {
return Err("use cancel order endpoint when cancelling order".to_string());
}
OrderStatus::parse(&payload.status)?;
let updated = self
.order_repo
.update_order_status(uuid, payload.status, operator_uuid)
.await?;
let schedule_status = ScheduleStatus::from_order_status(&updated.status);
let _ = self
.schedule_repo
.update_status(updated.uuid.clone(), schedule_status)
.await?;
Ok(updated.into())
}
取消订单必须走 cancel_order,原因很简单:取消不是一个普通状态切换,它需要取消原因,还要同步排班。
领域模型里也有明确限制:
rust
pub fn cancel(&mut self, reason: String) -> Result<(), String> {
if reason.trim().is_empty() {
return Err("Cancellation reason is required".to_string());
}
if self.status == OrderStatus::Completed {
return Err("Completed orders cannot be cancelled".to_string());
}
self.status = OrderStatus::Cancelled;
self.cancellation_reason = Some(reason.trim().to_string());
self.updated_at = Utc::now();
Ok(())
}
这比 status = "cancelled" 多了两个约束:
- 必须有取消原因
- 已完成订单不能再取消
我的习惯是:如果某个状态变化需要额外业务信息,就不要把它塞进通用状态接口。
否则接口看起来统一了,业务语义反而丢了。
三、状态校验不在 Handler 里,而在领域层
状态更新最终会进入事件溯源的 Decision:
rust
impl Decision for UpdateOrderStatusDecision {
type Event = OrderEventEnvelope;
type StateQuery = OrderState;
type Error = String;
fn process(&self, state: &Self::StateQuery) -> Result<Vec<Self::Event>, Self::Error> {
if !state.exists {
return Err(format!("order {} not found", self.order_uuid));
}
if self.next_status == OrderStatus::Cancelled {
return Err("use cancel order flow when changing to cancelled".to_string());
}
let current_status = OrderStatus::parse(
state
.status
.as_deref()
.unwrap_or(OrderStatus::Pending.as_str()),
)?;
OrderStatus::validate_transition(current_status, self.next_status)?;
let completed_at = if self.next_status == OrderStatus::Completed {
Some(self.updated_at)
} else {
None
};
Ok(vec![OrderEventEnvelope::OrderStatusChanged {
merchant_id: self.merchant_id.clone(),
order_uuid: self.order_uuid.clone(),
operator_uuid: self.operator_uuid.clone(),
status: self.next_status.as_str().to_string(),
completed_at,
updated_at: self.updated_at,
}])
}
}
这里有个设计点:process() 不直接改数据库,只根据当前状态判断能不能产生事件。
能流转,就返回 OrderStatusChanged 事件;不能流转,就直接报错。
比如 Pending -> Completed 会被挡掉:
rust
OrderStatus::validate_transition(OrderStatus::Pending, OrderStatus::Completed)
// Err("invalid order status transition: pending -> completed")
这样做的好处是,规则只在一个地方成立。
不管状态变化来自页面按钮、server function、排班联动,最后都要经过同一套 Decision。接口层可以做参数校验,但不能绕过领域规则。
四、排班会反推订单状态
订单状态机不是孤立存在的。家政 CRM 里还有一个排班状态:
rust
pub enum ScheduleStatus {
Planned,
InService,
Done,
Cancelled,
}
订单和排班之间有一层映射:
rust
pub fn from_order_status(status: &OrderStatus) -> Self {
match status {
OrderStatus::Pending | OrderStatus::Confirmed | OrderStatus::Dispatching => {
ScheduleStatus::Planned
}
OrderStatus::InService => ScheduleStatus::InService,
OrderStatus::Completed => ScheduleStatus::Done,
OrderStatus::Cancelled => ScheduleStatus::Cancelled,
}
}
pub fn target_order_status(&self) -> Option<OrderStatus> {
match self {
ScheduleStatus::Planned => None,
ScheduleStatus::InService => Some(OrderStatus::InService),
ScheduleStatus::Done => Some(OrderStatus::Completed),
ScheduleStatus::Cancelled => Some(OrderStatus::Cancelled),
}
}
这块很业务。
排班从 planned 进入 in_service,订单也应该进入 in_service。排班完成,订单也应该变成 completed。排班取消,订单也要走取消链路。
创建排班时还有一个自动推进:
rust
pub fn next_after_schedule_assignment(current: OrderStatus) -> OrderStatus {
match current {
OrderStatus::Pending | OrderStatus::Confirmed => OrderStatus::Dispatching,
other => other,
}
}
也就是说,一个 pending 订单只要创建了排班,就会自动推进到 dispatching。
这比让前端连续调用两个接口更可靠:
text
错误做法:
创建排班成功
-> 前端再调一次更新订单状态
-> 第二个请求失败,订单和排班状态不一致
当前做法:
应用服务编排
-> 创建/更新排班
-> 更新订单派工信息
-> 自动推进订单状态
状态机不是只管自己的字段,还要定义和其他聚合协作时的边界。
五、Completed 和 Cancelled 是"业务封口"
订单完工或取消后,很多字段就不应该再动了。
比如核心字段更新:
rust
pub fn update_details(&mut self, update: OrderDetailsUpdate) -> Result<(), String> {
if self.status == OrderStatus::Completed || self.status == OrderStatus::Cancelled {
return Err("Completed or cancelled orders cannot update core fields".to_string());
}
if update.customer_uuid.trim().is_empty() {
return Err("Customer is required".to_string());
}
if update.amount_cents < 0 {
return Err("Amount cents must be non-negative".to_string());
}
self.customer_uuid = Some(update.customer_uuid);
self.amount_cents = update.amount_cents;
self.notes = update.notes;
self.updated_at = Utc::now();
Ok(())
}
这里不是为了"代码洁癖",而是为了业务事实。
一个已经完工的订单,如果还能随便改客户和金额,那审计日志就会变得很奇怪;一个已经取消的订单,如果还能改核心字段,也会让后续统计出现脏数据。
所以我把 Completed 和 Cancelled 当作业务封口:
- 不能随便改核心字段
Completed不能再取消Cancelled不能通过普通状态接口跳回其他状态
这类规则越早放进领域层,后面页面越多、接口越多,收益越明显。
六、测试用 Given/When/Then 更直观
事件溯源的一个好处是,状态机测试可以写得很像业务剧本。
比如非法流转:
rust
#[test]
fn it_rejects_invalid_status_transition() {
TestHarness::given([seed_created_event(
"11111111-1111-1111-1111-111111111111",
&sample_order(),
None,
)])
.when(UpdateOrderStatusDecision::new(
"11111111-1111-1111-1111-111111111111",
"order-1",
OrderStatus::Completed,
ts(2),
None,
))
.then_err("invalid order status transition: pending -> completed".to_string());
}
读起来就是:
text
Given 一个刚创建的 pending 订单
When 尝试直接改成 completed
Then 应该报 invalid transition
再比如已完成订单不能更新派工:
rust
.then_err(
"schedule assignment can only be updated in planned status (current: done)".to_string(),
);
状态机这种东西,光靠脑子想很容易漏边界。测试最好覆盖两类:
- 允许的主路径:
pending -> confirmed -> dispatching -> in_service -> completed - 不允许的捷径和回退:
pending -> completed、completed -> confirmed、cancelled -> pending
规则不复杂,但要把"不能发生什么"写清楚。
总结
订单状态机这块,我现在的体会是:不要把 status 当成普通字段,它其实是业务流程的压缩包。
Pico-CRM 里的做法可以概括成几条:
- 用 Rust enum 收敛状态值,不让字符串满天飞
- 用
can_transition明确合法路径 Cancelled拆成独立取消流程,强制带取消原因- 排班状态和订单状态互相映射,由应用服务统一编排
Completed/Cancelled作为业务封口,限制核心字段修改- 用事件溯源 Decision 和 Given/When/Then 测试兜住边界
状态机不用一上来就搞得很"架构感"。先把状态、流转、终态、跨模块联动这几件事放对位置,系统后面就不会被一堆 if status == ... 拖住。
你们项目里的订单状态是集中管理,还是散在各个接口里判断?评论区聊聊。
下一篇准备写 N+1 查询优化:批量 is_in + HashMap 内存 join,怎么把逐条查询收回来。