Monday 6 March 2023

A Complete Unmanaged CDS Scenario Including Parent-Child Relationship

What a trip this case has been. The ABAP RESTful Programming model isn’t exactly new, and yet in my reality it hasn’t been used much. And suddenly I saw myself facing the need for an unmanaged scenario.

First off, this is what you will find here: every single step, from CDS data definitions, to behavior definitions with implementation, and finally to an OData service which can query, read, filter, and even expand/navigate to child entries (NavigationProperty as it is called in the metadata document of OData).

Please note: I do not claim to be an expert but thought this blog post and guide could be of help to anyone needing to solve a similar scenario. I very happily receive feedback and tips on things that maybe could have been done better.

Let’s start.

The Case


The company is using CATS today for time reporting. Additionally, there is an integration to JIRA, whereas a JIRA case creates a service order in SAP, which can be used for time reporting. The integration moves the times reported in JIRA into CATS and onto the corresponding service order.

SAP CDS, SAP ABAP Career, SAP ABAP Tutorial and Materials, SAP ABAP Skills, SAP ABAP Jobs, SAP ABAP Prep, SAP ABAP Preparation

The company is now moving to S/4HANA 2022 and needs a better solution for subcontractors. Subcontractors today get access to JIRA which is not desirable. Instead, we will offer them a simple web app for time reporting. Because of this, we need an OData service for Service Orders which can do the following:

◉ Create a new service order (replacing the old integration in JIRA)
◉ Update service orders (mostly for new status, closing the order when the JIRA case is resolved etc.)
◉ Read/query service orders for a certain user (“my orders I’m allowed to report time on”): of course we only want the correct type of orders
    ◉ For a future, possible web app for employees, we also want to include Project Networks in this query
    ◉ In this query read, we also need to include all hours that have so far been reported on an order (or network) as a sum
◉ Read all time reporting entries of a an order/network
◉ Create, update or delete a certain time entry

Solution


Trying to use as much SAP Standard as possible, we decided to use the OData service of the SAP Standard app “My Timesheets (Version 3)” (F3074) for anything related to creating, editing or deleting time entries. Unfortunately there is no Standard app handling service orders, and even less so, one that could handle both service orders and PS networks “as one object for time reporting”, as this case demands. That’s why we come to the unmanaged scenario: we need to let JIRA create and update service orders via BAPI_ALM_ORDER_MAINTAIN. The legacy code for this still exists of course and will be reused; a Z-function module which eventually calls the mentioned BAPI (I will not go into detail here, how that function works). However right of the bat I was a little worried; yes, I want to  control create and update, but read and query I want to work as usual and when I started this, I did not know how this is solved. We’ll get to that :)…

CDS Data Definitions


We need two CDS; one for the time reporting service order or PS network and one for time entries, which have been booked against them. So we also immediately see we need a parent-child relationship here.

Time Reporting Object CDS (Parent)

(Please note: I will not include every single line of code here but the most important ones)

define root view entity ZI_TimeReportingObject
  as select from           I_LogisticsOrder as TRO
  composition [0..*] of ZP_TimeReportingObjectEntries as _TimeEntries
  association [0..*] to I_WBSElement                  as _WBSElement      
    on  $projection.WBSElementID = _WBSElement.WBSElementInternalID
  association [0..*] to I_CostCenter                  as _CostCenter      
    on  $projection.ControllingArea       = _CostCenter.ControllingArea
    and $projection.ResponsibleCostCenter = _CostCenter.CostCenter
{
  key TRO.OrderID,

      TRO.OrderCategory,
      TRO.OrderType,
      TRO.OrderDescription,
      @UI.hidden
      TRO.IsMarkedForDeletion,

      // Admin
      @Semantics.systemDate.createdAt: true
      TRO.CreationDate,
      // ETC.....
      
      // Assignments
      TRO.ObjectInternalID                                as ObjectNumber,
      @Consumption.valueHelpDefinition: [ { entity: {
          name: 'I_WBSElementBasicDataStdVH', element: 'WBSElementInternalID'
      } } ]
      @ObjectModel.foreignKey.association: '_WBSElement'
      TRO.WBSElementInternalID_2                          as WBSElementID,
      cast(
          case
              when TRO.OrderCategory = '20' then 'X'
              else ''
          end as boole_d
      )                                                   as IsNetwork,
      // Responsible
      @Consumption.valueHelpDefinition: [ { entity: { name: 'I_CostCenterStdVH', element: 'CostCenter' } } ]
      @ObjectModel.foreignKey.association: '_CostCenter'
      TRO.ResponsibleCostCenter,
      _WBSElement.ResponsiblePerson                       as WBSResponsiblePerson,

      // Check status of order/network to see if it's allowed to report times
      @ObjectModel.virtualElement: true
      @ObjectModel.virtualElementCalculatedBy: 'ZCL_HCM_VIRT_ELEM'
      @EndUserText.label: 'Time Reporting Allowed'
      cast('' as zallowed_e )                             as IsAllowedForReporting,

      // Total hours reported
      @ObjectModel.virtualElement: true
      @ObjectModel.virtualElementCalculatedBy: 'ZCL_HCM_VIRT_ELEM'
      @EndUserText.label: 'Total Reported Hours'
      cast(0 as abap.dec(6,2) )                           as TotalReportHours,

      // FI/CO
      @ObjectModel.foreignKey.association: '_ControllingArea'
      TRO.ControllingArea,

      // Associations
      _TimeEntries,
      _WBSElement,
      _ControllingArea
}
where
  (
       TRO.OrderType           = 'SM01'
    or TRO.OrderType           = 'SM02'
    or TRO.OrderType           = 'PS02'
  )
  and  TRO.IsMarkedForDeletion = '';

