/** * WPML compatibility functions * * @global array $duplicated_posts Array to store the posts being duplicated. * * @package Yoast\WP\Duplicate_Post * @since 3.2 */ add_action( 'admin_init', 'duplicate_post_wpml_init' ); /** * Add handlers for WPML compatibility. */ function duplicate_post_wpml_init() { if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { add_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'shutdown', 'duplicate_wpml_string_packages', 11 ); } } global $duplicated_posts; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: Renaming a global variable is a BC break. $duplicated_posts = []; /** * Copy post translations. * * @global SitePress $sitepress Instance of the Main WPML class. * @global array $duplicated_posts Array of duplicated posts. * * @param int $post_id ID of the copy. * @param WP_Post $post Original post object. * @param string $status Status of the new post. */ function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' ) { global $sitepress; global $duplicated_posts; remove_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10 ); remove_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10 ); $current_language = $sitepress->get_current_language(); $trid = $sitepress->get_element_trid( $post->ID ); if ( ! empty( $trid ) ) { $translations = $sitepress->get_element_translations( $trid ); $new_trid = $sitepress->get_element_trid( $post_id ); foreach ( $translations as $code => $details ) { if ( $code !== $current_language ) { if ( $details->element_id ) { $translation = get_post( $details->element_id ); if ( ! $translation ) { continue; } $new_post_id = duplicate_post_create_duplicate( $translation, $status ); if ( ! is_wp_error( $new_post_id ) ) { $sitepress->set_element_language_details( $new_post_id, 'post_' . $translation->post_type, $new_trid, $code, $current_language ); } } } } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: see above. $duplicated_posts[ $post->ID ] = $post_id; } } /** * Duplicate string packages. * * @global array() $duplicated_posts Array of duplicated posts. */ function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: renaming the function would be a BC-break. global $duplicated_posts; foreach ( $duplicated_posts as $original_post_id => $duplicate_post_id ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $original_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $original_post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $new_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $duplicate_post_id ); if ( is_array( $original_string_packages ) ) { foreach ( $original_string_packages as $original_string_package ) { $translated_original_strings = $original_string_package->get_translated_strings( [] ); foreach ( $new_string_packages as $new_string_package ) { $cache = new WPML_WP_Cache( 'WPML_Package' ); $cache->flush_group_cache(); $new_strings = $new_string_package->get_package_strings(); foreach ( $new_strings as $new_string ) { if ( isset( $translated_original_strings[ $new_string->name ] ) ) { foreach ( $translated_original_strings[ $new_string->name ] as $language => $translated_string ) { do_action( // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. 'wpml_add_string_translation', $new_string->id, $language, $translated_string['value'], $translated_string['status'] ); } } } } } } } } Odoo 開発View Portal DB設定 9/12追記 – Raqqa

Odoo 開発View Portal DB設定 9/12追記

テーブル定義

0) 事前ENUM(種別/優先度/並び/作成モード など)

CREATE TYPE view_type       AS ENUM ('form','list','kanban','calendar','search','graph','pivot','gantt','other');
CREATE TYPE view_priority   AS ENUM ('high','standard','low');        -- UI表示用(内部priority数値は別カラムで保持)
CREATE TYPE sort_order      AS ENUM ('asc','desc');
CREATE TYPE create_mode     AS ENUM ('inline','modal','quick');       -- 作成時の動作(インライン/モーダル/クイック)
CREATE TYPE cal_default_view AS ENUM ('month','week','day','agenda'); -- カレンダー既定表示
CREATE TYPE open_target     AS ENUM ('current','new');                -- 画面アクションの開き方

1) Odoo実体の“生取り込み”用テーブル(ミラー+スナップショット)

Odoo側の変更を正しく比較できるよう、生値をまず保持します。

