Monday 12 August 2019

ABAP / Fiori Elements – Organizing your Data Architecture using CDS Views: Building from Scratch Walkthrough (UI and Logic)

Let us say that you must build a data architecture that needs to have an office that contains employees and workstations. Below I have put an image representing the data architecture of this example.

ABAP Development, NW ABAP Web Services, SAP ABAP Study Materials, SAP ABAP Tutorial and Material

Figure 1.1: Data Architecture


In this case, the Office object is the root object. There will be a relation of 1 to many between the Office object and the Employee object (1 Office for many Employees). There will also be a relation of 1 to many between the Office object and the Workstation object (1 Office for many Workstations).

Important: I used ABAP Development Tools for this walkthrough.

To complete the data architecture, I will need to do 4 things:

1- Create database tables for each of the entities

2- Define basic CDS views relying on the database tables

3- Define transactional CDS views relying on the basic CDS views

4- Define consumption CDS views for the UI relying on the transactional CDS views

Also: I know there is a way to put the CDS views concerning a single entity all into one single file. In other words, there is a way to combine the basic, transactional, and consumption CDS views of a single entity into a single view.

Step 1: Create Database Tables


So I have now created a new package which has nothing in it. I need to start off by creating database tables for each of the entities mentioned above, as your CDS views will require those.

To create a database table, right-click your package, select New->Other ABAP repository project. Then, select Dictionary -> Database Table from the new popup window.

Note: Do not forget to add a field about the abap client being used, as well as another field for uniquely identifying the entity.

Take a look again at Figure 1.1 to be able to design your database tables. You might notice some things are missing.

How can we uniquely identify each objects? This is the reason why I have added below a unique identifier for each entity. I have named them officeuuid, employeeuuid, and workstationuuid.

We are also missing something else: how are we going to connect a certain employee or workstation to a certain office? This is why we also need an officeuuid variable into the Workstation and Employee entities.

Note: In the case where you have other entities below the Employee, you would need an additional key. This is because, in the case of the Employee entity, it is a direct child of the root entity Office. We only need one key to identify the Employee belonging to the Office, because the root entity (Office) is the same as the parent entity of Employee (still Office). However, if there was an entity under Employee, say, DateOfBirth, then you would need a key for the parent entity (Employee) and another key for the root entity (Office).

So this is what it should look like for the different database tables :

(I added comments below in the CDS views to explain the reason why certain things are present)

ZPDB_OFFICE

@EndUserText.label : 'Database table for Office'
@AbapCatalog.enhancementCategory : #NOT_EXTENSIBLE   //change to EXTENSIBLE_ANY to add a structure to DB table
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #LIMITED
define table zpdb_office {
  key sapclient  : mandt not null;   // identifies which system are we running on
  key officeuuid : /bobf/conf_key not null;   // uniquely identifies the office
  name           : abap.string(256);
  location       : abap.string(256);

}

ZPDB_WORKSTATION

@EndUserText.label : 'Database table for Workstation.'
@AbapCatalog.enhancementCategory : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #LIMITED
define table zpdb_workstation {
  key sapclient       : mandt not null;   // which system are we running on?
  key workstationuuid : /bobf/conf_key not null;   // uniquely identifies the workstation
  officeuuid          : /bobf/conf_key;   // answers the question to which office does the workstation belong to?
  workstationid       : abap.string(256);

}

ZPDB_EMPLOYEE

@EndUserText.label : 'Database table for Employee.'
@AbapCatalog.enhancementCategory : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #LIMITED
define table zpdb_employee {
  key sapclient     : mandt not null;   // purpose: which system are we running on?
  key employeeuuid  : /bobf/conf_key not null;   // purpose: uniquely identifies the employee
  officeuuid        : /bobf/conf_key;   // purpose: in which office does the employee work?
  name              : abap.string(256);
  employee_position : abap.string(256);
  gender            : abap.char(1);
  experience_years  : abap.int4;

}

