1. Why a Custom Function Import, Not a Loop of Individual Calls
The most obvious approach — have the Fiori app call the single-item approval Function Import once per selected document, in a loop on the frontend — technically works, but it's a poor fit for this use case:
- Each call is its own HTTP round trip, which is slow for a batch of any real size.
- There's no natural "all or nothing" semantics — a network hiccup partway through a client-side loop leaves you with an inconsistent partial state and no clean way to know exactly where it stopped.
- You lose the ability to give the user one consolidated result screen; you'd have to stitch that together client-side from N separate responses.
Routing it through OData's native $batch changeset mechanism instead means the whole batch is one HTTP request, SAP Gateway hands you the individual operations to process, and you control the commit/rollback boundary explicitly — which turned out to matter a lot once I hit the rollback requirement below.
2. Declaring the Function Import and Routing to It
The Fiori app calls a single custom Function Import (docWorkflowMassApproval in my case) once, passing all selected work item IDs and the chosen action as part of a changeset. In CHANGESET_PROCESS, I route based on which function import fired — this pattern is worth keeping even if you only have one mass-action function import today, because it's the natural extension point if you add a second one later (mass forward, mass delegate, etc.) without restructuring anything:
METHOD /iwbep/if_mgw_appl_srv_runtime~changeset_process.
DATA: ls_request TYPE /iwbep/if_mgw_appl_types=>ty_s_changeset_request,
lo_func_req TYPE REF TO /iwbep/if_mgw_req_func_import.
READ TABLE it_changeset_request INTO ls_request INDEX 1.
IF sy-subrc = 0.
TRY.
lo_func_req ?= ls_request-request_context.
DATA(lv_function_import) = lo_func_req->get_function_import_name( ).
CASE lv_function_import.
WHEN 'docWorkflowMassApproval'.
CALL METHOD me->wf_mass_appr_changeset_process
EXPORTING
it_changeset_request = it_changeset_request
CHANGING
ct_changeset_response = ct_changeset_response.
WHEN OTHERS.
" reserved for future mass-action function imports
ENDCASE.
CATCH /iwbep/cx_mgw_busi_exception INTO DATA(lo_busi_exc).
mo_context->get_message_container( )->add_message_from_exception(
io_exception = lo_busi_exc ).
CATCH /iwbep/cx_mgw_tech_exception INTO DATA(lo_tech_exc).
mo_context->get_message_container( )->add_message_from_exception(
io_exception = lo_tech_exc ).
ENDTRY.
ENDIF.
me->log_messages( ).
ENDMETHOD.I'll be honest — I initially wrote this with a plain LOOP AT it_changeset_request, and only later realized that for a single function-import-driven batch (as opposed to a mix of entity create/update operations), reading just the first entry to determine the function import name and delegating the whole table onward to the dedicated handler method was cleaner than looping twice. Small thing, but it made the method easier to reason about.
3. Processing Each Work Item — And Why the Per-Item Check Matters
Inside the dedicated handler, each selected work item gets resolved from its workflow ID down to the underlying SRM document GUID, validated, and then decided on individually — even though they all arrived in one batch:
METHOD wf_mass_appr_changeset_process.
DATA: ls_request TYPE /iwbep/if_mgw_appl_types=>ty_s_changeset_request,
ls_response TYPE /iwbep/if_mgw_appl_types=>ty_s_changeset_response,
lo_func_req TYPE REF TO /iwbep/if_mgw_req_func_import,
ls_mass_approval TYPE ysc_st_wf_mass_approval,
lt_params TYPE /iwbep/t_mgw_name_value_pair,
lv_decision TYPE char20.
LOOP AT it_changeset_request INTO ls_request.
CLEAR: ls_response, ls_mass_approval, lv_decision.
lo_func_req ?= ls_request-request_context.
lt_params = lo_func_req->get_parameters( ).
ls_mass_approval = VALUE #(
wiid = VALUE #( lt_params[ name = 'WIID' ]-value OPTIONAL )
action = VALUE #( lt_params[ name = 'ACTION' ]-value OPTIONAL ) ).
CASE ls_mass_approval-action.
WHEN 'APPROVE'. lv_decision = 'APPROVED'.
WHEN 'REJECT'. lv_decision = 'REJECTED'.
WHEN OTHERS. CONTINUE.
ENDCASE.
" Resolve work item -> object instance -> SRM document GUID
SELECT SINGLE top_wi_id FROM swwwihead
INTO @DATA(lv_top_wi_id) WHERE wi_id = @LS_mass_approval-wiid.
IF lv_top_wi_id IS NOT INITIAL.
SELECT SINGLE instid FROM sww_wi2obj
INTO @DATA(lv_instid)
WHERE top_wi_id = @LV_top_wi_id AND wi_id = @LS_mass_approval-wiid.
IF lv_instid IS NOT INITIAL.
SELECT SINGLE object_id, guid FROM crmd_orderadm_h
INTO @DATA(ls_header) WHERE guid = @LV_instid.
IF ls_header-guid IS NOT INITIAL.
" Validate the document is actually in a decidable state before touching workflow
CALL FUNCTION 'BBP_PROCDOC_CHECK'
EXPORTING
i_guid = ls_header-guid
iv_check_all = 'X'
iv_check_all_items = 'X'
TABLES
e_messages = DATA(lt_check_messages).
CALL FUNCTION '/SAPSRM/FU_WF_RFC_DECISION'
EXPORTING
iv_header_guid = ls_header-guid
iv_mode = 'APPROVAL'
iv_wiid = ls_mass_approval-wiid
iv_decision = lv_decision
iv_approver = sy-uname
TABLES
message_lines = DATA(lt_messages)
EXCEPTIONS
OTHERS = 1.
" ... build per-item success/error message into gt_messages here ...
ENDIF.
ENDIF.
ENDIF.
copy_data_to_ref( EXPORTING is_data = ls_mass_approval
CHANGING cr_data = ls_response-entity_data ).
ls_response-operation_no = ls_request-operation_no.
ls_response-headers = ls_request-request_headers.
APPEND ls_response TO ct_changeset_response.
ENDLOOP.
ENDMETHOD.The BBP_PROCDOC_CHECK call before the decision matters more than it might look — without it, you can end up attempting a workflow decision on a document that's technically still inconsistent (an item was changed by someone else in the same window, for instance), and the resulting error is a lot less clear to the end user than catching it explicitly beforehand and surfacing a proper message.
4. The Rollback Decision — Where I Went Back and Forth
This is the part I spent the most time actually thinking about, not coding. CHANGESET_END is where you decide whether to commit or roll back the whole batch:
METHOD /iwbep/if_mgw_appl_srv_runtime~changeset_end.
IF line_exists( gt_messages[ type = 'E' ] ).
ROLLBACK WORK.
ENDIF.
CLEAR gt_messages.
ENDMETHOD.My first instinct was: if any item in the batch fails, roll back everything — clean, predictable, "all or nothing." But that's actually the wrong behavior for this specific use case. If an approver selects twenty documents and one of them fails validation (say, it was already decided by someone else in the meantime), rolling back the other nineteen successful decisions along with it is a genuinely bad user experience — the approver did real work processing nineteen documents correctly, and an unrelated failure on the twentieth would silently undo all of it.
I ended up not using a blanket rollback-on-any-error approach in the final version — instead tracking per-item success/failure in the message container and committing whatever succeeded, while clearly reporting which specific items failed and why. The all-or-nothing rollback pattern shown above is the simpler default and is genuinely the right call for scenarios where partial success is meaningless (e.g., a multi-step document creation where a half-created record is worse than no record) — but for a mass-decision scenario where each item is independent, per-item error reporting with partial commit is usually what the business actually wants. Worth deciding this deliberately rather than defaulting to whichever pattern you copy from an example.
5. Deferred Mode — Why CHANGESET_BEGIN Still Matters Here
Even with a single function import driving everything, CHANGESET_BEGIN is where you tell the framework whether operations need to be deferred and processed together rather than individually:
METHOD /iwbep/if_mgw_appl_srv_runtime~changeset_begin.
TRY.
LOOP AT it_operation_info INTO DATA(ls_operation_info).
IF ls_operation_info-operation_type = 'CE'
OR ls_operation_info-operation_type = 'UE'
OR ls_operation_info-operation_type = 'EA'.
cv_defer_mode = abap_true.
EXIT.
ENDIF.
ENDLOOP.
CATCH /iwbep/cx_mgw_busi_exception.
CATCH /iwbep/cx_mgw_tech_exception.
RETURN.
ENDTRY.
ENDMETHOD.'EA' (Execute Action / function import execution) is the operation type that matters for this scenario — setting cv_defer_mode = abap_true when it's present is what makes the framework hand you the whole changeset together in CHANGESET_PROCESS instead of processing entries independently, which is a prerequisite for the batch-level rollback decision in the previous section to even be meaningful.