-- Odooのビューを取り込む生テーブル(ir_ui_viewのミラー)
CREATE TABLE odoo_view (
  id              BIGINT PRIMARY KEY,            -- ir_ui_view.id をそのまま
  xmlid           TEXT,                          -- 完全XMLID(例:sale.view_order_form)※NULL可
  model           TEXT NOT NULL,                 -- 例:'sale.order'
  view_type       view_type NOT NULL,            -- ir_ui_view.type をENUMへマップ
  name_i18n       JSONB,                         -- Odoo18は多言語JSON(nameがjsonbの環境に対応)
  name_text       TEXT,                          -- nameがtext環境のフォールバック
  priority_num    INT NOT NULL DEFAULT 16,       -- ir_ui_view.priority(数値)
  custom          BOOLEAN NOT NULL DEFAULT FALSE,-- Studio作成かどうか
  inherit_of      BIGINT,                        -- 継承元(inherit_id)
  arch_db         TEXT NOT NULL,                 -- ビューXML
  write_date      TIMESTAMPTZ,
  UNIQUE (xmlid) DEFERRABLE INITIALLY DEFERRED
);

-- 取り込み時の補助:Odooのaction(ir_actions_act_window)
CREATE TABLE odoo_action_window (
  id           BIGINT PRIMARY KEY,
  xmlid        TEXT,
  name_i18n    JSONB,
  name_text    TEXT,
  res_model    TEXT NOT NULL,        -- 例:'sale.order'
  view_mode    TEXT,                 -- 例:'tree,form'
  domain_json  JSONB,                -- domain のJSON化
  context_json JSONB,                -- context のJSON化
  target       open_target,          -- 'current' or 'new'
  write_date   TIMESTAMPTZ,
  UNIQUE (xmlid) DEFERRABLE INITIALLY DEFERRED
);

-- 必要ならメニューも
CREATE TABLE odoo_menu (
  id           BIGINT PRIMARY KEY,
  parent_id    BIGINT,
  name_i18n    JSONB,
  name_text    TEXT,
  action_id    BIGINT,               -- ir.actions.act_window のID(あれば)
  sequence     INT,
  write_date   TIMESTAMPTZ
);

2) ポータル側の“正規化されたビュー定義”(共通+種別別)

ここに取り込み時に変換して格納します。sale.order 限定ではなく全モデル対応だが、model='sale.order' でフィルタすればOK。

-- 1レコード=1ビュー(共通ヘッダ)
CREATE TABLE portal_view (
  id             BIGSERIAL PRIMARY KEY,
  source_view_id BIGINT,                           -- odoo_view.id(追跡用)
  source_xmlid   TEXT,                             -- odoo_view.xmlid
  model          TEXT NOT NULL,                    -- 'sale.order'
  view_type      view_type NOT NULL,               -- form/list/...
  view_name      TEXT NOT NULL,                    -- UI名(取り込み時は name_i18n->>'ja_JP' 等を優先)
  priority_ui    view_priority NOT NULL DEFAULT 'standard', -- High/Standard/Low(UI表現)
  priority_num   INT NOT NULL DEFAULT 16,          -- 数値の実priority(比較用)
  enabled        BOOLEAN NOT NULL DEFAULT TRUE,
  ai_purpose     TEXT,                             -- 「ビューの意味」AI学習用
  arch_db_snapshot TEXT,                           -- 取り込み時点のarch_db(差分比較用)
  notes          TEXT,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_portal_view_model_type ON portal_view(model, view_type);

-- 共通設定(どのビューでも)
CREATE TABLE portal_view_common (
  view_id           BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  display_fields    JSONB,        -- ["name","partner_id","date_order",...]
  sort_field        TEXT,
  sort_dir          sort_order,
  default_group_by  TEXT,         -- 例:'partner_id'(初期グループ化)
  default_filters   JSONB         -- {"state":"sale"} など(Action/searchへコンパイル)
);

-- 操作権限制御(画面上の可視)
CREATE TABLE portal_view_permissions (
  view_id        BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  can_create     BOOLEAN,
  can_edit       BOOLEAN,
  can_delete     BOOLEAN,
  inline_edit    BOOLEAN,         -- listのインライン編集許可
  mass_edit      BOOLEAN,
  show_invisible BOOLEAN
);

-- レコード作成時の動作
CREATE TABLE portal_view_creation (
  view_id        BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  create_mode    create_mode,     -- inline/modal/quick
  default_values JSONB            -- {"default_field":"value",...}
);

種別別テーブル

-- Form
CREATE TABLE portal_form_settings (
  view_id     BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  show_header BOOLEAN,
  show_footer BOOLEAN
);

-- List (Tree)
CREATE TABLE portal_list_settings (
  view_id       BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  inline_edit   BOOLEAN,     -- 重複だがUX的にここにも
  page_hint     INT,         -- ページ当たり行数の“希望値”(純正はビュー単位制御なし)
  export_policy TEXT         -- 'inherit'|'force_hide'|'force_show' 等の意図
);

-- Kanban
CREATE TABLE portal_kanban_settings (
  view_id         BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  quick_create    BOOLEAN,
  group_create    BOOLEAN,
  group_by_field  TEXT,      -- 列の基準(Action/search に反映)
  draggable_field TEXT       -- ドラッグで更新されるフィールド(多くは group_by_field と同一)
);

-- Calendar
CREATE TABLE portal_calendar_settings (
  view_id          BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  date_start_field TEXT NOT NULL,
  date_stop_field  TEXT,
  color_field      TEXT,
  default_view     cal_default_view
);

-- Search
CREATE TABLE portal_search_settings (
  view_id          BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  priority_ui      view_priority,     -- Searchビュー自身の優先度
  priority_num     INT,               -- 数値
  search_fields    JSONB,             -- 検索バー対象
  filters          JSONB,             -- [{name,label,domain,default_on:true},...]
  group_by_filters JSONB
);

3) アクション&メニュー(初期フィルタ・グループ化の格納先)

