元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.ordersapb1.sales.order.line(order_idMany2one)
- Odooでモジュール更新(upgrade)→ Postgresにテーブル作成を確認
- CSV Importでデータ投入(ヘッダ → 明細の順)
すぐ使えるCSV(親子Import用に分割済み)
SAPB1の明細CSVは “ヘッダ/明細” に分けないと親子リンクが作りにくいので、分割版を用意しました(Odoo Importでそのまま使えます):
- Download: odoo_import_sapb1_sales_orders.csv(ヘッダ 284件)
- Download: odoo_import_sapb1_sales_order_lines.csv(明細 901件)
Import順:
- 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(ビューは次段階):
- portal_model / portal_fields に新モデル分の行を作る(import/bootstrap系があればそれを使う)
- extract → translate → package → chroma/upsert を回す
- 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仕様を確かめる」最小チェックリスト
古い実装ほど、ここでズレが出ます(なので最初に軽く確認が正解)。
- OpenAPIで “今の正” を確認
/packageが正か?(以前の通りなら/package)/chroma/upsertが正か?/chroma/docsで queued/upserted が見えるか?
- Chromaコレクション名が合ってるか
portal_field_japortal_view_common_ja
- 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_common の display_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_fieldsは object(配列ではない)by_modelは object、値は string[]aliases.ordersとaliases.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.py が extra="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 .
期待:
upsertedやprocessed的なカウントが増える- すでに同一 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_linesSAPB1受注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;
コメントを残す