Migracja danych z legacy systemu - najtrudniejszy etap transformacji, który decyduje o wszystkim

Nowy system jest gotowy. Phoenix LiveView, PostgreSQL, piękny interfejs, testy automatyczne, CI/CD. Wszystko działa idealnie. Na danych testowych.

Teraz trzeba przenieść 15 lat prawdziwych danych ze starego systemu. 500 000 klientów, 2 miliony zamówień, 8 milionów pozycji faktur, 200 000 produktów. Dane, które żyją w bazie Access, w tabelach SQL Server bez relacji, w Excelach na dysku sieciowym i w głowie Tomka.

To jest moment, w którym 60% projektów migracyjnych wpada w poważne problemy. Nie dlatego, że nowy system jest zły. Dlatego, że dane ze starego systemu nie pasują do nowego - i nikt tego nie sprawdził wcześniej.

Dlaczego migracja danych jest najtrudniejsza

Dane legacy to nie dane - to archeologia

Stary system rósł organicznie przez 15 lat. Każdy programista, każda zmiana, każde "tymczasowe rozwiązanie" zostawiło ślad w strukturze danych:

Kolumna status z 14 wartościami, z których 3 to "legacy z czasów starego systemu" i nikt nie wie, co oznaczają. status = 7 - co to jest? Tomek mówi: "to było używane przez moduł, który usunęliśmy w 2018, ale rekordy zostały".

Pole uwagi zamiast kolumny. Numer konta bankowego klienta? W polu uwagi. Preferowany kurier? W polu uwagi. Informacja o alergii na gluten? Też w polu uwagi. 15 lat danych niestrukturalnych wciśniętych w jedno pole tekstowe.

Duplikaty wszędzie. Klient "Jan Kowalski" istnieje 4 razy: "Jan Kowalski", "jan kowalski", "KOWALSKI JAN", "Kowalski Jan (stary)". Każdy z innym adresem. Który jest aktualny?

Brak integralności referencyjnej. Zamówienie odwołuje się do klienta o ID 4523. Klient 4523 nie istnieje w tabeli klientów. Został usunięty ręcznie z bazy w 2019 roku, ale zamówienia zostały.

Dane w wielu miejscach. Adres klienta jest w tabeli klienci, ale też w każdym zamówieniu (skopiowany w momencie składania). Który jest prawidłowy? Ten z tabeli klientów (aktualizowany) czy ten z zamówienia (historyczny)?

Typy danych jako string. Cena: "120,50 zł". Data: "15-sty-2023". NIP: "522-123-45-67" albo "5221234567" albo "PL5221234567" albo puste pole z komentarzem "sprawdzić".

Dane to serce firmy

Możesz wymienić system. Możesz wymienić serwer. Możesz wymienić zespół programistów. Ale nie możesz stracić danych. 15 lat historii zamówień, relacji z klientami, cen, faktur - to jest pamięć firmy. Utrata nawet fragmentu tych danych może oznaczać:

  • Faktury, których nie możesz odtworzyć (wymóg prawny: 5 lat przechowywania)
  • Klienci, których kontakty giną (utracona sprzedaż)
  • Historia zamówień, której nie da się odtworzyć (analityka, reklamacje)
  • Stany magazynowe, które się nie zgadzają (chaos operacyjny)

Fazy migracji danych

Faza 1: Audit - co mamy

Zanim ruszymy jakiekolwiek dane, musimy zrozumieć co mamy. To jest faza detektywistyczna - otwieramy starą bazę i dokumentujemy:

Inwentaryzacja tabel i plików.

Baza SQL Server "ERP_PROD":
  - dbo.Klienci          (487 231 wierszy, ostatnia zmiana: wczoraj)
  - dbo.Klienci_old      (23 445 wierszy, ostatnia zmiana: 2019)
  - dbo.Zamowienia       (2 134 567 wierszy)
  - dbo.Zamowienia_arch  (890 234 wierszy, "archiwum z 2020")
  - dbo.tmp_import_2021  (15 000 wierszy, "tymczasowa, nie usuwać!")
  - dbo.Tomek_test       (3 wiersze)

