DEV Community

Handling Large Payroll Files via Segmented Loops in Oracle Integration Cloud

Introduction:

During global enterprise payroll processing cycles, syncing time management, corporate cross-references, and financial allowances between systems introduces complex data handling challenges. Integrating large datasets requires a middleware layer that manages data formatting transformations while operating within cloud memory limits.

A common bottleneck occurs when standard data loops run into string size boundaries while transforming data lines or calling target reporting modules.

This case study demonstrates how to construct a robust, biweekly scheduled processing pipeline within Oracle Integration Cloud (OIC). The solution combines data enrichment using the Snowflake Adapter, array manipulation via Read File in Segments, iterative BI Publisher (BIP) report processing, and PGP-encrypted bulk uploads through HCM Data Loader (HDL).

Technical Integration Architecture:

Rather than straining server boundaries, the data pipeline processes files using a segmented, decoupled, stream-based lifecycle:

1. SFTP Pull ──► Retrieve & Write to Stage Directory
                     │
                     ▼
2. Snowflake Enrich ──► Dynamic Mapping Transformation
                     │
                     ▼
3. Segment Chunking ──► Concatenate Rows into String
                     │
                     ▼
4. Iterative BIP Call ──► Categorize Element / Abs
                     │
                     ▼
5. HDL Load ──► PGP Encrypt, Compress & Monitor ID
Enter fullscreen mode Exit fullscreen mode

Implementation Framework:

1. Inbound Processing & Snowflake Enrichment:
The scheduled orchestration begins by downloading the latest payroll raw file from a secure external SFTP directory server. If no files exist in the path, an inline Switch component catches the empty count (ItemCount = 0) and triggers a clean notifyNoFileFound exit routine, saving system resources.

When a payload is verified, OIC downloads the text file and writes it to a temporary local scratch directory via a Stage File Write action. The integration loop then immediately calls the endpoint getCompanyCodeCostCenterMapping using the Snowflake Adapter connection (MTWY Snowflake Connection). This function passes raw assignment parameters, returning validated corporate cost centers and company segment metadata strings straight into the OIC data object map.

2. Bypassing String Limits via Segment Chunking:
To prevent the application from hitting native string container memory thresholds when handling tens of thousands of employee rows, the workflow leverages an OIC Read File in Segments operation. This tool reads data in controlled, isolated arrays (e.g., chunks of 2,000 records per loop).

Inside the loop, the mapper concatenates variables into a delimited string. It structures individual column variables using a pipe separator (|) and joins row matrices together using standard comma flags (,).

The data transformation generates the following layout:

PERSON_NUM|ASG_NUM|DEPT_CD|COMPANY_CD,PERSON_NUM|ASG_NUM|DEPT_CD|COMPANY_CD
Enter fullscreen mode Exit fullscreen mode

This structured block is mapped directly to consecutive collection fields (BIPParameter 1 through BIPParameter 8) to prepare the data for the next reporting step.

3. Hierarchical Regex Tokenizer & Tri-Directional Slicing:
OIC passes the generated parameter blocks to the Fusion public SOAP utility ExternalReportWSSService to execute a high-performance BI Publisher data model.

The custom SQL engine parses the comma-separated data stream on the fly. It utilizes a hierarchical regex tokenizer (REGEXP_SUBSTR and CONNECT BY level) to break the strings back into individual table rows. It also implements an analytical ranking function (ROW_NUMBER() OVER) to determine active assignment contexts before using a UNION ALL split to organize the data into either Element Entries or Absence Plans automatically:

