项目深度

Odoo 员工价项目为什么不是“不同员工填不同单价”:employee_rate、sale line mapping 与工时默认归属的底层设计

很多公司做顾问项目时,希望高级顾问和初级顾问按不同价格计费。Odoo 并不是在 timesheet 上直接填一个价格,而是通过 project.sale.line.employee.map、默认销售行选择和成本覆写机制来实现。本文把这套设计讲透。

项目
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 33 阅读

先说结论

很多团队在做顾问、实施、技术支持项目时,都会有同一个需求:

  • 高级顾问 1 小时 300
  • 初级顾问 1 小时 150
  • 但他们做的是同一类服务、同一个项目

很多系统会选择最直觉的实现:

  • 在 timesheet 上直接填单价
  • 最后按工时 * 单价算收入

Odoo 不是这么做的。

如果你看 /home/ubuntu/odoo-temp/addons/sale_timesheet/models/project_project.pyproject_sale_line_employee_map.pyhr_timesheet.py,会发现官方真正的设计是:

  • 项目切到 employee_rate
  • 为每个员工映射一条销售订单行
  • timesheet 根据员工身份自动落到对应销售行
  • 必要时还可以覆写项目级员工成本

所以 Odoo 的“员工价项目”不是在工时记录上直接塞价格,而是用销售订单行映射来表达不同员工的计费规则


第一层:employee_rate 的本质不是多价格,而是多销售语义

project_project.py 里,项目的 pricing_type 有三类:

  • task_rate
  • fixed_rate
  • employee_rate

其中 employee_rate 的判断条件很直接:

  • 只要项目上存在 sale_line_employee_ids
  • 且项目允许 billable
  • 系统就把它视为 employee-rate 项目

这点很重要。

Odoo 并没有把员工差异定价建模成:

  • 员工 × 单价 的一张孤立价目表

而是建模成:

  • 员工 × 销售订单行 的映射

为什么这么做?

因为销售订单行不仅有价格,还有完整销售语义:

  • 属于哪张销售订单
  • 属于哪个客户
  • 用的是什么服务产品
  • 剩余可计费工时还有多少
  • 以后会按哪条销售链去开票

所以 Odoo 真正想解决的是:

某个员工的工时,默认应该落到哪条可计费销售承诺上。

而不是“这小时多少钱”这么孤立的问题。


第二层:为什么官方单独做了 project.sale.line.employee.map

project_sale_line_employee_map.py 定义了一个专门模型:

  • project_id
  • employee_id
  • sale_line_id
  • price_unit
  • cost
  • display_cost

这张表的设计很有意思,因为它把三个本来容易混在一起的概念拆开了:

1. 收入端:用 sale_line_id 表达

员工不是直接带价格,而是关联一条销售订单行。

price_unit 只是从这条销售行读出来的结果,不是主数据源。

2. 成本端:用 cost / display_cost 表达

员工映射还允许你在项目维度覆写该员工工时成本,这会覆盖员工 HR 设置里的默认 hourly cost。

3. 显示端:按公司工时录入单位动态换算

如果公司用“天”录工时,display_cost 还会按员工资源日历的 hours_per_day 换算成日成本。

这说明 Odoo 不是把“员工价”理解成一个字段,而是把它拆成:

  • 销售归属
  • 收入价格
  • 成本价格
  • 显示单位

四个层面分别处理。


第三层:为什么一条 timesheet 默认归哪条销售行,要走优先级逻辑

hr_timesheet.py 里,核心方法 _timesheet_determine_sale_line() 把这件事讲得很清楚。

大致优先级是:

场景 A:工时没有挂任务,只挂项目

如果项目是 employee_rate

  • 先找该员工在项目上的 mapping entry
  • 找到就用这条 entry 的 sale_line_id
  • 否则再退回项目级默认 sale_line_id

场景 B:工时挂在任务上,任务本身允许 billable

  • 如果任务定价是 task_rate / fixed_rate,优先用任务自己的 sale_line_id
  • 如果任务所在项目是 employee_rate,则优先找“员工 + 客户”匹配的 mapping
  • 找不到才退回任务自己的 sale_line_id

这套优先级说明,Odoo 真正在努力保持的是:

员工差异化计费应该优先于项目默认销售行,但又不能完全脱离任务与客户上下文。