Pliki na dysku sieciowym:
  - \\server\dane\cenniki\cennik_aktualny_v3_FINAL.xlsx
  - \\server\dane\cenniki\cennik_2024_Krysia.xlsx
  - \\server\dane\klienci_eksport_styczen.csv

Pliki w systemie:
  - C:\ERP\config\grupy_rabatowe.xml
  - C:\ERP\data\szablony_faktur\*.dot

Profiling danych - automatyczna analiza jakości:

-- Ile klientów nie ma emaila?
SELECT COUNT(*) FROM Klienci WHERE email IS NULL OR email = '';
-- Wynik: 34 567 (7.1%)

-- Ile unikatowych formatów NIP?
SELECT DISTINCT
  CASE
    WHEN nip LIKE '___-___-__-__' THEN 'XXX-XXX-XX-XX'
    WHEN nip LIKE '__________' THEN 'XXXXXXXXXX'
    WHEN nip LIKE 'PL%' THEN 'PL...'
    WHEN nip IS NULL OR nip = '' THEN 'BRAK'
    ELSE 'INNY: ' + nip
  END AS format,
  COUNT(*) as ilosc
FROM Klienci
GROUP BY ...
-- Wynik:
-- XXX-XXX-XX-XX:  234 567
-- XXXXXXXXXX:      89 432
-- PL...:           12 345
-- BRAK:           145 678
-- INNY:             5 209 (np. "do sprawdzenia", "xxx", "00000")

Mapa zależności - które tabele odwołują się do których:

Klienci ←── Zamowienia ←── Pozycje_zamowien ──→ Produkty
   ↑             ↑                                  ↑
   │             │                                  │
   └── Faktury ──┘──── Pozycje_faktur ──────────────┘

                     Cennik (który cennik?)

Wynik fazy 1: dokument z pełną mapą danych, statystykami jakości i listą problemów do rozwiązania. Typowy czas: 1-2 tygodnie.

Faza 2: Planowanie - co z tym zrobimy

Na podstawie auditu podejmujemy decyzje:

Co migrujemy, co zostawiamy?

Nie wszystko musi być przeniesione. Tabela Tomek_test z 3 wierszami? Nie. Archiwum zamówień sprzed 10 lat? Zależy od wymogu prawnego - jeśli faktury muszą być przechowywane 5 lat, zamówienia starsze niż 5 lat można zarchiwizować osobno (CSV + backup), nie migrować do nowego systemu.

Zasada: migruj dane aktywne (używane na co dzień) i dane wymagane prawnie (faktury, dokumenty księgowe). Resztę archiwizuj.

Jak mapujemy stary schemat na nowy?

Stary system ma tabelę Klienci z 47 kolumnami, z których 12 jest nieużywanych od lat. Nowy system ma moduł Accounts.Customer z 15 polami. Mapowanie:

STARY                    NOWY
Klienci.Nazwa        →   customers.company_name
Klienci.Imie         →   customers.contact_first_name
Klienci.Nazwisko     →   customers.contact_last_name
Klienci.NIP          →   customers.tax_id (po normalizacji)
Klienci.Tel1         →   customers.phone (po czyszczeniu)
Klienci.Tel2         →   (pominięte - 98% pustych)
Klienci.Fax          →   (pominięte - to jest 2026)
Klienci.Uwagi        →   (parsowane - wyciągnąć nr konta, kuriera)
Klienci.Status       →   customers.active (mapowanie 14 wartości → boolean)
Klienci.KodRabatowy  →   customers.discount_group_id (nowa tabela)
Klienci.DataUtworzenia → customers.inserted_at

Jak czyścimy dane?

Dla każdego problemu z fazy 1 - strategia:

ProblemStrategiaAutomatyzacja
Duplikaty klientówDeduplikacja po NIP + fuzzy matching na nazwę80% auto, 20% ręcznie
NIP w różnych formatachNormalizacja do 10 cyfr + walidacja checksum100% auto
Brak emaila (7%)Uzupełnij z pliku Excela Krysi lub zostaw NULL50% auto
Pole "uwagi" z danymiRegex extracting (nr konta, kurier, itp.)70% auto
Osierocone zamówieniaPrzypisz do klienta "Usunięty" lub pomiń100% auto
Ceny jako stringParse + konwersja na Decimal100% auto
Status 14 wartościTabela mapowania → boolean active100% auto

Faza 3: ETL - Extract, Transform, Load

ETL to trzy kroki przenoszenia danych:

Extract (wyciągnij) - pobierz dane ze starego systemu. SQL dump, CSV export, API call, czytanie plików Excel.

Transform (przekształć) - oczyść, znormalizuj, zmapuj na nowy schemat. To jest najtrudniejszy krok.

Load (załaduj) - wstaw przetworzone dane do nowej bazy PostgreSQL.

W Elixirze budujemy pipeline ETL jako osobną aplikację:

defmodule Migration.Pipeline do
  def run do
    # Extract
    legacy_customers = Migration.Extract.customers_from_sql_server()
    legacy_orders = Migration.Extract.orders_from_sql_server()
    excel_data = Migration.Extract.customers_from_excel("cennik_Krysia.xlsx")

    # Transform
    customers =
      legacy_customers
      |> Migration.Transform.deduplicate_by_nip()
      |> Migration.Transform.normalize_nip()
      |> Migration.Transform.normalize_phone()
      |> Migration.Transform.extract_from_uwagi()
      |> Migration.Transform.merge_excel_data(excel_data)
      |> Migration.Transform.map_status_to_active()
      |> Migration.Transform.validate_all()

    # Load
    Migration.Load.insert_customers(customers)

    # Walidacja po załadowaniu
    Migration.Validate.customer_count_matches()
    Migration.Validate.no_orphaned_orders()
    Migration.Validate.all_nips_valid()
  end
end

Każdy krok jest:

  • Testowalny - testy jednostkowe na każdą transformację
  • Powtarzalny - uruchamiamy pipeline wielokrotnie na danych testowych
  • Logowany - wiemy dokładnie co się stało z każdym rekordem
  • Odwracalny - możemy wyczyścić nową bazę i uruchomić ponownie

Faza 4: Walidacja - czy dane się zgadzają

Najważniejsza faza, którą większość projektów pomija. Po załadowaniu danych sprawdzamy czy wszystko się zgadza:

Zliczanie rekordów:

-- Stary system
SELECT COUNT(*) FROM Klienci WHERE Status != 'USUNIETY';
-- Wynik: 341 786

-- Nowy system
SELECT COUNT(*) FROM customers WHERE active = true;
-- Wynik: 341 786  ✓ ZGADZA SIĘ

-- Ale też:
SELECT COUNT(*) FROM customers;
-- Wynik: 341 902  (116 więcej - to klienci z deduplikacji, których
--                   połączyliśmy, ale zachowaliśmy z active=false)

Sumy kontrolne na kwotach:

-- Stary system: suma wszystkich faktur z 2025
SELECT SUM(CAST(REPLACE(REPLACE(Kwota, ' ', ''), ',', '.') AS DECIMAL(18,2)))
FROM Faktury WHERE YEAR(Data) = 2025;
-- Wynik: 12 456 789.45

-- Nowy system
SELECT SUM(total) FROM invoices WHERE date >= '2025-01-01' AND date < '2026-01-01';
-- Wynik: 12 456 789.45  ✓ CO DO GROSZA

Losowa weryfikacja:

Wylosuj 100 klientów ze starego systemu.
Dla każdego sprawdź w nowym:
- Czy dane osobowe się zgadzają?
- Czy zamówienia się zgadzają?
- Czy faktury się zgadzają?
- Czy stany rozrachunków się zgadzają?

Wynik: 97/100 OK, 3 rozbieżności (do ręcznego sprawdzenia)

Walidacja logiki biznesowej:

# Każde zamówienie musi mieć klienta
assert Repo.aggregate(
  from(o in Order, where: is_nil(o.customer_id)), :count
) == 0

# Każda faktura musi mieć poprawny NIP
invalid_nips = Repo.all(
  from(i in Invoice, where: not fragment("? ~ '^[0-9]{10}$'", i.tax_id))
)
assert invalid_nips == []

# Suma pozycji zamówienia = wartość zamówienia
mismatched = Repo.all(
  from o in Order,
  join: items in assoc(o, :items),
  group_by: o.id,
  having: sum(items.price * items.quantity) != o.total,
  select: o.id
)
assert mismatched == []

Faza 5: Migracja produkcyjna

Po wielokrotnym przetestowaniu pipeline'a na kopiach danych - czas na migrację produkcyjną:

Strategia 1: Big bang (nie rekomendowana)

  • Piątek wieczór: zatrzymaj stary system
  • Noc: uruchom migrację (4-8 godzin)
  • Sobota rano: walidacja
  • Poniedziałek: start na nowym systemie

Ryzyko: jeśli coś pójdzie nie tak w nocy, poniedziałek jest katastrofą.

Strategia 2: Przyrostowa (rekomendowana)

  • Dane historyczne (zamówienia, faktury) migrowane z wyprzedzeniem
  • Codziennie: synchronizacja nowych danych delta
  • Dzień D: finalna synchronizacja (ostatnie zmiany), przełączenie
  • Czas niedostępności: minuty, nie godziny
Tydzień -4:  Migracja danych historycznych (2020-2024)
Tydzień -3:  Walidacja, poprawki, druga migracja historycznych
Tydzień -2:  Migracja danych bieżących (2025-2026)
Tydzień -1:  Delta sync codziennie + walidacja
Dzień D:     Ostatnia delta sync + przełączenie
             Czas niedostępności: 30-60 minut
Dzień D+1:   Weryfikacja w nowym systemie
Dzień D+7:   Stary system read-only (backup)
Dzień D+30:  Stary system wyłączony

Strategia 3: Równoległa (najlepsza, ale najdroższa)

  • Oba systemy działają jednocześnie
  • Dane synchronizowane w obie strony (Foreign Data Wrappers)
  • Użytkownicy stopniowo przechodzą na nowy system
  • Stary system wyłączany, gdy nikt go nie używa

Zero przestoju. Zero ryzyka. Ale wymaga synchronizacji dwukierunkowej, co jest technicznie złożone.

Typowe problemy i jak je rozwiązujemy

Duplikaty klientów

Najczęstszy problem. Jeden klient ma 2-5 rekordów w starej bazie.

Automatyczna deduplikacja:

defmodule Migration.Transform.Dedup do
  def deduplicate_by_nip(customers) do
    customers
    |> Enum.group_by(&normalize_nip(&1.nip))
    |> Enum.map(fn {_nip, group} ->
      case group do
        [single] -> single
        multiple -> merge_duplicates(multiple)
      end
    end)
  end

  defp merge_duplicates(duplicates) do
    # Wybierz rekord z najnowszą datą modyfikacji jako "master"
    master = Enum.max_by(duplicates, & &1.updated_at)

    # Uzupełnij brakujące pola z pozostałych rekordów
    Enum.reduce(duplicates, master, fn dup, acc ->
      %{acc |
        email: acc.email || dup.email,
        phone: acc.phone || dup.phone,
        address: acc.address || dup.address
      }
    end)
  end
end

