先说结论
很多团队对 Odoo 工时计费的直觉是:
- 员工登记了 5 小时
- 销售订单行就交付 5 小时
- 因此也就该开 5 小时的票
这个理解只对了一半。
如果你看 /home/ubuntu/odoo-temp/addons/sale_timesheet/models/sale_order_line.py 和 /home/ubuntu/odoo-temp/addons/sale_timesheet/models/hr_timesheet.py,会发现 Odoo 真正做的是:
- 先定义哪些工时能进入 delivered quantity
- 再定义哪些工时现在还能进入 qty_to_invoice
- 已经开过票、被退款、被取消、跨期间结转的工时,还要走不同分支
所以“工时驱动开票”在 Odoo 里不是一个加总动作,而是一个带状态过滤的可结算量计算系统。
第一层:qty_delivered_method = timesheet 只是入口,不是结果
在 sale_timesheet 里,服务产品如果满足:
type = serviceservice_type = timesheet- 不是 expense line
那么销售订单行的 qty_delivered_method 会被设成 timesheet。
很多人看到这一步就以为系统逻辑已经讲完了。
其实这一步只是告诉 Odoo:
这条销售订单行的“已交付数量”不再靠手工填,也不靠物流,而是要去工时数据里算。
真正关键的是下面两个方法:
_prepare_qty_delivered()_recompute_qty_to_invoice()
前者回答“已经交付了多少”,后者回答“现在还能开多少”。
这两个数字既相关,又不能混为一谈。
第二层:delivered quantity 不等于“所有 timesheet 小时相加”
在 _prepare_qty_delivered() 里,Odoo 会先找出所有 qty_delivered_method == 'timesheet' 的销售订单行,然后调用:
_timesheet_compute_delivered_quantity_domain()_get_delivered_quantity_by_analytic(domain)
注意这里的关键词是 domain。
这意味着 delivered quantity 从一开始就不是“把工时表里和这条销售行相关的记录全加起来”,而是:
- 先根据业务规则筛出合法 analytic line
- 再按销售订单行聚合数量
默认 domain 至少要求:
project_id != False
如果上下文里带了 accrual_entry_date,还会再加:
date <= accrual_entry_date
这一步非常像财务截止逻辑。
也就是说,在某些期间结算或应计场景里,Odoo 会故意只看截止日前的工时,而不是看“数据库里目前所有工时”。
第三层:qty_to_invoice 比 delivered quantity 更保守
_recompute_qty_to_invoice(start_date, end_date) 展示了更严格的筛选规则。
它除了沿用 delivered quantity 的 domain 之外,还会额外限定:
timesheet_invoice_id = False- 或 timesheet 关联发票已取消
- 如果有退款单,还允许某些已开票工时重新进入可计费集合
- 并且可按
start_date/end_date只重算某个期间
这说明 qty_to_invoice 不是“已交付未开票”的口头概念,而是真正有一整套状态机。
最值得注意的是退款处理。
源码会拿:
- 已过账的客户退款单
out_refund - 再追它的
reversed_entry_id
然后把那些原来已开票、但后来被退款冲回的工时重新纳入可重算范围。
这背后的业务含义非常明确:
工时计费不是单向消耗。只要发票结果被退款逆转,系统就有理由把这些工时重新视为可结算。
第四层:为什么 delivered quantity 和 qty_to_invoice 不能直接划等号
因为 delivered quantity 更偏“履约事实”,而 qty_to_invoice 更偏“当前尚可开票的量”。
举几个典型场景就明白了。
场景 1:工时已经登记,但还没走到本次结算期间
- delivered quantity 可能已经包含
- qty_to_invoice 在本次分期重算里可能不包含
场景 2:工时已经开过票
- delivered quantity 还是交付事实
- qty_to_invoice 应该变成 0
场景 3:原发票被退款回冲
- delivered quantity 不会因为退款就变成没做过
- qty_to_invoice 却可能重新变大
这也是为什么很多顾问以为“系统算错了”,其实只是拿两个不同语义的数字互相比。
第五层:项目、销售行和 timesheet 的关系,本质上是 analytic line 的关系
无论是 delivered quantity 还是 qty_to_invoice,底层都不是直接从 project.task 加出来的,而是围绕 account.analytic.line 聚合。
这点特别重要,因为它解释了很多看起来“绕”的设计:
- timesheet 能挂
project_id - timesheet 能挂
task_id - timesheet 还能挂
so_line - 并带上
timesheet_invoice_id
Odoo 实际上是把一条工时记录同时当作:
- 项目执行事实
- 分析会计明细
- 销售计费候选项
- 开票追踪节点
也正因如此,项目、销售、会计这三边才能在同一条 analytic line 上会合。
第六层:为什么系统会区分 billable_time、timesheet_revenues、non_billable
在 hr_timesheet.py 里,timesheet_invoice_type 的计算特别关键。
对项目工时来说,源码至少会区分:
billable_timebillable_fixedbillable_milestonesbillable_manualnon_billabletimesheet_revenuesother_costs
这套分类说明,Odoo 不是把工时简单分成“可开票 / 不可开票”,而是在区分:
- 这条工时对应哪种销售计费政策
- 这条记录此刻代表待计费,还是已形成收入,还是只是成本
所以 delivered quantity 的计算虽然看起来只是数量问题,但它背后已经站着一整套计费语义分层。
第七层:为什么已开票工时不能随便改
_check_can_write() 会拦住一些关键字段的修改,只要这条工时已经通过 delivery 型策略开过票。
比如你再去改:
unit_amountemployee_idproject_idtask_idso_linedate
就可能被直接禁止。
这背后的逻辑很简单:
- 一旦工时已经变成发票事实
- 你就不能再随意改动它的核心计费维度
否则 delivered quantity、qty_to_invoice、利润面板和已开票追溯都会一起失真。
第八层:为什么“工时开票不准”很多时候不是开票问题,而是归属问题
_compute_so_line() 和 _timesheet_determine_sale_line() 决定工时默认归到哪条销售订单行。
如果这里归属就不对,后面所有 delivered / to invoice 计算都会跟着偏。
尤其在:
- 项目固定价
- 任务价
- 员工价
- 手工 non-billable
这些场景混用时,一条 timesheet 是不是该进某张销售行,根本不是“填没填 so_line”这么简单。
所以很多人排查“为什么某工时没进待开票”时,只盯发票向导是远远不够的。更常见的根因是:
- 工时根本没落到正确的销售行
- 或者它已经处于不可再次计费的状态
新手最容易误解的 5 件事
1. 以为 delivered quantity 就是 timesheet 总小时数
不对。它是带 domain 过滤后的 analytic line 聚合结果。
2. 以为 qty_to_invoice 只是 delivered quantity 的别名
不对。qty_to_invoice 还要扣掉已开票、结合退款状态,并可按期间重算。
3. 以为退款只影响会计,不影响工时计费
不对。退款可能把原本已开票的工时重新带回可开票集合。
4. 以为 timesheet 只属于项目模块
不对。它同时是分析会计明细,也是销售计费节点。
5. 以为开票不准就去看发票就够了
不对。要先看 timesheet 的销售行归属是否正确。
实战里最该注意什么
1. 做期间结算时,要特别留意 accrual_entry_date / 时间范围
因为同一批工时,在“累计视角”和“本期视角”下,进入 qty_to_invoice 的范围不一定一样。
2. 退款后如果想重开票,不要手工重填一堆数量
先确认系统是否已经把被冲回的 timesheet 重新纳入可重算集合。
3. 如果某条销售订单行的已交付数不对,优先检查 analytic line 的归属和状态
比起直接查发票,先看:
project_idso_linetimesheet_invoice_iddatetimesheet_invoice_type
往往更快找到根因。
一句话记忆法
Odoo 的工时型服务计费不是“登记几小时就能开几小时”,而是 analytic line 在交付、期间、发票状态和退款状态共同约束下,动态算出的可结算量。
DISCUSSION
评论区