项目深度

Odoo 工时计费为什么不是“登记了几小时就能开几小时”:delivered quantity、qty_to_invoice 与退款回冲的真实链路

在 Odoo 里,工时型服务的已交付数量和待开票数量并不是简单等于 timesheet 的小时数。源码里还掺着 domain 过滤、退款单回冲、未开票工时筛选和期间重算逻辑。本文把这条链讲透。

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

先说结论

很多团队对 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 = service
  • service_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_timetimesheet_revenuesnon_billable

hr_timesheet.py 里,timesheet_invoice_type 的计算特别关键。

对项目工时来说,源码至少会区分:

  • billable_time
  • billable_fixed
  • billable_milestones
  • billable_manual
  • non_billable
  • timesheet_revenues
  • other_costs

这套分类说明,Odoo 不是把工时简单分成“可开票 / 不可开票”,而是在区分:

  • 这条工时对应哪种销售计费政策
  • 这条记录此刻代表待计费,还是已形成收入,还是只是成本

所以 delivered quantity 的计算虽然看起来只是数量问题,但它背后已经站着一整套计费语义分层。


第七层:为什么已开票工时不能随便改

_check_can_write() 会拦住一些关键字段的修改,只要这条工时已经通过 delivery 型策略开过票。

比如你再去改:

  • unit_amount
  • employee_id
  • project_id
  • task_id
  • so_line
  • date

就可能被直接禁止。

这背后的逻辑很简单:

  • 一旦工时已经变成发票事实
  • 你就不能再随意改动它的核心计费维度

否则 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_id
  • so_line
  • timesheet_invoice_id
  • date
  • timesheet_invoice_type

往往更快找到根因。


一句话记忆法

Odoo 的工时型服务计费不是“登记几小时就能开几小时”,而是 analytic line 在交付、期间、发票状态和退款状态共同约束下,动态算出的可结算量。

DISCUSSION

评论区

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