80% duplikatów jest rozwiązywanych automatycznie (ten sam NIP = ten sam klient). Pozostałe 20% (różne NIP-y, ale ta sama firma - zmiana NIP, przejęcie, przekształcenie) wymaga ręcznej decyzji.

Tworzymy interfejs w LiveView, w którym operator przegląda podejrzane duplikaty i decyduje: "połącz", "zostaw osobno", "usuń":

┌─────────────────────────────────────────────────┐
│ Podejrzany duplikat #47/234                     │
├─────────────────────┬───────────────────────────┤
│ Rekord A            │ Rekord B                  │
│ Jan Kowalski Sp.z.o │ JK KOWALSKI SP ZOO        │
│ NIP: 5221234567     │ NIP: 5221234567            │
│ ul. Marszałkowska 1 │ Marszałkowska 1/2          │
│ tel: 601-234-567    │ tel: (brak)               │
│ email: jan@firma.pl │ email: kowalski@gmail.com  │
│ Zamówienia: 47      │ Zamówienia: 3              │
│ Ostatnie: 2026-01   │ Ostatnie: 2024-03          │
├─────────────────────┴───────────────────────────┤
│ [Połącz → A] [Połącz → B] [Zostaw osobno]      │
└─────────────────────────────────────────────────┘

Dane w polu "uwagi"

Lata wciskania danych w jedno pole tekstowe:

"Konto bankowe: 12 1234 5678 9012 3456 7890 1234
Preferowany kurier: DPD
UWAGA: nie dzwonić przed 10:00
Rabat 5% uzgodniony z Markiem - faktura za netto
adres dostawy inny: Krucza 15/3, Warszawa"

Pipeline ekstrakcji:

defmodule Migration.Transform.ParseUwagi do
  def extract(uwagi) when is_binary(uwagi) do
    %{
      bank_account: extract_bank_account(uwagi),
      preferred_courier: extract_courier(uwagi),
      delivery_notes: extract_notes(uwagi),
      discount_info: extract_discount(uwagi),
      alt_address: extract_address(uwagi),
      remaining: clean_remaining(uwagi)  # to, czego nie sparsowaliśmy
    }
  end

  defp extract_bank_account(text) do
    case Regex.run(~r/(?:konto|rachunek|bank)[:\s]*(\d{2}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4})/i, text) do
      [_, account] -> String.replace(account, ~r/[\s-]/, "")
      nil -> nil
    end
  end

  defp extract_courier(text) do
    cond do
      text =~ ~r/dpd/i -> "DPD"
      text =~ ~r/inpost/i -> "InPost"
      text =~ ~r/dhl/i -> "DHL"
      text =~ ~r/ups/i -> "UPS"
      true -> nil
    end
  end
end

Logujemy każdą ekstrakcję z confidence score. To, czego nie sparsowaliśmy automatycznie (remaining), trafia do kolejki ręcznej weryfikacji.

Daty i formaty liczbowe

Stary system polskojęzyczny z datami w formacie "15-sty-2023", cenami "1 234,56 zł", wagami "2.5 kg":

defmodule Migration.Transform.Formats do
  @polish_months %{
    "sty" => 1, "lut" => 2, "mar" => 3, "kwi" => 4,
    "maj" => 5, "cze" => 6, "lip" => 7, "sie" => 8,
    "wrz" => 9, "paź" => 10, "lis" => 11, "gru" => 12
  }

  def parse_polish_date(str) do
    case Regex.run(~r/(\d{1,2})-(\w{3})-(\d{4})/, str) do
      [_, day, month, year] ->
        month_num = @polish_months[String.downcase(month)]
        Date.new!(String.to_integer(year), month_num, String.to_integer(day))
      nil ->
        # Spróbuj inne formaty
        parse_other_formats(str)
    end
  end

  def parse_polish_decimal(str) do
    str
    |> String.replace(~r/[^\d,.-]/, "")  # usuń "zł", "PLN", spacje
    |> String.replace(",", ".")            # polska notacja → angielska
    |> Decimal.new()
  end