/*
---------------------------------------------------------------------------------------------------------
--                      Copyright(C) Oracle
--                         All Rights Reserved
---------------------------------------------------------------------------------------------------------
--  Application     : ORACLE ERP CLOUD
--  Report Name     : ORA PY Kronos Time Usage Scheduled Time Off Data Model 
--  Purpose         : 

--  CHANGE LOG
--  Date           Author                             Version      Description
---------------------------------------------------------------------------------------------------------
    dd-mm-yyyy    Satyanarayana Swamy Chinnamsetti   1.0          Initial Version
---------------------------------------------------------------------------------------------------------
*/
WITH input_value AS (
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value1, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value1, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value1, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value1, ',') + 1
  UNION ALL
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value2, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value2, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value2, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value2, ',') + 1
  UNION ALL
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value3, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value3, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value3, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value3, ',') + 1
  UNION ALL
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value4, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value4, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value4, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value4, ',') + 1
  UNION ALL
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value5, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value5, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value5, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value5, ',') + 1
  UNION ALL
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value6, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value6, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value6, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value6, ',') + 1
  UNION ALL
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value7, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value7, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value7, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value7, ',') + 1
  UNION ALL
  SELECT TRIM(REGEXP_SUBSTR(:p_input_value8, '[^,]+', 1, level)) AS param_set
  FROM dual
  WHERE TRIM(REGEXP_SUBSTR(:p_input_value8, '[^,]+', 1, level)) IS NOT NULL
    OR TRIM(REGEXP_SUBSTR(:p_input_value8, '[^,]+', 1, level)) <> ''
  CONNECT BY level <= REGEXP_COUNT(:p_input_value8, ',') + 1
), param_values AS (
  SELECT REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 1, 'n', 1) AS person_number,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 2, 'n', 1) AS position_code,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 3, 'n', 1) AS job_code,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 4, 'n', 1) AS pay_code,
    TO_DATE(REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 5, 'n', 1), 'YYYYMMDD') AS time_recorded_date,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 6, 'n', 1) AS hours,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 7, 'n', 1) AS rate,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 8, 'n', 1) AS shift,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 9, 'n', 1) AS gl_department,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 10, 'n', 1) AS gl_account,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 11, 'n', 1) AS gl_sub_account,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 12, 'n', 1) AS gl_company,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 13, 'n', 1) AS activity,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 14, 'n', 1) AS activity_account_category,
    REGEXP_SUBSTR(param_set, '(.*?)(\||$)', 1, 15, 'n', 1) AS units
  /* ,row_number() over (order by null) as rn */
  FROM input_value
), element_entry_name AS (
    SELECT DISTINCT
        pv.pay_code,
        pett_main.element_name AS element_name,
        CASE
            WHEN pett_main.element_name IS NOT NULL
            THEN ''
            ELSE 'Element Name not found'
        END AS error_reason
    FROM param_values pv
    LEFT OUTER JOIN (
        SELECT pett.element_name,
            pett.description
        FROM pay_element_types_f petf
        INNER JOIN pay_ele_classifications pec
            ON pec.classification_id = petf.secondary_classification_id
        INNER JOIN pay_element_types_tl pett
            ON petf.element_type_id = pett.element_type_id
        WHERE pec.base_classification_name <> 'Standard Earnings Premium'
            AND UPPER(pett.element_name) LIKE '%BC%'
    ) pett_main
  ON pett_main.description = pv.pay_code
), premium_element_entry_name AS (
  SELECT DISTINCT
    pv.pay_code,
    fuci.value AS premium_element_name
  FROM ff_user_tables fut
  INNER JOIN ff_user_rows_f furf
    ON fut.user_table_id = furf.user_table_id
  INNER JOIN ff_user_columns fuc
    ON fut.user_table_id = fuc.user_table_id
  INNER JOIN ff_user_columns_tl fuct
    ON fuc.user_column_id = fuct.user_column_id
  INNER JOIN ff_user_column_instances_f fuci
    ON (
      fuc.user_column_id = fuci.user_column_id AND furf.user_row_id = fuci.user_row_id
    )
  INNER JOIN param_values pv
    ON furf.row_low_range_or_name = pv.pay_code
  WHERE fut.base_user_table_name = 'EARNINGS_TABLE'
    AND fuct.user_column_name = 'PREMIUM_ELEMENT'
), absence_plans AS (
  SELECT DISTINCT
    pv.pay_code,
    fuci.value AS absence_plan_name
  FROM ff_user_tables fut
  INNER JOIN ff_user_rows_f furf
    ON fut.user_table_id = furf.user_table_id
  INNER JOIN ff_user_columns fuc
    ON fut.user_table_id = fuc.user_table_id
  INNER JOIN ff_user_columns_tl fuct
    ON fuc.user_column_id = fuct.user_column_id
  INNER JOIN ff_user_column_instances_f fuci
    ON (
      fuc.user_column_id = fuci.user_column_id AND furf.user_row_id = fuci.user_row_id
    )
  INNER JOIN param_values pv
    ON furf.row_low_range_or_name = pv.pay_code
  WHERE fut.base_user_table_name = 'EARNINGS_TABLE'
    AND fuct.user_column_name = 'ABSENCE_PLAN'
), person AS (
  SELECT DISTINCT
    papf.person_id,
    pv.person_number,
    CASE WHEN papf.person_id IS NOT NULL THEN '' ELSE 'Person not found in Oracle' END AS error_reason
  /* ,pv.rn */
  FROM param_values pv
  LEFT OUTER JOIN per_all_people_f papf
    ON (
      papf.person_number = pv.person_number
      AND TRUNC(SYSDATE, 'DD') BETWEEN papf.effective_start_date AND papf.effective_end_date
    )
), latest_assignment AS (
  SELECT pe.person_number,
    paam.assignment_number,
    ROW_NUMBER() OVER (PARTITION BY paam.person_id ORDER BY paam.effective_start_date DESC) AS rn_assignment
  FROM per_all_assignments_m paam
  INNER JOIN hr_organization_units_f_tl houft
    ON houft.organization_id = paam.business_unit_id
  INNER JOIN person pe
    ON paam.person_id = pe.person_id
  INNER JOIN param_values pv
    ON pe.person_number = pv.person_number
  LEFT OUTER JOIN hr_all_positions_f hapf
    ON paam.position_id = hapf.position_id
  LEFT OUTER JOIN per_jobs_f pjf
    ON paam.job_id = pjf.job_id
  WHERE TRUNC(SYSDATE, 'DD') BETWEEN houft.effective_start_date AND houft.effective_end_date
    AND paam.assignment_type = 'E'
    AND houft.language = USERENV('LANG')
    AND UPPER(houft.name) = UPPER('Oracle BU')
    AND (
      (
        (
                    pv.position_code IS NOT NULL OR pv.position_code <> ''
                )
        AND 
                (
                    hapf.position_code = pv.position_code
                )
      )
      OR (
        (
          pv.position_code IS NULL OR pv.position_code = ''
        )
        AND 
                (
                    pv.job_code IS NOT NULL OR pv.job_code <> ''
                )
        AND 
                (
                    pjf.job_code = pv.job_code
                )
            )
      OR (
        (
          pv.position_code IS NULL OR pv.position_code = ''
        )
        AND 
                (
          pv.job_code IS NULL OR pv.job_code = ''
        )
                AND 
                (
                    TRUNC(pv.time_recorded_date) BETWEEN paam.effective_start_date AND paam.effective_end_date AND paam.primary_flag = 'Y' AND paam.assignment_status_type = 'ACTIVE'
                )   
            )
    )
    )