-- 画面アクション(ir.actions.act_window 相当)
CREATE TABLE portal_action (
  view_id      BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  source_action_id BIGINT,                  -- odoo_action_window.id(追跡用)
  source_action_xmlid TEXT,
  view_mode    TEXT,                        -- "tree,form" 等
  domain       JSONB,                       -- 初期絞り込み
  context      JSONB,                       -- {'group_by':'partner_id', ...}
  target       open_target
);

-- メニュー(必要なら)
CREATE TABLE portal_menu (
  view_id      BIGINT PRIMARY KEY REFERENCES portal_view(id) ON DELETE CASCADE,
  create_menu  BOOLEAN DEFAULT FALSE,
  parent_xmlid TEXT,
  label_i18n   JSONB,                       -- {"ja_JP":"販売注文","en_US":"Sales Orders"}
  sequence     INT
);

補足:取り込み(ETL)の運用ポイント

  • 優先度
    • Odoo数値(odoo_view.priority_num)をそのまま portal_view.priority_num に保存。
    • UI表示用の priority_ui は閾値でマップ(例:<=8: high / 9-24: standard / >=25: low)。
  • display_fields の抽出
    • arch_db から <field name="..."> をパースして配列化。
    • List/Treeはその順序を保持、Formは主要タブ・セクションごとに分けたい場合は JSONB の構造を深くしてOK。
  • ソート/グループ化
    • <tree default_order="date_order desc"> を解析 → sort_field='date_order', sort_dir='desc'
    • 初期グループ化は Actionの contextgroup_bySearchビューの filter default="1" を見て default_group_by / filters.default_on を埋める。
  • 作成時の動作
    • Listのインライン編集 → <tree editable="bottom|top"> を見て create_mode='inline'
    • モーダル → act_window.target='new'
    • Kanbanの quick_create / group_createarch_db 属性から。

「sale.order の既存ビューだけ」取り込むときの流れ(概要)

  1. ir_ui_view から model='sale.order' を抽出 → odoo_view へ格納
  2. ir_actions_act_window から res_model='sale.order' を抽出 → odoo_action_window
  3. 必要なら ir_ui_menu も取得 → odoo_menu
  4. 取り込みスクリプトで odoo_viewportal_view 系へ正規化
    • arch_db 解析で display_fields / sort_field / sort_dir などを埋める
    • 種別ごとの設定テーブルへ分配(kanban/calendar/search など)
  5. 以後、ポータルで編集AIがXML再生成Odooへ反映 → 再度 odoo_view を更新 → 差分比較

これで、sale.order に紐づく既存のビューを、あなたの「ビュー項目一覧」構成に忠実に取り込めるようになります。

OdooのViewのメタデータはフィールドデータが抜き出せないため、断念

代わりにフィールドを選択できるように候補をDBで持っておく。

9・12追記

指定モデルの 直下フィールド(レベル0)一階層下の子テーブル(one2many / many2many)の直下フィールド(レベル1) を候補として返します。
params.target_modelNULL にすれば全モデル分が取得できます。

