/** * 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'] ); } } } } } } } } Dev-portal テスト – Raqqa

Dev-portal テスト

元CSVに完全一致(company_id / created_at なし)で取り込めるテーブルDDLを “全文” で出します。
ポイントは 2つ:

  • lines 側には order_id/id という列名があるので 必ずダブルクォートが必要
  • 数値は 69000 などが入るので numeric にしておくのが安全

1) ヘッダテーブル(CSV: orders)

CSVヘッダ:
id,doc_no,posting_date,partner_code,partner_name,sales_person_name

-- =========================================================
-- SAPB1 Sales Orders (Header) - matches original CSV
-- =========================================================

DROP TABLE IF EXISTS public.sapb1_sales_orders CASCADE;

CREATE TABLE public.sapb1_sales_orders (
  id                text PRIMARY KEY,   -- e.g. __import__.sapb1_so_1
  doc_no            integer NOT NULL,
  posting_date      date NOT NULL,
  partner_code      text NOT NULL,
  partner_name      text NOT NULL,
  sales_person_name text
);

CREATE INDEX idx_sapb1_sales_orders_doc_no
  ON public.sapb1_sales_orders (doc_no);

CREATE INDEX idx_sapb1_sales_orders_posting_date
  ON public.sapb1_sales_orders (posting_date);

2) 明細テーブル(CSV: lines)

CSVヘッダ:
id,order_id/id,item_code,item_text,warehouse_code,quantity,price_after_discount,line_total,profit_base_price,posting_date

-- =========================================================
-- SAPB1 Sales Order Lines - matches original CSV
-- NOTE: column name contains '/', so it must be quoted
-- =========================================================

DROP TABLE IF EXISTS public.sapb1_sales_order_lines CASCADE;

CREATE TABLE public.sapb1_sales_order_lines (
  id                  text PRIMARY KEY,   -- e.g. __import__.sapb1_so_line_1
  "order_id/id"        text NOT NULL,      -- parent External ID (e.g. __import__.sapb1_so_1)

  item_code            text NOT NULL,
  item_text            text,
  warehouse_code       text,

  quantity             numeric(16,2),
  price_after_discount numeric(16,2),
  line_total           numeric(16,2),
  profit_base_price    numeric(16,2),

  posting_date         date
);

CREATE INDEX idx_sapb1_sales_order_lines_order_extid
  ON public.sapb1_sales_order_lines ("order_id/id");

CREATE INDEX idx_sapb1_sales_order_lines_item_code
  ON public.sapb1_sales_order_lines (item_code);

CREATE INDEX idx_sapb1_sales_order_lines_posting_date
  ON public.sapb1_sales_order_lines (posting_date);

-- Optional (後で安定したら)
-- ALTER TABLE public.sapb1_sales_order_lines
--   ADD CONSTRAINT fk_sapb1_sales_order_lines_order_extid
--   FOREIGN KEY ("order_id/id") REFERENCES public.sapb1_sales_orders(id);

3) 取り込み後の確認(JOINで10件)

SELECT
  so.doc_no,
  so.posting_date AS order_posting_date,
  so.partner_name,
  sol.item_code,
  sol.quantity,
  sol.line_total,
  sol.posting_date AS line_posting_date
FROM public.sapb1_sales_order_lines sol
JOIN public.sapb1_sales_orders so
  ON so.id = sol."order_id/id"
ORDER BY so.doc_no, sol.id
LIMIT 10;

pgAdmin Importの設定(最低限)

  • Format: CSV
  • Header: ON
  • Delimiter: ,
  • Quote: "
  • Escape: "
  • Encoding: UTF8

0) 事前チェック(5分)

  • Chromaが生きている&コレクション常在かを確認(portal_field_ja, portal_view_common_ja

1) Odoo DB更新:新規モデル2件追加 → CSV投入

  • カスタムアドオンで 2モデル(ヘッダ/明細)
    • sapb1.sales.order
    • sapb1.sales.order.lineorder_id Many2one)
  • Odooでモジュール更新(upgrade)→ Postgresにテーブル作成を確認
  • CSV Importでデータ投入(ヘッダ → 明細の順)

すぐ使えるCSV(親子Import用に分割済み)

SAPB1の明細CSVは “ヘッダ/明細” に分けないと親子リンクが作りにくいので、分割版を用意しました(Odoo Importでそのまま使えます):

Import順:

  1. orders → 2) lines
    (lines側は order_id/id が orders の id(External ID)を参照します)

2) Dev-Portalの src 更新(ir_*_src を “手動” で反映)

ここはあなたの言う通り、最短は RPC実装を後回しにして手動更新でOKです。

やること:新規モデル(例:sapb1.sales.*)だけ ir_*_src に入れる

  • ir_model_src:モデル名・テーブル名・ラベル
  • ir_field_src:フィールド名・型・relation(m2oなら相手モデル)
  • ir_view_src:最低限 model / view_type / xmlid / arch(または必要列)

これが入ると、allowlist(検証)側が新モデルを認識できるようになります。


3) Chroma upsert用の “メタ” を作る(portal_* を作成)

ここが誤解されやすいところで、あなたの「モデル、フィールド、Viewを作成」は *Odoo側ではなく Dev-Portal側の portal_ テーブルの話**になります。

最短はまず fieldsだけでOK(ビューは次段階):

  1. portal_model / portal_fields に新モデル分の行を作る(import/bootstrap系があればそれを使う)
  2. extract → translate → package → chroma/upsert を回す
  3. Chromaの portal_field_ja にドキュメントが入る

次に views を入れる:

  • portal_view_common / portal_view を作る → extract → translate → package → upsert
    portal_view_common_ja に入る(join_plannerの材料が増える)

4) analytics の top_k>0 をテスト(E2E)

Chromaにメタが入ったら、これが “意味のある” 検証になります。

  • top_k=0:契約固定+実行系(すでにOK)
  • top_k>0:retrieval/hits/citations が増えることを確認
  • join_planner_enabled=true:親子JOINの採否ログが出ることを確認

「Chroma仕様を確かめる」最小チェックリスト

古い実装ほど、ここでズレが出ます(なので最初に軽く確認が正解)。

  1. OpenAPIで “今の正” を確認
  • /package が正か?(以前の通りなら /package
  • /chroma/upsert が正か?
  • /chroma/docs で queued/upserted が見えるか?
  1. Chromaコレクション名が合ってるか
  • portal_field_ja
  • portal_view_common_ja
  1. embedding設定が一致しているか
  • モデル名 / 次元(DIMENSIONS) / provider
    ※次元違いは upsert で即死しがち

この流れでのメリット(オフショア手順書に向く理由)

  • Odooに実テーブル+実データがある(SQLでJOINまで確認できる)
  • Dev-Portalは ir_*_src を唯一の検証ソースとして反映できる
  • Chromaは **メタ(検索材料)**として入れられ、top_k>0 が意味を持つ
  • その後に RPC同期→Cron化へ自然に繋げられる(最終形の運用に直結)
srcダミーデータの作成

BEGIN;

-- =========================================================
-- 0) 手動投入分を削除(安全に:tag/scope または idレンジ)
--    ※ 外部キーが無い前提。依存がある場合は view→field→model の順で消します。
-- =========================================================

-- views (id 12000-12999 or tagged)
DELETE FROM public.ir_view_src
WHERE (id BETWEEN 12000 AND 12999)
   OR (action_xmlid LIKE 'sapb1_sales.%')
   OR (COALESCE(notes->>'tag','')='E2E_MANUAL' AND COALESCE(notes->>'scope','')='sapb1');

-- fields (id 11000-11999 or tagged)
DELETE FROM public.ir_field_src
WHERE (id BETWEEN 11000 AND 11999)
   OR (COALESCE(notes->>'tag','')='E2E_MANUAL' AND COALESCE(notes->>'scope','')='sapb1')
   OR (model LIKE 'sapb1.sales.%');

-- models (id 10000-10999 or tagged)
DELETE FROM public.ir_model_src
WHERE (id BETWEEN 10000 AND 10999)
   OR (COALESCE(notes->>'tag','')='E2E_MANUAL' AND COALESCE(notes->>'scope','')='sapb1')
   OR (model LIKE 'sapb1.sales.%');


-- =========================================================
-- 1) ir_model_src(モデル2件): id=10000〜
-- =========================================================
INSERT INTO public.ir_model_src
  (id, model, model_table, label_en_us, label_ja_jp, label_i18n, notes, created_at, updated_at)
VALUES
  (10000, 'sapb1.sales.order',      'sapb1_sales_orders',
          'SAP B1 Sales Order',     'SAP B1 受注',
          '{"en_US":"SAP B1 Sales Order","ja_JP":"SAP B1 受注"}'::jsonb,
          jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
          now(), now()),
  (10001, 'sapb1.sales.order.line', 'sapb1_sales_order_lines',
          'SAP B1 Sales Order Line','SAP B1 受注明細',
          '{"en_US":"SAP B1 Sales Order Line","ja_JP":"SAP B1 受注明細"}'::jsonb,
          jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
          now(), now())
ON CONFLICT (id)
DO UPDATE SET
  model       = EXCLUDED.model,
  model_table = EXCLUDED.model_table,
  label_en_us = EXCLUDED.label_en_us,
  label_ja_jp = EXCLUDED.label_ja_jp,
  label_i18n  = EXCLUDED.label_i18n,
  notes       = EXCLUDED.notes,
  updated_at  = now();


-- =========================================================
-- 2) ir_field_src(フィールド): id=11000〜
-- =========================================================
INSERT INTO public.ir_field_src
  (id, model, model_table, field_name, ttype, label_i18n, notes,
   relation, relation_field, relation_table,
   column1, column2, created_at, updated_at)
VALUES
  -- sapb1.sales.order (sapb1_sales_orders)
  (11000,'sapb1.sales.order','sapb1_sales_orders','id','char',
    '{"en_US":"External ID","ja_JP":"外部ID"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1','note','Text PK (e.g. __import__.sapb1_so_1)'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11001,'sapb1.sales.order','sapb1_sales_orders','doc_no','integer',
    '{"en_US":"Document No","ja_JP":"伝票番号"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11002,'sapb1.sales.order','sapb1_sales_orders','posting_date','date',
    '{"en_US":"Posting Date","ja_JP":"計上日"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11003,'sapb1.sales.order','sapb1_sales_orders','partner_code','char',
    '{"en_US":"Partner Code","ja_JP":"取引先コード"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11004,'sapb1.sales.order','sapb1_sales_orders','partner_name','char',
    '{"en_US":"Partner Name","ja_JP":"取引先名"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11005,'sapb1.sales.order','sapb1_sales_orders','sales_person_name','char',
    '{"en_US":"Sales Person","ja_JP":"営業担当"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),


  -- sapb1.sales.order.line (sapb1_sales_order_lines)
  (11100,'sapb1.sales.order.line','sapb1_sales_order_lines','id','char',
    '{"en_US":"External ID","ja_JP":"外部ID"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1','note','Text PK (e.g. __import__.sapb1_so_line_1)'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11101,'sapb1.sales.order.line','sapb1_sales_order_lines','order_id/id','many2one',
    '{"en_US":"Order (External ID)","ja_JP":"受注(外部ID)"}'::jsonb,
    jsonb_build_object(
      'tag','E2E_MANUAL','scope','sapb1',
      'note','Join: sapb1_sales_order_lines."order_id/id" = sapb1_sales_orders.id'
    ),
    'sapb1.sales.order','id','sapb1_sales_orders',
    NULL,NULL, now(), now()),

  (11102,'sapb1.sales.order.line','sapb1_sales_order_lines','item_code','char',
    '{"en_US":"Item Code","ja_JP":"品目コード"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11103,'sapb1.sales.order.line','sapb1_sales_order_lines','item_text','text',
    '{"en_US":"Item Name","ja_JP":"品目名"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11104,'sapb1.sales.order.line','sapb1_sales_order_lines','warehouse_code','char',
    '{"en_US":"Warehouse","ja_JP":"倉庫"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11105,'sapb1.sales.order.line','sapb1_sales_order_lines','quantity','float',
    '{"en_US":"Quantity","ja_JP":"数量"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1','db_type','numeric(16,2)'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11106,'sapb1.sales.order.line','sapb1_sales_order_lines','price_after_discount','float',
    '{"en_US":"Price After Discount","ja_JP":"値引後単価"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1','db_type','numeric(16,2)'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11107,'sapb1.sales.order.line','sapb1_sales_order_lines','line_total','float',
    '{"en_US":"Line Total","ja_JP":"行合計"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1','db_type','numeric(16,2)'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11108,'sapb1.sales.order.line','sapb1_sales_order_lines','profit_base_price','float',
    '{"en_US":"Profit Base Price","ja_JP":"利益基準単価"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1','db_type','numeric(16,2)'),
    NULL,NULL,NULL, NULL,NULL, now(), now()),

  (11109,'sapb1.sales.order.line','sapb1_sales_order_lines','posting_date','date',
    '{"en_US":"Posting Date","ja_JP":"計上日"}'::jsonb,
    jsonb_build_object('tag','E2E_MANUAL','scope','sapb1'),
    NULL,NULL,NULL, NULL,NULL, now(), now())
ON CONFLICT (id)
DO UPDATE SET
  model          = EXCLUDED.model,
  model_table    = EXCLUDED.model_table,
  field_name     = EXCLUDED.field_name,
  ttype          = EXCLUDED.ttype,
  label_i18n     = EXCLUDED.label_i18n,
  notes          = EXCLUDED.notes,
  relation       = EXCLUDED.relation,
  relation_field = EXCLUDED.relation_field,
  relation_table = EXCLUDED.relation_table,
  updated_at     = now();


-- =========================================================
-- 3) ir_view_src(最低限:tree/form): id=12000〜
--    arch_db_hash は sha256 を計算
-- =========================================================
WITH v AS (
  SELECT * FROM (VALUES
    (12000,'sapb1_sales.view_sales_order_tree','SAP B1 Sales Order (Tree)',
      'sapb1.sales.order','sapb1_sales_orders','tree', NULL::int, 'primary', 16, TRUE,
      $$<tree string="SAP B1 Sales Orders">
          <field name="doc_no"/>
          <field name="posting_date"/>
          <field name="partner_code"/>
          <field name="partner_name"/>
          <field name="sales_person_name"/>
        </tree>$$
    ),
    (12001,'sapb1_sales.view_sales_order_form','SAP B1 Sales Order (Form)',
      'sapb1.sales.order','sapb1_sales_orders','form', NULL::int, 'primary', 16, TRUE,
      $$<form string="SAP B1 Sales Order">
          <sheet>
            <group>
              <field name="doc_no"/>
              <field name="posting_date"/>
            </group>
            <group>
              <field name="partner_code"/>
              <field name="partner_name"/>
              <field name="sales_person_name"/>
            </group>
          </sheet>
        </form>$$
    ),
    (12010,'sapb1_sales.view_sales_order_line_tree','SAP B1 Sales Order Line (Tree)',
      'sapb1.sales.order.line','sapb1_sales_order_lines','tree', NULL::int, 'primary', 16, TRUE,
      $$<tree string="SAP B1 Sales Order Lines">
          <field name="order_id/id"/>
          <field name="item_code"/>
          <field name="item_text"/>
          <field name="warehouse_code"/>
          <field name="quantity"/>
          <field name="price_after_discount"/>
          <field name="line_total"/>
          <field name="profit_base_price"/>
          <field name="posting_date"/>
        </tree>$$
    ),
    (12011,'sapb1_sales.view_sales_order_line_form','SAP B1 Sales Order Line (Form)',
      'sapb1.sales.order.line','sapb1_sales_order_lines','form', NULL::int, 'primary', 16, TRUE,
      $$<form string="SAP B1 Sales Order Line">
          <sheet>
            <group>
              <field name="order_id/id"/>
              <field name="posting_date"/>
            </group>
            <group>
              <field name="item_code"/>
              <field name="item_text"/>
              <field name="warehouse_code"/>
            </group>
            <group>
              <field name="quantity"/>
              <field name="price_after_discount"/>
              <field name="line_total"/>
              <field name="profit_base_price"/>
            </group>
          </sheet>
        </form>$$
    )
  ) AS t(id, xml_id, name, model, model_table, view_type, inherit_id, mode, priority, active, arch_db)
)
INSERT INTO public.ir_view_src
  (id, xml_id, name, model, model_table, view_type, inherit_id, mode, priority, active,
   arch_db, arch_db_hash, created_at, updated_at,
   action_xmlid, action_id, action_name, model_label, model_tech, view_types, primary_view_type,
   help_i18n_html, help_ja_html, help_ja_text, help_en_html, help_en_text, view_mode, context, domain, notes)
SELECT
  v.id, v.xml_id, v.name, v.model, v.model_table, v.view_type, v.inherit_id, v.mode, v.priority, v.active,
  v.arch_db,
  encode(digest(convert_to(v.arch_db,'utf8'),'sha256'),'hex') AS arch_db_hash,
  now(), now(),
  v.xml_id AS action_xmlid, NULL::int, v.name,
  NULL::text, v.model, ARRAY[v.view_type]::text[], v.view_type,
  NULL::jsonb, NULL::text, NULL::text, NULL::text, NULL::text,
  NULL::text, NULL::jsonb, NULL::jsonb,
  jsonb_build_object('tag','E2E_MANUAL','scope','sapb1')
FROM v
ON CONFLICT (id)
DO UPDATE SET
  xml_id       = EXCLUDED.xml_id,
  name         = EXCLUDED.name,
  model        = EXCLUDED.model,
  model_table  = EXCLUDED.model_table,
  view_type    = EXCLUDED.view_type,
  inherit_id   = EXCLUDED.inherit_id,
  mode         = EXCLUDED.mode,
  priority     = EXCLUDED.priority,
  active       = EXCLUDED.active,
  arch_db      = EXCLUDED.arch_db,
  arch_db_hash = EXCLUDED.arch_db_hash,
  notes        = EXCLUDED.notes,
  updated_at   = now();

COMMIT;

作った後削除 こちらがOKというまでそのままで。

BEGIN;

DELETE FROM public.ir_view_src
WHERE (id BETWEEN 12000 AND 12999)
   OR (action_xmlid LIKE 'sapb1_sales.%')
   OR (COALESCE(notes->>'tag','')='E2E_MANUAL' AND COALESCE(notes->>'scope','')='sapb1');

DELETE FROM public.ir_field_src
WHERE (id BETWEEN 11000 AND 11999)
   OR (COALESCE(notes->>'tag','')='E2E_MANUAL' AND COALESCE(notes->>'scope','')='sapb1')
   OR (model LIKE 'sapb1.sales.%');

DELETE FROM public.ir_model_src
WHERE (id BETWEEN 10000 AND 10999)
   OR (COALESCE(notes->>'tag','')='E2E_MANUAL' AND COALESCE(notes->>'scope','')='sapb1')
   OR (model LIKE 'sapb1.sales.%');

COMMIT;

まず portal_model / portal_fields / portal_view_common を “SAPB1一式” で入れて、最後に display_fields を流し込む順でいきましょう。
オフショアにも「このSQL=このボタン」みたいに渡しやすいように、**4ブロック(A〜D)**に分けます(全部 pgAdmin でそのまま実行可能)。


A) portal_model(2モデル:ヘッダ/明細)

BEGIN;

-- === SAPB1 models (E2E manual) ===
INSERT INTO public.portal_model (model, model_table, label_i18n, notes)
VALUES
  (
    'sapb1.sales.order',
    'sapb1_sales_orders',
    jsonb_build_object('en_US','SAP B1 Sales Order','ja_JP','SAPB1 受注'),
    'E2E_MANUAL'
  ),
  (
    'sapb1.sales.order.line',
    'sapb1_sales_order_lines',
    jsonb_build_object('en_US','SAP B1 Sales Order Line','ja_JP','SAPB1 受注明細'),
    'E2E_MANUAL'
  )
ON CONFLICT (model) DO UPDATE
SET
  model_table = EXCLUDED.model_table,
  label_i18n  = EXCLUDED.label_i18n,
  notes       = EXCLUDED.notes,
  updated_at  = now();

COMMIT;

B) portal_fields(2モデル分 “全部” 入れる)

portal_fields は(あなたの環境だと)日本語列 "モデル技術名" 等があるので、そこも埋めます
既に入ってる場合は UPDATE、無ければ INSERT します(制約に依存しない方式)。

BEGIN;

WITH model_ids AS (
  SELECT id, model, model_table
  FROM public.portal_model
  WHERE model IN ('sapb1.sales.order','sapb1.sales.order.line')
),
src AS (
  SELECT * FROM (VALUES
    -- =========================
    -- sapb1.sales.order (header)
    -- =========================
    ('sapb1.sales.order','sapb1_sales_orders','id','char',      jsonb_build_object('en_US','External ID','ja_JP','外部ID'),                 '文字列'),
    ('sapb1.sales.order','sapb1_sales_orders','doc_no','integer',jsonb_build_object('en_US','Document No','ja_JP','伝票番号'),              '整数'),
    ('sapb1.sales.order','sapb1_sales_orders','posting_date','date',jsonb_build_object('en_US','Posting Date','ja_JP','計上日'),             '日付'),
    ('sapb1.sales.order','sapb1_sales_orders','partner_code','char',jsonb_build_object('en_US','Partner Code','ja_JP','取引先コード'),       '文字列'),
    ('sapb1.sales.order','sapb1_sales_orders','partner_name','char',jsonb_build_object('en_US','Partner Name','ja_JP','取引先名'),           '文字列'),
    ('sapb1.sales.order','sapb1_sales_orders','sales_person_name','char',jsonb_build_object('en_US','Sales Person','ja_JP','営業担当'),      '文字列'),

    -- =============================
    -- sapb1.sales.order.line (lines)
    -- =============================
    ('sapb1.sales.order.line','sapb1_sales_order_lines','id','char',jsonb_build_object('en_US','External ID','ja_JP','外部ID'),              '文字列'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','order_id/id','many2one',jsonb_build_object('en_US','Order (External ID)','ja_JP','受注(外部ID)'),'文字列'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','item_code','char',jsonb_build_object('en_US','Item Code','ja_JP','品目コード'),      '文字列'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','item_text','char',jsonb_build_object('en_US','Item Text','ja_JP','品目名'),          '文字列'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','warehouse_code','char',jsonb_build_object('en_US','Warehouse','ja_JP','倉庫'),       '文字列'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','quantity','float',jsonb_build_object('en_US','Quantity','ja_JP','数量'),             '数値'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','price_after_discount','float',jsonb_build_object('en_US','Price After Discount','ja_JP','値引後単価'),'数値'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','line_total','float',jsonb_build_object('en_US','Line Total','ja_JP','明細合計'),     '数値'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','profit_base_price','float',jsonb_build_object('en_US','Profit Base Price','ja_JP','利益基準単価'),'数値'),
    ('sapb1.sales.order.line','sapb1_sales_order_lines','posting_date','date',jsonb_build_object('en_US','Posting Date','ja_JP','計上日'),    '日付')
  ) AS t(model, model_table, field_name, ttype, label_i18n, jp_datatype)
),

-- 1) UPDATE existing rows (by model + field_name)
upd AS (
  UPDATE public.portal_fields pf
  SET
    model_id = mi.id,
    model_table = s.model_table,
    ttype = s.ttype,
    label_i18n = s.label_i18n,
    notes = 'E2E_MANUAL',
    origin = 'E2E_MANUAL',
    "モデル技術名" = s.model,
    "モデル物理名" = s.model_table,
    "フィールド技術名" = s.field_name,
    "データ型" = s.jp_datatype
  FROM src s
  JOIN model_ids mi ON mi.model = s.model
  WHERE pf.model = s.model
    AND pf.field_name = s.field_name
  RETURNING pf.id
)

-- 2) INSERT missing rows
INSERT INTO public.portal_fields
  (model_id, model, model_table, field_name, ttype, label_i18n, notes, origin,
   "モデル技術名", "モデル物理名", "フィールド技術名", "データ型")
SELECT
  mi.id, s.model, s.model_table, s.field_name, s.ttype, s.label_i18n,
  'E2E_MANUAL', 'E2E_MANUAL',
  s.model, s.model_table, s.field_name, s.jp_datatype
FROM src s
JOIN model_ids mi ON mi.model = s.model
WHERE NOT EXISTS (
  SELECT 1
  FROM public.portal_fields pf
  WHERE pf.model = s.model
    AND pf.field_name = s.field_name
);

COMMIT;

C) portal_view_common(SAPB1 “ヘッダ+明細” 画面の共通行を作る)

action_xmlidフロントのボタン/画面と紐づく “業務キー” になります。

BEGIN;

INSERT INTO public.portal_view_common
  (action_xmlid, action_name, model_label, model_tech, model_table, view_types, primary_view_type,
   ai_purpose, help_ja_text, display_fields)
VALUES
  (
    'sapb1.sales.order_with_lines',
    'SAP B1 Sales Order (Header + Lines)',
    'SAPB1 受注',
    'sapb1.sales.order',
    'sapb1_sales_orders',
    '{form}',
    'form',
    'SAPB1受注ヘッダと明細を結合して参照・分析する(E2E用)',
    '受注ヘッダと明細を同時に確認します。E2EのJOIN/表示フィールド検証用。',
    '[]'::jsonb
  )
ON CONFLICT (action_xmlid) DO UPDATE
SET
  action_name = EXCLUDED.action_name,
  model_label = EXCLUDED.model_label,
  model_tech  = EXCLUDED.model_tech,
  model_table = EXCLUDED.model_table,
  view_types  = EXCLUDED.view_types,
  primary_view_type = EXCLUDED.primary_view_type,
  ai_purpose  = EXCLUDED.ai_purpose,
  help_ja_text = EXCLUDED.help_ja_text,
  updated_at  = now();

COMMIT;

D) 最後に display_fields を流し込む(モデル別+alias+flat)

ir_field_src.id=11101(あなたの many2one 行)を にして、

  • by_model(モデル→field配列)
  • aliases(orders/lines → model)
  • flat(orders.xxx / lines.xxx の修飾名配列)
    portal_view_common.display_fields に保存します。
    default_filters は触りません。
BEGIN;

WITH seed AS (
  SELECT
    relation AS header_model,  -- sapb1.sales.order
    model    AS line_model     -- sapb1.sales.order.line
  FROM public.ir_field_src
  WHERE id = 11101
),
by_model AS (
  SELECT jsonb_object_agg(model, fields_arr) AS v
  FROM (
    SELECT
      m.model,
      to_jsonb(array_agg(f.field_name ORDER BY f.field_name)) AS fields_arr
    FROM (
      SELECT header_model AS model FROM seed
      UNION ALL
      SELECT line_model   AS model FROM seed
    ) m
    JOIN public.ir_field_src f ON f.model = m.model
    GROUP BY m.model
  ) t
),
aliases AS (
  SELECT jsonb_build_object(
    'orders', (SELECT header_model FROM seed),
    'lines',  (SELECT line_model   FROM seed)
  ) AS v
),
flat AS (
  SELECT to_jsonb(array_agg(qualified ORDER BY qualified)) AS v
  FROM (
    SELECT 'orders.' || f.field_name AS qualified
    FROM public.ir_field_src f
    JOIN seed s ON f.model = s.header_model
    UNION ALL
    SELECT 'lines.' || f.field_name
    FROM public.ir_field_src f
    JOIN seed s ON f.model = s.line_model
  ) q
),
payload AS (
  SELECT jsonb_build_object(
    'by_model', (SELECT v FROM by_model),
    'aliases',  (SELECT v FROM aliases),
    'flat',     (SELECT v FROM flat),
    'tag',      'E2E_MANUAL',
    'scope',    'sapb1',
    'seed_ir_field_src_id', 11101
  ) AS v
)
UPDATE public.portal_view_common pvc
SET display_fields = payload.v,
    updated_at = now()
FROM payload
WHERE pvc.action_xmlid = 'sapb1.sales.order_with_lines'
RETURNING pvc.action_xmlid, jsonb_typeof(pvc.display_fields) AS display_fields_type, pvc.display_fields;

COMMIT;

確認用SELECT(最後にこれだけ見ればOK)

SELECT model, model_table, label_i18n, notes
FROM public.portal_model
WHERE model LIKE 'sapb1.%'
ORDER BY model;

SELECT model, model_table, field_name, ttype, label_i18n, notes, origin
FROM public.portal_fields
WHERE model LIKE 'sapb1.%'
ORDER BY model, field_name;

SELECT action_xmlid, model_tech, model_table, view_types, primary_view_type, ai_purpose, display_fields
FROM public.portal_view_common
WHERE action_xmlid = 'sapb1.sales.order_with_lines';

オフショアに渡す “ボタン設計” の言い方(わかりやすい)

  • ボタン①: Seed SAPB1 Models/Fields → SQL(A)+(B)
  • ボタン②: Seed SAPB1 View Common → SQL(C)
  • ボタン③: Build display_fields (by_model/aliases/flat) → SQL(D)

これで「どのSQLとどの画面(action_xmlid)が紐づくか」が明確になります。


必要なら、最後に 削除(クリーンアップ)SQL も一緒に作ります(notes='E2E_MANUAL'model LIKE 'sapb1.%' で一括削除できる形)。

display_filedsをフロント画面から入力するときにこの形で入力できますよね?react使います。

はい、その形(jsonbオブジェクト)をフロント(React)から入力できます
やり方は「JSONをそのままテキストで入力 → バリデーション → APIに display_fields として送る」です。

ただし一点だけ重要で、いまの API が portal_view_commondisplay_fields を PATCH/PUT で受け取ってDBに保存できる実装になっている必要があります(DB側は jsonb なので問題なし)。もしまだなら、オフショアには「UIは作れるが保存はAPIが必要」と伝える形になります。


フロント入力で現実的なUI案(React)

1) 方式:JSONテキスト入力(最短)

  • <textarea> に JSON を貼り付け
  • JSON.parse で検証
  • 必須キーだけチェックして送信

期待する入力(例):

{
  "by_model": {
    "sapb1.sales.order": ["doc_no","partner_code","posting_date"],
    "sapb1.sales.order.line": ["item_code","quantity","line_total","order_id/id"]
  },
  "aliases": {
    "orders": "sapb1.sales.order",
    "lines": "sapb1.sales.order.line"
  },
  "flat": [
    "orders.doc_no",
    "orders.posting_date",
    "lines.item_code",
    "lines.quantity"
  ],
  "tag": "E2E_MANUAL",
  "scope": "sapb1",
  "seed_ir_field_src_id": 11101
}

2) 方式:フォーム入力(後で)

  • models を2つ選ぶ(ヘッダ/明細)
  • fields をチェックボックスで選ぶ
  • 裏でこのJSONを生成してプレビュー

E2E最短なら(1)で十分です。


フロント側で最低限チェックすべきルール

(これだけ守れば “保存できて後工程で壊れない”)

  • display_fieldsobject(配列ではない)
  • by_model は object、値は string[]
  • aliases.ordersaliases.lines がある(任意でもいいが、あると便利)
  • flat は string[](orders. / lines. が付いている想定)
  • tag/scope は文字列(任意)

※ field名に "order_id/id" が入っても問題ないです(JSONとしてはただの文字列)。


送信するAPIの形

API仕様がどうなっているかで変わりますが、一般的にはこんなpayloadになります。

{
  "display_fields": { ...上のJSON... }
}
  • PATCHなら PATCH /portal/view_common/{id} とか
  • もし action_xmlid で更新したいなら、サーバ側に PATCH /portal/view_common/by_action_xmlid/{action_xmlid} みたいなルートがあると便利

重要:DBはOK。詰まるのは「APIが受け付けるか」

あなたの過去ログを見ると、スキーマは schemas.pyextra="allow" で柔らかいことが多いですが、view_common の更新APIが display_fields を許可していないと保存されません。

オフショア向けに仕様書に書くなら、最低限これを入れておくのが安全です:

  • portal_view_common.display_fields は jsonb
  • UIは JSON editor で入力
  • APIは display_fields をそのまま jsonb として保存(型は object/array 両対応、推奨は object形式)

2) オフショア向け:view_common ができたところから upsert までの手順(確定版)

前提:

  • Dev-Portal API は http://127.0.0.1:30080
  • Idempotency-Key 必須(uuid)
  • 今回の action_xmlid は sapb1.sales.order_with_lines
  • コレクションは portal_view_common_ja

display_fieldsに値を入れて、upsertするまでの流れ

Step 0. view_common 行が DB にあることを確認(必須)

まず portal_view_common に対象があるか確認。

select action_xmlid, display_fields, ai_purpose, help_ja_text, updated_at
from public.portal_view_common
where lower(action_xmlid) = lower('sapb1.sales.order_with_lines');

ここで display_fields が入っていて、ai_purpose / help_ja_text が日本語で入っていればOK。


Step 1. Extract(view_common → portal_translate へUPSERT)

/extract/view_common を呼ぶ(ここで translate キューが作られる)。

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/portal/view_common/extract \
  -d '{
    "action_xmlids": ["sapb1.sales.order_with_lines"],
    "targets": ["ai_purpose", "help"],
    "mode": "upsert_if_changed"
  }' | jq .

期待:

  • upsertedprocessed 的なカウントが増える
  • すでに同一 source_hash なら skipped_no_change 的に増える(それでOK)

Step 2. Translate(pending → translated)

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/translate/run \
  -d '{
    "limit": 200,
    "source_lang": "ja_JP",
    "target_lang": "en_US",
    "entities": ["view_common"]
  }' | jq .

期待:

  • translated が増える
  • failed が 0(多少出ても次工程は進められるが、まずは原因確認)

Step 3. Package(translated → portal_chroma_doc を queued/upserted に)

OpenAPI上 canonical は POST /package

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/package \
  -d '{
    "entities": ["view_common"],
    "lang": "ja",
    "collections": { "view_common": "portal_view_common_ja" },
    "limit": 500
  }' | jq .

期待:

  • queued が増える(または「差分なし」で skipped_no_change が増える)

Step 4. Chroma Upsert(portal_chroma_doc.state=queued → Chromaへ upsert)

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/chroma/upsert \
  -d '{
    "collections": ["portal_view_common_ja"],
    "limit": 1000,
    "dry_run": false
  }' | jq .

期待:

  • upserted が増える
  • failed=0

Step 5. 状態確認(DBで最終確認)

portal_chroma_doc 側で対象が upserted になっていること。

select id, entity, natural_key, lang, collection, state, updated_at
from public.portal_chroma_doc
where natural_key = 'view_common::sapb1.sales.order_with_lines::ai_purpose'
order by id desc
limit 5;

Step 6. Search(引けることを確認)

Dev-Portal の検索経路(run_chroma_query を通るAPI)で、portal_view_common_ja を指定して hit が出ることを確認。

検索キーワード例:

  • sapb1.sales.order_with_lines
  • SAPB1受注
  • orders.doc_no

同じことをフィールドで行う。現時点でフィールドの英語名も入っているはずので、これを抜かないとupsertされないため、まず英語名を抜く

1) sapb1 だけ、label_i18n の en_US を削除

BEGIN;

-- 事前確認(何件対象か)
SELECT COUNT(*) AS target_rows
FROM public.portal_fields
WHERE model LIKE 'sapb1.%'
  AND COALESCE(label_i18n, '{}'::jsonb) ? 'en_US';

-- 更新(en_US キーだけ除去)
UPDATE public.portal_fields
SET label_i18n = (COALESCE(label_i18n, '{}'::jsonb) - 'en_US')
WHERE model LIKE 'sapb1.%'
  AND COALESCE(label_i18n, '{}'::jsonb) ? 'en_US';

-- 確認
SELECT id, model, field_name, label_i18n
FROM public.portal_fields
WHERE model LIKE 'sapb1.%'
ORDER BY model, field_name
LIMIT 50;

COMMIT;

1) Extract(portal_fields → portal_translate に UPSERT)

sapb1 全モデルを対象(おすすめ)

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/portal/field/extract \
  -d '{
    "models": ["sapb1.sales.order", "sapb1.sales.order.line"],
    "fields": [],
    "mode": "upsert_if_changed"
  }' | jq .

2) Translate(pending → translated)

field だけ回す:

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/translate/run \
  -d '{
    "limit": 500,
    "source_lang": "ja_JP",
    "target_lang": "en_US",
    "entities": ["field"]
  }' | jq .

3) Package(translated → portal_chroma_doc(state=queued))

field だけ・ja doc を作って portal_field_ja に流す:

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/package \
  -d '{
    "entities": ["field"],
    "lang": "ja",
    "collections": { "field": "portal_field_ja" },
    "limit": 5000
  }' | jq .

4) Upsert(portal_chroma_doc queued → Chroma)

IDK=$(uuidgen)
curl -sS -X POST \
  -H "Idempotency-Key: $IDK" \
  -H "Content-Type: application/json" \
  http://127.0.0.1:30080/chroma/upsert \
  -d '{
    "collections": ["portal_field_ja"],
    "limit": 5000,
    "dry_run": false
  }' | jq .

5) 検索確認(/chroma/search は GET)

例:伝票番号 が field で引けるか

curl -sS -G \
  -H "Idempotency-Key: $(uuidgen)" \
  http://127.0.0.1:30080/chroma/search \
  --data-urlencode "q=伝票番号" \
  --data-urlencode "collections=portal_field_ja" \
  --data-urlencode "n=5" | jq .

物理名で確認するなら:

curl -sS -G \
  -H "Idempotency-Key: $(uuidgen)" \
  http://127.0.0.1:30080/chroma/search \
  --data-urlencode "q=sapb1_sales_orders" \
  --data-urlencode "collections=portal_field_ja" \
  --data-urlencode "n=5" | jq .

そのあとDBで存在確認(リレーションチェックに引っかからないため)

"sapb1.sales.order.line"	"order_id/id"	"sapb1.sales.order"	"many2one"	"sapb1.sales.order"

SELECT
  f.model,
  f.field_name,
  f.relation,
  f.ttype,
  m_rel.model AS relation_model_exists
FROM ir_field_src f
LEFT JOIN ir_model_src m_rel ON m_rel.model = f.relation
WHERE f.model = 'sapb1.sales.order.line'
  AND f.ttype = 'many2one'
  AND f.relation IS NOT NULL;

最後にこの質問が通って同じような結果になればOK

 【QUESTION】
curl -sS -X POST "127.0.0.1:30080/analytics/query"   -H "Content-Type: application/json"   -H "Idempotency-Key: ${IDEMP}"   -d "{
    \"question\":\"受注データで、月別の受注件数と合計金額を出して。直近12ヶ月で。\",
    \"top_k\": 10,
    \"locale\": \"ja_JP\",
    \"join_planner_enabled\": true
  }" | python -c 'import json,sys; d=json.load(sys.stdin);
print("status:", d.get("status"));
print("reason_code:", d.get("reason_code"));
print("allowed_models(sample):", (d.get("allowed_models") or [])[:10]);
print("sql_head:", (d.get("sql") or "")[:500]);
diag=d.get("diagnostics") or {};
print("diagnostics.retrieval:", diag.get("retrieval"));
print("retrieval_citations_count:", len(d.get("retrieval_citations") or []));
'

【RESULT】
status: ok
reason_code: ok
allowed_models(sample): ['sapb1.sales.order', 'sapb1.sales.order.line']
sql_head: SELECT DATE_TRUNC('month', so.posting_date) AS month, COUNT(so.id) AS order_count, SUM(sol.line_total) AS total_amount
FROM sapb1_sales_orders so
LEFT JOIN sapb1_sales_order_lines sol ON so.id = sol."order_id/id"
WHERE so.posting_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY month
ORDER BY month
LIMIT 200
diagnostics.retrieval: {'enabled': True, 'top_k': 10, 'top_k_requested': 10, 'score_threshold': 0.1, 'collections': ['portal_field_ja', 'portal_view_common_ja'], 'hits_total': 20, 'hits_kept': 20, 'hits_dropped_missing_meta': 0, 'hits_dropped_below_threshold': 0, 'citations_count': 10, 'sql_fallback_used': False, 'pod': 'portal-api-7ddd4dfcc5-67qv7', 'allowlist_source': 'ir_field_src', 'allowlist_models_total': 267, 'allowlist_fields_total': 5016, 'allowlist_scope_tables_total': 267, 'allowlist_returned_models': 2, 'allowlist_returned_fields': 16, 'allowlist_models_truncated': False, 'allowlist_fields_truncated': False, 'scope_tables_provided': False, 'scope_tables_inferred_from_ir_field_src': False, 'scope_tables_inferred': True, 'effective_scope_tables_count': 2, 'effective_scope_tables_source': 'chroma_meta_model_table', 'llm_scope_tables_max': 60, 'llm_scope_tables_source': 'chroma_meta_model_table', 'llm_scope_tables_count': 2, 'llm_scope_tables_sample': ['sapb1_sales_order_lines', 'sapb1_sales_orders'], 'llm_scope_tables_capped': False, 'hits_total_field': 10, 'hits_total_view_common': 10, 'allowlist_fields_count': 13, 'allowlist_models_count': 5, 'allowlist_dropped_missing_model_or_field': 0, 'allowlist_dropped_view_model': 0, 'allowlist_scoped': {'models_total': 2, 'fields_total': 16, 'tables_count': 2, 'source': 'ir_field_src_scoped'}, 'sql_rewrite': {'enabled': True, 'changed': False, 'replacements': 0, 'bases_mapped': 1, 'mapped_bases_sample': ['order_id']}, 'reason_code': 'ok'}
retrieval_citations_count: 10

読み取りユーザーに変える場合

odoo_ro のパスワードを使って、Deployment に env を上書きします。

# 1) odoo_ro に切り替え(ユーザー)
kubectl -n portal-dev set env deploy/portal-api ODOO_DB_USER=odoo_ro

# 2) パスワードも odoo_ro 用に(ここはあなたの odoo_ro のPWにする)
kubectl -n portal-dev set env deploy/portal-api ODOO_DB_PASSWORD='YOUR_ODOO_RO_PASSWORD'

# 3) 反映(通常 set env で rollout 走るけど、明示したい場合)
kubectl -n portal-dev rollout status deploy/portal-api

反映確認:

kubectl -n portal-dev exec -it deploy/portal-api -- printenv | egrep 'ODOO_DB_USER|ODOO_DB_PASSWORD|ODO

2) Postgres側で odoo_ro に必要権限を付与

odoo_ro を使うなら、少なくとも以下が必要です。

-- まず接続できる前提:GRANT CONNECT は通常不要だが念のため
GRANT CONNECT ON DATABASE odoo TO odoo_ro;

-- スキーマ利用(public以外ならそのスキーマも)
GRANT USAGE ON SCHEMA public TO odoo_ro;

-- 既存テーブルへの SELECT(Odoo標準 + sapb1_* も含む想定)
GRANT SELECT ON ALL TABLES IN SCHEMA public TO odoo_ro;

-- 既存シーケンスの参照(Odooはsequenceを読むことがある)
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO odoo_ro;

3) 今後増えるテーブルにも自動で付くように(おすすめ)

(※これは テーブルを作る側のロールで実行しないと効きません。Odooがownerで作るなら、owner側で。)

ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO odoo_ro;

ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO odoo_ro;

Comments

コメントを残す

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