Oracle EBS R12表结构解析:多组织隔离、VPD与主数据设计

简介:Oracle EBS R12 表结构资料包面向从事EBS维护、二次开发与数据迁移的技术人员,聚焦R12数据模型与数据库架构的快速查阅需求。包内共114个文件,由58个PDF和56个HTML文档组成,压缩包约6.01MB;PDF便于离线阅读、打印标注,HTML适合在浏览器中按模块检索与跳转。目前已有1605人学习下载,具有一定参考热度。内容围绕财务、供应链、人力资源、项目管理、销售服务等模块展开,涉及数据字典、核心交易表及其关联方式、职责与角色权限控制,以及升级迁移中的数据转换和验证思路,可帮助读者定位业务字段、梳理表间关系,并为自定义报表、触发器、存储过程和索引优化提供依据,适合需要系统查表与理解EBS数据模型的中初级技术人员使用。

1. 表结构的真相:为什么 Oracle EBS R12 里一个业务对象总是散在五张表

第一次翻 Oracle EBS R12 表结构的人,多半带着“一张表对应一个业务对象”的预期进来,然后被现实打脸。查一张应付发票,AP_INVOICES_ALL 只给抬头,行在 AP_INVOICE_LINES_ALL,会计分配在 AP_INVOICE_DISTRIBUTIONS_ALL,付款计划在 AP_PAYMENT_SCHEDULES_ALL,供应商干脆不在 AP 的命名体系里,得绕到 AP_SUPPLIERS 甚至 HZ_PARTIES。这不是建模没做完,而是一套在上世纪九十年代定型的结构,把“锁粒度可控、并发不打架、加字段不动 DDL”排在范式之前。搞懂它为什么这么拆,你才知道查数时该在哪一层停手,也才不会把接口和报表写成玄学。

2. 多组织与 _ALL:ORG_ID 到底是怎么把一张表切成 N 个租户

EBS 的多组织不是“一个公司一个库”,也不是“一个公司一套表”,而是所有业务单位的数据挤在同一张物理表里,靠一列 ORG_ID 区分。这一列是整个表结构里最容易被忽略、也最容易让人翻车的地方——你少写一个条件,报表数字就翻几倍;你多写一个条件,用户又说查不到单。理解这一层,等于拿到了读 EBS 表的钥匙。

2.1 _ALL 不是视图的标记,是「带 ORG_ID 的物理表」的命名约定

在 EBS 的命名体系里,后缀_ALL是一条硬约定:这张对象是物理表,里面存了所有业务实体(Operating Unit)的数据,每一行都带 ORG_ID。不带_ALL的同名对象,绝大多数是视图,视图的定义里藏着一个针对 ORG_ID 的过滤谓词。

所以 PO_HEADERS_ALL 是表,PO_HEADERS 是视图;AP_INVOICES_ALL 是表,AP_INVOICES 是视图。视图不是简单的列裁剪,它在编译期就把“只看当前 OU”这件事写死进了 SQL 文本。你用普通账号查 PO_HEADERS 觉得一切正常,换成直连数据库的工具,行为就开始飘,原因就在这里。

先确认你手里的名字到底是表还是视图,这一步值得养成习惯:

-- 同一个业务名,背后可能是表,也可能是视图 SELECT owner, object_name, object_type FROM all_objects WHERE object_name IN ('PO_HEADERS', 'PO_HEADERS_ALL') AND object_type IN ('TABLE', 'VIEW'); -- 视图定义里通常能看到针对 ORG_ID 的过滤谓词 SELECT text FROM all_views WHERE view_name = 'PO_HEADERS';

all_objects.object_type告诉你对象种类,这是判断“我查的是不是真表”的最快方式;all_views.text里能直接读到谓词长什么样,参数通常是MO_GLOBAL.GET_CURRENT_ORG_ID()这类会话函数,而不是常量。

第二件事是索引。_ALL 表上的主键和唯一索引,前缀几乎都带 ORG_ID,比如订单行、发票行这类表,唯一性约束是“在某个 OU 内唯一”,不是全局唯一。这意味着两件事:跨 OU 查询如果不带 ORG_ID,索引前缀用不上,很容易退化成全表扫;表之间的 join 如果只按业务单据号而忘了 ORG_ID,理论上还可能撞上别的 OU 的同号数据。我一般会这么理解:ORG_ID 在 EBS 里不是“一个过滤条件”,而是所有业务主键的隐含组成部分。