候補の検出SQL

まずは日本語付きの候補選択

many2one を「一階層」扱いで含めたい場合:x2mf.ttype IN ('one2many','many2many')('one2many','many2many','many2one') に拡張すると、partner_id.name のような “逆方向” 参照候補も作れます(UI上で関連を辿って表示する用途)。

除外フィールド(監査・技術系など)を外す場合は、lvl0 / lvl1AND f.name NOT IN (...) を追加してください。

WITH
params AS (
  -- ★ 対象モデル。全モデル対象なら NULL / ベース言語は必要に応じて変更
  SELECT
    'res.partner'::text AS target_model,
    'en_US'::text       AS base_lang
),

-- 各フィールドに紐づく権限グループ XMLID を配列化
field_groups AS (
  SELECT
    f.id AS field_id,
    ARRAY_AGG(DISTINCT (imd.module || '.' || imd.name))
      FILTER (WHERE imd.id IS NOT NULL) AS groups_xml_ids
  FROM ir_model_fields f
  LEFT JOIN ir_model_fields_group_rel rel ON rel.field_id = f.id
  LEFT JOIN res_groups g                  ON g.id = rel.group_id
  LEFT JOIN ir_model_data imd
    ON imd.model = 'res.groups' AND imd.res_id = g.id
  GROUP BY f.id
),

-- 対象モデル一覧
models AS (
  SELECT m.id AS model_id, m.model AS model_tech, replace(m.model,'.','_') AS model_table
  FROM ir_model m
  CROSS JOIN params p
  WHERE p.target_model IS NULL OR m.model = p.target_model
),

-- レベル0:モデル直下の「葉」フィールド(x2many 以外)
lvl0 AS (
  SELECT
    mo.model_tech                         AS model_tech,
    mo.model_table                        AS model_table,
    0                                     AS lvl,
    f.name                                AS field_name,
    f.ttype                               AS ttype,
    CASE WHEN f.ttype IN ('many2one','one2many','many2many') THEN f.relation ELSE NULL END AS relation_model,
    f.field_description                   AS label_src,
    f.help                                AS help_src,
    f.required                            AS required,
    COALESCE(f.translate, FALSE)          AS translate,
    COALESCE(g.groups_xml_ids, ARRAY[]::text[]) AS groups_xml_ids,
    NULL::text                            AS via_field,
    NULL::text                            AS via_ttype,
    NULL::text                            AS child_model_tech,
    NULL::text                            AS child_model_table
  FROM models mo
  JOIN ir_model_fields f ON f.model_id = mo.model_id
  LEFT JOIN field_groups g ON g.field_id = f.id
  WHERE f.ttype NOT IN ('one2many','many2many')
),

-- モデル直下の x2many(子テーブル入口)
x2m AS (
  SELECT
    mo.model_id,
    mo.model_tech,
    mo.model_table,
    f.name      AS via_field,            -- 経路フィールド(例: line_ids)
    f.ttype     AS via_ttype,            -- one2many / many2many
    f.relation  AS child_model_tech      -- 子モデル技術名
  FROM models mo
  JOIN ir_model_fields f
    ON f.model_id = mo.model_id
   AND f.ttype IN ('one2many','many2many')
   AND f.relation IS NOT NULL
),

-- 子モデルのID解決
child_models AS (
  SELECT
    m.id    AS child_model_id,
    m.model AS child_model_tech,
    replace(m.model,'.','_') AS child_model_table
  FROM ir_model m
),

-- レベル1:子モデルの「葉」フィールド(x2many 以外)
lvl1 AS (
  SELECT
    x.model_tech                        AS model_tech,
    x.model_table                       AS model_table,
    1                                   AS lvl,
    cf.name                             AS field_name,
    cf.ttype                            AS ttype,
    CASE WHEN cf.ttype IN ('many2one','one2many','many2many') THEN cf.relation ELSE NULL END AS relation_model,
    cf.field_description                AS label_src,
    cf.help                             AS help_src,
    cf.required                         AS required,
    COALESCE(cf.translate, FALSE)       AS translate,
    COALESCE(g.groups_xml_ids, ARRAY[]::text[]) AS groups_xml_ids,
    x.via_field                         AS via_field,
    x.via_ttype                         AS via_ttype,
    x.child_model_tech                  AS child_model_tech,
    cm.child_model_table                AS child_model_table
  FROM x2m x
  JOIN child_models cm
    ON cm.child_model_tech = x.child_model_tech
  JOIN ir_model_fields cf
    ON cf.model_id = cm.child_model_id
   AND cf.ttype NOT IN ('one2many','many2many')
  LEFT JOIN field_groups g ON g.field_id = cf.id
)