Few things I want to especially point out:

  • I use “define root view entity” instead of just “define view” which is the more modern version! Use the newest!
  • I use standard CDS I_LogisticsOrder to once again make use of what has already been done for me, instead of creating my own with join over AUFK and AFKO etc.
  • Parent-child relationship starts here with composition [0..*] of command, telling that the associated CDS isn’t just a regular association, but is the child of the current CDS
  • The rest is quiet regular CDS, but a few additional remarks:
    • I made a simple boolean field to distinguish between orders and networks (IsNetwork)
    • I have two virtual elements to add some extra ABAP logic:
      • Reading the status of the order/network to discern if the order/network is allowed to receive any time reporting at all
      • Calculating the sum of all reported hours
  • Lastly, in my where-clause I restrict the orders/network OrderTypes, which are the ones we need for time reporting in CATS (and of course, we are not interested in deletion-marked objects)

Time Entries (on Time Reporting Objects) (Child)

(Please note: I will not include every single line of code here but the most important ones)

define view entity ZP_TimeReportingObjectEntries 
  as select from catsdb
  association to parent ZI_TimeReportingObject as _TRObject
    on $projection.TRObjectID = _TRObject.OrderID
{
    key counter,
    raufnr                              as ReceiverOrderID,
    rnplnr                              as NetworkID,
    case
        when raufnr = '' then rnplnr
        else raufnr
    end                                 as TRObjectID,
    
    // reporting info
    skostl                              as CostCenter,
    workdate                            as WorkDate,
    lstar                               as ActivityType,
    awart                               as AttAbsType,
    ltxa1                               as ShortDesciption,
    
    // status
    status                              as Status,
    
    // time
    @DefaultAggregation: #SUM
    @Semantics.quantity.unitOfMeasure: 'ReportUnit'
    catshours                           as ReportHours,
    meinh                               as ReportUnit,
    
    // Associations
    _TRObject
}

Very simple CDS, here a few remarks:

◉ I called the view ZP_* for private view because this child view is not meant to be used on its own
◉ Once again, “define view entity” (not root this time) instead of “define view”
◉ Parent-child relationship on child entity: with command association to parent
◉ I created my own field TRObjectID which either received the order number or network of the time reporting entry, to have a single field as ID to the parent (I was worried this could cause an issue since the “parent key” isn’t part of the “child key” but it works like this, which was nice to find out)
◉ Rest is regular CDS

CDS Data Projections


Next, because we are doing RAP, we of course need projections.

Time Reporting Object Projection (Parent)

define root view entity ZC_TimeReportingObject
    provider contract transactional_query
    as projection on ZI_TimeReportingObject
{
    key OrderID,
    OrderCategory,
    OrderType,
    // all other fields...

    /* Associations */
    _TimeEntries  : redirected to composition child ZC_TimeReportingObEntries,
    _ControllingArea,
    _WBSElement,
}

I left out the listing of fields but most important in this one:

◉ We also need to use the same on the projection: “define root view entity“
◉ The line “provider contract transactional_query“: I am honestly not sure why it is needed – maybe someone can comment below and explain – but I added this line because ADT gave me a warning or error when it wasn’t there
◉ Lastly, in order for OData to add a navigation property for the parent-child, we have to use the “redirect to composition child” command (entering the CDS data definition projection view name), similar as we defined the composition in our data definition

