很多团队第一次接触 Intrastat,直觉会把它理解成:
- 挑一个期间;
- 把欧盟往来发票加总;
- 导出一张申报表。
但如果你真去读 /home/ubuntu/odoo-temp/enterprise/account_intrastat 的源码,会发现 Odoo 企业版根本不是在做“简单汇总”,而是在维护一条很严格的监管数据链:
- 发票头先决定 Intrastat 国家;
- 发票行再继承或计算交易代码、原产国;
- 产品本身决定商品编码、补充计量单位与原产国来源;
- 报表再按一整套 grouping keys 聚合;
- 最后 return checks 再检查是否符合 Intrastat 申报边界。
本文主要基于:
account_intrastat/models/account_move.pyaccount_intrastat/models/product.pyaccount_intrastat/models/res_company.pyaccount_intrastat/models/account_intrastat_report.pyaccount_intrastat/tests/test_intrastat_report.pyaccount_intrastat/tests/test_product.pyaccount_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_idintrastat_product_origin_country_id
其中交易代码的计算方法 _compute_intrastat_transaction_id() 很值得注意。
默认规则按单据类型分流
只要单据有 intrastat_country_id,Odoo 就会按 move_type 自动给行赋默认交易代码:
out_invoice/in_invoice→company.intrastat_default_invoice_transaction_code_idout_refund/in_refund→company.intrastat_default_refund_transaction_code_id
这意味着 Odoo 在这里回答的不是“这一行业务员填了什么”,而是:
这张单据在监管口径上,默认应该被当成普通发票交易还是退款/更正交易。
为什么退款会天然分到另一套代码?
因为源码明确把 invoice 和 refund 分成两条默认路径。
所以业务上常见的误解是:
- “退款不就是负数发票吗?报表里抵掉就完了。”
但在 Intrastat 里,Odoo 没这么偷懒。
它会先把 交易性质 单独建模,再决定进哪种 transaction code。这也是为什么同样一组商品、同样一个国家,正向发票和退款不一定会落到同一申报组。
锁账后为什么这些字段也不能乱改?
_get_lock_date_protected_fields() 又把:
intrastat_product_origin_country_idintrastat_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_idintrastat_supplementary_unitintrastat_supplementary_unit_amountintrastat_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.product或product.template表单里改 Intrastat 编码、补充计量数量、原产国; - 这些值会落到真正用于发票行和报表的产品记录上。
这背后的设计思想很稳:
报表字段并不靠月底人工凑,而是尽量前置到产品主数据。
五、Intrastat 报表真正的核心,不是“金额合计”,而是 grouping keys
models/account_intrastat_report.py 一上来就定义了 _grouping_keys,里面包含:
intrastat_typesystemcountry_codetransaction_codetransport_coderegion_codecommodity_codecountry_namepartner_vatincoterm_codeintrastat_product_origin_country_codeintrastat_product_origin_country_nameinvoice_currency_idsupplementary_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.py 和 tests/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_codecheck_intrastat_thresholdcheck_intrastat_only_b2b_customercheck_intrastat_only_goodscheck_intrastat_only_intra_eucheck_intrastat_uomcheck_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 最容易被低估的地方,是很多人把它当“欧盟发票加总器”,而源码把它设计成了一条完整监管链:
- 发票头决定 Intrastat 国家
- 发票行决定交易代码与原产国
- 产品主档提供商品编码、补充单位、原产国基础数据
- 报表按多维 grouping keys 重新聚合
- return checks 再验证记录是否真的合规可报
所以在 Odoo 企业版里,Intrastat 报表变化往往不是“金额算错了”,而是某个监管维度变了,导致记录进入了另一组、另一种方向,甚至直接变成需要审查的异常。
把这条链看懂之后,你再去做实施、培训、对账,或者做本地化二开,就不会再把 Intrastat 当成一张普通会计汇总表了。
DISCUSSION
评论区