企业版 Intrastat

Odoo 企业版 Intrastat 为什么不是“发票金额汇总”那么简单:国家、交易代码、原产国与报表分组边界讲透

很多人第一次看 Odoo 企业版 Intrastat,会以为它只是把欧盟往来发票按期间加总。可真正决定报表结果的,不只是金额,还有销售看收货国、采购看往来方国家、行上交易代码默认值、产品原产国、补充计量单位,以及一整套分组键与校验规则。源码里它本质上是一条“发票字段 → 行字段 → 报表分组 → return checks”的链路。

企业 会计
进阶 开发者 3 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多团队第一次接触 Intrastat,直觉会把它理解成:

  • 挑一个期间;
  • 把欧盟往来发票加总;
  • 导出一张申报表。

但如果你真去读 /home/ubuntu/odoo-temp/enterprise/account_intrastat 的源码,会发现 Odoo 企业版根本不是在做“简单汇总”,而是在维护一条很严格的监管数据链:

  1. 发票头先决定 Intrastat 国家
  2. 发票行再继承或计算交易代码、原产国
  3. 产品本身决定商品编码、补充计量单位与原产国来源
  4. 报表再按一整套 grouping keys 聚合
  5. 最后 return checks 再检查是否符合 Intrastat 申报边界。

本文主要基于:

  • account_intrastat/models/account_move.py
  • account_intrastat/models/product.py
  • account_intrastat/models/res_company.py
  • account_intrastat/models/account_intrastat_report.py
  • account_intrastat/tests/test_intrastat_report.py
  • account_intrastat/tests/test_product.py
  • account_intrastat/tests/test_intrastat_return.py

先说结论:Odoo 企业版 Intrastat 报表不是“按发票金额求和”,而是“按国家、单据方向、交易代码、商品编码、原产国、补充单位等监管维度重新分组后的结果”。任何一个字段改动,都可能让同一张发票落到另一组申报行。


一、第一步不是算金额,而是先决定这张发票到底对应哪个 Intrastat 国家

很多人会忽略 models/account_move.py 里的:

  • intrastat_country_id
  • _get_invoice_intrastat_country_id()
  • _compute_intrastat_country_id()

这里最关键的设计不是“多了个国家字段”,而是 销售和采购的取值来源不一样

销售单据:优先看收货国家

如果是销售单据,Odoo 看的不是单纯的 partner_id.country_id,而是:

  • partner_shipping_id.country_id

而且还要求这个国家属于 INTRASTAT 国家组。

这背后的业务逻辑非常清楚:

对销售来说,Intrastat 更关心货物实际发往哪里,而不是客户主档挂在哪个国家。

所以你会看到一个很典型、但最容易被误解的现象:

  • 客户主档是 A 国;
  • 收货地址是 B 国;
  • 最终 Intrastat 统计按 B 国走。

采购单据:默认看往来方国家

如果不是销售单据,源码直接回落到:

  • partner_id.country_id

也就是说,对采购方向,Odoo 默认把供应商国家当成 Intrastat 国家来源。

为什么这一步这么重要?

因为后续很多字段只有在 move_id.intrastat_country_id 存在时才会继续计算。

换句话说:

先有可识别的 Intrastat 国家,后面才有交易代码、原产国与报表归组这条链。

如果国家本身不在 INTRASTAT 国家组里,很多下游逻辑会直接失效或被清空。


二、发票行上的交易代码不是手填优先,而是先吃公司默认值

AccountMoveLine 在同一个文件里又加了两个关键字段:

  • intrastat_transaction_id
  • intrastat_product_origin_country_id

其中交易代码的计算方法 _compute_intrastat_transaction_id() 很值得注意。

默认规则按单据类型分流

只要单据有 intrastat_country_id,Odoo 就会按 move_type 自动给行赋默认交易代码:

  • out_invoice / in_invoicecompany.intrastat_default_invoice_transaction_code_id
  • out_refund / in_refundcompany.intrastat_default_refund_transaction_code_id

这意味着 Odoo 在这里回答的不是“这一行业务员填了什么”,而是:

这张单据在监管口径上,默认应该被当成普通发票交易还是退款/更正交易。

为什么退款会天然分到另一套代码?

因为源码明确把 invoice 和 refund 分成两条默认路径。

所以业务上常见的误解是:

  • “退款不就是负数发票吗?报表里抵掉就完了。”

但在 Intrastat 里,Odoo 没这么偷懒。

它会先把 交易性质 单独建模,再决定进哪种 transaction code。这也是为什么同样一组商品、同样一个国家,正向发票和退款不一定会落到同一申报组。