2.2 VPD 把过滤藏进了优化器:DBMS_RLS 与策略函数

_ALL表和它的视图之间那层过滤,不是靠应用代码自觉加 WHERE 实现的,而是数据库层面的行级安全(VPD,也叫 RLS)。机制很直接:给表挂一个策略(policy),策略绑定一个 PL/SQL 函数,这个函数在语句解析时被调用,返回一段字符串,数据库把这段字符串当成额外的 WHERE 谓词拼进去。

这套东西在 EBS 里的落地方式是:策略名通常形如 ORG_SEC,处理函数是 MO_GLOBAL.ORG_SECURITY。想验证某一刻到底有哪些表被挂了策略,直接查数据字典:

-- 看某几张核心表上挂了哪些行级安全策略 SELECT object_name, policy_name, policy_function, statement_types, enable FROM dba_policies WHERE object_name IN ('AP_INVOICES_ALL', 'PO_HEADERS_ALL', 'MTL_SYSTEM_ITEMS_B');

statement_types说明策略对 SELECT、INSERT、UPDATE、DELETE 中的哪些生效,policy_function就是那个返回谓词的函数。字段enable为 YES 时,任何走这张表的语句都会被改写,包括你从 SQL*Plus 手敲的那条。

这里有个代价必须说清楚:谓词是运行时动态拼的,所以同一条 SQL 在不同会话里可能生成不同的执行计划。你昨天调好的索引,今天换个人跑就慢了,不一定是统计信息的问题,可能是策略函数返回的谓词形状变了。更麻烦的是策略函数的性能——它是逐语句调用的,一旦函数内部去查大表或者走了递归 SQL,整个查询都会被拖慢。所以看 EBS 的执行计划,永远要在“已经初始化过上下文”的会话里看,否则你看到的是一份没有谓词的假计划。

2.3 CLIENT_INFO 与 MO_GLOBAL.INIT:一条 SQL 背后的会话状态

策略函数不直接去查组织表,那样太慢。它读的是会话级的上下文变量——也就是DBMS_APPLICATION_INFO.SET_CLIENT_INFO写进去的那串字符,或者SYS_CONTEXT里的自定义命名空间。这串字符里装的是当前会话能看到哪些 OU。

当用户从 Forms 界面登录时,EBS 会根据职责(Responsibility)算出这个职责对应的 OU 集合,然后调用 MO_GLOBAL.INIT 把它们写进上下文。整个过程对最终用户完全透明:他点开一个表单,会话里已经带好了“我能看哪几个 OU”。

问题出在绕过 Forms 的时候。第三方工具、ETL 抽取程序、直连的 SQL 客户端,默认会话里没有这串上下文,策略函数读不到值,返回的谓词就可能变成“什么都看不到”或者“什么都看得到”,两种都很难排查。手工补初始化是这样的:

-- 在直连会话里模拟 Forms 的上下文初始化 BEGIN mo_global.init('PO'); -- 按应用初始化,参数是应用短名 mo_global.set_policy_context('S', 204); -- S=单 OU,204 是目标 org_id END; /

mo_global.init的入参是应用短名(PO、AP、AR 等),它决定按哪个应用的配置去算 OU 集合;set_policy_context的第一个参数是访问模式,S表示单组织、M表示多组织,第二个参数才是具体的 ORG_ID。多组织模式下,上下文里装的是一组 ID,谓词会变成ORG_ID IN (...)的形式,这也是 R12 的 MOAC(多组织访问控制)能在一个界面上跨 OU 操作的基础。

注意:在直连会话里调过set_policy_context之后,别指望它能跨会话保留。会话一断,上下文就没了,下次连上来还得重来一遍。做批量抽取时把这段初始化写进连接池的初始化脚本里,比每次手工敲可靠得多。

3. 主数据的三根支柱:PARTY、ITEM、CCID 如何压扁实体关系