end

Testy na każdy format:

test "parsuje polskie daty" do
  assert parse_polish_date("15-sty-2023") == ~D[2023-01-15]
  assert parse_polish_date("1-paź-2024") == ~D[2024-10-01]
  assert parse_polish_date("31-gru-2025") == ~D[2025-12-31]
end

test "parsuje polskie kwoty" do
  assert parse_polish_decimal("1 234,56 zł") == Decimal.new("1234.56")
  assert parse_polish_decimal("100,00") == Decimal.new("100.00")
  assert parse_polish_decimal("1234.56 PLN") == Decimal.new("1234.56")
end

Hasła użytkowników

Stary system przechowuje hasła w MD5 albo plain text. Nie możesz ich przenieść jako Argon2, bo nie znasz haseł w postaci jawnej (chyba że plain text - wtedy znasz, ale nie powinieneś).

Strategia:

defmodule Migration.Transform.Passwords do
  def migrate_password(user) do
    case user.password_format do
      :plain_text ->
        # Mamy hasło w jawnej postaci - zahashuj Argon2
        %{user | password_hash: Argon2.hash_pwd_salt(user.password)}

      :md5 ->
        # Nie znamy hasła. Przenieś hash MD5.
        # Przy pierwszym logowaniu: sprawdź MD5, zahashuj Argon2
        %{user | legacy_hash: user.password_hash, password_hash: nil}

      :unknown ->
        # Wymuś reset hasła
        %{user | password_hash: nil, force_reset: true}
    end
  end
end

Przy logowaniu w nowym systemie:

def authenticate(email, password) do
  user = Repo.get_by(User, email: email)

  cond do
    # Nowy hash Argon2 - normalne logowanie
    user.password_hash && Argon2.verify_pass(password, user.password_hash) ->
      {:ok, user}

    # Legacy hash MD5 - zweryfikuj i przekonwertuj
    user.legacy_hash && md5(password) == user.legacy_hash ->
      # Sukces! Przekonwertuj na Argon2
      user
      |> User.changeset(%{password_hash: Argon2.hash_pwd_salt(password), legacy_hash: nil})
      |> Repo.update!()
      {:ok, user}

    # Wymuszone resetowanie hasła
    user.force_reset ->
      {:error, :password_reset_required}

    true ->
      {:error, :invalid_credentials}
  end
end

Użytkownicy logują się starym hasłem. System w tle konwertuje na Argon2. Po miesiącu większość haseł jest w bezpiecznym formacie. Ci, którzy się nie logowali, dostaną email z resetem.

Narzędzia, których używamy

Foreign Data Wrappers - czytanie starej bazy bez kopiowania

PostgreSQL FDW pozwala odpytywać starą bazę (SQL Server, MySQL, Oracle) bezpośrednio z nowego PostgreSQL:

CREATE EXTENSION tds_fdw;  -- dla SQL Server

CREATE SERVER legacy_server
  FOREIGN DATA WRAPPER tds_fdw
  OPTIONS (servername 'stary-serwer.firma.pl', database 'ERP_PROD');

CREATE USER MAPPING FOR CURRENT_USER
  SERVER legacy_server
  OPTIONS (username 'readonly', password '...');

IMPORT FOREIGN SCHEMA dbo
  LIMIT TO (Klienci, Zamowienia, Faktury)
  FROM SERVER legacy_server
  INTO legacy;

-- Teraz mogę zapytać starą bazę z PostgreSQL
SELECT * FROM legacy.Klienci WHERE NIP = '5221234567';

Idealne do:

  • Porównywania danych po migracji
  • Przyrostowej synchronizacji (delta)
  • Debugowania rozbieżności

Ecto Multi - transakcyjna migracja

Każda paczka danych migrowana w jednej transakcji:

def migrate_customer_batch(legacy_customers) do
  Ecto.Multi.new()
  |> Ecto.Multi.insert_all(:customers, Customer, transform_batch(legacy_customers))
  |> Ecto.Multi.run(:validate, fn repo, %{customers: {count, _}} ->
    if count == length(legacy_customers), do: {:ok, count}, else: {:error, :count_mismatch}
  end)
  |> Repo.transaction()
end

Albo cała paczka się wstawia, albo żadna. Nie ma sytuacji "połowa klientów zmigrowana, połowa nie".

Raport migracji

Po każdym uruchomieniu pipeline'a generujemy raport:

╔══════════════════════════════════════════════════╗
║  RAPORT MIGRACJI - 2026-02-26 14:30              ║
╠══════════════════════════════════════════════════╣
║                                                  ║
║  Klienci:                                        ║
║    Źródło:        487 231                        ║
║    Zmigrowane:    341 786  (aktywni)             ║
║    Duplikaty:      12 345  (połączone)           ║
║    Pominięte:     133 100  (usunięci w legacy)   ║
║    Błędy:              0                         ║
║                                                  ║
║  Zamówienia:                                     ║
║    Źródło:      2 134 567                        ║
║    Zmigrowane:  2 134 234                        ║
║    Pominięte:         333  (brak klienta)        ║
║    Błędy:              0                         ║
║                                                  ║
║  Faktury:                                        ║
║    Źródło:      1 567 890                        ║
║    Zmigrowane:  1 567 890                        ║
║    Suma netto:  ✓ ZGODNA (234 567 890.12 PLN)    ║
║    Błędy:              0                         ║
║                                                  ║
║  Czas wykonania:  47 minut                       ║
║  Status:  SUKCES                                 ║
╚══════════════════════════════════════════════════╝

Ile to trwa i ile kosztuje

Typowy harmonogram

FazaCzasCo się dzieje
Audit danych1-2 tygodnieAnaliza jakości, mapa zależności
Planowanie migracji1 tydzieńMapowanie, strategia czyszczenia
Budowa pipeline ETL2-4 tygodnieKod, testy, automatyzacja
Testy na kopii danych1-2 tygodnie3-5 uruchomień, poprawki
Interfejs do ręcznej weryfikacji1 tydzieńDuplikaty, edge case'y
Ręczna weryfikacja1-2 tygodnieOperator przegląda wątpliwe dane
Migracja produkcyjna1-2 dniFinalna migracja + walidacja
Łącznie8-14 tygodni

Typowy koszt

Migracja danych to zwykle 20-30% budżetu całego projektu. Przy projekcie za 300 000 PLN - migracja kosztuje 60 000-90 000 PLN.

Firmy, które próbują zaoszczędzić na migracji ("wrzucimy dane ręcznie" albo "zrobimy prosty import CSV"), płacą za to wielokrotnie więcej:

  • Miesiące ręcznego przepisywania
  • Błędy w danych, które wychodzą przez kolejne miesiące
  • Utracone dane, których nikt nie zauważył
  • Rozbieżności, które podważają zaufanie do nowego systemu

Najważniejsza zasada

Migracja danych to nie jednorazowy skrypt. To proces z testami, walidacją i planem B.

Każdy krok jest powtarzalny. Każdy krok jest testowalny. Każdy krok ma walidację. I jeśli coś pójdzie nie tak - wracamy do starego systemu, naprawiamy pipeline i próbujemy ponownie.

Stary system działa do momentu, aż nowy jest w 100% zwalidowany. Nie "pewnie działa". Nie "wygląda OK". 100% rekordów, co do grosza, co do jednego zamówienia.

Planujesz migrację ze starego systemu i boisz się o dane? Porozmawiajmy - zaczynamy od bezpłatnego auditu jakości Twoich danych. Powiemy Ci dokładnie, z jakimi problemami się zmierzysz i ile to będzie kosztować.