锁账后为什么这些字段也不能乱改?

_get_lock_date_protected_fields() 又把:

  • intrastat_product_origin_country_id
  • intrastat_transaction_id

加入 fiscal lock date 保护字段。

这说明在 Odoo 看来,这不是可有可无的备注字段,而是:

会影响监管申报口径的会计敏感字段。


三、原产国不是报表时现算,而是由“发票是否进入 Intrastat + 产品主档”共同决定

很多人会以为原产国在报表阶段才补。

其实不是。

_compute_origin_country() 的逻辑很直接:

  • 如果这条发票行所在 move 已经有 intrastat_country_id
  • 那么行上的 intrastat_product_origin_country_id 就取自产品模板的 intrastat_origin_country_id
  • 否则就是 False。

这里有两个很重要的边界。

1)原产国属于产品主档,不属于报表临时计算

源码真正的来源在 models/product.py

  • product.template / product.product 都有 intrastat_origin_country_id
  • 模板层通过 inverse 写回 variant

所以原产国更像商品主数据的一部分,而不是申报员在月底临时补的口径。

2)不是所有发票行都会生成原产国

只有当该行所在 move 已经被识别为 Intrastat 相关时,这个字段才会真正挂上去。

这意味着:

“产品上配了原产国”不等于“报表一定会用到”;只有进入 Intrastat 监管范围的单据行,才会把这层主数据带进申报链。


四、商品编码与补充计量单位也不是孤立字段,而是联动的产品建模

models/product.py,你会发现 Odoo 把 Intrastat 商品维度基本都挂在产品上:

  • intrastat_code_id
  • intrastat_supplementary_unit
  • intrastat_supplementary_unit_amount
  • intrastat_origin_country_id

商品还是服务,决定可选编码域

_compute_intrastat_code_domain() 会根据:

  • 公司 fiscal country
  • 当前国家是否提供 service codes
  • product.type

动态决定是允许选 commodity 还是 service 类型的 Intrastat code。

所以商品编码不是“所有产品共用一张万能表”,而是跟公司 fiscal country 和产品类型绑定。

补充计量单位不是报表里凭空来的

如果编码本身带 supplementary unit,产品上还要有:

  • intrastat_supplementary_unit_amount

它表达的是:

每 1 个产品数量,折算成多少补充计量单位。

测试 test_product.py 也专门验证了:

  • 你在 product.productproduct.template 表单里改 Intrastat 编码、补充计量数量、原产国;
  • 这些值会落到真正用于发票行和报表的产品记录上。

这背后的设计思想很稳:

报表字段并不靠月底人工凑,而是尽量前置到产品主数据。


五、Intrastat 报表真正的核心,不是“金额合计”,而是 grouping keys

models/account_intrastat_report.py 一上来就定义了 _grouping_keys,里面包含:

  • intrastat_type
  • system
  • country_code
  • transaction_code
  • transport_code
  • region_code
  • commodity_code
  • country_name
  • partner_vat
  • incoterm_code
  • intrastat_product_origin_country_code
  • intrastat_product_origin_country_name
  • invoice_currency_id
  • supplementary_units_code

这串键值其实就是报表灵魂。

它说明 Odoo 不是按“客户 + 金额”聚合

而是按监管要求,把一条 Intrastat 记录理解成:

  • 到达 / 发运哪一边;
  • 使用哪套 system code;
  • 面向哪个国家;
  • 对应什么 transaction code;
  • 什么商品编码;
  • 什么原产国;
  • 是否有 VAT、Incoterm、运输方式、补充计量单位等附加条件。

也就是说:

同一个客户、同一个月、甚至同一张发票,只要这些维度里任何一个变了,Odoo 就可能把它拆成不同报表行。

为什么“明明还是这张票,报表行却变了”?

因为你改的也许不是金额,而是分组键。

例如:

  • 收货国变了;
  • 交易代码从 invoice 默认换成 refund 默认;
  • 商品编码或原产国改了;
  • 产品带了不同的 supplementary unit code。

这些都会直接改变 grouping key。


六、Arrival / Dispatch 不是显示标签,而是决定会进哪些单据类型

_custom_options_initializer()_determine_inclusion() 里还有一层容易被忽略的设计。

用户在筛选器里看到:

  • Arrival
  • Dispatch

表面像展示选项,实际上源码会把它映射成不同的 move_type 集合:

  • Arrival → in_invoice, out_refund
  • Dispatch → out_invoice, in_refund

这背后其实是在表达货物流向,而不是会计上的“销售/采购”两个词。