所以 employee-rate 不是粗暴覆盖一切,而是在已有项目 / 任务销售语义之上,插入“按员工细分”的计费路由。


第四层:为什么 mapping 里要强校验客户边界

project_sale_line_employee_map.py 里,sale_line_id 的 domain 不只是“服务类销售行”。它还要求:

  • 销售行的客户要与项目 partner 对得上

同时 _compute_sale_line_id() 也会在客户边界变化时,把不再匹配的 sale_line_id 清掉。

这说明官方非常明确地把 mapping 看成:

  • 项目客户上下文里的员工计费入口

而不是全局任意可选的价格表。

否则会出现一种很危险的情况:

  • 员工在客户 A 的项目上录工时
  • 却默认落到了客户 B 的销售订单行

一旦发生这种错绑,后面不只是开票会错,项目利润和剩余可售工时也都会错。


第五层:为什么 employee-rate 项目还会出现 warning

project_project.py 里有个 warning_employee_rate,其计算逻辑是:

  • 先找项目下已有工时出现过哪些员工
  • 再看这些员工是不是都在 sale_line_employee_ids 里有映射
  • 如果有员工已经开始录工时,但还没有 mapping,就打 warning

这个设计非常实用。

它不是在防技术错误,而是在防运营漏配:

  • 项目已经开干
  • 顾问已经录了工时
  • 但项目经理还没把这个人对应到哪条销售行配好

如果系统不提醒,这些工时就可能:

  • 落到兜底销售行
  • 或直接变成非预期归属
  • 让项目收入结构悄悄偏掉

第六层:为什么 employee-rate 还会改变 hourly cost

很多人只盯着销售端价格,但 hr_timesheet.py_hourly_cost() 说明,employee-rate 不只决定收入,还会影响成本归集。

如果项目是 employee_rate

  • 系统先找 mapping entry
  • 找到后优先使用 mapping 上的 cost
  • 没有时再退回员工默认 hourly cost

这一步很关键,因为它意味着 employee-rate 项目其实同时在解决两件事:

  1. 该员工的工时对客户按什么价格卖
  2. 该员工的工时在这个项目里按什么成本算

于是项目利润就不再只是“统一价格 - 统一人工成本”,而会变成更真实的顾问层级利润结构。


第七层:为什么 mapping 变更后系统还要回写历史可更新工时

project_sale_line_employee_map.py 在 create / write 后都会调用:

  • _update_project_timesheet()

再由项目上的 _update_timesheets_sale_line_id() 去批量更新:

  • 尚可更新的 timesheet
  • 且不是手工改过 so_line 的记录

这段逻辑特别值得注意。

它说明官方默认认为:

  • mapping 是“项目当前计费规则”
  • 只要工时还没进入不可修改状态
  • 那么调整 mapping 后,系统应该尽量把这套规则同步到相关工时

也就是说,employee-rate 不是“以后新录的工时才生效”,而是尽量作为项目的现行默认规则回灌到历史可更新工时里。


新手最容易误解的 5 件事

1. 以为 employee-rate 就是在工时上直接填不同价格

不对。Odoo 主要通过“员工 → 销售订单行”的映射来表达差异化计费。

2. 以为 mapping 只管收入,不管成本

不对。mapping 还能覆写项目维度的人工成本。

3. 以为员工映射和客户没关系

不对。客户边界是 mapping 成立的前提。

4. 以为项目默认销售行一定优先生效

不对。employee-rate 场景里,员工映射通常优先于项目默认销售行。

5. 以为配完 mapping 只影响未来工时

不对。系统会尝试把规则同步到仍可更新的历史工时。


实战里最该注意什么

1. 不要把“不同级别顾问不同价格”做成一个自定义单价字段

如果你想保留 Odoo 原生开票、待开票、项目利润链路,最好沿着销售订单行映射这套模型走,而不是另起一套散的价格逻辑。

2. employee-rate 项目开工前,优先补齐所有会录工时的员工映射

这能避免后面工时跑到兜底销售行,导致收入归属混乱。

3. 调试利润不准时,别只看销售价,也要看 mapping cost

因为员工成本覆写会直接影响项目盈利结果。


一句话记忆法

Odoo 的 employee-rate 项目不是“员工工时带不同单价”,而是“员工工时默认路由到不同销售订单行,并可在项目维度同时改写收入与成本归属”的一整套映射机制。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。