Warning: Make sure the keys you create to uniquely identify your objects (which, in my case, all finish by uuid) are of type /bobf/conf_key and that you add not null after otherwise the key will show as an error. This is because you cannot uniquely identify an object using null keys; you are required to have a key to be able to find your object.

Note: If you want to add a structure to the table, make sure you change the value of the annotation AbapCatalog.enhancementCategory to #EXTENSIBLE_ANY

Step 2: Create Basic CDS Views


To create a CDS view, right-click on your package, and select New -> Other ABAP repository project. From that list, select Core Data Services -> Data Definition.

The CDS views will be divided into three types of views: the basic, transactional, and consumption view. I believe you can all combine them into one, but it seems more logic to me to separate them this way, as it makes things clearer.

For each of those views, you will only list the fields you want to take from your database tables. You will also have to include the mention that the view is of type basic.

Notice that the line defining the view includes the name of the database table I created in the previous step. This is where we extract the data from.

(I added comments below in the CDS views to explain the reason why certain things are present)

ZIPDB_OFFICE

@AbapCatalog.sqlViewName: 'ZIPDBOFFICE'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Interface for office.'

@VDM.viewType: #BASIC
define view ZIPDB_OFFICE as select from ZPDB_OFFICE {
  key ZPDB_OFFICE.officeuuid,  //taking the officeuuid variable from our DB table ZPDB_OFFICE
      ZPDB_OFFICE.name,   //taking the name variable ...
      ZPDB_OFFICE.location
}

ZIPDB_WORKSTATION

@AbapCatalog.sqlViewName: 'ZIPDBWORKSTATION'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Interface for workstation.'

@VDM.viewType: #BASIC
define view ZIPDB_WORKSTATION as select from zpdb_workstation {
  key zpdb_workstation.workstationuuid,
      zpdb_workstation.officeuuid,
      zpdb_workstation.workstationid
}

ZIPDB_EMPLOYEE

@AbapCatalog.sqlViewName: 'ZIPDBEMPLOYEE'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Interface for employee.'

@VDM.viewType: #BASIC
define view ZIPDB_EMPLOYEE as select from zpdb_employee {
  key zpdb_employee.employeeuuid,
      zpdb_employee.officeuuid,
      zpdb_employee.name,
      zpdb_employee.employee_position,
      zpdb_employee.gender,
      zpdb_employee.experience_years
}

Note: The SQL view name (first annotation) must have a different name from the CDS view. I just remove the underscores from the name of the CDS view to make the SQL view name different.

Step 3: Create Transactional CDS Views


This type of view will create the associations between the different CDS views. Notice that now, my data source will be the CDS views I created in the previous step, not the database tables themselves. Annotations will get added for this type of CDS view.

This is where your keys that you created to relate the children entities Employee and Workstation become important: they will allow you to create associations with Office.

(I added comments below in the CDS views to explain the reason why certain things are present)

ZIPDB_OFFICE_TP

@AbapCatalog.sqlViewName: 'ZIPDBOFFICETP'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Transactional view for office.'
@ObjectModel: {
  modelCategory: #BUSINESS_OBJECT,
  transactionalProcessingEnabled: true,
  transactionalProcessingUnitRoot: true,   //identifies this entity as root
  compositionRoot: true,   //identifies this entity as root
  writeActivePersistence: 'zpdb_office',   //writes all changes to our database table zpdb_office, which is the one for the office entity
  createEnabled: true,   //allows you to create new entities of type office
  updateEnabled: true,   //allows you to update ...
  deleteEnabled: true,   //allows you to delete ...
  representativeKey: 'officeuuid',   //indicates that the officeuuid variable uniquely identifies the office; that there is no two same values of officeuuid.
  semanticKey: ['name'],   //indicates that the name variable identifies the office through the gui, as we will not want to use the officeuuid to classify the offices (00A5E333 is not user-friendly identification)
  usageType: {
    dataClass: #TRANSACTIONAL,
    sizeCategory: #L,
    serviceQuality: #C
  }

}