SELECT *
FROM (
  SELECT pv.person_number, /* rn */
    pv.position_code,
    pv.job_code,
    pv.pay_code,
    TO_CHAR(pv.time_recorded_date, 'YYYY/MM/DD') AS time_recorded_date,
    pv.hours,
    pv.rate,
    pv.shift,
    pv.gl_department,
    pv.gl_account,
    pv.gl_sub_account,
    pv.gl_company,
    pv.activity,
    pv.activity_account_category,
    pv.units,
    la.assignment_number,
    een.element_name,
        TO_CHAR(
            1000 + ROW_NUMBER() OVER (PARTITION BY pv.person_number, pv.pay_code ORDER BY pv.person_number, pv.pay_code, pv.time_recorded_date) - 1
        ) AS create_entry_sequence,
    'Element' AS hdl_type,
    CASE
      WHEN pe.error_reason IS NOT NULL AND een.error_reason IS NOT NULL
      THEN pe.error_reason || ',' || een.error_reason
      WHEN pe.error_reason IS NOT NULL
      THEN pe.error_reason
      WHEN een.error_reason IS NOT NULL
      THEN een.error_reason
      WHEN la.assignment_number IS NULL
      THEN 'Assignment Number not found'
      ELSE ''
    END AS error_reason,
      CASE WHEN pv.gl_company IS NOT NULL AND pv.gl_department IS NOT NULL THEN pv.gl_company || '.' || pv.gl_department ELSE '' END AS CONCAT
  FROM param_values pv
  INNER JOIN person pe
    ON pe.person_number = pv.person_number
  INNER JOIN element_entry_name een
    ON pv.pay_code = een.pay_code
  LEFT OUTER JOIN latest_assignment la
    ON (
      pv.person_number = la.person_number AND la.rn_assignment = 1
    )
  UNION ALL
  SELECT pv.person_number, /* rn */
    pv.position_code,
    pv.job_code,
    pv.pay_code,
    TO_CHAR(pv.time_recorded_date, 'YYYY/MM/DD') AS time_recorded_date,
    pv.hours,
    pv.rate,
    pv.shift,
    pv.gl_department,
    pv.gl_account,
    pv.gl_sub_account,
    pv.gl_company,
    pv.activity,
    pv.activity_account_category,
    pv.units,
    la.assignment_number,
    peen.premium_element_name,
        TO_CHAR(
            1000 + ROW_NUMBER() OVER (PARTITION BY pv.person_number, pv.pay_code ORDER BY pv.person_number, pv.pay_code, pv.time_recorded_date) - 1
        ) AS create_entry_sequence,
    'Element' AS hdl_type,
    CASE WHEN pe.error_reason IS NOT NULL THEN pe.error_reason ELSE '' END AS error_reason,
      CASE WHEN pv.gl_company IS NOT NULL AND pv.gl_department IS NOT NULL THEN pv.gl_company || '.' || pv.gl_department ELSE '' END AS CONCAT
  FROM param_values pv
  INNER JOIN person pe
    ON pe.person_number = pv.person_number
  INNER JOIN premium_element_entry_name peen
    ON pv.pay_code = peen.pay_code
  INNER JOIN latest_assignment la
    ON (
      pv.person_number = la.person_number AND la.rn_assignment = 1
    )
  UNION ALL
  SELECT pv.person_number, /* rn */
    pv.position_code,
    pv.job_code,
    pv.pay_code,
    TO_CHAR(pv.time_recorded_date, 'YYYY/MM/DD') AS time_recorded_date,
    pv.hours,
    pv.rate,
    pv.shift,
    pv.gl_department,
    pv.gl_account,
    pv.gl_sub_account,
    pv.gl_company,
    pv.activity,
    pv.activity_account_category,
    pv.units,
    la.assignment_number,
    ap.absence_plan_name,
        '' AS create_entry_sequence,
    'Absence' AS hdl_type,
    CASE WHEN pe.error_reason IS NOT NULL THEN pe.error_reason ELSE '' END AS error_reason,
      CASE WHEN pv.gl_company IS NOT NULL AND pv.gl_department IS NOT NULL THEN pv.gl_company || '.' || pv.gl_department ELSE '' END AS CONCAT
  FROM param_values pv
  INNER JOIN person pe
    ON pe.person_number = pv.person_number
  INNER JOIN absence_plans ap
    ON pv.pay_code = ap.pay_code
  INNER JOIN latest_assignment la
    ON (
      pv.person_number = la.person_number AND la.rn_assignment = 1
    )
)
ORDER BY TO_NUMBER(person_number)
Enter fullscreen mode Exit fullscreen mode

