Contents
- Project Scope: What Are We Building?
- Object List and Naming
- Step 1: Two Tables and a Composition
- Step 2: Behavior Definition for Header and Items
- Step 3: From Item to Header — Currency and Total
- Step 4: Submit, Approve, Reject
- Step 5: Projection and Service
- Step 6: The Screen with Metadata Extensions
- Step 7: Read Authorization for Items
- Creation Order in ADT
- Test Scenarios
- What I Deliberately Left Out
- Common Mistakes in Header–Item Objects
- Conclusion
In the SAP RAP guide I used a draft-enabled leave request to explain RAP's layers, the behavior definition, determinations, validations, actions, EML and unmanaged save. This article is its sequel: instead of explaining the same concepts again, I focus on the questions you face on day one of a real project. How are items attached to the header? Where is the total calculated? How does the screen update when the user changes an item? Where does the items' authorization come from?
Project Scope: What Are We Building?
The scenario is a process everyone knows: an employee opens an expense report, enters the expenses line by line and submits the report for approval. The manager approves it or rejects it with a reason. What we expect from the app:
- Header: employee, description, currency, total amount, status and rejection reason.
- Items: expense date, expense type, amount and note. A report has a single currency; items use the header's currency.
- Total: not typed by the user — calculated from the items and updated on screen as items change.
- Status flow: Open → Submitted → Approved / Rejected. A rejected report can be corrected and resubmitted.
- Rules: an item amount must be greater than zero, a report without items cannot be submitted, a rejection reason is mandatory.
- Draft: the user must be able to leave a report half-finished and continue later.
Object List and Naming
Starting a RAP project with an object list makes both estimating and splitting the work within a team easier. These are the objects for this project, all in one package (ZEXPENSE):
| Object | Type | Responsibility |
|---|---|---|
ZEXP_REP, ZEXP_ITEM | Database table | Header and item data |
ZEXP_REP_D, ZEXP_ITEM_D | Draft table | Unsaved changes (generated by ADT) |
ZR_ExpenseReport, ZR_ExpenseItem | CDS view entity | The business object's data model (interface layer) |
ZR_ExpenseReport | Behavior definition | The behavioural contract; both entities in one BDEF |
ZBP_R_EXPENSEREPORT | ABAP class (behavior pool) | Determination, validation and action code |
ZD_RejectReason | Abstract entity | Parameter of the reject action |
ZC_ExpenseReport, ZC_ExpenseItem | Projection view + projection BDEF | The slice exposed to the Fiori app |
ZC_ExpenseReport, ZC_ExpenseItem | Metadata extension | Layout of the list, object page and buttons |
ZUI_EXPENSE, ZUI_EXPENSE_O4 | Service definition / binding | OData V4 - UI service |
ZR_EXPENSEREPORT, ZR_EXPENSEITEM | Access control (DCL) | Read authorization |
ZEXPENSE | Message class | Validation and action messages |
The prefixes follow the habit SAP uses in its own development: R_ for the reusable layer of the business object, C_ for the projection for a specific app. If your team has an established naming convention, use that; what matters is that the layer can be read from the object name.
Step 1: Two Tables and a Composition
The header table contains RAP's administrative fields, as in the guide. What is new is the currency-dependent amount field: @Semantics.amount.currencyCode tells which currency field the amount is interpreted against.
@EndUserText.label : 'Expense report header'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zexp_rep {
key client : abap.clnt not null;
key report_uuid : sysuuid_x16 not null;
employee_id : abap.char(8);
description : abap.char(80);
@Semantics.amount.currencyCode : 'zexp_rep.currency_code'
total_amount : abap.curr(15,2);
currency_code : abap.cuky;
status : abap.char(1);
reject_reason : abap.char(100);
local_created_by : abp_creation_user;
local_created_at : abp_creation_tstmpl;
local_last_changed_by : abp_locinst_lastchange_user;
local_last_changed_at : abp_locinst_lastchange_tstmpl;
last_changed_at : abp_lastchange_tstmpl;
}The item table carries its own UUID key and a report_uuid field pointing to the header. The child entity does not need created/changed-by information; local_last_changed_at is enough for concurrency control:
@EndUserText.label : 'Expense report item'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zexp_item {
key client : abap.clnt not null;
key item_uuid : sysuuid_x16 not null;
report_uuid : sysuuid_x16;
expense_date : abap.dats;
expense_type : abap.char(2);
@Semantics.amount.currencyCode : 'zexp_item.currency_code'
amount : abap.curr(15,2);
currency_code : abap.cuky;
note : abap.char(80);
local_last_changed_at : abp_locinst_lastchange_tstmpl;
}The relationship is defined in the CDS layer. The root entity declares the items with a composition; the item knows its parent through association to parent. These two lines tell RAP "the item is part of the header": an item cannot exist without a header, deleting the header deletes its items, and locking and draft are managed from the header.
@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Expense report'
define root view entity ZR_ExpenseReport
as select from zexp_rep
composition [0..*] of ZR_ExpenseItem as _Item
{
key report_uuid as ReportUuid,
employee_id as EmployeeId,
description as Description,
@Semantics.amount.currencyCode: 'CurrencyCode'
total_amount as TotalAmount,
currency_code as CurrencyCode,
status as Status,
reject_reason as RejectReason,
@Semantics.user.createdBy: true
local_created_by as LocalCreatedBy,
@Semantics.systemDateTime.createdAt: true
local_created_at as LocalCreatedAt,
@Semantics.user.localInstanceLastChangedBy: true
local_last_changed_by as LocalLastChangedBy,
@Semantics.systemDateTime.localInstanceLastChangedAt: true
local_last_changed_at as LocalLastChangedAt,
@Semantics.systemDateTime.lastChangedAt: true
last_changed_at as LastChangedAt,
_Item
}@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Expense report item'
define view entity ZR_ExpenseItem
as select from zexp_item
association to parent ZR_ExpenseReport as _Report
on $projection.ReportUuid = _Report.ReportUuid
{
key item_uuid as ItemUuid,
report_uuid as ReportUuid,
expense_date as ExpenseDate,
expense_type as ExpenseType,
@Semantics.amount.currencyCode: 'CurrencyCode'
amount as Amount,
currency_code as CurrencyCode,
note as Note,
@Semantics.systemDateTime.localInstanceLastChangedAt: true
local_last_changed_at as LocalLastChangedAt,
_Report
}The item's key is its own UUID only. ReportUuid is not a key but an ordinary field; RAP fills it itself when the item is created through the header. That is why the item code can rely on ReportUuid to find the header.
Step 2: Behavior Definition for Header and Items
Both entities are defined in a single behavior definition. Compared with the single-entity example in the guide, I explain the new lines one by one below:
managed implementation in class zbp_r_expensereport unique;
strict ( 2 );
with draft;
define behavior for ZR_ExpenseReport alias ExpenseReport
persistent table zexp_rep
draft table zexp_rep_d
lock master
total etag LastChangedAt
authorization master ( global, instance )
etag master LocalLastChangedAt
{
create;
update;
delete;
association _Item { create; with draft; }
field ( numbering : managed, readonly ) ReportUuid;
field ( readonly ) TotalAmount, Status, RejectReason,
LocalCreatedBy, LocalCreatedAt,
LocalLastChangedBy, LocalLastChangedAt, LastChangedAt;
field ( mandatory ) EmployeeId, Description, CurrencyCode;
determination setInitialStatus on modify { create; }
determination syncItemCurrency on modify { field CurrencyCode; }
action ( features : instance ) submit result [1] $self;
action ( features : instance ) approve result [1] $self;
action ( features : instance ) reject parameter ZD_RejectReason result [1] $self;
internal action recalcTotal;
side effects
{
field CurrencyCode affects entity _Item;
}
draft action Edit;
draft action Activate optimized;
draft action Discard;
draft action Resume;
draft determine action Prepare
{
validation ExpenseItem~validateAmount;
}
mapping for zexp_rep
{
ReportUuid = report_uuid;
EmployeeId = employee_id;
Description = description;
TotalAmount = total_amount;
CurrencyCode = currency_code;
Status = status;
RejectReason = reject_reason;
LocalCreatedBy = local_created_by;
LocalCreatedAt = local_created_at;
LocalLastChangedBy = local_last_changed_by;
LocalLastChangedAt = local_last_changed_at;
LastChangedAt = last_changed_at;
}
}
define behavior for ZR_ExpenseItem alias ExpenseItem
persistent table zexp_item
draft table zexp_item_d
lock dependent by _Report
authorization dependent by _Report
etag master LocalLastChangedAt
{
update;
delete;
association _Report { with draft; }
field ( numbering : managed, readonly ) ItemUuid;
field ( readonly ) ReportUuid, CurrencyCode, LocalLastChangedAt;
field ( mandatory ) ExpenseDate, ExpenseType, Amount;
determination setCurrency on modify { create; }
determination recalcReportTotal on modify { create; field Amount; }
validation validateAmount on save { create; field Amount; }
side effects
{
field Amount affects field _Report.TotalAmount;
}
mapping for zexp_item
{
ItemUuid = item_uuid;
ReportUuid = report_uuid;
ExpenseDate = expense_date;
ExpenseType = expense_type;
Amount = amount;
CurrencyCode = currency_code;
Note = note;
LocalLastChangedAt = local_last_changed_at;
}
}association _Item { create; with draft; }: Items are only created through the header, which is why the item entity has no standalonecreate.with draftmakes sure an item is created as a draft while the header is in draft. The item'sassociation _Report { with draft; }says the same in the other direction.lock dependent by _Report,authorization dependent by _Report: The item has no lock or authorization check of its own; both come from the header. When the user changes an item, the whole report is locked.authorization master ( global, instance ): In the guide I only usedinstance.globalchecks operations for which no record exists yet (such as creating a report);instancechecks changes and actions on a specific report. If you also want to restrict who may create reports, you need both.internal action recalcTotal: An action that is not exposed and can only be called from the object's own code. I use it to keep the total calculation in one place.parameter ZD_RejectReason: The reject action takes a parameter. Fiori Elements automatically opens an input dialog for it when the button is pressed.side effects: Tells the screen "when this field changes, reload that". When an item amount changes the header total is refreshed, and when the header currency changes the item table is. Without this the value is calculated correctly in the background, but the user sees the old figure until they refresh.validation ExpenseItem~validateAmount: The child entity's validation is added to the header'sPrepareaction with the entity name as a prefix, so item errors also show up during the draft phase.
The item currency is read-only: the user does not type it, it is copied from the header. This is a deliberate simplification; supporting receipts in foreign currencies requires currency conversion, which is outside the scope of this example.
The reject parameter: an abstract entity
Action parameters are defined with an abstract entity, which has no counterpart in the database:
@EndUserText.label: 'Rejection reason'
define abstract entity ZD_RejectReason
{
@EndUserText.label: 'Reason'
RejectReason : abap.char(100);
}Step 3: From Item to Header — Currency and Total
The behavior pool contains two handler classes: one for the header and one for the items. ADT generates the method skeleton for every determination, validation and action in the BDEF via a quick fix:
CLASS lhc_expensereport DEFINITION INHERITING FROM cl_abap_behavior_handler.
PRIVATE SECTION.
CONSTANTS:
BEGIN OF status,
open TYPE c LENGTH 1 VALUE 'O',
submitted TYPE c LENGTH 1 VALUE 'S',
approved TYPE c LENGTH 1 VALUE 'A',
rejected TYPE c LENGTH 1 VALUE 'R',
END OF status.
METHODS get_global_authorizations FOR GLOBAL AUTHORIZATION
IMPORTING REQUEST requested_authorizations FOR ExpenseReport RESULT result.
METHODS get_instance_authorizations FOR INSTANCE AUTHORIZATION
IMPORTING keys REQUEST requested_authorizations FOR ExpenseReport RESULT result.
METHODS get_instance_features FOR INSTANCE FEATURES
IMPORTING keys REQUEST requested_features FOR ExpenseReport RESULT result.
METHODS set_initial_status FOR DETERMINE ON MODIFY
IMPORTING keys FOR ExpenseReport~setInitialStatus.
METHODS sync_item_currency FOR DETERMINE ON MODIFY
IMPORTING keys FOR ExpenseReport~syncItemCurrency.
METHODS submit FOR MODIFY
IMPORTING keys FOR ACTION ExpenseReport~submit RESULT result.
METHODS approve FOR MODIFY
IMPORTING keys FOR ACTION ExpenseReport~approve RESULT result.
METHODS reject FOR MODIFY
IMPORTING keys FOR ACTION ExpenseReport~reject RESULT result.
METHODS recalc_total FOR MODIFY
IMPORTING keys FOR ACTION ExpenseReport~recalcTotal.
ENDCLASS.
CLASS lhc_expenseitem DEFINITION INHERITING FROM cl_abap_behavior_handler.
PRIVATE SECTION.
METHODS set_currency FOR DETERMINE ON MODIFY
IMPORTING keys FOR ExpenseItem~setCurrency.
METHODS recalc_report_total FOR DETERMINE ON MODIFY
IMPORTING keys FOR ExpenseItem~recalcReportTotal.
METHODS validate_amount FOR VALIDATE ON SAVE
IMPORTING keys FOR ExpenseItem~validateAmount.
ENDCLASS.set_initial_status and approve follow exactly the pattern from the guide, so I do not repeat them here. The authorization methods hold nothing new either: you run an AUTHORITY-CHECK against your own authorization object and write the outcome to result.
The item currency comes from the header
When a new item is created, the header's currency is copied to it. If the header currency changes later, the second determination updates all items. Both rely on the same idea: the item's ReportUuid and draft state are enough to find the right header.
METHOD set_currency.
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseItem
FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
RESULT DATA(items).
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseItem BY \_Report
FIELDS ( CurrencyCode ) WITH CORRESPONDING #( keys )
RESULT DATA(reports).
MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseItem
UPDATE FIELDS ( CurrencyCode )
WITH VALUE #( FOR item IN items
( %tky = item-%tky
CurrencyCode = reports[ %is_draft = item-%is_draft
ReportUuid = item-ReportUuid ]-CurrencyCode ) ).
ENDMETHOD.
" in lhc_expensereport
METHOD sync_item_currency.
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
FIELDS ( CurrencyCode ) WITH CORRESPONDING #( keys )
RESULT DATA(reports).
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport BY \_Item
FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
RESULT DATA(items).
MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseItem
UPDATE FIELDS ( CurrencyCode )
WITH VALUE #( FOR item IN items
( %tky = item-%tky
CurrencyCode = reports[ %is_draft = item-%is_draft
ReportUuid = item-ReportUuid ]-CurrencyCode ) ).
ENDMETHOD.Including %is_draft in the match matters: the same report can have an active and a draft version at the same time, and both share the same ReportUuid.
The total is calculated in one place
The total is calculated in the internal action recalcTotal. The item-side determination only says "recalculate my parent's total"; it knows nothing about the calculation itself:
" in lhc_expenseitem
METHOD recalc_report_total.
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseItem BY \_Report
FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
RESULT DATA(reports).
MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
EXECUTE recalcTotal FROM CORRESPONDING #( reports ).
ENDMETHOD.
" in lhc_expensereport
METHOD recalc_total.
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
FIELDS ( TotalAmount ) WITH CORRESPONDING #( keys )
RESULT DATA(reports).
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport BY \_Item
FIELDS ( ReportUuid Amount ) WITH CORRESPONDING #( keys )
RESULT DATA(items).
LOOP AT reports ASSIGNING FIELD-SYMBOL(<report>).
CLEAR <report>-TotalAmount.
LOOP AT items INTO DATA(item)
WHERE ReportUuid = <report>-ReportUuid
AND %is_draft = <report>-%is_draft.
<report>-TotalAmount += item-Amount.
ENDLOOP.
ENDLOOP.
MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
UPDATE FIELDS ( TotalAmount ) WITH CORRESPONDING #( reports ).
ENDMETHOD.The benefit of this split shows when another place needs to recalculate the total: the submit action below calls the same internal action. If the calculation rule changes (for example, some expense types should be excluded from the total), you change a single method.
recalcReportTotal are item creation and amount changes; deletion is not on the list. When an item is deleted in a draft, the total on screen and the saved total may stay stale until another item changes. In this example I guarantee that the figure sent for approval is always correct by recalculating the total once more in the submit action. If you also want it updated at the moment of deletion, check whether your release supports delete as a determination trigger for child entities.Item validation and %path
The validation pattern is the same as in the guide; the only difference is that for child entities the message is linked to the parent with %path. Fiori Elements uses this path information to find the faulty row in the item table:
METHOD validate_amount.
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseItem
FIELDS ( Amount ) WITH CORRESPONDING #( keys )
RESULT DATA(items).
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseItem BY \_Report
FROM CORRESPONDING #( items )
LINK DATA(links).
LOOP AT items INTO DATA(item).
APPEND VALUE #( %tky = item-%tky
%state_area = 'VALIDATE_AMOUNT' ) TO reported-expenseitem.
IF item-Amount <= 0.
APPEND VALUE #( %tky = item-%tky ) TO failed-expenseitem.
APPEND VALUE #( %tky = item-%tky
%state_area = 'VALIDATE_AMOUNT'
%msg = new_message(
id = 'ZEXPENSE'
number = '001'
severity = if_abap_behv_message=>severity-error )
%path = VALUE #( expensereport-%tky =
links[ KEY id source-%tky = item-%tky ]-target-%tky )
%element-Amount = if_abap_behv=>mk-on ) TO reported-expenseitem.
ENDIF.
ENDLOOP.
ENDMETHOD.Step 4: Submit, Approve, Reject
Submitting: refresh the total, refuse empty reports
submit does two things: it recalculates the total from the items and refuses reports without items. Reports that fail are written to failed and reported; only the valid ones change status:
METHOD submit.
MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
EXECUTE recalcTotal FROM CORRESPONDING #( keys ).
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport BY \_Item
FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
RESULT DATA(items).
DATA(valid_keys) = keys.
LOOP AT valid_keys INTO DATA(report_key).
IF NOT line_exists( items[ ReportUuid = report_key-ReportUuid ] ).
APPEND VALUE #( %tky = report_key-%tky ) TO failed-expensereport.
APPEND VALUE #( %tky = report_key-%tky
%msg = new_message(
id = 'ZEXPENSE'
number = '002'
severity = if_abap_behv_message=>severity-error ) )
TO reported-expensereport.
DELETE valid_keys.
ENDIF.
ENDLOOP.
MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
UPDATE FIELDS ( Status )
WITH VALUE #( FOR valid IN valid_keys
( %tky = valid-%tky
Status = status-submitted ) ).
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
ALL FIELDS WITH CORRESPONDING #( valid_keys )
RESULT DATA(reports).
result = VALUE #( FOR report IN reports
( %tky = report-%tky
%param = report ) ).
ENDMETHOD.I did not include the draft state in this check because the feature control below only enables the action on saved (active) reports.
Rejecting with a parameter
The parameter arrives under %param in each key row. If the reason is empty, the report is not rejected:
METHOD reject.
DATA(valid_keys) = keys.
LOOP AT valid_keys INTO DATA(report_key).
IF report_key-%param-RejectReason IS INITIAL.
APPEND VALUE #( %tky = report_key-%tky ) TO failed-expensereport.
APPEND VALUE #( %tky = report_key-%tky
%msg = new_message(
id = 'ZEXPENSE'
number = '003'
severity = if_abap_behv_message=>severity-error ) )
TO reported-expensereport.
DELETE valid_keys.
ENDIF.
ENDLOOP.
MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
UPDATE FIELDS ( Status RejectReason )
WITH VALUE #( FOR valid IN valid_keys
( %tky = valid-%tky
Status = status-rejected
RejectReason = valid-%param-RejectReason ) ).
" The result is filled with the current records, as in submit
ENDMETHOD.Which button is enabled when?
The status flow is built with feature control. For a report in draft, all three actions are disabled; the user has to save first:
METHOD get_instance_features.
READ ENTITIES OF zr_expensereport IN LOCAL MODE
ENTITY ExpenseReport
FIELDS ( Status ) WITH CORRESPONDING #( keys )
RESULT DATA(reports).
result = VALUE #( FOR report IN reports
LET is_active = xsdbool( report-%is_draft = if_abap_behv=>mk-off )
can_submit = COND #( WHEN is_active = abap_true
AND ( report-Status = status-open
OR report-Status = status-rejected )
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
can_decide = COND #( WHEN is_active = abap_true
AND report-Status = status-submitted
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
IN ( %tky = report-%tky
%action-submit = can_submit
%action-approve = can_decide
%action-reject = can_decide ) ).
ENDMETHOD.The message class ZEXPENSE holds three messages: 001 "Amount must be greater than zero", 002 "A report without items cannot be submitted", 003 "A rejection reason is required".
Step 5: Projection and Service
In the projection views the composition is redirected to the new layer with redirected to. Without it, the service would see a relationship pointing to the underlying interface entity instead of the projection:
@AccessControl.authorizationCheck: #CHECK
@Metadata.allowExtensions: true
@EndUserText.label: 'Expense report - projection'
define root view entity ZC_ExpenseReport
provider contract transactional_query
as projection on ZR_ExpenseReport
{
key ReportUuid,
EmployeeId,
Description,
TotalAmount,
@Consumption.valueHelpDefinition: [{ entity: { name: 'I_CurrencyStdVH', element: 'Currency' } }]
CurrencyCode,
Status,
RejectReason,
LocalLastChangedAt,
LastChangedAt,
_Item : redirected to composition child ZC_ExpenseItem
}@AccessControl.authorizationCheck: #CHECK
@Metadata.allowExtensions: true
@EndUserText.label: 'Expense report item - projection'
define view entity ZC_ExpenseItem
as projection on ZR_ExpenseItem
{
key ItemUuid,
ReportUuid,
ExpenseDate,
ExpenseType,
Amount,
CurrencyCode,
Note,
LocalLastChangedAt,
_Report : redirected to parent ZC_ExpenseReport
}The value help on the currency field uses SAP's released standard currency view. For the expense type you would add a domain with fixed values and a small value help view on top of it; I leave it out here to keep the article short.
The projection BDEF has two new lines: use side effects and use association for the items. The internal action recalcTotal does not appear here; it is not exposed:
projection;
strict ( 2 );
use draft;
use side effects;
define behavior for ZC_ExpenseReport alias ExpenseReport
use etag
{
use create;
use update;
use delete;
use action submit;
use action approve;
use action reject;
use action Edit;
use action Activate;
use action Discard;
use action Resume;
use action Prepare;
use association _Item { create; with draft; }
}
define behavior for ZC_ExpenseItem alias ExpenseItem
use etag
{
use update;
use delete;
use association _Report { with draft; }
}@EndUserText.label: 'Expense report service'
define service ZUI_EXPENSE {
expose ZC_ExpenseReport as ExpenseReport;
expose ZC_ExpenseItem as ExpenseItem;
}Once you create and publish an OData V4 - UI binding named ZUI_EXPENSE_O4 from the service definition, the Preview in the binding editor opens a working List Report / Object Page app. But one more step is needed before the screen becomes usable.
Step 6: The Screen with Metadata Extensions
A preview without annotations shows a list with no columns; metadata extensions decide which columns, filters, sections and buttons appear. Putting the screen description into a separate object instead of the CDS view lets you change the screen without cluttering the data model. I covered this Fiori Elements approach in detail in Fiori Elements or Freestyle?.
@Metadata.layer: #CORE
@UI.headerInfo: {
typeName: 'Expense Report',
typeNamePlural: 'Expense Reports',
title: { type: #STANDARD, value: 'Description' },
description: { type: #STANDARD, value: 'EmployeeId' }
}
annotate entity ZC_ExpenseReport with
{
@UI.facet: [
{ id: 'General', purpose: #STANDARD, type: #IDENTIFICATION_REFERENCE,
label: 'General Information', position: 10 },
{ id: 'Items', purpose: #STANDARD, type: #LINEITEM_REFERENCE,
label: 'Items', position: 20, targetElement: '_Item' }
]
@UI.hidden: true
ReportUuid;
@UI: { lineItem: [ { position: 10 } ],
identification: [ { position: 10 } ],
selectionField: [ { position: 10 } ] }
EmployeeId;
@UI: { lineItem: [ { position: 20 } ],
identification: [ { position: 20 } ] }
Description;
@UI: { lineItem: [ { position: 30 } ],
identification: [ { position: 30 } ] }
TotalAmount;
@UI.identification: [ { position: 40 } ]
CurrencyCode;
@UI: { lineItem: [ { position: 40 },
{ type: #FOR_ACTION, dataAction: 'approve', label: 'Approve' },
{ type: #FOR_ACTION, dataAction: 'reject', label: 'Reject' } ],
identification: [ { position: 50 },
{ type: #FOR_ACTION, dataAction: 'submit', label: 'Submit' },
{ type: #FOR_ACTION, dataAction: 'approve', label: 'Approve' },
{ type: #FOR_ACTION, dataAction: 'reject', label: 'Reject' } ],
selectionField: [ { position: 20 } ] }
Status;
@UI.identification: [ { position: 60 } ]
RejectReason;
}This file has three important parts:
@UI.facet: The sections of the object page. The first shows the fields marked withidentificationas a form; the second adds the item table with#LINEITEM_REFERENCEandtargetElement: '_Item'. The table's columns come from the item entity's ownlineItemannotations.#FOR_ACTION: Places actions as buttons. Those insidelineItemappear in the list (the manager can approve straight from the list without opening the report), those insideidentificationappear in the object page header. Whether a button is enabled is decided by feature control, not by the annotation.@UI.headerInfo: The object page title and the name of the object type. Nothing extra is needed for the currency to appear next to the amount column;@Semantics.amount.currencyCodecomes from the interface layer.
The item side is shorter:
@Metadata.layer: #CORE
@UI.headerInfo: {
typeName: 'Expense Item',
typeNamePlural: 'Expense Items',
title: { type: #STANDARD, value: 'ExpenseType' }
}
annotate entity ZC_ExpenseItem with
{
@UI.facet: [
{ id: 'Item', purpose: #STANDARD, type: #IDENTIFICATION_REFERENCE,
label: 'Item', position: 10 }
]
@UI.hidden: true
ItemUuid;
@UI.hidden: true
ReportUuid;
@UI: { lineItem: [ { position: 10 } ],
identification: [ { position: 10 } ] }
ExpenseDate;
@UI: { lineItem: [ { position: 20 } ],
identification: [ { position: 20 } ] }
ExpenseType;
@UI: { lineItem: [ { position: 30 } ],
identification: [ { position: 30 } ] }
Amount;
@UI: { lineItem: [ { position: 40 } ],
identification: [ { position: 40 } ] }
Note;
}In the preview you now see a list of reports that can be filtered by employee and status; opening a report shows the general information and the item table; a total that updates by itself as items are added; buttons that are enabled or disabled depending on the status; and a dialog asking for a reason when "Reject" is pressed. Not a single line of JavaScript was written for any of this.
Step 7: Read Authorization for Items
I covered the header's read authorization with pfcg_auth in the guide. The point often missed in header–item objects is that the item entity needs a role as well. If an entity marked #CHECK has no role at all, reading it is not restricted. Because the service also exposes the items as a separate entity, someone who cannot see the header could query the items directly.
The solution is not to rewrite the item's conditions but to inherit them from the header:
@EndUserText.label: 'Expense item read authorization'
@MappingRole: true
define role ZR_EXPENSEITEM {
grant select on ZR_ExpenseItem
where inheriting conditions from entity ZR_ExpenseReport
replacing { root with _Report };
}replacing { root with _Report } applies the conditions of the header role to the item via the _Report association. When the header's rule changes, the item's rule changes with it. The projection views get one role each along the same lines, inheriting from the corresponding interface entity.
Creation Order in ADT
Because the objects depend on each other, the order matters. This is the order I follow:
- Package and message class.
- The two tables; activate them.
- The two interface views. The composition and the to-parent association point to each other, so activate them together.
- The abstract entity
ZD_RejectReason. - The behavior definition. The quick fix on the warnings in the
draft tablelines generates the two draft tables, and the one on the class name generates the behavior pool. Missing methods are also added with a quick fix. - The code in the behavior pool; activate it together with the BDEF.
- Projection views, metadata extensions and the projection BDEF.
- Access control roles.
- Service definition, OData V4 - UI service binding, publishing and preview.
Depending on your release, ADT also offers a wizard that starts from a table and generates most of this skeleton in one step. For your first project I still recommend building the objects by hand: skipping past generated code you do not understand means not knowing what to do at the first error.
Test Scenarios
Before handing over the project I verify it manually in the preview and cover the most critical rules with ABAP Unit. The minimum list for this project:
| Scenario | Expected |
|---|---|
| New report with two items of 100 and 250 | Total 350; updated on screen after each item |
| Change the currency in the header | The currency of all items changes too |
| Save with an item whose amount is 0 | The report is not saved; the amount field of that item is marked as faulty |
| Submit a report without items | Error message; status unchanged |
| A report in draft | Submit, Approve and Reject disabled |
| A submitted report | Submit disabled; Approve and Reject enabled |
| Reject without a reason | Error message; status unchanged |
| Delete an item, save, submit | The total sent for approval matches the remaining items |
| Query the item entity directly with an unauthorized user | Items of reports whose header the user cannot see are not returned |
The first candidates for automated tests are validate_amount and the empty-report rule in submit; both are pure business rules whose outcome can be verified through failed / reported. I explained how to break dependencies with test doubles in the ABAP Unit article.
What I Deliberately Left Out
Keeping an example project small means saying clearly what is missing. A real expense app would also have:
A readable report number
The UUID key is not shown to users. In a real project you add a separate report number drawn from a number range; if the number must be gap-free for legal reasons, it has to be assigned during the save phase (late numbering).
Locking submitted reports against editing
In this example a submitted report can still be edited. In a real project, editing (Edit) and adding items are also tied to the status with feature control.
Currency conversion, attachments and a real approval workflow
Receipts in foreign currencies, attaching receipt images, determining the manager automatically, substitution and multi-level approval. Once the approval flow grows, it is better managed by a workflow solution than by actions.
Posting to accounting and notifications
Creating an accounting document from an approved report should go through APIs released by SAP; you do not write to the tables directly. RAP business events can be used to notify other systems at approval time; I covered that in the SAP integration scenarios article.
Common Mistakes in Header–Item Objects
Forgetting with draft
Writing association _Item { create; } in a draft-enabled object and omitting with draft keeps the items out of the draft flow. The error usually surfaces when the first item is added. The same rule applies to use association in the projection.
Not adding item validations to Prepare
It is common to list only header validations in the header's Prepare. If child validations such as ExpenseItem~validateAmount are not added there, item errors do not show up during the draft phase.
Calculating the total separately everywhere
Calculating the total separately in the item determination, in an action and in a report ends with one of them being forgotten when the rule changes. The calculation should live in a single internal action that the other places call.
Not enabling side effects in the projection
Defining side effects in the BDEF but not writing use side effects in the projection BDEF leaves the screen unaware of the change. The value is correct in the background, but the user cannot see it until they refresh — and it comes back to you as a "wrong total" ticket.
Not writing a role for the item entity
The header role does not protect the items. If no role inheriting from the header is written for the items, the item entity exposed by the service can be read without any authorization restriction.
Mixing up draft and active records
Matching an item to its header by ReportUuid alone can mix up the draft and active versions of the same report. Include %is_draft in the match or carry keys with %tky.
Conclusion
Moving from a single-entity RAP example to a real project actually involves few new concepts: composition, parent-dependent locking and authorization, internal actions, side effects and metadata extensions. The real work is putting them in the right place. Rules belong in the interface layer, calculations in one place, the screen description in a separate file, and authorization inherited from the header.
This skeleton is not specific to expense reports: most header–item processes, such as purchase pre-requests, maintenance notifications or contract approvals, are built with the same pattern. When you try it on your own system, merge the objects with the skeleton ADT generates and walk through the test scenario table manually first. You can find the scope I offer on the development side on the SAP development services page.
Get support with architecture, code reviews or team coaching while building your first RAP project.
Request an Initial Consultation