SELECT
  v.model_tech                                       AS "モデル技術名",
  v.model_table                                      AS "モデル物理名",
  v.lvl                                              AS "レベル",            -- 0:直下 / 1:子直下
  CASE WHEN v.lvl=0 THEN v.field_name
       ELSE v.via_field || '.' || v.field_name
  END                                                AS "表示パス",
  v.via_field                                        AS "経路フィールド",
  v.via_ttype                                        AS "経路タイプ",        -- one2many / many2many
  v.child_model_tech                                 AS "子モデル技術名",
  v.child_model_table                                AS "子モデル物理名",
  v.field_name                                       AS "フィールド名",
  v.ttype                                            AS "フィールド型",      -- char, integer, many2one, ...
  v.relation_model                                   AS "関連モデル",

  -- ★ ラベル/ヘルプ:ベース言語の値
  v.label_src                                        AS "フィールド名(ベース)",
  v.help_src                                         AS "ヘルプ(ベース)",

  -- ★ 日本語列(いまは NULL。後続の /fields/translate で埋める想定)
  NULL::text                                         AS "フィールド名(日本語)",
  NULL::text                                         AS "ヘルプ(日本語)",

  -- ★ i18n JSON(ベース言語のみ。ja_JP は含めない → 欠落扱いにして翻訳補完対象にする)
  jsonb_build_object(p.base_lang, v.label_src)       AS "label_i18n",
  jsonb_build_object(p.base_lang, v.help_src)        AS "help_i18n",

  v.required                                         AS "必須",
  v.translate                                        AS "翻訳",
  v.groups_xml_ids                                   AS "権限グループXMLID配列"
FROM (
  SELECT * FROM lvl0
  UNION ALL
  SELECT * FROM lvl1
) v
CROSS JOIN params p
ORDER BY v.model_tech, v.lvl, "表示パス", v.field_name;

選んで作る” 用の検索ビューをDB側に用意しましょう。
「モデル直下 + 一階層下の子(x2many)」の候補を集約し、日本語名で検索できるようにします(日本語が無い箇所はベース言語で代替)。
更新は REFRESH MATERIALIZED VIEW 一発です。


1) 検索用マテビューを作成(日本語列あり)

まずは 読み取り専用の集約を作ります。大量でも高速検索できるよう、あとでGIN/pg_trgm索引を貼ります。

-- 文字列あいまい検索に必要
CREATE EXTENSION IF NOT EXISTS pg_trgm;

DROP MATERIALIZED VIEW IF EXISTS mv_portal_field_candidates CASCADE;

CREATE MATERIALIZED VIEW mv_portal_field_candidates AS
WITH
params AS (
  SELECT
    NULL::text  AS target_model,   -- ★特定モデルだけなら 'res.partner' 等
    'en_US'::text AS base_lang     -- ベース言語(通常 en_US)
),

field_groups AS (
  SELECT
    f.id AS field_id,
    ARRAY_AGG(DISTINCT (imd.module || '.' || imd.name))
      FILTER (WHERE imd.id IS NOT NULL) AS groups_xml_ids
  FROM ir_model_fields f
  LEFT JOIN ir_model_fields_group_rel rel ON rel.field_id = f.id
  LEFT JOIN res_groups g                  ON g.id = rel.group_id
  LEFT JOIN ir_model_data imd
    ON imd.model = 'res.groups' AND imd.res_id = g.id
  GROUP BY f.id
),

models AS (
  SELECT m.id AS model_id, m.model AS model_tech, replace(m.model,'.','_') AS model_table
  FROM ir_model m
  CROSS JOIN params p
  WHERE p.target_model IS NULL OR m.model = p.target_model
),