@VDM.viewType: #TRANSACTIONAL
define view ZIPDB_OFFICE_TP as select from ZIPDB_OFFICE //notice our data source is our basic view created in the previous step!
  association [1..*] to ZIPDB_EMPLOYEE_TP as _Employee on $projection.officeuuid = _Employee.officeuuid
  association [1..*] to ZIPDB_WORKSTATION_TP as _Workstation on $projection.officeuuid = _Workstation.officeuuid
{
  key ZIPDB_OFFICE.officeuuid,
      ZIPDB_OFFICE.name,
      ZIPDB_OFFICE.location,
      
//indicates that the CDS view with alias _Employee is a child of this CDS view, ZIPDB_OFFICE_TP (which is a view for our office entity)
      @ObjectModel.association.type: [#TO_COMPOSITION_CHILD]
      _Employee,
//similar case...
      @ObjectModel.association.type: [#TO_COMPOSITION_CHILD]
      _Workstation
}

What you should know:

◈ This file contains the annotations @ObjectModel.transactionProcessingUnitRoot: true and @ObjectModel.compositionRoot: true because the Office entity is the root entity.

◈ The value of the annotation @ObjectModel.writeActivePersistence is zpdb_office, which is the database table I created for the Office entity.

◈ The values for ObjectModel.createEnabled, ObjectModel.updateEnabled, and ObjectModel.deleteEnabled can be set to false,it is up to you.

◈ The ObjectModel.representativeKey is the key that is identifying your object from the computer’s perspective (in my case, officeuuid)

◈ The ObjectModel.semanticKey is the key that is identifying the entity from the user’s perspective (in this case, it is the name of the office that distinguishes the offices because we refer to them by name, not by key)

◈ Notice that there is now the keyword association following the definition of the CDS view. It describes the relationship between entities (0..1, 1..1, 1..*, etc) along with the key that is used to establish that relationship. $projection.officeuuid means that this CDS view’s officeuuid variable will be used to establish an association. _Employee.officeuuid means that I am selecting the variable officeuuid for the CDS view ZIPDB_EMPLOYEE_TP (which has an alias of _Employee). Altogether, $projection.officeuuid = _Employee.officeuuid means that those two variables will hold the same value to be able to relate the Employee to its respective Office.

◈ The ObjectModel.association.type annotation describes the association the CDS view ZIPDB_OFFICE_TP has with the other CDS views mentioned in its associations’ definitions. In this case, both associations are, from the Office entity point of view, children to it.

ZIPDB_WORKSTATION_TP

@AbapCatalog.sqlViewName: 'ZIPDBWORKSTATP'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Transactional view for workstation.'
@ObjectModel: {
  modelCategory: #BUSINESS_OBJECT,
  transactionalProcessingEnabled: true,
  writeActivePersistence: 'zpdb_workstation',
  createEnabled: true,
  updateEnabled: true,
  deleteEnabled: true,
  representativeKey: 'workstationuuid',
  semanticKey: ['workstationid'],
  usageType: {
    dataClass: #TRANSACTIONAL,
    sizeCategory: #L,
    serviceQuality: #C
  }

}

@VDM.viewType: #TRANSACTIONAL
define view ZIPDB_WORKSTATION_TP as select from ZIPDB_WORKSTATION 
  association [1..1] to ZIPDB_OFFICE_TP as _Office on $projection.officeuuid = _Office.officeuuid
{
  key ZIPDB_WORKSTATION.workstationuuid,
  
      @ObjectModel.foreignKey.association: '_Office'
      ZIPDB_WORKSTATION.officeuuid,
      
      ZIPDB_WORKSTATION.workstationid,
      
      @ObjectModel.association.type: [#TO_COMPOSITION_PARENT, #TO_COMPOSITION_ROOT]
      _Office
}

What you should know:

◈ The sqlViewName was too long, so I had to reduce it to ZIPDBWORKSTATP

◈ The annotation ObjectModel.foreignKey.association is used to tell to which CDS view is the variable is connected to. In this case, it means the variable ZIPDB_WORKSTATION.officeuuid is the key representing the office in which this workstation is in.

◈ Notice that now from the Workstation point of view, there is only itself related to 1 Office entity. So, this is why the association is 1 to 1 which is written as [1..1].

◈ Also notice that the ObjectModel.association.type value is now TO_COMPOSITION_PARENT and TO_COMPOSITION_ROOT, because the Office entity is parent to Workstation and also the root of the structure.

ZIPDB_EMPLOYEE_TP

@AbapCatalog.sqlViewName: 'ZIPDBEMPLOYEETP'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Transactional view for employee.'
@ObjectModel: {
  modelCategory: #BUSINESS_OBJECT,
  transactionalProcessingEnabled: true,
  writeActivePersistence: 'zpdb_employee',
  createEnabled: true,
  updateEnabled: true,
  deleteEnabled: true,
  representativeKey: 'employeeuuid',
  semanticKey: ['name'],
  usageType: {
    dataClass: #TRANSACTIONAL,
    sizeCategory: #L,
    serviceQuality: #C
  }

}

@VDM.viewType: #TRANSACTIONAL
define view ZIPDB_EMPLOYEE_TP as select from ZIPDB_EMPLOYEE 
  association [1..1] to ZIPDB_OFFICE_TP as _Office on $projection.officeuuid = _Office.officeuuid
{
  key ZIPDB_EMPLOYEE.employeeuuid,
  
      @ObjectModel.foreignKey.association: '_Office'
      ZIPDB_EMPLOYEE.officeuuid,
      
      ZIPDB_EMPLOYEE.name,
      ZIPDB_EMPLOYEE.employee_position,
      ZIPDB_EMPLOYEE.gender,
      ZIPDB_EMPLOYEE.experience_years,
      
      @ObjectModel.association.type: [#TO_COMPOSITION_PARENT, #TO_COMPOSITION_ROOT]
      _Office
}

The reasons that the values were set this way is explained in the two previous paragraphs.

If there are no error in your CDS views, then there will be a BOPF Business Object appearing in your project with the name of the root CDS view, which is ZIPDB_OFFICE_TP.

Step 4: Create Consumption CDS Views


Consumption views are used to give details on how the information should be displayed with Fiori Elements (the UI). This is outside the scope of this walkthrough, so I will not cover it in much detail. I will, however, show some annotations you can add that will have an influence on Fiori Elements.

(I added comments below in the CDS views to explain the reason why certain things are present)

ZCPDB_OFFICE_TP

@AbapCatalog.sqlViewName: 'ZCPDBOFFICETP'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Consumption view for office.'
@ObjectModel:{
  transactionalProcessingDelegated: true,

  //modelCategory: #BUSINESS_OBJECT,
  representativeKey: 'officeuuid',
  semanticKey: ['name'],
  usageType: {
   dataClass:      #TRANSACTIONAL,
   serviceQuality: #B,
   sizeCategory:   #L
  },

  createEnabled: true,
  updateEnabled: true,
  deleteEnabled: true

}
@UI: {
  headerInfo: {
    typeName: 'Office',
    typeNamePlural: 'Offices'
  }
}
@Search.searchable: true   //will show a search field to be able to search specific instances of Office
@VDM.viewType: #CONSUMPTION
define view ZCPDB_OFFICE_TP as select from ZIPDB_OFFICE_TP 
  association [1..*] to ZCPDB_EMPLOYEE_TP as _Employee on $projection.officeuuid = _Employee.officeuuid
  association [1..*] to ZCPDB_WORKSTATION_TP as _Workstation on $projection.officeuuid = _Workstation.officeuuid
{
      
//allows to organize the order of the entities in the Object Page in Fiori Elements. This is why
//in one picture I have of the UI below, the Office information is before that of Employee, and 
//Employee is before Workstation.
      @UI:{

        facet:[
          {
            label:'Employee',
            position: 20,
            type: #LINEITEM_REFERENCE,
            targetElement: '_Employee'
          },
          {
            label: 'Workstation',
            position: 30,
            type: #LINEITEM_REFERENCE,
            targetElement: '_Workstation'
          },
          {
           label: 'Office',
             id: 'officeId',
             position: 10,
             type: #COLLECTION
           },
           {
             parentId: 'officeId',
             type: #FIELDGROUP_REFERENCE,
             targetQualifier: 'officeIdFG'
           }
        ]
      }
      @Consumption.filter.hidden: true
      
      key ZIPDB_OFFICE_TP.officeuuid,
      
      @Search.defaultSearchElement: true   //annotation required on one field if you want to be able to search instances
      //fieldGroup is important to determine where the variable is placed - is it placed with Employee entity? Office entity? Set the qualifier appropriately. 
      @UI: {
        fieldGroup: [{ qualifier: 'officeIdFG', position: 10, label: 'Office Name' }],
        identification: { position: 10, importance: #HIGH },    //position holds same importance; determines what shows first
        lineItem: [{position: 10, label: 'Office Name'}]
      }
      ZIPDB_OFFICE_TP.name,
      
      @UI: {
        fieldGroup: [{ qualifier: 'officeIdFG', position: 20, label: 'Office Location' }],
        identification: { position: 20, importance: #HIGH },
        lineItem: [{position: 20, label: 'Office Location'}]
      }
      ZIPDB_OFFICE_TP.location,
      
      @ObjectModel.association.type: [#TO_COMPOSITION_CHILD]
      _Employee,
      @ObjectModel.association.type: [#TO_COMPOSITION_CHILD]
      _Workstation
}

What you need to know:

◈ UI.headerInfo annotation will be used to show the titles in the UI through Fiori Elements.

◈ UI.facet annotation organizes the UI to make it understand that the parent CDS view is Office, and that the children views are Employee and Workstation. The position factor indicates which one will appear first when you click on an object from the ListReport page of the Fiori Elements application. To connect the CDS’ views variables to your UI, you have to add the UI.fieldGroup, UI.indentification, and UI.lineItem annotations.

◈ The Search.searchable annotation allows to automatically create a search input field for the CDS view. You also have to add the annotation Search.defaultSearchElement to tell for which variable in this CDS view is it possible to search for.

◈ The UI.hidden annotation allows you to hide a parameter from the GUI.

ZCPDB_EMPLOYEE_TP

@AbapCatalog.sqlViewName: 'ZCPDBEMPLOYEETP'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Consumption View'
@ObjectModel:{
  transactionalProcessingDelegated: true,

  //modelCategory: #BUSINESS_OBJECT,
  representativeKey: 'employeeuuid',
  semanticKey: ['name'],
  usageType: {
   dataClass:      #TRANSACTIONAL,
   serviceQuality: #B,
   sizeCategory:   #L
  },

  createEnabled: true,
  updateEnabled: true,
  deleteEnabled: true

}
@UI: {
  headerInfo: {
    typeName: 'Employee',
    typeNamePlural: 'Employees'
  }
}
@Search.searchable: true
@VDM.viewType: #CONSUMPTION
define view ZCPDB_EMPLOYEE_TP as select from ZIPDB_EMPLOYEE_TP 
  association [1..1] to ZCPDB_OFFICE_TP as _Office on $projection.officeuuid = _Office.officeuuid
{

  @UI: {
       facet: [
       {
       label: 'Employee',
         id: 'employeeId',
         position: 10,
         type: #COLLECTION
       },
       {
         parentId: 'employeeId',
         type: #FIELDGROUP_REFERENCE,
         targetQualifier: 'employeeIdFG'
       }]
       
  }
  @UI.lineItem.position: 10
  @UI.hidden: true
  key ZIPDB_EMPLOYEE_TP.employeeuuid,
      @UI.hidden: true
      ZIPDB_EMPLOYEE_TP.officeuuid,
      
      @Search.defaultSearchElement: true
      @UI: {
        fieldGroup: [{ qualifier: 'employeeIdFG', position: 10, label: 'Employee Name' }],
        identification: { position: 10, importance: #HIGH },
        lineItem: [{position: 10, label: 'Employee Name'}]
      }
      ZIPDB_EMPLOYEE_TP.name,
      
      @UI: {
        fieldGroup: [{ qualifier: 'employeeIdFG', position: 20, label: 'Employee Position' }],
        identification: { position: 20, importance: #HIGH },
        lineItem: [{position: 20, label: 'Employee Position'}]
      }
      ZIPDB_EMPLOYEE_TP.employee_position,
      
      @UI: {
        fieldGroup: [{ qualifier: 'employeeIdFG', position: 30, label: 'Employee Gender' }],
        identification: { position: 30, importance: #HIGH },
        lineItem: [{position: 30, label: 'Employee Gender'}]
      }
      ZIPDB_EMPLOYEE_TP.gender,
      
      @UI: {
        fieldGroup: [{ qualifier: 'employeeIdFG', position: 40, label: 'Employee Experience in Years' }],
        identification: { position: 40, importance: #HIGH },
        lineItem: [{position: 40, label: 'Employee Experience in Years'}]
      }
      ZIPDB_EMPLOYEE_TP.experience_years,
      
      @ObjectModel.association.type: [#TO_COMPOSITION_PARENT, #TO_COMPOSITION_ROOT]
      _Office
}

ZCPDB_WORKSTATION_TP

@AbapCatalog.sqlViewName: 'ZCPDBWORKSTATP'
@AbapCatalog.compiler.compareFilter: true
@AbapCatalog.preserveKey: true
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Consumption view for workstation.'
@ObjectModel:{
  transactionalProcessingDelegated: true,

  //modelCategory: #BUSINESS_OBJECT,
  representativeKey: 'workstationuuid',
  semanticKey: ['workstationid'],
  usageType: {
   dataClass:      #TRANSACTIONAL,
   serviceQuality: #B,
   sizeCategory:   #L
  },

  createEnabled: true,
  updateEnabled: true,
  deleteEnabled: true

}
@UI: {
  headerInfo: {
    typeName: 'Workstation',
    typeNamePlural: 'Workstations'
  }
}
@Search.searchable: true
@VDM.viewType: #CONSUMPTION
define view ZCPDB_WORKSTATION_TP as select from ZIPDB_WORKSTATION_TP 
  association [1..1] to ZCPDB_OFFICE_TP as _Office on $projection.officeuuid = _Office.officeuuid
{
  @UI: {
       facet: [
       {
       label: 'Workstation',
         id: 'workstationId',
         position: 10,
         type: #COLLECTION
       },
       {
         parentId: 'workstationId',
         type: #FIELDGROUP_REFERENCE,
         targetQualifier: 'workstationIdFG'
       }]
       
  }
  @UI.lineItem.position: 10
  @UI.hidden: true
  key ZIPDB_WORKSTATION_TP.workstationuuid,
      @UI.hidden: true
      ZIPDB_WORKSTATION_TP.officeuuid,
      
      @Search.defaultSearchElement: true
      @UI: {
        fieldGroup: [{ qualifier: 'workstationIdFG', position: 10, label: 'Workstation Identifier' }],
        identification: { position: 10, importance: #HIGH },
        lineItem: [{position: 10, label: 'Workstation Identifier'}]
      }
      ZIPDB_WORKSTATION_TP.workstationid,
      
      @ObjectModel.association.type: [#TO_COMPOSITION_PARENT, #TO_COMPOSITION_ROOT]
      _Office
}

4 comments:

  1. Very nice article for SAP which I have seen and it's absolutely great stuff on SAP ABAP. Thanks for such a cool article about SAP ABAP topics. Very good explanation on SAP concepts we do SAP Training in Chennai for all SAP Modules.
    Regards,
    SAP ABAP Training Institutes in Chennai | SAP ABAP Training in Chennai

    ReplyDelete
  2. Very good blog for SAP which I have seen and it's absolutely great stuff on SAP Topics. Thanks for such a cool article about SAP topics. Very good explanation on SAP concepts we do SAP Training in Chennai for all SAP Modules.
    Regards,
    SAP Training Institutes in Chennai | Best SAP Training in Chennai

    ReplyDelete
  3. This comment has been removed by the author.

    ReplyDelete