先说结论
很多团队在做顾问、实施、技术支持项目时,都会有同一个需求:
- 高级顾问 1 小时 300
- 初级顾问 1 小时 150
- 但他们做的是同一类服务、同一个项目
很多系统会选择最直觉的实现:
- 在 timesheet 上直接填单价
- 最后按工时 * 单价算收入
Odoo 不是这么做的。
如果你看 /home/ubuntu/odoo-temp/addons/sale_timesheet/models/project_project.py、project_sale_line_employee_map.py 和 hr_timesheet.py,会发现官方真正的设计是:
- 项目切到
employee_rate - 为每个员工映射一条销售订单行
- timesheet 根据员工身份自动落到对应销售行
- 必要时还可以覆写项目级员工成本
所以 Odoo 的“员工价项目”不是在工时记录上直接塞价格,而是用销售订单行映射来表达不同员工的计费规则。
第一层:employee_rate 的本质不是多价格,而是多销售语义
在 project_project.py 里,项目的 pricing_type 有三类:
task_ratefixed_rateemployee_rate
其中 employee_rate 的判断条件很直接:
- 只要项目上存在
sale_line_employee_ids - 且项目允许 billable
- 系统就把它视为 employee-rate 项目
这点很重要。
Odoo 并没有把员工差异定价建模成:
- 员工 × 单价 的一张孤立价目表
而是建模成:
- 员工 × 销售订单行 的映射
为什么这么做?
因为销售订单行不仅有价格,还有完整销售语义:
- 属于哪张销售订单
- 属于哪个客户
- 用的是什么服务产品
- 剩余可计费工时还有多少
- 以后会按哪条销售链去开票
所以 Odoo 真正想解决的是:
某个员工的工时,默认应该落到哪条可计费销售承诺上。
而不是“这小时多少钱”这么孤立的问题。
第二层:为什么官方单独做了 project.sale.line.employee.map
project_sale_line_employee_map.py 定义了一个专门模型:
project_idemployee_idsale_line_idprice_unitcostdisplay_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 项目其实同时在解决两件事:
- 该员工的工时对客户按什么价格卖
- 该员工的工时在这个项目里按什么成本算
于是项目利润就不再只是“统一价格 - 统一人工成本”,而会变成更真实的顾问层级利润结构。
第七层:为什么 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
评论区