业务单据表看着多,但真正决定查询复杂度的是三类主数据:往来单位、物料、会计科目。它们在 R12 里各有一套压缩策略——统一实体、组织级复制、整数化引用。理解这三套策略,剩下的表基本是排列组合。

3.1 HZ_PARTIES 的 TCA 模型:客户和供应商为什么共用一套表

在更早的版本里,客户在 AR 模块有一套表,供应商在 PO 模块有一套表,同一个人既买又卖,就得在两个地方各录一遍,地址也对不上。R12 做了一次大迁移:把往来单位统一到 TCA(Trading Community Architecture)模型下,核心是 HZ_PARTIES,一个人、一个组织、一个团体,在库里就是一行。

围绕这张表挂了一圈卫星表:HZ_PARTY_SITES 记录这个实体在某个地址上有过一个“站点”,HZ_LOCATIONS 存地址本身,HZ_CUST_ACCOUNTS 是客户账户,HZ_CUST_ACCT_SITES_ALL 把账户和站点连起来。供应商这边,AP_SUPPLIERS 通过 PARTY_ID 指回 HZ_PARTIES,供应商地点在 AP_SUPPLIER_SITES_ALL。

这套设计的收益很实在:一个实体只有一份档案,地址复用,重名可去重;代价是查询要多跳几层。而且 R12 里 PO_VENDORS 这个名字还在,但它已经是对 AP_SUPPLIERS 的兼容层,新供应商在某些情况下从这里查不全,这是踩坑高发区,后面单独说。

从供应商回溯到实体和地址,大概长这样:

-- 从供应商一路回溯到 party 与地址信息 SELECT s.vendor_id, s.vendor_name, p.party_id, p.party_type, loc.address1, loc.city, loc.country FROM ap_suppliers s JOIN hz_parties p ON p.party_id = s.party_id JOIN hz_party_sites ps ON ps.party_id = p.party_id JOIN hz_locations loc ON loc.location_id = ps.location_id WHERE s.vendor_name LIKE '某%';

ap_suppliers.party_id是通往统一模型的外键;hz_party_sites一个 party 可能有多行,所以这条 SQL 的结果行数会大于供应商数,做报表时要么加站点类型过滤,要么先聚合。party_type区分 PERSON、ORGANIZATION、GROUP,决定了下游哪些属性字段有意义。

3.2 MTL_SYSTEM_ITEMS_B 与 ORGANIZATION_ID:物料是全局的还是组织级的

这个问题我被问过很多次,答案有点别扭:INVENTORY_ITEM_ID 全局唯一,但 MTL_SYSTEM_ITEMS_B 里同一个物料有 N 行,每个分配到的组织一行。

机制是这样的:EBS 先指定一个主组织(master org),MTL_PARAMETERS 里ORGANIZATION_ID = MASTER_ORGANIZATION_ID的那一行就是它。物料在主组织里定义,主组织那一行相当于模板;分配到其他组织时,系统从模板复制出一行,允许在组织级属性上覆盖,比如提前期、安全库存、成本方法。

-- 找到主组织,再去主组织那一行取物料定义 SELECT organization_id, master_organization_id, organization_code FROM mtl_parameters WHERE organization_id = master_organization_id;

master_organization_id这个字段在非主组织的行上指向主组织,在主组织自己那行上等于自己的 ORGANIZATION_ID,所以用等值条件就能精准捞出主组织。

这解释了一个常见困惑:只按 INVENTORY_ITEM_ID 查 MTL_SYSTEM_ITEMS_B,会拿到一堆行,字段值还不完全一样。不是数据脏,是你没带 ORGANIZATION_ID。查询物料主数据时,要么显式指定组织,要么先定位主组织再查,别让它自己飘。

3.3 GL_CODE_COMBINATIONS 与 CCID:会计科目被压成一个整数

会计科目在 EBS 里不是字符串,是一个整数——CODE_COMBINATION_ID,社区里简称 CCID。每一个合法的科目组合,在 GL_CODE_COMBINATIONS 里占一行,SEGMENT1 到 SEGMENT30 分别对应科目弹性域的各个段,CHART_OF_ACCOUNTS_ID 标识它属于哪套科目表。