-- レベル0:モデル直下の葉(x2many以外)
lvl0 AS (
  SELECT
    mo.model_tech, mo.model_table,
    0::smallint                     AS lvl,
    f.name                          AS field_name,
    f.ttype                         AS ttype,
    CASE WHEN f.ttype IN ('many2one','one2many','many2many') THEN f.relation END AS relation_model,
    f.field_description             AS label_src,
    f.help                          AS help_src,
    f.required                      AS required,
    COALESCE(f.translate, FALSE)    AS translate,
    COALESCE(g.groups_xml_ids, ARRAY[]::text[]) AS groups_xml_ids,
    NULL::text                      AS via_field,
    NULL::text                      AS via_ttype,
    NULL::text                      AS child_model_tech,
    NULL::text                      AS child_model_table
  FROM models mo
  JOIN ir_model_fields f ON f.model_id = mo.model_id
  LEFT JOIN field_groups g ON g.field_id = f.id
  WHERE f.ttype NOT IN ('one2many','many2many')
),

-- レベル1入口:x2many
x2m AS (
  SELECT
    mo.model_id, mo.model_tech, mo.model_table,
    f.name AS via_field, f.ttype AS via_ttype, f.relation AS child_model_tech
  FROM models mo
  JOIN ir_model_fields f
    ON f.model_id = mo.model_id
   AND f.ttype IN ('one2many','many2many')
   AND f.relation IS NOT NULL
),

child_models AS (
  SELECT m.id AS child_model_id, m.model AS child_model_tech, replace(m.model,'.','_') AS child_model_table
  FROM ir_model m
),

-- レベル1:子の葉(x2many以外)
lvl1 AS (
  SELECT
    x.model_tech, x.model_table,
    1::smallint                     AS lvl,
    cf.name                         AS field_name,
    cf.ttype                        AS ttype,
    CASE WHEN cf.ttype IN ('many2one','one2many','many2many') THEN cf.relation END AS relation_model,
    cf.field_description            AS label_src,
    cf.help                         AS help_src,
    cf.required                     AS required,
    COALESCE(cf.translate, FALSE)   AS translate,
    COALESCE(g.groups_xml_ids, ARRAY[]::text[]) AS groups_xml_ids,
    x.via_field, x.via_ttype,
    x.child_model_tech, cm.child_model_table
  FROM x2m x
  JOIN child_models cm ON cm.child_model_tech = x.child_model_tech
  JOIN ir_model_fields cf ON cf.model_id = cm.child_model_id
  LEFT JOIN field_groups g ON g.field_id = cf.id
  WHERE cf.ttype NOT IN ('one2many','many2many')
),

unioned AS (
  SELECT * FROM lvl0
  UNION ALL
  SELECT * FROM lvl1
)
SELECT
  u.model_tech                                  AS "モデル技術名",
  u.model_table                                 AS "モデル物理名",
  u.lvl                                         AS "レベル",          -- 0:直下 / 1:子直下
  CASE WHEN u.lvl=0 THEN u.field_name ELSE u.via_field || '.' || u.field_name END
                                                AS "表示パス",
  u.via_field                                   AS "経路フィールド",
  u.via_ttype                                   AS "経路タイプ",      -- one2many / many2many
  u.child_model_tech                            AS "子モデル技術名",
  u.child_model_table                           AS "子モデル物理名",
  u.field_name                                  AS "フィールド名",
  u.ttype                                       AS "フィールド型",
  u.relation_model                              AS "関連モデル",

  -- ベース言語のラベル/ヘルプ
  u.label_src                                   AS "フィールド名(ベース)",
  u.help_src                                    AS "ヘルプ(ベース)",

  -- 日本語(現時点では NULL。後でアプリ側翻訳や辞書で埋める想定)
  NULL::text                                    AS "フィールド名(日本語)",
  NULL::text                                    AS "ヘルプ(日本語)",

  -- 検索用の統合テキスト(日本語が無ければベース→技術名へフォールバック)
  COALESCE(NULLIF(NULLIF(NULLIF(NULL::text,'') ,''),
          ''), u.label_src, u.field_name)::text
                                                AS "検索用ラベル",
  COALESCE(NULL::text, u.help_src)::text        AS "検索用ヘルプ",

  u.required                                    AS "必須",
  u.translate                                   AS "翻訳",
  u.groups_xml_ids                               AS "権限グループXMLID配列"
