1) テーブル定義(ポータル画面の項目にジャスト)
— 補助スキーマ/関数(未作成なら一度だけ)
CREATE SCHEMA IF NOT EXISTS portal_stage;
CREATE OR REPLACE FUNCTION portal_stage.attr_val_hard(node XML, attr TEXT)
RETURNS TEXT LANGUAGE SQL IMMUTABLE AS $$
WITH x AS (
SELECT
(xpath(‘string(./@’ || attr || ‘)’, node))[1]::text AS v1,
((xpath(‘./@’ || attr, node))[1])::text AS v2,
xmlserialize(CONTENT node AS text) AS s
)
SELECT NULLIF(
COALESCE(
NULLIF(v1, ”),
NULLIF(regexp_replace(v2, ‘^[^”]“(.)”.$’, ‘\1’), ”), CASE WHEN s ~ (‘(?s)’ || attr || ‘=”‘) THEN regexp_replace(s, ‘(?s).‘ || attr || ‘=”([^”])”.‘, ‘\1’)
ELSE NULL END
),
”
)
FROM x;
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname=’portal_origin’) THEN
CREATE TYPE portal_origin AS ENUM (‘code’,’module’,’studio’,’portal’,’standard’,’unknown’);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname=’portal_subview_policy’) THEN
CREATE TYPE portal_subview_policy AS ENUM (‘reference’,’auto’,’none’);
END IF;
END$$;
— 画面項目に合わせた最小テーブル
CREATE TABLE IF NOT EXISTS portal_tab_portal (
id BIGSERIAL PRIMARY KEY,
view_id BIGINT NOT NULL REFERENCES ir_ui_view(id) ON DELETE CASCADE,
tab_key TEXT NOT NULL, — name:<@name> or page:
page_idx INTEGER NOT NULL, — 簡易順序(継承は+1000など)
tab_label_ja TEXT, — タブ名(日本語)
tab_label_en TEXT, — タブ名(英語)
— 子モデル
child_model TEXT,
child_link_field TEXT,
— 出自
origin portal_origin NOT NULL DEFAULT ‘standard’,
module TEXT,
— コード生成/備考/GitHubリンク
is_codegen_target BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT,
github_url TEXT,
— サブビュー設定(tree/formの参照 or 生成)
view_mode TEXT, — ‘tree’,’form’,’tree,form’
tree_view_xmlid TEXT,
form_view_xmlid TEXT,
subview_policy_tree portal_subview_policy,
subview_policy_form portal_subview_policy,
— 表示制御(要否+値)
use_domain BOOLEAN,
domain_raw TEXT,
use_context BOOLEAN,
context_raw TEXT,
— 編集動作(インライン/行追加可否)
inline_edit BOOLEAN,
allow_create_rows BOOLEAN,
allow_delete_rows BOOLEAN,
options_raw TEXT, — options=”{‘no_create’: False, …}” などの原文
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(view_id, tab_key)
);
— updated_at 自動更新
CREATE OR REPLACE FUNCTION portal_stage.touch_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_portal_tab_portal_touch ON portal_tab_portal;
CREATE TRIGGER trg_portal_tab_portal_touch
BEFORE UPDATE ON portal_tab_portal
FOR EACH ROW EXECUTE FUNCTION portal_stage.touch_updated_at();
CREATE INDEX IF NOT EXISTS idx_ptp_view ON portal_tab_portal(view_id, page_idx);
CREATE INDEX IF NOT EXISTS idx_ptp_origin ON portal_tab_portal(origin);
② 既存のタブ情報(sale.order)→ portal_tab_portal へ投入
WITH
param AS (
SELECT ‘sale.order’::text AS model
),
views AS (
SELECT
v.id AS view_id,
v.model AS view_model,
— 既定XML(優先: ja_JP → ja → en_US → 先頭)
COALESCE(
CASE WHEN pg_typeof(v.arch_db)::text=’jsonb’ THEN
COALESCE(v.arch_db->>’ja_JP’, v.arch_db->>’ja’, v.arch_db->>’en_US’,
(SELECT value FROM jsonb_each_text(v.arch_db) LIMIT 1))
ELSE v.arch_db::text END,
”
) AS arch_text,
— 日本語XML(あれば)
CASE WHEN pg_typeof(v.arch_db)::text=’jsonb’ THEN v.arch_db->>’ja_JP’ ELSE NULL END AS arch_text_ja,
— 英語XML(あれば)
CASE WHEN pg_typeof(v.arch_db)::text=’jsonb’ THEN v.arch_db->>’en_US’ ELSE NULL END AS arch_text_en
FROM ir_ui_view v
JOIN param p ON p.model = v.model
WHERE v.type=’form’
),
xml_main AS (
SELECT
view_id, view_model,
xmlparse(document regexp_replace(arch_text, ‘^[\uFEFF\s]‘, ”)) AS x_main, CASE WHEN arch_text_ja IS NOT NULL AND arch_text_ja LIKE ‘%<%’ THEN xmlparse(document regexp_replace(arch_text_ja, ‘^[\uFEFF\s]‘, ”)) END AS x_ja,
CASE WHEN arch_text_en IS NOT NULL AND arch_text_en LIKE ‘%<%’
THEN xmlparse(document regexp_replace(arch_text_en, ‘^[\uFEFF\s]*’, ”)) END AS x_en
FROM views
WHERE arch_text LIKE ‘%<%’
),
view_origin AS (
SELECT
d.res_id AS view_id,
CASE WHEN d.module=’web_studio’ THEN ‘studio’
WHEN d.module IS NOT NULL THEN ‘module’
ELSE ‘standard’ END AS origin,
d.module AS module
FROM ir_model_data d
WHERE d.model=’ir.ui.view’
),
— (直書き)
pages_form AS (
SELECT
x.view_id,
s.arr[g.idx] AS page_node,
g.idx::int AS page_idx
FROM xml_main x
CROSS JOIN LATERAL (SELECT xpath(‘//form//notebook/page’, x.x_main) AS arr) s
CROSS JOIN LATERAL generate_subscripts(s.arr, 1) AS g(idx)
),
— (継承:概算で+1000)
pages_inherit AS (
SELECT
x.view_id,
s.arr[g.idx] AS page_node,
(g.idx + 1000)::int AS page_idx
FROM xml_main x
CROSS JOIN LATERAL (SELECT xpath(‘//data//*[local-name()=”xpath”]//page’, x.x_main) AS arr) s
CROSS JOIN LATERAL generate_subscripts(s.arr, 1) AS g(idx)
),
pages_all AS (
SELECT view_id, page_node, page_idx FROM pages_form
UNION ALL
SELECT view_id, page_node, page_idx FROM pages_inherit
),
— ページの基本情報
pages_info AS (
SELECT
p.view_id,
p.page_idx,
COALESCE(
NULLIF((xpath(‘string(./@string)’, p.page_node))[1]::text,”),
NULLIF((xpath(‘string(./@name)’, p.page_node))[1]::text,”)
) AS tab_label_default,
(xpath(‘string(./@name)’, p.page_node))[1]::text AS page_name_attr,
(COALESCE(array_length(xpath(‘.//tree’, p.page_node), 1), 0) > 0) AS has_inline_tree
FROM pages_all p
),
— ★ 修正点:多言語ラベルの取得を「動的XPath文字列の組み立て」で行う(変数バインドは不可)
labels_ja AS (
SELECT
m.view_id,
pf.page_idx,
COALESCE(
CASE WHEN m.x_ja IS NOT NULL AND NULLIF(pf.page_name_attr,”) IS NOT NULL THEN
(xpath(
‘string((//form//notebook/page[@name=’
|| quote_literal(pf.page_name_attr) || ‘]/@string)[1])’,
m.x_ja
))[1]::text
END,
pf.tab_label_default
) AS tab_label_ja
FROM xml_main m
JOIN pages_info pf ON pf.view_id=m.view_id
),
labels_en AS (
SELECT
m.view_id,
pf.page_idx,
COALESCE(
CASE WHEN m.x_en IS NOT NULL AND NULLIF(pf.page_name_attr,”) IS NOT NULL THEN
(xpath(
‘string((//form//notebook/page[@name=’
|| quote_literal(pf.page_name_attr) || ‘]/@string)[1])’,
m.x_en
))[1]::text
END,
pf.tab_label_default
) AS tab_label_en
FROM xml_main m
JOIN pages_info pf ON pf.view_id=m.view_id
),
— sale.order の one2many 定義
o2m_defs AS (
SELECT
mf.name AS field_name,
mf.relation AS child_model,
mf.relation_field AS child_link_field
FROM ir_model_fields mf
JOIN param p ON p.model = mf.model
WHERE mf.ttype = ‘one2many’
),
— ページ内の field 群
page_fields AS (
SELECT
p.view_id,
p.page_idx,
f.field_node,
portal_stage.attr_val_hard(f.field_node, ‘name’) AS field_name
FROM pages_all p
CROSS JOIN LATERAL unnest(xpath(‘.//field[@name]’, p.page_node)) AS f(field_node)
),
— one2many に該当する field のみ注釈(代表=先頭)
page_o2m AS (
SELECT
pf.view_id, pf.page_idx, pf.field_node, pf.field_name,
d.child_model, d.child_link_field,
— 編集動作/オプション系
NULLIF((xpath(‘string(.//tree/@editable)’, pf.field_node))[1]::text,”) AS tree_editable,
NULLIF((xpath(‘string(.//tree/@create)’, pf.field_node))[1]::text,”) AS tree_create,
NULLIF((xpath(‘string(.//tree/@delete)’, pf.field_node))[1]::text,”) AS tree_delete,
NULLIF((xpath(‘string(./@options)’, pf.field_node))[1]::text,”) AS options_raw,
— view_mode / domain / context
NULLIF((xpath(‘string(./@mode)’, pf.field_node))[1]::text,”) AS view_mode,
NULLIF((xpath(‘string(./@domain)’, pf.field_node))[1]::text,”) AS domain_raw,
NULLIF((xpath(‘string(./@context)’, pf.field_node))[1]::text,”) AS context_raw,
— 参照ビュー(context 内の _view_ref) CASE WHEN (xpath(‘string(./@context)’, pf.field_node))[1]::text ~ ‘tree_view_ref’ THEN COALESCE( regexp_replace((xpath(‘string(./@context)’, pf.field_node))[1]::text, ‘(?s).tree_view_ref\s:\s”([^”]+)”.‘, ‘\1’), regexp_replace((xpath(‘string(./@context)’, pf.field_node))[1]::text, ‘(?s).tree_view_ref\s:\s“([^”]+)”.‘, ‘\1’) ) END AS tree_view_xmlid, CASE WHEN (xpath(‘string(./@context)’, pf.field_node))[1]::text ~ ‘form_view_ref’ THEN COALESCE( regexp_replace((xpath(‘string(./@context)’, pf.field_node))[1]::text, ‘(?s).form_view_ref\s:\s”([^”]+)”.‘, ‘\1’), regexp_replace((xpath(‘string(./@context)’, pf.field_node))[1]::text, ‘(?s).form_view_ref\s:\s“([^”]+)”.*’, ‘\1’)
)
END AS form_view_xmlid
FROM page_fields pf
LEFT JOIN o2m_defs d
ON pf.field_name = d.field_name
OR pf.field_name = d.field_name || ‘_ids’
),
page_o2m_first AS ( — 代表 one2many(先頭)を採用
SELECT DISTINCT ON (view_id, page_idx)
view_id, page_idx,
field_name, child_model, child_link_field,
tree_editable, tree_create, tree_delete, options_raw,
view_mode, domain_raw, context_raw,
tree_view_xmlid, form_view_xmlid
FROM page_o2m
WHERE child_model IS NOT NULL
ORDER BY view_id, page_idx, field_name
),
— 最終行の整形
tab_rows AS (
SELECT
pi.view_id,
pi.page_idx,
— キー:nameがあれば name:、無ければ page:
COALESCE(‘name:’||NULLIF(pi.page_name_attr,”), ‘page:’||pi.page_idx::text) AS tab_key,
-- タブ名(日英)
COALESCE(lj.tab_label_ja, pi.tab_label_default) AS tab_label_ja,
COALESCE(le.tab_label_en, pi.tab_label_default) AS tab_label_en,
-- 出自
vo.origin, vo.module,
-- 子モデル(代表)
p1.child_model, p1.child_link_field,
-- サブビュー:ビューモードと参照先
COALESCE(NULLIF(p1.view_mode,''), 'tree,form') AS view_mode,
p1.tree_view_xmlid, p1.form_view_xmlid,
CASE WHEN p1.tree_view_xmlid IS NOT NULL THEN 'reference'::portal_subview_policy
WHEN pi.has_inline_tree THEN 'auto'::portal_subview_policy
ELSE 'none'::portal_subview_policy END AS subview_policy_tree,
CASE WHEN p1.form_view_xmlid IS NOT NULL THEN 'reference'::portal_subview_policy
ELSE 'none'::portal_subview_policy END AS subview_policy_form,
-- 表示制御(要否+値)
(p1.domain_raw IS NOT NULL) AS use_domain,
p1.domain_raw,
(p1.context_raw IS NOT NULL) AS use_context,
p1.context_raw,
-- 編集動作(インライン/行追加/削除)
(NULLIF(p1.tree_editable,'') IS NOT NULL) AS inline_edit,
CASE
WHEN p1.tree_create='false' THEN FALSE
WHEN p1.options_raw ~* '\bno_create\b\s*:\s*(1|true)\b' THEN FALSE
ELSE TRUE
END AS allow_create_rows,
CASE
WHEN p1.tree_delete='false' THEN FALSE
WHEN p1.options_raw ~* '\bno_delete\b\s*:\s*(1|true)\b' THEN FALSE
ELSE TRUE
END AS allow_delete_rows,
p1.options_raw
FROM pages_info pi
LEFT JOIN page_o2m_first p1 ON p1.view_id=pi.view_id AND p1.page_idx=pi.page_idx
LEFT JOIN labels_ja lj ON lj.view_id=pi.view_id AND lj.page_idx=pi.page_idx
LEFT JOIN labels_en le ON le.view_id=pi.view_id AND le.page_idx=pi.page_idx
LEFT JOIN view_origin vo ON vo.view_id=pi.view_id
)
— ★ UPSERT:portal_tab_portal へ投入
INSERT INTO portal_tab_portal (
view_id, tab_key, page_idx,
tab_label_ja, tab_label_en,
child_model, child_link_field,
origin, module,
is_codegen_target, notes, github_url,
view_mode, tree_view_xmlid, form_view_xmlid,
subview_policy_tree, subview_policy_form,
use_domain, domain_raw, use_context, context_raw,
inline_edit, allow_create_rows, allow_delete_rows, options_raw
)
SELECT
t.view_id, t.tab_key, t.page_idx,
NULLIF(t.tab_label_ja,”), NULLIF(t.tab_label_en,”),
t.child_model, t.child_link_field,
COALESCE(t.origin,’standard’)::portal_origin, t.module,
FALSE, NULL, NULL, — is_codegen_target / notes / github_url は初期空
t.view_mode, t.tree_view_xmlid, t.form_view_xmlid,
t.subview_policy_tree, t.subview_policy_form,
t.use_domain, t.domain_raw, t.use_context, t.context_raw,
t.inline_edit, t.allow_create_rows, t.allow_delete_rows, t.options_raw
FROM tab_rows t
ON CONFLICT (view_id, tab_key) DO UPDATE SET
page_idx = EXCLUDED.page_idx,
tab_label_ja = EXCLUDED.tab_label_ja,
tab_label_en = EXCLUDED.tab_label_en,
child_model = EXCLUDED.child_model,
child_link_field = EXCLUDED.child_link_field,
origin = EXCLUDED.origin,
module = EXCLUDED.module,
view_mode = EXCLUDED.view_mode,
tree_view_xmlid = EXCLUDED.tree_view_xmlid,
form_view_xmlid = EXCLUDED.form_view_xmlid,
subview_policy_tree = EXCLUDED.subview_policy_tree,
subview_policy_form = EXCLUDED.subview_policy_form,
use_domain = EXCLUDED.use_domain,
domain_raw = EXCLUDED.domain_raw,
use_context = EXCLUDED.use_context,
context_raw = EXCLUDED.context_raw,
inline_edit = EXCLUDED.inline_edit,
allow_create_rows = EXCLUDED.allow_create_rows,
allow_delete_rows = EXCLUDED.allow_delete_rows,
options_raw = EXCLUDED.options_raw,
updated_at = now();
ポータル「タブ」入力ガイド(コンサル向け)
このレコードが表すもの
- 対象:Odoo フォームビュー内の 1 つのタブ(
<notebook><page>) - 主目的:ポータル上で明細タブの表示・編集・遷移を宣言的に管理し、必要ならXMLを自動生成できる状態にする
入力項目と記入ルール
1) タブ名(日本語 / 英語)
- 何を書く:画面に表示するタブラベル。
- 形式:自由テキスト。和英どちらも推奨(英語のみでも可)。
- 自動取得の目安:
- 既定:
ir_ui_viewの<page string="..."> - 代替:
<page name="...">(継承でattribute name="string"上書きの可能性あり)
- 既定:
- コツ:業務用語をそのまま/句読点は最小限。例)
受注明細 / Order Lines
2) 子モデル
- 何を書く:このタブで扱う明細モデル名。例)
sale.order.line - 形式:モデルのテクニカル名(ドット区切り)。
- 自動取得の目安:
<page>配下のone2manyフィールドの relation(例:order_line→sale.order.line)。- 複数ある場合は「主タブ」に相当するもの(通常は最上段/最大件数)を選択。
- 注意:セットしたら リンクフィールド も埋める(次項)。
3) リンクフィールド(子側の Many2one 名)
- 何を書く:子モデル側で親を指す Many2one フィールド名。例)
order_id - 形式:フィールド名(スネークケース)。
- 自動取得の目安:
ir_model_fields.relation_field(one2manyの逆参照)。
4) 出自(origin)
- 何を書く:このタブ定義の出どころ。
- 選択肢:
standard(標準),module(カスタムモジュール),studio(Web Studio),portal(本ポータル起点),unknown - 自動取得の目安:
ir_model_data(module)がweb_studioならstudio、値ありならmodule、なしはstandard。
5) コード生成対象(トグル)
- 何を書く:このタブをXML自動生成の対象にするか。
- 推奨:運用で固定化するタブは ON、暫定や検証中は OFF。
- 影響:ONにすると「生成ジョブ(開発者依頼)」の対象に載ります。
6) 備考
- 何を書く:要件・設計意図・注意事項、関連チケット番号など。
- 形式:自由テキスト(短く箇条書き推奨)。
サブビュー & 遷移
7) ビューモード
- 何を書く:
tree/form/tree,form - 決め方:
- 一覧のみで十分 →
tree - 既存フォームに遷移したい →
tree,form - 行クリックでフォーム不要 →
tree+editableで代替
- 一覧のみで十分 →
8) サブビュー(参照 or 生成方針)
- 何を書く:参照先ビューXMLIDまたは生成ポリシー
tree_view_xmlid例:sale.view_order_line_treeform_view_xmlid例:sale.view_order_line_form- 生成ポリシー:
reference(参照優先) /auto(インライン構造から自動生成) /none
- 自動取得の目安:
one2manyのcontextにtree_view_ref/form_view_refがあれば参照可。 - ルール:
view_modeにtreeが含まれるなら ツリー参照 or 生成のいずれかは必須。
表示制御 & 既定値
9) 表示制御(Domain / Context の要否)
- 何を書く:フィルタ条件(
domain)と、既定値/挙動(context)。 - 形式:
- domain:Odoo ドメイン式の文字列
- 例)
[('state','!=','cancel')]
- 例)
- context:辞書式の文字列
- 例)
{'default_order_id': active_id, 'search_default_confirmed': 1}
- 例)
- domain:Odoo ドメイン式の文字列
- 自動取得の目安:
<field name="...">のdomain/context属性。 - 注意:
use_domain/use_contextは値が入っているときに ON(空なら OFF)。
編集動作
10) インライン編集(editable)
- 何を書く:ツリーでのインライン編集可否。
- 入力例:
editable="bottom"(末尾に追加行 / 行内編集可) - 自動取得の目安:
<tree editable="...">。 - ヒント:伝票明細は
bottomが一般的。承認済は OFF などの運用ルールを備考に。
11) 行の追加/削除可否
- 何を書く:行追加・削除の許容。
- 決め方:
- 追加:
optionsにno_create: Trueがあれば 不可 - 削除:
optionsにno_delete: Trueがあれば 不可 - 直接属性:
<tree create="false" delete="false">があればそれを優先
- 追加:
- 自動取得の目安:
<tree @create/@delete>と<field @options>の両方を見て合成。
12) GitHub リンク
- 何を書く:参照モジュールの XML / Python の永続URL(コミット or ブランチ固定)。
- 用途:差分・レビューの導線確保。
origin=module/codeの場合は必須推奨。
入力チェック(クイックルール)
- 必須:
- タブ名(少なくとも英語または日本語)
- 子モデル+リンクフィールド(明細タブの場合)
- ビューモード
- 一貫性:
view_modeにtreeがある →tree_view_xmlid参照 or 生成ポリシーautoをセット- ドメインのフィールド名は子モデル側のフィールドで書く(親の
stateを参照するならdomain側でparent.stateなどに注意)
- 権限:ビュー参照先にグループ制限がある場合は備考に記載(実行時の404/権限不足を避ける)
画面項目 ↔ DB カラム対応(実装メモ)
| 画面項目 | portal_tab のカラム |
|---|---|
| タブ名(日本語 / 英語) | tab_label_ja / tab_label_en |
| 子モデル | child_model |
| リンクフィールド | child_link_field |
| 出自 | origin(+ module) |
| コード生成対象 | is_codegen_target |
| 備考 | notes |
| GitHub リンク | github_url |
| ビューモード | view_mode(例:tree,form) |
| 生成方針(tree/form) | subview_policy_tree / subview_policy_form(reference / auto / none) |
| Domain 要否 / 値 | use_domain / domain_raw |
| Context 要否 / 値 | use_context / context_raw |
| インライン編集 | inline_edit |
| 行追加可否 | allow_create_rows |
| 行削除可否 | allow_delete_rows |
入力例(sale.order の「受注明細」タブの想定)
- タブ名(日/英):
受注明細/Order Lines - 子モデル:
sale.order.line - リンクフィールド:
order_id - 出自:
standard - コード生成対象:ON
- ビューモード:
tree,form - サブビュー:
- ツリー参照:
sale.view_order_line_tree(policy=reference) - フォーム参照:
sale.view_order_line_form(policy=reference)
- ツリー参照:
- 表示制御:
- Domain:
[('state','!=','cancel')] - Context:
{'default_order_id': active_id}
- Domain:
- 編集動作:
- インライン編集:ON(
editable="bottom"相当) - 行追加:可、行削除:可
- インライン編集:ON(
- GitHub:
https://github.com/<org>/<repo>/.../views/sale_order_views.xml#L... - 備考:
承認後は編集不可。FY25から税計算は外部連携。
コメントを残す