Skip to content

GenFit Odoo Operating Guide

Status: DRAFT — written from code + scripts/odoo-setup/README.md, not from a live walkthrough of the Odoo instance. Sections marked [NEEDS LIVE REVIEW] need someone who has actually clicked through odoo.test.boonetek.com to confirm the navigation path before staff rely on this doc.

Who this is for: anyone at GenFit or Ordermatic who used to work in Prophet21 (P21) for GenFit’s day-to-day order/customer/AR/procurement work and now needs to do the same things in Odoo.

Who runs the migration script: an engineer with Odoo admin credentials and R2/Iceberg (Infisical) access — see scripts/odoo-setup/README.md.

GenFit = General Fittings LLC, org org_33vHtt6OUnCFD3QGmZeAJXSYsH7, ERP connection a0b89aa8-d4ed-42e7-8e30-7db3f6e5d05b (“Genfit Production”).

Owner: whoever ran the 2026-08-05/06 orders/AR work (Devin AI commits 314e7c004, fb94a14b2, cb0b5b0e0) and the 2026-08-22 purchase-order work — reassign once someone owns Odoo operations day to day.


What’s migrated vs not (as of 2026-08-22)

Section titled “What’s migrated vs not (as of 2026-08-22)”

GenFit’s P21 data has been migrated into Odoo in pieces, not all at once. Know which piece you’re looking at before you trust a number:

Domain Odoo home Status
Products product.template — 10 price levels as x_price1…x_price10 ✅ Migrated
Customers res.partner — with a custom pricing formula (see below) ✅ Migrated
Cross-references, shipping addresses product.customer.info, res.partner delivery addresses ✅ Migrated
Inventory quantities stock.quant ✅ Migrated
Sales orders sale.order / sale.order.line (phase 6, shipped 2026-08-05) ✅ Migrated
AR invoice history ar.invoice.history — custom read-only model, not live account.move (phase 7, shipped 2026-08-05) ⚠️ Migrated as history only — see warning below
Purchase orders — headers purchase.order: vendor, PO number, dates (phase 8, shipped 2026-08-22) ⚠️ Code correct, real root-cause crash fixed going forward (PR #3188) — verify Production has no pre-existing drift before promoting, see Known gaps §1
Purchase orders — line items purchase.order.line: item, qty, price, UOM ✅ Migrated, live-validated 2026-08-22 (350 real rows synced) — see Known gaps §2 for the P21-side bug that had to be worked around
Vendors res.partner, real supplier_name (confirmed 2026-08-22), no address fields at all in p21_view_supplier ✅ Migrated with real names, no address; live-validated 2026-08-22 (101 real rows synced) — see Known gaps §3
CRM (leads/opportunities) — ❌ Removed from scope (2026-08-22) — P21 has no CRM-shaped data at all; the crm module was dropped from the install list rather than building an empty migration
AP (accounts payable) — ❌ Not buildable via this integration — no P21 API surface exists
GL / journal entries — ❌ Not buildable via this integration — no P21 API surface exists

“Migrated” above means the migration code exists and has been reviewed against known field mappings. For products/customers/cross-references/inventory/sales orders/AR history it does not mean the script has been run against live GenFit data — logs/ is gitignored and not committed, so there’s no in-repo run evidence either way for those phases; confirm actual row counts by running pnpm migrate -- --connection-name GenFit --dry-run first, then a real run, and checking the printed summary against P21. Purchase order line items and vendors are now live-validated at full scale (2026-08-22, against the Genfit Playground 2 sandbox, not GenFit’s real connection). Purchase order headers hit a real crash past ~980 rows during that validation; the root cause is now found and fixed going forward (PR #3188) — see Known gaps §1 for what still needs verifying against Production before promoting.

The AR warning matters. ar.invoice.history is a custom read-only record of past invoices, not Odoo’s real Accounting app. It will not show up as an open or paid invoice anywhere in Odoo’s standard Invoicing/Accounting views, will not affect Odoo’s AR aging reports, and posting a payment against it does nothing — there’s no account.move behind it. Use it to look up “did this invoice exist and for how much,” not to run AR collections out of Odoo.


  • URL: https://odoo.test.boonetek.com/web (per scripts/odoo-setup/README.md’s manual-fallback section). The hostname says “test.” Do not treat this as GenFit’s production instance until someone confirms it is — if it’s actually a separate prod URL, entering or editing real orders/customers here would write into the wrong database. Confirm before relying on it for anything operational.
  • Login: ask whoever holds the admin credentials (ODOO_USERNAME/ODOO_PASSWORD in scripts/odoo-setup/.env, not committed) to create you a named user — don’t share the admin login.
  • Odoo user permissions matter for what you can see/edit. If something you expect to edit looks read-only, your user’s Settings → Users & Companies → Users → Access Rights may need adjusting (see scripts/odoo-setup/README.md’s “Manual Fallback Steps” for the exact fields the migration script needs — day-to-day users likely need less than that).

Customer pricing — the part that’s different from P21

Section titled “Customer pricing — the part that’s different from P21”

Every customer (res.partner) has two custom fields:

  • Source Price Level (x_source_price_code, 1-10) — which of the product’s ten migrated price levels (x_price1…x_price10) this customer’s pricing is based on.
  • Price Multiplier (x_price_multiplier) — applied on top, e.g. 0.85 = 85% of that price level.

A sale order line’s price is calculated automatically from these two fields — sale_order_line.py overrides Odoo’s normal pricelist logic, and the standard Odoo pricelist field is intentionally hidden on these records so nobody tries to set pricing the normal Odoo way and gets confused when it’s ignored.

If a customer’s price looks wrong: check their Source Price Level and Multiplier first, not a pricelist. If those are wrong, scripts/odoo-setup has a fix command that re-derives them from source data without a full re-migration:

Terminal window
pnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --only-fix-pricelists --yes

product.customer.info holds the cross-reference between a customer’s own part number and the Odoo product. It surfaces as a “Customer Codes” tab on the product form (packages/odoo-module/erp_unlocked/views/product_customer_info_views.xml) and a search_by_customer_code lookup helper on the model — but neither is wired into the Sales order line UI (sale_order_views.xml has no reference to either), so staff cannot search by customer part number while building an order today; it’s lookup-only via the product record.

sale.order/sale.order.line — this is standard Odoo Sales app behavior once pricing (above) is understood; nothing else is customized on order creation. [NEEDS LIVE REVIEW] to write actual click-by-click steps.

ar.invoice.history — search/filter by customer, invoice number, or date; each record shows total_amount, amount_paid, and a computed balance_due. The “AR Invoice History” menu item lives under the Sales app’s top-level nav (menu_ar_invoice_history’s parent is sale.sale_menu_root — not a stock Accounting screen, and not its own app). [NEEDS LIVE REVIEW] to confirm that placement reads sensibly to staff in the actual UI, since it’s a somewhat unexpected home for an AR record.


1. Purchase orders were blocked on the wrong Prophet21 orchestrator (fixed 2026-08-22)

Section titled “1. Purchase orders were blocked on the wrong Prophet21 orchestrator (fixed 2026-08-22)”

P21 connections run one of two sync orchestrators, gated by the p21_custom_source_enabled Flagsmith flag (per-connection, identity connection_<connectionId>):

  • V1 (Prophet21SyncOrchestrator) — the default when the flag is off. Has no sync_vendors or sync_purchase_orders methods, so bronze purchase_orders/vendors Iceberg tables stay empty for any connection running V1.
  • V2 (Prophet21SyncOrchestratorV2) — has sync_vendors, sync_purchase_orders, sync_purchase_order_lines, plus everything V1 has.

GenFit was on V1 (confirmed via flags.json’s environment default and the absence of any segment/identity override for GenFit prior to this change), so bronze purchase_orders/vendors were always empty and there was nothing to migrate. Fixed by adding a p21_custom_source_enabled override for the genfit_prod segment in infrastructure/flagsmith/configs/segment-overrides.json — applied to live Flagsmith automatically on the next deploy (set-segment-overrides.py runs from CI on every merge, per that script’s own docstring).

This switches ALL of GenFit’s P21 sync to V2, not just vendors/purchase orders — products, customers, orders, invoices, cross-references, shipping addresses, and ship-to-links all move from V1 to V2 too. Two things worth knowing before treating this as routine:

  • V2 was previously enabled prod-wide once (PR #666) and reverted within ~11 days (→ #911/#912, 2026-04) — see the warning banner at the top of apps/dagster/docs/migration/p21-custom-source-migration.md. The revert’s root cause isn’t documented in a single place, and there is no incident report of V2 itself misbehaving — the likelier explanation is that PR #739 (the same-week Infisical env-var migration) silently dropped the USE_P21_CUSTOM_SOURCE env var it depended on, not a deliberate rollback for a demonstrated bug. Either way, the visible aftermath was V1 needing sync_orders/sync_order_lines/sync_invoices/sync_invoice_payments backfilled (commit 7f42b6fda) because those had only existed on V2 while it was live.
  • V2 already runs unconditionally in prod today for product_pricing (bronze_pricing.py:126, independent of this flag), so it isn’t an unexercised code path — but the per-connection override mechanism used here was only fixed 2026-08-20 (PR #3129, before that the evaluator ignored connectionId entirely), and GenFit is the first connection to use it since that fix landed. That’s the real unknown, more than V2’s April history.

Status (2026-08-22): confirmed applied to the Staging Flagsmith environment (verified via the seed-flags job log on the staging GCP Deploy run — p21_custom_source_enabled × genfit_prod -> enabled=True). Not yet applied to Production — the commit hasn’t been promoted to main yet, and Production Flagsmith is a separate environment/deploy from Staging’s. GenFit’s real production P21 sync is unaffected until that promotion happens and its seed-flags job runs against Production. When promoting, verify the same way: pull that job’s log and confirm the line appears under === Environment: Production ===, not just a green checkmark — a staging→ main promotion can report SUCCESS in GitHub without the deploy step actually running.

After Production deploy: confirm bronze row counts for products/customers/ orders/invoices are unchanged from their V1-synced values, and spot-check a few records, before relying on the new purchase-order/vendor data. If anything regresses, the rollback is disabling the genfit_prod override (flip enabled to false in segment-overrides.json, or delete the entry) — GenFit falls back to V1 immediately, same as any other P21 connection.

Live-validated against the sandbox (2026-08-22): ran sync_vendors, sync_purchase_orders, and sync_purchase_order_lines directly against the Genfit Playground 2 sandbox (d4758042-0b8c-4670-a9de-22c8939dd622, not GenFit’s real connection — bypasses the flag entirely by instantiating Prophet21SyncOrchestratorV2 directly, same pattern bronze_pricing.py already uses in prod). All three wrote real data to bronze Iceberg: 101 vendor rows, 350 purchase order line rows (see §2), and up to 980 purchase order header rows.

sync_purchase_orders had a hard, reproducible crash at exactly 981 rows in a single write. Bisected precisely: max_items=980 succeeds every time, 981 crashes every time, same connection, same data. Three hypotheses were tested and ruled out that day — an empty target table (pyiceberg already handles this), a worker-thread stack overflow (pyarrow.set_cpu_count(1) made no difference), and DLT mis-inferring a type for an unhinted passthrough field (the same bug class documented earlier in this section — fixed regardless, transform_p21_purchase_order now stringifies every passthrough field, kept as a correct defense — but re-tested and the boundary was unchanged, ruling it out too). The theory that followed from there, a genuine unfixable-in-repo pyarrow/pyiceberg hash-join bug needing internal chunked writing, was wrong.

Real root cause found and fixed (claude/relaxed-murdock-ebaeca, PR #3188). A deeper follow-up live-instrumented the actual crash site: it’s Transaction.upsert()’s target-table match-scan, before the join/groupby functions originally suspected are ever entered, and lldb showed genuine heap corruption (EXC_BAD_ACCESS inside libsystem_malloc, not a clean exception — this is why the exit code varied 138/132 run to run). The sandbox’s purchase_orders table had live Parquet files whose physical type for po_no (and ~43 other text columns) disagreed — some string, some large_string — despite one shared logical Iceberg schema that always canonicalizes to large_string; DLT’s own arrow-schema coercion never widened plain string/binary. Fixed globally via ensure_iceberg_large_string_patch() in erp_pipeline/utils/dlt_iceberg.py (active at import, no per-resource opt-in). See apps/dagster/docs/dlt-column-hints.md’s “2026-08-22 incident” section for the full writeup.

This only prevents NEW drift — it does not repair the sandbox table’s existing files, confirmed by re-running the exact crashing case with the fix active (still crashes, as expected). A tenant-scoped compaction is the retroactive-repair half, tracked in TODOS.md.

Before promoting p21_custom_source_enabled to Production, verify no pre-existing drift. Bronze tables are shared and partitioned by a connection_id column, not physically split per tenant at the file level — so even though GenFit’s own purchase_orders/vendors partition in Production is empty today (V1 never wrote to it), a data file GenFit’s first sync touches could still physically mix in another tenant’s pre-existing drift. Run apps/dagster/scripts/probe_iceberg_table_schema_drift.py (read-only) against Production’s purchase_orders and vendors tables first — a clean result closes this blocker, drift found means compact before promoting. See the TODOS.md entry for the current status.

2. Purchase order line items — schema confirmed AND live sync fixed 2026-08-22

Section titled “2. Purchase order line items — schema confirmed AND live sync fixed 2026-08-22”

p21_view_po_line’s real field names were unknown as of the previous version of this doc — the OData response for this view contains unescaped control characters that break naive JSON parsing. Prophet21Client already worked around the parse failure (sanitizes control chars, re-parses, tolerates a bounded number of malformed pages — see apps/dagster/CHANGELOG.md, PR #2873), but that fixed only parsing, not schema knowledge. Resolved by a live, read-only probe (apps/dagster/scripts/probe_p21_vendor_and_po_line_fields.py, $top=1, no Iceberg write) against the Genfit Playground 2 sandbox (connection d4758042-0b8c-4670-a9de-22c8939dd622, distinct from GenFit’s real production connection a0b89aa8-... — never point this script at production). The existing sanitizer alone was enough; the probe returned clean JSON on the first try. Confirmed fields (86 total, mapped subset): item_id, item_description, qty_ordered, qty_received, unit_price, unit_of_measure, date_due, cancel_flag, complete — item_id/ qty_ordered/unit_price/unit_of_measure use the exact same names order_lines already uses. erp_pipeline/transformers/purchase_order_lines.py, packages/erp-data-py/erp_data/schema.py (bumped to v2), scripts/odoo-setup/scripts/extract-purchase-orders.sql, and scripts/odoo-setup/src/mappers/purchase-orders.ts all updated same-day. Cancelled lines (cancel_flag=true) are dropped, not migrated.

The live sync initially couldn’t complete a full pull — root-caused and fixed same day. Running sync_purchase_order_lines against the sandbox reproducibly failed at OData pagination offset $skip=200 with malformed JSON that survived the existing control-character sanitizer’s retry. With $format=json explicit (what the sync always sends), P21 returns a clean OData error instead of garbled text: "The 'gpor_run_uid' property on 'p21_view_po_line' could not be set to a 'System.Int64' value. You must set this property to a non-null value of type 'System.Int32'." — a genuine Epicor server-side Entity Framework type-mapping bug (the column’s declared type doesn’t match the real database value’s range), confirmed deterministic across most rows via a live probe ($skip=1,2,3 individually all hit it — not one bad row). This also fully explains the original $skip=200 symptom: in a multi-row page, P21 has already started streaming earlier rows into the response when it hits this exception on a later one, so it can’t cleanly return a 500 and dumps the .NET exception text mid-JSON instead — the earlier “malformed JSON” wasn’t a different corruption pattern, it was the same bug manifesting mid-stream.

Fix: p21_purchase_order_lines (erp_pipeline/sources/prophet21/dlt_source.py) now sends OData $select with only the confirmed/mapped fields, excluding gpor_run_uid entirely. Live-validated end to end: 350 real rows synced and confirmed in bronze Iceberg, crossing every page boundary that used to fail. Deliberately selects only the ~12 already-vetted fields rather than “everything except gpor_run_uid” — several other _uid-suffixed columns on this view (po_line_uid, inv_mast_uid, purchase_pricing_page_uid) look like the same class of internal ID that might overflow Int32 and haven’t been individually verified; extend the select list deliberately, one verified field at a time, if more columns are needed later.

3. Vendor names — real names confirmed 2026-08-22, address genuinely unavailable

Section titled “3. Vendor names — real names confirmed 2026-08-22, address genuinely unavailable”

Same probe also confirmed p21_view_supplier’s supplier_name field (e.g. "Pressure Components Inc.") — vendor res.partner records now use the real name. erp_pipeline/transformers/vendors.py and schema.py (bumped to v2) updated. Address remains unavailable, and this is now confirmed rather than just unconfirmed — the view’s 80 fields contain no street/city/state/zip at all. If a real vendor address is ever needed, it would require a different P21 view (e.g. a p21_view_address join keyed by supplier_id, the same pattern customer/ship-to addresses already use) — not attempted here since nothing in this task required it. A vendor whose supplier_id is referenced by a PO but absent from the vendors bronze extract still falls back to a synthetic Vendor <supplier_id> name (rare — only when the vendors sync and PO sync disagree on which suppliers exist).

4. CRM — confirmed absent, removed from scope

Section titled “4. CRM — confirmed absent, removed from scope”

Checked every P21 entity erp_pipeline/sources/prophet21/entities.py declares (11 total) — none are CRM-shaped, and no lead/opportunity/pipeline view exists anywhere in the P21 OData catalog referenced by this codebase. GenFit does not appear to use P21 for CRM at all. The crm module has been removed from scripts/odoo-setup/src/phases/01-modules.ts’s install list (2026-08-22) rather than building a migration with nothing to migrate. If GenFit later confirms they want CRM sourced from somewhere else, add the module back with real migration code at that point — don’t re-add it speculatively.

5. AP and GL/journal entries — not an engineering gap, a decision

Section titled “5. AP and GL/journal entries — not an engineering gap, a decision”

GenFit’s P21 (Epicor-hosted, not on-prem) has no API surface for AP or GL at all — confirmed absent: p21_view_ap_invoice_hdr, p21_view_ap_invoice_line, p21_view_ap_check_hdr, and every GL table name guessed (glm_hdr, gl_control, account, gl_period_bal). External P21 API docs independently confirm OData is read-only with no GL/AP endpoints. This can’t be fixed by writing more extraction code — real full-GL P21 migrations are normally done via direct SQL Server access, which an Epicor-hosted tenant doesn’t offer.

Three real options, needs a decision from whoever owns the GenFit relationship:

  1. Fresh start — new chart of accounts + opening balances in Odoo, no historical AP/GL import. Cheapest, loses history.
  2. Separate books-of-record — keep AP/GL in whatever system GenFit uses today, feed it from Odoo’s other data where needed. No data loss, but two systems to reconcile.
  3. Odoo Enterprise Accounting — self-hosted Odoo Community only ships the stripped “Invoicing” subset; full Accounting (with real AP workflows) needs Enterprise licensing. Adds a recurring cost, closes the gap cleanly.

[NEEDS LIVE REVIEW] — exact Odoo UI navigation

Section titled “[NEEDS LIVE REVIEW] — exact Odoo UI navigation”

The sections above describe what exists in Odoo, not always the exact click path to find it — that needs someone who has actually clicked through odoo.test.boonetek.com. Flagging explicitly rather than guessing:

  • [NEEDS LIVE REVIEW] Whether https://odoo.test.boonetek.com (Access, above) is actually GenFit’s day-to-day instance or just the one the migration script targets.
  • [NEEDS LIVE REVIEW] Actual click-by-click steps for creating a sales order (Odoo Sales app is standard here, but nobody has walked it with real access yet).
  • [NEEDS LIVE REVIEW] Whether the “AR Invoice History” menu placement under Sales (confirmed from menu_ar_invoice_history’s XML parent) reads sensibly to staff in the actual UI — it’s an unexpected home for an AR record.
  • [NEEDS LIVE REVIEW] Where the migrated purchase orders show up relative to POs GenFit staff create manually going forward — whether they need any visual distinction (a tag, a note) so nobody confuses a migrated historical record with a live, actionable PO.
  • [NEEDS LIVE REVIEW] Whether the small number of vendors that still fall back to a synthetic Vendor <supplier_id> name (PO references a supplier_id absent from the vendors extract — see “Known gaps” §3) need manual cleanup, and if so, Odoo’s contact-merge UI vs. a scripted update.

If you’re reading this with instance access, please fill these in and remove the corresponding bullet once done.


Straight from scripts/odoo-setup/README.md — reproduced here since staff running into a broken price or a missing record won’t necessarily go looking in a script’s README:

Terminal window
# Fix a customer's pricing formula without a full re-migration
pnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --only-fix-pricelists --yes
# Fix country codes on addresses
pnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --only-fix-countries --yes
# Re-migrate cross-references only (after a module upgrade)
pnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --only-cross-refs --yes
# See what a full re-migration WOULD do, without touching data
pnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --dry-run

Full migration logs land in scripts/odoo-setup/logs/migration-{timestamp}/ (gitignored, local to whoever ran it) — migration.log, warnings.log, errors.log, report.md. Ask around for the most recent report if you need to confirm what was actually migrated and when — this doc can’t tell you that.

  • scripts/odoo-setup/README.md — running the migration script
  • TODOS.md § “GenFit / Odoo Migration” — open work items
  • apps/dagster/docs/migration/p21-custom-source-migration.md — V1/V2 orchestrator mechanics and history
  • infrastructure/flagsmith/configs/segment-overrides.json — the p21_custom_source_enabled / genfit_prod override

Whoever authored the 2026-08-05/06 orders/AR commits and the 2026-08-22 purchase-order work is the closest thing to a domain owner right now. This doc should get a named owner and the [NEEDS LIVE REVIEW] items filled in from an actual walkthrough before it’s treated as ground truth for training anyone new.