子账模块的分布表全都只存这个整数:AP_INVOICE_DISTRIBUTIONS_ALL、RA_CUST_TRX_LINE_GL_DIST_ALL、MTL_TRANSACTION_ACCOUNTS,连总账的 GL_JE_LINES 也是。要看科目长什么样,必须 join 回组合表。

-- 从总账凭证明细回连科目组合,翻译成人能读的段值 SELECT jel.je_header_id, jel.code_combination_id, gcc.segment1 AS 公司段, gcc.segment2 AS 成本中心, gcc.segment3 AS 科目段, jel.accounted_dr, jel.accounted_cr FROM gl_je_lines jel JOIN gl_code_combinations gcc ON gcc.code_combination_id = jel.code_combination_id WHERE jel.je_header_id = :header_id;

accounted_dr/accounted_cr是已折算的本位币金额,和entered_dr/entered_cr(原币)要区分开,做报表时选错列是很典型的“数对不上”。

这个设计的性能收益极大:GL_JE_LINES 动辄上亿行,如果每行都存三段字符串,存储和索引都会爆;压成一个 NUMBER 之后,行变窄,join 的又是相对小的组合表。代价是可读性归零,任何取数都必须回连。还有一个副作用——组合表会膨胀,因为它是各段取值的笛卡尔积中被“实际用到”的稀疏子集,一旦有人在科目上乱建组合,这张表就会长出一堆垃圾行,进而拖慢所有 join。

4. 通用列与弹性域:ATTRIBUTE1-15 和 SEGMENT1-30 的工程学

EBS 要卖给不同行业、不同国家的客户,每个客户都想加几个自己的字段,但没人愿意为了加字段停机打补丁。这套需求催生了弹性域机制,也留下了表结构里最“丑”的一批列。

4.1 描述性弹性域:15 个通用列的存取机制

随便打开一张 EBS 业务表,你大概率能看到ATTRIBUTE_CATEGORY、ATTRIBUTE1到ATTRIBUTE15这一组列,类型统一是 VARCHAR2(150)。这就是描述性弹性域(DFF)的物理落地:客户的“自定义字段”全部塞进这 15 个通用列,具体每个位置代表什么,记在元数据表里。

元数据在 FND_DESCRIPTIVE_FLEXS 和 FND_DESCR_FLEX_COL_USAGE 这一组表里,记录了哪个应用的哪张表、哪个上下文(category)、第几个 attribute 对应什么含义、什么校验。应用层读元数据决定界面上显示什么标签,数据库层对这 15 列一无所知。

这种做法在商业上极其成功——客户加字段不需要 DDL,升级不会冲突,DBA 不用给业务账号开 ALTER 权限。但代价全压在查询端:所有值都是字符串。

-- 按上下文取某个自定义字段,注意值本身是字符型 SELECT invoice_id, attribute_category, attribute1 AS 合同号, attribute3 AS 项目编号, TO_DATE(attribute5, 'YYYY/MM/DD') AS 立项日期 FROM ap_invoices_all WHERE attribute_category = '某行业扩展';

attribute_category必须作为条件之一,因为同一个 attribute 位置在不同 category 下含义完全不同,不加这个条件等于把几种语义混在一起。attribute5存的是日期,但列类型是 VARCHAR2,比较大小必须显式 TO_DATE 并给出格式,直接attribute5 > '2024-01-01'在数据格式不统一时结果不可控。

4.2 关键性弹性域:组合表与账户生成器的校验链路

关键性弹性域(KFF)走的是另一条路。它不塞进通用列,而是给每个 KFF 配一张组合表:会计科目用 GL_CODE_COMBINATIONS,资产用 FA_ASSET_KEY_COMBINATIONS,物料类别用 MTL_CATEGORY_SETS 相关的一组表。

组合表的每一行就是一个“段值的合法组合”,主键是组合 ID。业务表存的都是这个 ID。校验则走 FND_FLEX_KEYVAL 这类 API:把各段值传进去,它去查值集(value set)验证每个段值是否合法,再查组合表验证这个组合是否已存在、是否启用,不存在就按规则创建一行。社区里把这段逻辑叫“账户生成器”,因为它真的会在你录入时往组合表插数据。

想看某个 KFF 的段结构,查这两张表就够了:

-- 查会计科目 KFF(GL#)的段定义 SELECT ifs.id_flex_name, seg.segment_num, seg.segment_name, seg.flex_value_set_id FROM fnd_id_flex_structures ifs JOIN fnd_id_flex_segments seg ON seg.id_flex_num = ifs.id_flex_num AND seg.application_id = ifs.application_id AND seg.id_flex_code = ifs.id_flex_code WHERE ifs.id_flex_code = 'GL#' AND ifs.application_id = 101 ORDER BY seg.segment_num;

id_flex_code是弹性域代码,会计科目的固定是GL#,application_id = 101对应总账应用。segment_num决定段的顺序,flex_value_set_id指向这个段允许取哪些值。组合表里的 SEGMENT1、SEGMENT2 顺序,就是由这里的 segment_num 决定的,不是随便排的。

组合表的稀疏特性值得留意:理论上三段各 100 个值就是百万级组合,实际业务只会用到其中几千个。所以组合表行数增长是业务行为驱动的,不是配置驱动的——这也解释了为什么有些环境里 GL_CODE_COMBINATIONS 只有几万行,有些却有上百万行。

4.3 WHO 列:谁在什么时候改了这一行

EBS 每张业务表都有一组标准审计列,社区里叫 WHO 字段:CREATED_BY、CREATION_DATE、LAST_UPDATED_BY、LAST_UPDATE_DATE、LAST_UPDATE_LOGIN,再外加并发相关的REQUEST_ID、PROGRAM_APPLICATION_ID、PROGRAM_ID、PROGRAM_UPDATE_DATE。

填充这组列的不是数据库触发器,而是 Forms 层和并发程序统一调用 FND_STANDARD 这组 API,在 INSERT/UPDATE 时把当前用户 ID、当前时间、当前登录 ID 写进去。所以如果有人在数据库里直接 UPDATE,这组列不会自动更新,数据看起来就像“从没被改过”,给事后追溯留下一个黑匣子。

LAST_UPDATE_LOGIN是这组列里最有价值也最少人用的一个:它存的是 FND_LOGINS.LOGIN_ID,能反查到那次登录的会话信息。

-- 追溯某张发票最后一次是谁、在哪个会话里改的 SELECT h.invoice_id, h.last_updated_by, u.user_name, h.last_update_date, h.last_update_login, l.start_time AS 登录时间 FROM ap_invoices_all h LEFT JOIN fnd_user u ON u.user_id = h.last_updated_by LEFT JOIN fnd_logins l ON l.login_id = h.last_update_login WHERE h.invoice_id = :invoice_id;

fnd_user.user_id和last_updated_by对应,fnd_logins.login_id和last_update_login对应,两个 join 都是 LEFT,因为历史数据里可能残留已删除的用户或者空值。l.start_time是那次会话的开始时间,配合last_update_date基本能还原操作时序。

注意:这组列记录的是“最后一次”,不是变更历史。要看完整的修改轨迹,得去各模块自己的历史表或者审计表,别指望 WHO 列给你后悔药。

5. 避坑排查:五条从「数对不上」倒推的经验

表结构读得再熟,落到真实取数还是会翻车。下面这几条是我在实际排查里反复遇到的,按现象、原因、解决三段写,遇到类似症状可以拿来对号。

5.1 组织隔离类:漏掉 ORG_ID 的两种表现

第一条,数字翻倍。现象是报表金额比单据界面大好几倍,而且倍数不固定。原因是 join 了两张 _ALL 表却只按业务单据号关联,没带 ORG_ID,同一个单据号在多个 OU 里都存在,笛卡尔式地放大了结果。解决办法是把 ORG_ID 补进 join 条件,或者在进入查询前用MO_GLOBAL.SET_POLICY_CONTEXT把会话限定到单个 OU,让 VPD 帮你挡掉。我一般倾向后者,因为人总会忘。

第二条,什么都查不到。现象是同一条 SQL 在 A 同事那里正常,在 ETL 服务器上返回零行。原因是 ETL 会话没有初始化上下文,策略函数读不到 OU 列表,谓词变成一个永假条件。解决办法是在连接初始化时调MO_GLOBAL.INIT,或者对明确只跑单 OU 的抽取任务显式设置上下文再执行。

5.2 对象指向类:视图、同义词与模型迁移

第三条,新供应商查不到。现象是业务说供应商明明建好了,用 PO_VENDORS 查就是没有。原因是 R12 把供应商主体迁到了 AP_SUPPLIERS 加 HZ_PARTIES,PO_VENDORS 只是兼容层,某些新建路径下数据不一定同步反映。解决办法是把查询改到 AP_SUPPLIERS,需要地址就继续往下 join AP_SUPPLIER_SITES_ALL。老代码里所有FROM po_vendors都值得过一遍。

第四条,同一条 SQL 两个工具结果不一样。现象是 Forms 里查到的行数比 SQLPlus 里少。原因是 Forms 走的是不带_ALL的视图,视图里带着 ORG_ID 过滤;SQLPlus 里你直接查了_ALL表,看到了所有 OU 的数据。解决办法是先确认自己查的是表还是视图,需要看全量就明确查_ALL,需要模拟用户视角就查视图并初始化上下文。这条听着简单,但排查时最容易绕进去。

5.3 数据类型与精度类:字符型与时间戳的暗坑

第五条,日期区间总少一天。现象是按日期过滤的单据总是漏掉当天的。原因是 EBS 的日期列是 DATE 类型,Oracle 的 DATE 带时分秒,BETWEEN 月初 AND 月末里的月末被解释成月末零点,当天白天的数据全被排除。解决办法是改成左闭右开区间:>= 月初 AND < 次日零点,或者对列做 TRUNC 后比较(代价是索引失效,大表上慎用)。这个坑在 DFF 的字符型日期字段上更严重,因为还要额外处理格式不统一的问题。

6. 这套结构的取舍:为什么它丑,却没被替换掉

把前面几章串起来看,EBS R12 的表结构其实是一组非常一致的取舍,而不是一堆历史遗留的随机产物。核心约束只有三条:业务账号不能有 DDL 权限,所以扩展字段只能塞进通用列;多个业务单位要共享一套表,所以隔离只能靠行级策略而不是分库分表;单据量会到亿级,所以引用必须压成整数。这三条约束在九十年代是硬约束,在今天已经不是了,但结构改不动——因为改一张表意味着改几千个表单、几千个并发程序、无数客户的自定义逻辑。

设计选择当年解决的问题今天付出的代价
通用列 ATTRIBUTE1-15客户加字段不动 DDL,升级不冲突类型信息丢失,查询必须靠元数据翻译
组合表 + CCID科目引用压成一个整数,行变窄可读性归零,任何取数都要回连
_ALL 表 + VPD多 OU 共享物理表,隔离在数据库层执行计划随会话变化,直连工具行为不可预期
主数据统一到 TCA客户与供应商不再重复建档查询链路变长,兼容层语义模糊
WHO 列而非全量审计每行只多几个字段,写入开销可控只能看到最后一次修改,追溯要另找历史表

如果要在现有环境里验证自己对这套结构的理解,我建议用一条具体的业务流做端到端反推:从一张发票的界面号码出发,依次定位抬头、行、分配、科目组合、供应商 party、WHO 列,每跳一次都问自己“这个 join 靠的是业务主键还是代理主键,ORG_ID 在哪一层被过滤掉了”。能一路走通且行数始终对得上,说明你对这一层的机制是真的理解了,而不是背下了表名。

再往深一层,可以对比子账和总账的存储差异:子账的分布表按单据存明细,总账的 GL_JE_LINES 按期间存汇总后的凭证行,两者的 CCID 引用方式一样,但粒度完全不同。搞清楚这个差异,多币种、跨期调整这类看着很玄的问题,大多能自己推出来。

最后说个教训。早年我图快,直接对着 _ALL 表写报表 SQL,在测试环境一路顺畅,上线当天数字全错——测试库只有一个 OU,生产库有七个。从那以后我每次写跨模块取数,都会强制走一遍“确认对象是表还是视图、确认 ORG_ID 在哪一层生效、确认日期列的时分秒怎么处理”这三步,哪怕多花十分钟。这份按模块整理的表结构资料,我建议就当字典放在手边,遇到不确定的表先查关联关系和主键构成,比在线上试错划算得多。希望帮到你。

本文还有配套的精品资源,点击获取