Time Entries (on Time Reporting Object) Projection (Child)

define view entity ZC_TimeReportingObEntries 
  as projection on ZP_TimeReportingObjectEntries
{
    key counter,
    ReceiverOrderID,
    NetworkID,
    TRObjectID,
    // all fields...
    /* Associations */
    _TRObject : redirected to parent ZC_TimeReportingObject
}

Even simpler as the other one, with just two remarks:

◉ Using “define view entity“, same as data definition
◉ Adding navigation property in OData to our parent via “redirected to parent” command (giving the CDS data definition projection view name)

So far not so hard. Let’s talk about behavior next.

CDS Behavior Definitions


Now comes the fun part. The behavior definitions themselves aren’t all that complicated, but remember we have an unmanaged scenario and need to handle the BOs lifecycle methods. Let’s get started.

First of, I only have one behavior definition for the complete “composition tree”, from root/parent to child. This one behavior definition handles both, my parent and child:

unmanaged implementation in class zbp_i_timereportingobject unique;
strict ( 2 );

define behavior for ZI_TimeReportingObject alias TRObject
etag master LastChangeDate
{
  create;
  update;

  field(mandatory) OrderID, OrderCategory, OrderType, OrderDescription;
  field(readonly) IsAllowedForReporting, TotalReportHours;

  association _TimeEntries { }
}

define behavior for ZP_TimeReportingObjectEntries alias TREntry
//implementation in class zbp_c_timereportingobjectentries unique
{
  field ( readonly ) TRObjectID, counter;

  association _TRObject { }
}

The class name zbp_i_timereportingobject was suggested by ADT and I didn’t change it. But basically it stands for z-behavior pool and then the name of the CDS data definition, without the z-namespace. I didn’t choose a different class for the child entry (it’s commented away, but possible) so everything is one class for me.

You may noticed that I’m not using the “persistent table” statement and no mapping to said table. The reason for that is simply that one table is not enough. Service orders are spread over several tables. Secondly, as said before, we want to create and update via a BAPI.
The time entries come only from CATSDB, so there I could have done the table mapping. But, as explained before, we will use the SAP Standard OData service for time entries, and all I want is to READ; no modify at all.

I created the class via right-click in ADT and quick fix, after activating this behavior definition and it of course complaining that a class with that name does not yet exist. I especially mention this because if you do it in this way, ADT will pre-create the whole shell of your behavior pool, including all methods needed. So highly recommended. Let’s look at the class next.

Behavior Definition Implementation (Behavior Pool Class)

Global Class (generated by ADT)

class zbp_i_timereportingobject definition public abstract final 
  for behavior of zi_timereportingobject.
endclass.

class zbp_i_timereportingobject implementation.
endclass.

The CDS name here refers to the Behavior Definition name above.

We have 2 local class types, because we decided to handle parent and child in the same behavior definition. Let’s start with the parent, alias TRObject (from behavior definition, highly recommended to use):

class lhc_TRObject definition inheriting from cl_abap_behavior_handler.
  private section.
    methods create for modify
      importing entities for create TRObject.

    methods update for modify
      importing entities for update TRObject.

    methods read for read
      importing keys for read TRObject result result.

    methods rba_TimeEntries for read
      importing keys_rba for read TRObject\_TimeEntries full result_requested result result link association_links.

endclass.

Depending on what is defined in the unmanaged behavior definition, we need the corresponding methods. In our case we have:

  • create, update, read (quite self-explanatory)
  • rba_TimeEntries for read: rba stands for “ready by association”: this is the navigation between child and parent, which, in an unmanaged scenario, we have to implement manually. The name _TimeEntries is the name of the association in the behavior definition

This is the class shell, now let’s look at the implementation.

