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.
Access [NEEDS LIVE REVIEW]
Section titled “Access [NEEDS LIVE REVIEW]”- URL:
https://odoo.test.boonetek.com/web(perscripts/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_PASSWORDinscripts/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:
pnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --only-fix-pricelists --yesCustomer part numbers → products
Section titled “Customer part numbers → products”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.
Creating a sales order
Section titled “Creating a sales order”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.
Looking up AR history
Section titled “Looking up AR history”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.
Known gaps and what to do about them
Section titled “Known gaps and what to do about them”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 nosync_vendorsorsync_purchase_ordersmethods, so bronzepurchase_orders/vendorsIceberg tables stay empty for any connection running V1. - V2 (
Prophet21SyncOrchestratorV2) — hassync_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 theUSE_P21_CUSTOM_SOURCEenv var it depended on, not a deliberate rollback for a demonstrated bug. Either way, the visible aftermath was V1 needingsync_orders/sync_order_lines/sync_invoices/sync_invoice_paymentsbackfilled (commit7f42b6fda) 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 ignoredconnectionIdentirely), 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:
- Fresh start — new chart of accounts + opening balances in Odoo, no historical AP/GL import. Cheapest, loses history.
- 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.
- 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.
Troubleshooting
Section titled “Troubleshooting”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:
# Fix a customer's pricing formula without a full re-migrationpnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --only-fix-pricelists --yes
# Fix country codes on addressespnpm --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 datapnpm --filter @repo/odoo-setup migrate -- --connection-name GenFit --dry-runFull 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.
Related
Section titled “Related”scripts/odoo-setup/README.md— running the migration scriptTODOS.md§ “GenFit / Odoo Migration” — open work itemsapps/dagster/docs/migration/p21-custom-source-migration.md— V1/V2 orchestrator mechanics and historyinfrastructure/flagsmith/configs/segment-overrides.json— thep21_custom_source_enabled/genfit_prodoverride
Who to ask
Section titled “Who to ask”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.