Migrating a business with a live ERP and online store — orders coming in every day and a team that depends on the system to operate — is the scenario that most resembles changing a plane's engine mid-flight. The case of Rehabmedic, a company in the medical rehabilitation equipment sector, was exactly that: a migration from Microsoft Dynamics NAV (Navision) and Magento to Odoo under real operating conditions, with no margin for service downtime and the additional goal of launching a new rental business unit positioned on Google from day one. What follows is a technical description of how it was planned and executed.
Context: the legacy stack and why it didn't scale
Navision as ERP: powerful but closed
Microsoft Dynamics NAV — historically known as Navision — was the central management system at Rehabmedic: accounting, inventory, suppliers, billing and all operations for the medical equipment sales business. It is a solid ERP for its target segment, but it presents a well-known problem profile when a company needs modern integration:
- Limited API: integrating with external systems in older versions of Dynamics NAV is costly to maintain, fragile under updates, and requires proprietary connectors or additional middleware.
- Per-user licence cost: scaling system access has a direct and growing licence cost, which discourages granting access to operational roles that could benefit from ERP visibility.
- Closed customisation: adapting Navision to Rehabmedic's specific business model — with its particularities in the rental process, loan equipment management and periodic billing — required development in AL (Application Language), Microsoft's proprietary language, with its associated cost and dependency.
Magento as storefront: powerful but disconnected
Rehabmedic's online store ran on Magento: an e-commerce platform with notable functional capability, but one that in practice operated as a system separate from the ERP. Inventory, pricing and order synchronisation between Magento and Navision relied on custom integrations with inherent fragility. Every update to either system was a potential breaking point.
The clearest symptom of the problem: when the operations team updated stock in Navision, the store did not reflect it in real time. Orders in Magento generated manual data-entry work into the ERP. Special prices managed in Navision for specific customers were not visible in the store. In practice, Magento and Navision were two systems that coexisted but did not collaborate.
The new business unit: the catalyst for change
The catalyst for the migration was the strategic decision to launch Alquiler Rehabmedic: an independent business unit for renting medical rehabilitation equipment. Articulated beds, electric wheelchairs, patient lifts, mobility scooters: a product catalogue rented by the day, week or month with its own billing cycle, deposit management, equipment status tracking, and delivery and collection logistics.
This business unit could not be built on the existing stack without multiplying complexity. Adding a third system for rentals on top of Navision and Magento would have created three disconnected silos. The decision was to migrate to a unified platform that simultaneously addressed ERP management, sales e-commerce and the rental portal from a single database and a single application instance.
Migration strategy: phases, priorities and success criteria
Design principle: zero operational downtime
The non-negotiable success criterion from the outset was that the store could not close at any point during the migration process. Online orders are a continuous revenue source and any interruption has a direct, quantifiable cost. This principle dictated the entire migration architecture: parallel operation during the transition phase, incremental validation by module, and a tested rollback plan before the final cutover.
Migration sequence
The migration was organised into four phases with their own independent cutover dates:
- Phase 0 — Preparation and Odoo configuration: installation of the Odoo instance, configuration of the Spanish localisation (l10n_es), chart of accounts, configuration of the Multi-web module for the two sites (sales + rental), staging of test data.
- Phase 1 — Master data migration: customers, suppliers and product catalogue from Navision. Parallel operation: orders continue to be processed in Navision while Odoo is validated with real data.
- Phase 2 — Magento e-commerce migration: web catalogue, categories, pending orders and registered customers from Magento. Cutover to Odoo eCommerce with the Magento site maintained in read-only mode for a 48-hour safety window.
- Phase 3 — ERP cutover and Rental launch: definitive operational cutover from Navision, migration of accounting balances and receivables portfolio, activation of the rental portal in production with full Schema.org markup from day one.
Data mapping: from Navision and Magento to Odoo
Customers and contacts: Navision → Odoo
Navision's customer model has specific characteristics that require explicit mapping decisions before any extraction:
| Navision field (Customer table) | Odoo field (res.partner) | Transformation notes |
|---|---|---|
| No. (customer code) | ref | Keep as internal reference for traceability |
| Name | name | Normalise: trim, remove double spaces |
| VAT Registration No. | vat | Add ES prefix if not present; validate NIF/CIF format |
| Address / Address 2 | street / street2 | Direct mapping; verify lengths |
| Post Code / City | zip / city | Normalise municipalities against INE catalogue if geocoding is required |
| Phone No. / Mobile Phone No. | phone / mobile | Normalise to E.164 format (+34...) |
| Validate syntax; discard nulls and invalid entries | ||
| Payment Terms Code | property_payment_term_id | Create equivalence table first; create payment terms in Odoo |
| Customer Price Group | property_product_pricelist | Map to Odoo pricelists; recreate discount rules |
| Salesperson Code | user_id | Require that salespeople are created in Odoo before loading |
| Balance (outstanding balance) | Opening accounting entry | Do not create as a partner field; migrate as an opening journal entry |
Product catalogue: Navision → Odoo
Rehabmedic's medical equipment catalogue has sector-specific characteristics: mandatory manufacturer references for traceability, serial number management for high-value equipment, and mixed units of measure (units, kits, consumables). Mapping Navision's product model to Odoo requires decisions on variants and traceability:
| Navision field (Item table) | Odoo field (product.template / product.product) | Notes |
|---|---|---|
| No. (internal reference) | default_code | Internal product reference in Odoo |
| Description | name | Product name; enrich with long description in website_description for SEO |
| Item Category Code | categ_id | Recreate category hierarchy in Odoo before loading |
| Base Unit of Measure | uom_id / uom_po_id | Verify existence in Odoo; create specific UoMs if they do not exist |
| Unit Price | list_price | Base sale price; special prices go to pricelists |
| Unit Cost | standard_price | Standard cost; affects inventory valuation |
| Item Tracking Code (serial/lot) | tracking ('serial' / 'lot' / 'none') | High-value equipment: tracking=serial; consumables: tracking=none |
| Vendor No. + Vendor Item No. | product.supplierinfo | Create a supplierinfo record for each supplier; it is a separate table in Odoo |
| Sales VAT Bus. Posting Group | taxes_id | Map to taxes in the Spanish localisation (VAT 21%, 10%, 4%, 0%) |
Magento orders → Odoo eCommerce
The migration of Magento's order history was not planned as a complete migration of all historical orders — high cost, low value — but a selective one: orders from the last 12 months with an active status (in process, pending shipment, returns in progress) plus the portfolio of registered customers with their recent history. Earlier orders were kept in Magento for read-only lookup.
The Magento and Odoo schemas have relevant structural differences in the order model:
| Magento entity | Odoo entity | Transformation notes |
|---|---|---|
| sales_order | sale.order | Direct header mapping; verify states (pending/processing/complete → sale/done/cancel) |
| sales_order_item | sale.order.line | Verify that product_id exists in Odoo before importing lines |
| customer_entity | res.partner (customer_rank=1) | Deduplicate against customers already migrated from Navision by email and VAT |
| customer_address | res.partner (child, type='delivery') | Parent-child model in Odoo; one address per child record |
| catalog_product | product.template | Link by SKU / default_code; do not duplicate products already migrated from Navision |
| catalog_category | website_page + product.category | Recreate web category tree in Odoo Website |
| cms_page (CMS pages) | website.page (Odoo Website) | Migrate HTML content; review URLs to preserve slugs and avoid 404s |
The most critical point of the Magento migration was URL preservation: product and category pages indexed by Google had URLs with their own structure in Magento. To avoid losing accumulated authority or generating 404s that would destroy rankings, a complete 301 redirect map was generated before the cutover, mapping each Magento URL to its equivalent in Odoo eCommerce, and configured in Nginx before the new site was activated.
The ETL process: extraction, transformation and loading
Extraction from Navision
Dynamics NAV exposes its database through SQL Server. With read-only access to the database server, extraction is straightforward via queries. Navision's table naming convention includes the company name as a prefix:
-- Ejemplo: extraer clientes activos de Navision (SQL Server)
-- El prefijo de tabla varía según el nombre configurado de la empresa
SELECT
c.[No_] AS navision_code,
c.[Name] AS name,
c.[VAT Registration No_] AS vat,
c.[Address] AS street,
c.[Address 2] AS street2,
c.[Post Code] AS zip,
c.[City] AS city,
c.[Phone No_] AS phone,
c.[Mobile Phone No_] AS mobile,
c.[E-Mail] AS email,
c.[Payment Terms Code] AS payment_term_code,
c.[Customer Price Group] AS pricelist_code,
c.[Salesperson Code] AS salesperson_code,
c.[Blocked] AS blocked
FROM
[RehabmedicDB].[dbo].[Rehabmedic$Customer] c
WHERE
c.[Blocked] = 0 -- Solo clientes no bloqueados
ORDER BY
c.[No_];
For products and stock, the same direct table-access pattern against the Item, Item Ledger Entry and Value Entry tables allows the complete catalogue and current inventory to be extracted in a single consistent extraction phase (checkpoint at the time of extraction).
Extraction from Magento
Magento stores its data in MySQL with an EAV (Entity-Attribute-Value) schema that makes direct extractions more complex than with a conventional relational schema. For product attributes stored in EAV tables (catalog_product_entity_varchar, catalog_product_entity_decimal, etc.) extraction requires joins with the attribute table:
-- Extraer productos de Magento (MySQL) con sus atributos principales
-- Magento 1.x / 2.x (adaptar según versión)
SELECT
e.entity_id AS magento_id,
e.sku AS sku,
e.type_id AS product_type,
va_name.value AS name,
va_desc.value AS description,
va_url.value AS url_key,
de_price.value AS price,
de_cost.value AS cost,
ins.qty AS stock_qty
FROM
catalog_product_entity e
LEFT JOIN catalog_product_entity_varchar va_name
ON va_name.entity_id = e.entity_id
AND va_name.attribute_id = (SELECT attribute_id FROM eav_attribute
WHERE attribute_code = 'name' AND entity_type_id = 4)
LEFT JOIN catalog_product_entity_text va_desc
ON va_desc.entity_id = e.entity_id
AND va_desc.attribute_id = (SELECT attribute_id FROM eav_attribute
WHERE attribute_code = 'description' AND entity_type_id = 4)
LEFT JOIN catalog_product_entity_varchar va_url
ON va_url.entity_id = e.entity_id
AND va_url.attribute_id = (SELECT attribute_id FROM eav_attribute
WHERE attribute_code = 'url_key' AND entity_type_id = 4)
LEFT JOIN catalog_product_entity_decimal de_price
ON de_price.entity_id = e.entity_id
AND de_price.attribute_id = (SELECT attribute_id FROM eav_attribute
WHERE attribute_code = 'price' AND entity_type_id = 4)
LEFT JOIN catalog_product_entity_decimal de_cost
ON de_cost.entity_id = e.entity_id
AND de_cost.attribute_id = (SELECT attribute_id FROM eav_attribute
WHERE attribute_code = 'cost' AND entity_type_id = 4)
LEFT JOIN cataloginventory_stock_item ins
ON ins.product_id = e.entity_id
WHERE
e.type_id IN ('simple', 'configurable')
ORDER BY
e.entity_id;
Transformation and cleansing
The Python transformation pipeline was the core of the ETL process. The main tasks:
import pandas as pd
import re
# --- Transformación de clientes (Navision) ---
def normalizar_vat_es(vat_raw):
"""Normaliza NIF/CIF español: quita guiones/espacios, añade prefijo ES."""
if not vat_raw or str(vat_raw).strip() == '':
return False
vat = re.sub(r'[\s\-\.]+', '', str(vat_raw).upper())
if not vat.startswith('ES'):
vat = 'ES' + vat
return vat
def normalizar_telefono_es(tel_raw):
"""Intenta normalizar a formato +34XXXXXXXXX."""
if not tel_raw or str(tel_raw).strip() == '':
return False
digits = re.sub(r'\D', '', str(tel_raw))
if digits.startswith('34') and len(digits) == 11:
return '+' + digits
if len(digits) == 9 and digits[0] in ('6', '7', '8', '9'):
return '+34' + digits
return False # No normalizable automáticamente
df_nav = pd.read_csv('navision_customers_extract.csv', encoding='utf-8')
df_nav['vat_odoo'] = df_nav['vat'].apply(normalizar_vat_es)
df_nav['phone_odoo'] = df_nav['phone'].apply(normalizar_telefono_es)
df_nav['name_odoo'] = df_nav['name'].str.strip().str.title()
# Detectar clientes bloqueados (no migrar como activos)
assert 'blocked' in df_nav.columns
df_nav_activos = df_nav[df_nav['blocked'] == 0].copy()
print(f"Clientes a migrar: {len(df_nav_activos)} / {len(df_nav)} total")
# --- Deduplicación cruzada Navision + Magento ---
# Los clientes que compraron también en la tienda Magento
# pueden existir en ambos sistemas con distintos códigos.
# Clave de deduplicación: VAT (más fiable) o email (segundo nivel)
df_mag_customers = pd.read_csv('magento_customers_extract.csv', encoding='utf-8')
df_mag_customers['vat_odoo'] = df_mag_customers['taxvat'].apply(normalizar_vat_es)
# Marcar cuáles de Magento ya existen en Navision (no duplicar en Odoo)
mag_vats = set(df_mag_customers[df_mag_customers['vat_odoo'] != False]['vat_odoo'])
nav_vats = set(df_nav_activos[df_nav_activos['vat_odoo'] != False]['vat_odoo'])
solapados_vat = mag_vats & nav_vats
print(f"Clientes en ambos sistemas (por VAT): {len(solapados_vat)}")
# Sólo cargar en Odoo los clientes Magento que NO estén ya en Navision
df_mag_nuevos = df_mag_customers[
~df_mag_customers['vat_odoo'].isin(solapados_vat) |
(df_mag_customers['vat_odoo'] == False)
].copy()
print(f"Clientes Magento nuevos a crear en Odoo: {len(df_mag_nuevos)}")
Loading into Odoo via XML-RPC
The load was executed using Odoo's XML-RPC API, which allows records to be created and updated programmatically with full traceability. The loading script included error handling, logging for each operation, and the ability to rerun only failed records without reprocessing successful ones:
import xmlrpc.client
import json
import logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[logging.FileHandler('migration_load.log'), logging.StreamHandler()])
log = logging.getLogger('migration')
ODOO_URL = 'https://odoo-staging.rehabmedic.com' # Primero staging, luego prod
DB = 'rehabmedic_odoo'
USER = 'migration@rehabmedic.com'
PASSWORD = '[REDACTED]'
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(DB, USER, PASSWORD, {})
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
# Obtener IDs de países, comunidades autónomas, etc. para los campos many2one
spain_id = models.execute_kw(DB, uid, PASSWORD, 'res.country', 'search',
[[('code', '=', 'ES')]])[0]
def cargar_cliente(row, pricelist_map, payment_term_map):
vals = {
'name': row['name_odoo'],
'ref': str(row['navision_code']),
'customer_rank': 1,
'lang': 'es_ES',
'country_id': spain_id,
}
for campo_src, campo_dst in [
('vat_odoo', 'vat'),
('phone_odoo', 'phone'),
('street', 'street'),
('zip', 'zip'),
('city', 'city'),
('email', 'email'),
]:
val = row.get(campo_src)
if val and str(val).strip() not in ('', 'False', 'nan'):
vals[campo_dst] = str(val).strip()
if row.get('pricelist_code') in pricelist_map:
vals['property_product_pricelist'] = pricelist_map[row['pricelist_code']]
if row.get('payment_term_code') in payment_term_map:
vals['property_payment_term_id'] = payment_term_map[row['payment_term_code']]
return models.execute_kw(DB, uid, PASSWORD, 'res.partner', 'create', [vals])
errores = []
ok = 0
for idx, row in df_nav_activos.iterrows():
try:
pid = cargar_cliente(row, pricelist_map, payment_term_map)
ok += 1
if ok % 200 == 0:
log.info(f'Progreso: {ok} clientes cargados')
except Exception as exc:
errores.append({'idx': idx, 'code': row['navision_code'], 'error': str(exc)})
log.error(f'Error en cliente {row["navision_code"]}: {exc}')
log.info(f'Carga completada. OK: {ok} | Errores: {len(errores)}')
if errores:
with open('errores_carga.json', 'w') as f:
json.dump(errores, f, ensure_ascii=False, indent=2)
The SEO layer: Odoo Multi-web and Schema.org from day one
The launch of the rental portal coincided with the final phase of the migration. The decision to implement full Schema.org markup from day one — not as a subsequent optimisation, but as part of the architecture from the outset — was what made accelerated search rankings possible.
Odoo Multi-web allowed two independent websites to be managed from the same instance: the equipment sales store (continuing its normal operations) and the new rental portal, with its own domain, its own visual identity and its own content architecture. The unified ERP was the data source for both: the same product catalogue, the same inventory, the same customers, but exposed differently on each site according to its business logic.
The Schema.org markup implemented on the rental portal's product pages covered the types that Google best interprets for this type of content:
- Product with
offers(rental price, availability, currency),brand,imageandaggregateRatingon each product page. - Organization with Rehabmedic's complete data, contact coordinates and service area.
- LocalBusiness with the geographic coverage of the rental service.
- FAQPage on category pages answering the highest-volume search questions (rental process, timelines, deposits, coverage).
- BreadcrumbList across the entire navigation hierarchy to facilitate structural comprehension by crawlers.
The result is verifiable and documented: the rental portal reached positions in the Top 3 on Google for the target keywords of the Spanish market in less than 60 days from launch, without paid advertising investment and without a link-building campaign.
Infrastructure: live PostgreSQL replica and observability with Telegram
High availability with streaming replication
The infrastructure on which Odoo runs at Rehabmedic is designed so that a primary node failure does not mean downtime for the user. The central element is the real-time PostgreSQL streaming replica: the secondary server continuously receives the WAL (Write-Ahead Log) from the primary, maintaining an accurate copy of the database state with minimal lag.
The architecture also covers load distribution: read-only queries — which on an e-commerce site represent the majority of traffic — can be routed to the replica, reducing pressure on the primary node and improving response times during peak periods.
Replica status monitoring is part of the observability system and can be verified directly from PostgreSQL:
-- Verificar estado de la replicación en el nodo primario
SELECT
client_addr,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
(sent_lsn - replay_lsn)::bigint AS replication_lag_bytes,
write_lag,
flush_lag,
replay_lag
FROM
pg_stat_replication
ORDER BY
client_addr;
-- Verificar desde la réplica que está recibiendo WAL correctamente
SELECT
status,
receive_start_lsn,
received_lsn,
last_msg_send_time,
last_msg_receipt_time,
sender_host,
sender_port
FROM
pg_stat_wal_receiver;
Observability with Telegram bots
The alerting system was implemented with a pragmatic approach: not the most sophisticated dashboard, but the fastest alert when something requires attention. Telegram bots are the notification channel because they are immediate, ubiquitous (they work on mobile without an additional app) and allow alerts to be structured by severity across different channels.
Alerts cover multiple layers of the stack:
- PostgreSQL replication lag exceeding the configured threshold (early indicator of primary overload or network issue).
- Odoo worker crashes detected in server logs (failures in background processes, cron jobs, web workers).
- HTTP response time of the site above the alert threshold (user-perceptible degradation before it becomes an outage).
- Server CPU and memory usage approaching configured limits.
- Failures in automatic backup jobs (database backup).
The operational result is a proactive posture: problems are detected and resolved before the end user experiences them. During the post-launch operating period, including traffic spikes from campaigns and periods of high seasonal demand, the platform maintained full availability.
Validation: how we verified that the migration was correct
Loading data into Odoo is not the end of the migration process. Validation is the step that determines whether the data is correct or whether there are errors that will only surface in production days or weeks later. The verifications carried out before each cutover:
- Record counts: the number of customers, products and suppliers in Odoo must match the count from the source system, minus records explicitly excluded (blocked, inactive, duplicates). Every difference is documented and justified.
- Accounting balance reconciliation: the total accounts-receivable balance in Odoo must match Navision's closing balance at the cutover date. Any difference above the tolerance threshold blocks the go-live.
- Inventory verification: total stock value in Odoo versus Navision. Sampling of high-turnover and high-value products for individual verification.
- Full-cycle test: create a real end-to-end order in Odoo (order → confirmation → preparation → delivery slip → invoice → payment) using a real customer and product from the migrated system before enabling user access.
- Key-user validation: the sales manager reviews their customer portfolio and pricing; the warehouse manager verifies stock; administration confirms accounting balances. Operational users detect problems that no automated script will find.
- URL and redirect verification: automated traversal of the Magento sitemap verifying that each URL redirects correctly to its Odoo equivalent with HTTP code 301, with no 404s.
Verifiable results
- Zero operational downtime throughout the entire migration process. The sales store did not interrupt its service at any point. Orders were processed without interruption.
- Top 3 on organic Google in under 60 days for the rental portal's target keywords, without paid advertising investment, thanks to the Schema.org architecture implemented from launch.
- Unified platform: replacing Navision and Magento with Odoo eliminated the operational cost of maintaining two disconnected systems and the friction of manual synchronisation between them.
- Real high availability: the live PostgreSQL replica absorbed the read load during traffic peaks without impacting primary node performance. Telegram alerts allowed minor incidents to be resolved proactively.
- New business unit live: Alquiler Rehabmedic launched on Odoo Multi-web with its own portal, its own business logic and its own SEO positioning, without additional infrastructure.
Lessons applicable to your migration
1. The redirect map is as important as the data map
In an e-commerce migration, losing indexed URLs means losing accumulated rankings. The inventory of Magento URLs and their mapping to equivalent URLs in Odoo must be done before the cutover, not after. A misconfigured or missing 301 can cost months of SEO recovery.
2. Cross-system deduplication is the most laborious work
When the same customer has a record in the ERP (Navision) and another in the store (Magento), with partially different data, deciding which is the source of truth and how to merge the records requires business judgement, not just technical logic. It is the work that cannot be fully automated and that consumes the most time in practice.
3. Schema.org is not an SEO detail: it is platform architecture
If product data exists correctly in the ERP — price, availability, category, description — Schema.org markup is the natural result of flowing that data through to the HTML layer with the correct structure. The cost of implementing it from the start is minimal. The cost of not having done it from the start, when data is already in production, is a complete refactoring of templates.
4. The PostgreSQL replica is life insurance, not a luxury for advanced architecture
An Odoo instance without database replication in production is a gamble: the gamble that the disk won't fail, that the server won't go down, that Friday's manual backup will be enough when the problem occurs on Wednesday. Streaming replication in PostgreSQL is mature, well documented and has an implementation cost far lower than the cost of a production outage.
5. Proactive observability is profitable from day one
A Telegram bot that notifies when replication lag exceeds 30 seconds costs a few hours of configuration. Detecting that same problem after it has already caused data inconsistency or service downtime costs days of analysis and recovery, plus the commercial cost of the downtime. The cost-benefit ratio is beyond dispute.