class lhc_TRObject implementation.

  method create.
    data ls_msg type symsg.

    loop at entities assigning field-symbol(<ls_trobject>).
      clear ls_msg.
      call function 'Z_LEGACY_FUNCTION'
        exporting
          is_data_in     = <ls_trobject>
        importing
          es_return      = ls_msg.
      if ls_msg is initial.
        mapped-TRObject = value #( base mapped-TRObject
                                ( %cid = <ls_trobject>-%cid
                                  OrderID = <ls_trobject>-OrderID
                                ) ).
      else.
        append value #( %cid = <ls_trobject>-%cid
            OrderID = <ls_trobject>-OrderID )
            to failed-TRObject.

        append value #( %msg = new_message( id       = ls_msg-msgid
                                            number   = ls_msg-msgno
                                            v1       = ls_msg-msgv1
                                            v2       = ls_msg-msgv2
                                            v3       = ls_msg-msgv3
                                            v4       = ls_msg-msgv4
                                            severity = if_abap_behv_message=>severity-error )
                        %key-OrderID = <ls_trobject>-OrderID
                        %cid =  <ls_trobject>-%cid
                        %create = 'X'
                        OrderID = <ls_trobject>-OrderID )
                        to reported-TRObject.
      endif.
    endloop.

  endmethod.

  method update.
    data ls_msg type symsg.

    loop at entities assigning field-symbol(<ls_trobject>).
      clear ls_msg.
      call function 'Z_LEGACY_FUNCTION'
        exporting
          is_data_in     = <ls_trobject>
        importing
          es_return      = ls_msg.
      if ls_msg is not initial.
        append value #( %cid = <ls_trobject>-%cid_ref
            OrderID = <ls_trobject>-OrderID )
            to failed-TRObject.

        append value #( %msg = new_message( id       = ls_msg-msgid
                                            number   = ls_msg-msgno
                                            v1       = ls_msg-msgv1
                                            v2       = ls_msg-msgv2
                                            v3       = ls_msg-msgv3
                                            v4       = ls_msg-msgv4
                                            severity = if_abap_behv_message=>severity-error )
                        %key-OrderID = <ls_trobject>-OrderID
                        %cid =  <ls_trobject>-%cid_ref
                        %update = 'X'
                        OrderID = <ls_trobject>-OrderID )
                        to reported-TRObject.
      endif.
    endloop.

  endmethod.

  method read.

    select * from ZC_TimeReportingObject
        for all entries in @keys
        where OrderID = @keys-OrderID
        into corresponding fields of table @result.

  endmethod.

  method rba_TimeEntries.
    data: lt_time_entries type table of ZP_TimeReportingObjectEntries,
          ls_result       like line of result.

    loop at keys_rba assigning field-symbol(<ls_key>).
      " get time entries
      select * from ZP_TimeReportingObjectEntries
          into table @lt_time_entries
          where TRObjectID = @<ls_key>-OrderID.
      if sy-subrc = 0.
        loop at lt_time_entries assigning field-symbol(<time_entry>).
          insert
              value #(
                source-%tky = <ls_key>-%tky
                target-%tky = value #(
                                  counter = <time_entry>-counter
                ) )
          into table association_links.

          if result_requested = abap_true.
            "ls_time_entry = CORRESPONDING #( <time_entry> MAPPING TO ENTITY ).
            move-corresponding <time_entry> to ls_result.
            insert ls_result into table result.
          endif.
        endloop.

      endif.

      sort association_links by source ascending.
      delete adjacent duplicates from association_links comparing all fields.

      sort result by %tky ascending.
      delete adjacent duplicates from result comparing all fields.
    endloop.

  endmethod.

endclass.