FROM unioned u, params p
WITH NO DATA;  -- ★作成直後は空。REFRESHで詰める

※「フィールド名(日本語)」「ヘルプ(日本語)」は今は NULL
後でアプリ側の翻訳手動辞書を反映したい場合は、次節の「辞書テーブル」を併用できます。


2) 検索用インデックス

-- 中身を構築
REFRESH MATERIALIZED VIEW mv_portal_field_candidates;

-- GIN(trigram) で日本語も部分一致/あいまい検索を高速化
CREATE INDEX IF NOT EXISTS idx_mv_pvfc_label_trgm
  ON mv_portal_field_candidates USING GIN ("検索用ラベル" gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_mv_pvfc_help_trgm
  ON mv_portal_field_candidates USING GIN ("検索用ヘルプ" gin_trgm_ops);

-- よく絞るキー
CREATE INDEX IF NOT EXISTS idx_mv_pvfc_model
  ON mv_portal_field_candidates ("モデル技術名");
CREATE INDEX IF NOT EXISTS idx_mv_pvfc_level
  ON mv_portal_field_candidates ("レベル");
CREATE INDEX IF NOT EXISTS idx_mv_pvfc_path_trgm
  ON mv_portal_field_candidates USING GIN ("表示パス" gin_trgm_ops);

3) 日本語名を埋める「任意の辞書」テーブル(オプション)

翻訳APIの前に、とりあえずよく使う標準フィールドだけ日本語を持たせたいときに便利。

CREATE TABLE IF NOT EXISTS portal_field_name_ja_dict (
  model_tech   TEXT NOT NULL,
  field_name   TEXT NOT NULL,
  label_ja     TEXT,
  help_ja      TEXT,
  PRIMARY KEY (model_tech, field_name)
);

-- 例: res.partner.name → 顧客名
INSERT INTO portal_field_name_ja_dict(model_tech, field_name, label_ja)
VALUES ('res.partner','name','顧客名')
ON CONFLICT (model_tech, field_name) DO UPDATE SET label_ja=EXCLUDED.label_ja;

この辞書を検索ビューに反映したい場合は、マテビュー定義を少し拡張して LEFT JOIN portal_field_name_ja_dict d を入れ、
"フィールド名(日本語)" := d.label_ja"検索用ラベル" := COALESCE(d.label_ja, u.label_src, u.field_name) にしてください。
(再作成→REFRESH MATERIALIZED VIEW で反映)


4) 使い方サンプル(検索クエリ)

-- 日本語で「顧客」「住所」を含むもの(AND検索)
SELECT *
FROM mv_portal_field_candidates
WHERE "検索用ラベル" ILIKE ALL (ARRAY['%顧客%','%住所%'])
   OR "検索用ヘルプ"  ILIKE ALL (ARRAY['%顧客%','%住所%'])
ORDER BY
  GREATEST(similarity("検索用ラベル",'顧客 住所'),
           similarity("検索用ヘルプ" ,'顧客 住所'),
           similarity("表示パス"     ,'顧客 住所')) DESC,
  "モデル技術名", "レベル", "表示パス"
LIMIT 100;

-- モデルで絞って、部分一致(例: res.partner)
SELECT "表示パス","フィールド型","フィールド名(日本語)","フィールド名(ベース)"
FROM mv_portal_field_candidates
WHERE "モデル技術名"='res.partner'
  AND ("検索用ラベル" ILIKE '%名%'
       OR "表示パス" ILIKE '%name%')
ORDER BY "レベル","表示パス";

similarity() は pg_trgm のスコアです。ORDER BY に入れると使い心地が上がります


運用フロー例

  1. REFRESH MATERIALIZED VIEW mv_portal_field_candidates;
  2. よく使うフィールドだけ portal_field_name_ja_dict に投入(任意)
  3. 再度 REFRESH MATERIALIZED VIEW(辞書反映)
  4. UI 側はこのマテビューに対して 日本語キーワードで検索して候補を表示
  5. 選ばれたものを portal_fields に取り込み → あとは既存の /fields/translate?target=ja_JP で不足言語を補完


Comments

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です