所以你会发现:

  • 销售退款会进 Arrival;
  • 采购退款会进 Dispatch。

如果只按“销售单/采购单”理解 Intrastat,很容易把这层方向性看错。


七、报表总值还会主动排除“缺失商品数据”的行

_report_custom_engine_intrastat() 里,Odoo 汇总 value 时有个很关键的条件:

  • 只对 not line['missing_product'] 的行求和

这说明官方默认态度不是“先把金额报出来,缺字段以后再补”,而是:

商品信息缺失的行,不应被悄悄混进正式 Intrastat 汇总值里。

这也是为什么很多企业会感觉:

  • 发票都过账了;
  • 报表里却看起来少了金额;
  • 或者 warning 很多。

问题常常不在发票金额,而在 产品编码、原产国、补充单位、B2B/VAT 条件 这类监管字段没准备好。


八、测试里其实已经把“为什么会拆组、为什么会报警”讲得很清楚

tests/test_intrastat_report.pytests/test_intrastat_return.py 非常值得看,因为它们不是只验证数字,而是在验证口径边界。

报表测试说明了什么?

test_intrastat_report_values() 里:

  • 一张销售发票;
  • 一张采购发票;
  • 同一个国家;
  • 同一套 commodity / transaction / region 代码;

最后仍然会在报表上拆成:

  • 29 (Arrival)
  • 19 (Dispatch)

并按各自 weight、value 分开展示。

这说明 Odoo 首先在乎的是 申报维度正确,而不是把所有东西先求一个总和。

Return checks 又在补什么?

test_intrastat_checks() 里刷新 return checks 后,至少会校验:

  • check_intrastat_commodity_code
  • check_intrastat_threshold
  • check_intrastat_only_b2b_customer
  • check_intrastat_only_goods
  • check_intrastat_only_intra_eu
  • check_intrastat_uom
  • check_intrastat_vat_exclusive

这里最有意思的是 only_b2b_customer 测试:

  • 当 private individual 混入时,check 会变成 anomaly
  • 把异常记录拿掉,check 又恢复 reviewed

这意味着 Odoo 不只是帮你“算表”,而是在帮你判断:

这些记录到底是不是应该进入这张监管报表。


九、实战里最容易误解的 5 个点

1. 以为 Intrastat 国家永远等于客户国家

不对。

销售默认看 partner_shipping_id.country_id,不是只看客户主档国家。

2. 以为退款就是负数发票,直接抵掉即可

不对。

Odoo 会按 refund 走另一套默认 transaction code 逻辑。

3. 以为原产国是月底临时补的报表字段

不对。

它本质上是产品主数据,通过发票行在 Intrastat 场景中被带进来。

4. 以为报表少金额就是系统漏算

不一定。

也可能是缺失产品监管字段,导致行被标记为 missing_product 或触发 warning/check。

5. 以为报表按发票号聚合

不对。

真正控制拆组的是 _grouping_keys,不是发票号本身。


十、如果你要二开 Intrastat,最该尊重哪些边界?

如果你准备在企业版 Intrastat 上做本地化或报表扩展,我建议先守住这几条:

1. 不要把 intrastat_country_id 简化成单一 partner country

销售看收货国、采购看往来方国家,是源码明确表达的监管语义。

2. 不要跳过公司默认 transaction code 逻辑

invoice / refund 的默认代码分流,是报表正确拆组的重要前提。

3. 不要把产品上的 commodity / origin / supplementary unit 临时报表化

官方设计是把这些字段前置到产品主数据,而不是月末补丁式录入。

4. 不要随便改 grouping keys 却不重看导出和校验

只要分组键变了,导出文件、监管口径、return checks 都可能一起受影响。

5. 锁账后的监管字段不要放开随意改

源码已经把关键 Intrastat 字段并入 fiscal lock 保护,二开时别把这层边界拆掉。


总结

account_intrastat 最容易被低估的地方,是很多人把它当“欧盟发票加总器”,而源码把它设计成了一条完整监管链:

  1. 发票头决定 Intrastat 国家
  2. 发票行决定交易代码与原产国
  3. 产品主档提供商品编码、补充单位、原产国基础数据
  4. 报表按多维 grouping keys 重新聚合
  5. return checks 再验证记录是否真的合规可报

所以在 Odoo 企业版里,Intrastat 报表变化往往不是“金额算错了”,而是某个监管维度变了,导致记录进入了另一组、另一种方向,甚至直接变成需要审查的异常。

把这条链看懂之后,你再去做实施、培训、对账,或者做本地化二开,就不会再把 Intrastat 当成一张普通会计汇总表了。

DISCUSSION

评论区

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