I’m not going into absolute detail here, but basically we use a combination of ABAP and EML:

  • Both create and update methods simply look over the imported table entities and then call the legacy function. The function will return an error message if anything went wrong, in which case we add the entity that failed into the failed object (append value #…. to failed-TRObject). We also send back the error message into the reported object (append value #…. to reported-TRObject). If everything goes well, only on create, the newly created Order is added into the mapped object (mapped-TRObject = value #(…)). Honestly, I copied this from a guide that I followed and I’m not sure why this step is needed. My own interpretation is that the order id would be freshly generated, and to return this new order id, we do this step, and that’s why it’s only in create and not update. But maybe someone can explain this better in the comments.
  • In the read method, I simply and directly read from the CDS view. I was not sure if this would work, because again, I wanted all read and query operations to work as if it was managed, so I wasn’t sure what to do here. This seems to work though, but once again, if someone can add some elaboration here, whether my code is fine or should be changed, that would be great!
  • Lastly we have the implementation of the “read by association” where once again I read directly from the corresponding CDS view and fill both the association_links and result returning parameters. When I test my OData service this works, but if anyone has any comments here, go ahead!

Next, because we have “modifying” methods and we have an unmanaged scenario, a behavior pool for saving is added:

class lsc_ZC_TIMEREPORTINGOBJECT definition 
  inheriting from cl_abap_behavior_saver.
  protected section.

    methods finalize redefinition.

    methods check_before_save redefinition.

    methods save redefinition.

    methods cleanup redefinition.

    methods cleanup_finalize redefinition.

endclass.

Since in our case all the logic is already handled in the legacy function. I didn’t need to implement any additional checks or changes here. I only added a code for save:

class lsc_ZC_TIMEREPORTINGOBJECT implementation.

  method finalize.
  endmethod.

  method check_before_save.
  endmethod.

  method save.
    call function 'BAPI_TRANSACTION_COMMIT'.
  endmethod.

  method cleanup.
  endmethod.

  method cleanup_finalize.
  endmethod.

endclass.

Lastly we have the implementation on the child entity. We only have read and “read by association”. I will simply paste the code here, but not further explain it, as it’s already been covered with the parent:

class lhc_TREntry definition inheriting from cl_abap_behavior_handler.
  private section.

    methods read for read
      importing keys for read TREntry result result.

    methods rba_TRObject for read
      importing keys_rba for read TREntry\_TRObject full result_requested result result link association_links.

endclass.
 

CDS Behavior Projection


Next comes the projection of the behavior. Because I only have 1 behavior definition, I also only have 1 projection:

projection;
strict ( 2 );

define behavior for ZC_TimeReportingObject alias TRObject
{
  use create;
  use update;
  use association _TimeEntries { }
}

define behavior for ZC_TimeReportingObEntries alias TREntry
{
  use association _TRObject { }
}

The names after “define behavior” are the names of the CDS data definition projection!

Be sure to include the associations here, or the parent-child relationship won’t be transferred to the OData service.

OData Service


Last but not least we create the OData service. One thing that bugs me when working with RAP, are the loads of steps to take before we can even test our code. The CDS itself can be tested well enough with the Data Preview, but it already fails for example Virtual Elements. I wish that SAP could add a way to test things earlier. Simulate a service, as if there were shadow projections (always managed in the case of testing of course) that simply include everything. Just a little side note from me.

Service Definition

define service ZUI_TimeReportingObject {
  expose ZC_TimeReportingObject;
  expose ZC_TimeReportingObEntries;
}

Simply exposing the CDS projections. I chose ZUI_* in the name to create an OData service for Fiori.

Service Binding

I created service binding ZUI_V4_TIMEREPORTINGOBJECT with binding type OData V4 – UI. Since this is an OData V4, I published the Service via transaction /IWFND/V4_ADMIN (see note 3101976).

Result Example


Once my OData service was ready, I of couse ran my tests and for example this OData call works as intended:

/zui_v4_timereportingobject/srvd/sap/zui_timereportingobject/0001/
ZC_TimeReportingObjec/4000500?$expand=_TimeEntries

{
  "@odata.context" : "$metadata#ZC_TimeReportingObject(_TimeEntries())/$entity",
  "@odata.metadataEtag" : "W/\"20230302113047\"",
  "OrderID" : "4000500",
  "OrderCategory" : "30",
  "OrderType" : "SM01",
  "OrderDescription" : "Regular service order test",
  "IsMarkedForDeletion" : false,
  "ProjectID" : "0",
  "WBSElementID" : "0",
  "IsNetwork" : false,
  "ResponsibleCostCenter" : "17101321",
  "WBSResponsiblePerson" : "0",
  "ResponsibleUser" : "FSM_TECH1",
  "IsAllowedForReporting" : true,
  "TotalReportHours" : 8.00,
  "ControllingArea" : "A000",
  "__EntityControl" : {
    "Updatable" : true
  },
  "SAP__Messages" : [

  ],
  "_TimeEntries" : [
    {
      "counter" : "000000000642",
      "ReceiverOrderID" : "4000500",
      "NetworkID" : "",
      "TRObjectID" : "000004000500",
      "CostCenter" : "17101902",
      "PersonnelID" : "59",
      "WorkDate" : "2023-02-21",
      "ActivityType" : "T002",
      "AttAbsType" : "0800",
      "ShortDesciption" : "",
      "Status" : "20",
      "ReportHours" : 8.00,
      "ReportUnit" : "H"
    }
  ]
}

(this is only test data of course)

Final Words


I hope this helps anyone needing to tackle unmanaged scenarios with RAP. Personally I wish there were a bit better documentation resources (such as SAPUI5 is an excellent example where the SDK is just phenomenal). And no, SAP Help isn’t it. It’s fairly terrible with only ever giving half the information needed and hardly any example. I had to search far and wide to able to solve this, while, looking at it now, it doesn’t look too complex. But if you don’t know, nor find a good place to look for your answers, you will be simply stuck in Google search mess.

No comments:

Post a Comment