An inline validation rule (reportBytes != DefaultContent) catches any blank data blocks early. This ensures that only valid, populated responses move forward to the file writing stage.

4: Formatting Native HDL Schema Files:
OIC decodes the Base64 binary text from the report response and reads the entries line-by-line. Based on the calculated hdl_type tag column value, the integration splits rows into two separate local stage files, structuring them to match the exact schema layouts expected by the HCM Data Loader engine:

File 1: ElementEntryWithCosting.dat:

This file handles payroll allocations and maps Snowflake dimensions directly to the entry costing segments (Segment1 through Segment3):

METADATA|ElementEntryWithCosting|SourceSystemOwner|SourceSystemId|ElementName|EffectiveStartDate|EffectiveEndDate|AssignmentNumber|EntryType|InputValueName1|ScreenEntryValue1|InputValueName2|ScreenEntryValue2|CreateEntrySequence|Segment1|Segment2|Segment3
MERGE|ElementEntryWithCosting|HRC_SQLLOADER|HRC_SQLLOADER_E112698_102|BC101 - Reg Pay|2025/08/02|4712/12/31|E112698|E|Hours|0.25|Rate|0|1000|6000|1001|6000
Enter fullscreen mode Exit fullscreen mode

File 2: PersonAccrualDetail.dat:

METADATA|PersonAccrualDetail|PersonNumber|WorkTermsNumber|PlanName|AccrualType|Value|ProcdDate|AdjustmentReason
MERGE|PersonAccrualDetail|110684|ET110684|Extended Leave|ADJOTH|4|2025/07/26|CP
Enter fullscreen mode Exit fullscreen mode

Once written, both files are compressed into a single zip archive. To maintain absolute data privacy compliance for sensitive employee financial data, the integration runs a secure command wrapper that encrypts the zip payload using a corporate PGP Public Key before passing the file to the HCM Cloud Adapter bulk load service.

4. Process Monitoring and Diagnostic Logging Flow:

The integration tracks execution health by capturing the unique Process ID returned by the background loading engine. A structured tracking matrix logs every phase of the lifecycle:

[OIC_TRACKING]  [BIWEEKLY_RUN] Started payroll pipeline sequence.
[SFTP_STAGE]   Successfully uncompressed source text stream.
[SNOWFLAKE]    Enriched 4,500 record streams with matching company codes.
[SEGMENT_LOOP] Processing File Chunk 1 (Rows 1-2000). Total length within limits.
[BIP_INVOKE]   Iterative Data Model call complete. Generated XML response elements.
[HDL_ENGINE]   PGP Encryption complete. Submitted zip payload. Process Tracking ID: 890241.
[HDL_STATUS]   Process ID 890241 returned: SUCCESS. Sending confirmation alerts.
[SFTP_ARCHIVE] Cleaned source folder pathing parameters. Pipeline shut down.
Enter fullscreen mode Exit fullscreen mode

If an processing exception occurs, global fault-handling catch blocks intercept the failed payload instance, generate a detailed administrative alert, and safely archive the source file to prevent duplicate processing cycles.

5. Summary:

This integration has been developed to support the creation of Element Entries and Absence Entries as part of the payroll process. It operates on a biweekly schedule, automatically retrieving the latest encoded file from the SFTP server. Once the file is received, it is decoded and written to the OIC temporary directory using the Stage File action. The data is then processed and transformed, incorporating department and company code mappings sourced through the Snowflake adapter to ensure consistency and accuracy.

Following the transformation, the data is structured by separating columns with a pipe (“|”) and combining rows using commas. This formatting is performed within the “Read File in Segments” operation, enabling efficient processing and transmission to a BI Publisher report. Due to input size limitations, the data is divided into manageable segments, and the report is triggered iteratively until all records are processed. Based on the report output, records are categorized as either Element Entries or Absence Entries, and the corresponding DAT files are generated. These files are then compressed and encrypted using a PGP key before being submitted to Oracle HCM Cloud through the HCM Data Loader.

The outcome of the data load process is monitored using the generated Process ID, allowing appropriate success or failure notifications to be issued. Once processing is complete, the source file is archived from the SFTP directory. The integration also includes configurable flags to manage notifications and encryption requirements. Comprehensive error handling is implemented through both default and global fault handlers, and reusable integrations are leveraged to ensure consistent and efficient notification delivery and SFTP file movement.

Conclusion:

Combining BI Publisher, Oracle Integration Cloud, and HCM Data Loader into a single automated pipeline creates a highly scalable data integration framework. By extracting datasets in bulk using high-performance database models and processing data parsing entirely within local OIC memory maps, you protect environment stability, ensure clean data updates, and avoid system failures during business-critical integration windows.

Top comments (0)