はじめに
List ReportからExcelデータをアップロードしてデータを登録したいというケースはよくあると思います。このために、2種類のアプローチがあります。
フロントエンド側で対応
フロントエンドでExcelデータを読み込んでリクエストを組み立て、ODataのPOSTリクエストを送信してデータを登録する方法です。
このためのツールとして、オープンソース(一部有償)のui5-cc-spreadsheetimporterが存在します。メタデータをベースに動くので汎用性が高い点が優れています。
このツールについては過去に以下のブログで検証していますが、それからかなりのアップデートがあるはずなので、使い方はドキュメントを参照してください。
バックエンド側で対応
Excelデータをバックエンドに送り、バックエンド側でExcelデータを読み取り、登録処理を行う方法です。
以下のブログシリーズで、RAPとFiori elementsの拡張を使用して、Excelデータをアップロードしたり、ファイルテンプレートをダウンロードする方法が紹介されていました。
こちらの場合、ファイル読み取りは各RAP BOのBehaviorロジックの中で行う必要があります。汎用性という点ではui5-cc-spreadsheetimporterに負けますが、オープンソースのツールを使えなかったり、バックエンド側で入力値をチェック・編集したり、登録以外の処理を行いたい場合にはこちらの方法が参考になると思います。
この記事で紹介すること
この記事では、後者の方法:バックエンド側でExcelデータを読み取り、登録処理を行う方法について紹介します。元の記事では全てのソースコードは掲載されておらず、行間を補う必要があったため、以下のGitリポジトリで私が作成したコードを公開します。
環境
- BTP ABAP Environment(トライアル)
作成したもの
List ReportからExcelファイルをアップロードして製品を登録できるアプリです。
アップロード用のダイアログからテンプレートがダウンロードできます。
ダウンロードしたテンプレートは以下のような内容になっています。
以降でバックエンド、フロントエンドそれぞれの実装ポイントについて解説します。
バックエンドの実装ポイント
1. テーブル定義
テーブル定義は以下のようになっています。
アップロード/ダウンロードとは直接関係がありませんが、数量単位(unit_of_measure)の型をmsehi
にすることがポイントです。基本タイプabap.unit(3)
型を使った場合、変換ルーチンがないため画面から内部形式でしか入力が許可されません(以下参考)。
https://community.sap.com/t5/technology-blogs-by-sap/how-to-deal-with-unit-of-measures-when-calling-odata-services-from-sap-s-4/ba-p/13465919
@EndUserText.label : 'Product'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zyproduct {
key client : abap.clnt not null;
key product_id : zde_product_id not null;
product_name : zde_product_name;
@Semantics.amount.currencyCode : 'zyproduct.currency'
price : abap.curr(13,2);
currency : abap.cuky;
@Semantics.quantity.unitOfMeasure : 'zyproduct.unit_of_measure'
stock : abap.quan(13,2);
unit_of_measure : msehi;
created_by : abp_creation_user;
created_at : abp_creation_tstmpl;
last_changed_by : abp_locinst_lastchange_user;
local_last_changed_at : abp_locinst_lastchange_tstmpl;
last_changed_at : abp_lastchange_tstmpl;
}
2. Behavior Definition / Projection
ファイルをアップロード、ダウンロードするためのアクションをBehavior Definitionで定義します。
static action fileUpload parameter ZI_FILE_ABS;
static action downloadFile result[1] ZI_FILE_ABS;
アクションのパラメータとして使うAbstract Entityの定義は以下のようになっています。
@EndUserText.label: 'Upload file action'
define abstract entity ZI_FILE_ABS
{
mimeType : abap.string(0);
fileName: abap.string(0);
fileContent: abap.rawstring(0);
fileExtension: abap.string(0);
}
作成したアクションをBehavior Projectionに公開します。
use action fileUpload;
use action downloadFile;
3. アクションの実装
3.1. アップロード用アクション
Excelファイルを読み取り、エンティティを登録します。
METHOD fileUpload.
DATA lt_product TYPE STANDARD TABLE OF ty_product.
DATA lt_product_c TYPE TABLE FOR CREATE zr_yproduct.
READ TABLE keys ASSIGNING FIELD-SYMBOL(<ls_keys>) INDEX 1.
CHECK sy-subrc = 0.
DATA(lv_filecontent) = <ls_keys>-%param-fileContent.
"XCOライブラリを使用したExcelファイルの読み取り
DATA(lo_read_access) = xco_cp_xlsx=>document->for_file_content( lv_filecontent )->read_access( ).
DATA(lo_worksheet) = lo_read_access->get_workbook( )->worksheet->at_position( 1 ).
DATA(lo_selection_pattern) = xco_cp_xlsx_selection=>pattern_builder->simple_from_to(
)->from_column( xco_cp_xlsx=>coordinate->for_alphabetic_value( 'A' )
)->to_column( xco_cp_xlsx=>coordinate->for_alphabetic_value( 'F' )
)->from_row( xco_cp_xlsx=>coordinate->for_numeric_value( 2 )
)->get_pattern( ).
lo_worksheet->select( lo_selection_pattern )->row_stream(
)->operation->write_to( REF #( lt_product )
)->if_xco_xlsx_ra_operation~execute( ).
"create new entity
lt_product_c = CORRESPONDING #( lt_product ).
"数量単位、金額の内部変換
loop at lt_product_c ASSIGNING FIELD-SYMBOL(<product>).
<product>-UnitOfMeasure = convert_unit( <product>-UnitOfMeasure ).
<product>-price = convert_price(
i_price = <product>-price
i_currency = <product>-currency ).
endloop.
MODIFY ENTITIES OF zr_yproduct IN LOCAL MODE
ENTITY Product
CREATE AUTO FILL CID FIELDS ( ProductId
ProductName
Price
Currency
Stock
UnitOfMeasure ) WITH lt_product_c
MAPPED DATA(lt_mapped_create)
REPORTED DATA(lt_mapped_reported)
FAILED DATA(lt_failed_create).
ENDMETHOD.
ポイント1: XCOライブラリを使用したExcelファイルの読み取り
XCO XSLXモジュールを使用すると、Excelファイルのシートから任意の範囲を読み取り、結果を内部テーブルに格納するということが簡単にできます。
以下の処理で読み取り範囲を指定しています。ここでは、A~F列かつ2行目以降のデータを取得するという指定になっています。
DATA(lo_selection_pattern) = xco_cp_xlsx_selection=>pattern_builder->simple_from_to(
)->from_column( xco_cp_xlsx=>coordinate->for_alphabetic_value( 'A' )
)->to_column( xco_cp_xlsx=>coordinate->for_alphabetic_value( 'F' )
)->from_row( xco_cp_xlsx=>coordinate->for_numeric_value( 2 )
)->get_pattern( ).
ポイント2: 数量単位、金額の内部変換
アップロードされた金額、および数量単位は外部形式のため、登録前に内部形式に変換が必要です。BTP ABAP EnvironmentにはCONVERSION_EXIT_CUNIT_INPUT
やBAPI_CURRENCY_CONV_TO_INTERNAL
といった汎用モジュールが存在はしていますが、未リリースのため使うことができません。そのため、リリース済みCDS Viewを使用して変換を行っています。
METHOD convert_unit.
select single UnitOfMeasure from I_UnitOfMeasure
where UnitOfMeasure_E = @i_unit
into @r_unit.
ENDMETHOD.
METHOD convert_price.
* https://userapps.support.sap.com/sap/support/knowledge/en/2973787
select single * from i_currency
where currency = @i_currency
into @data(ls_curx).
check sy-subrc is initial.
r_price = i_price * ( 10 ** ls_curx-decimals / 100 ).
ENDMETHOD.
3.2. ダウンロード用アクション
以下の処理でExcelファイルにデータを書き込み、バイナリデータを返します。ここでもXCO XLSXモジュールを使用します。
METHOD downloadFile.
DATA lt_product TYPE STANDARD TABLE OF ty_product_header WITH DEFAULT KEY.
"XCOライブラリを使用したExcelファイルの書き込み
DATA(lo_write_access) = xco_cp_xlsx=>document->empty( )->write_access( ).
DATA(lo_worksheet) = lo_write_access->get_workbook(
)->worksheet->at_position( 1 ).
DATA(lo_selection_pattern) = xco_cp_xlsx_selection=>pattern_builder->simple_from_to(
)->from_column( xco_cp_xlsx=>coordinate->for_alphabetic_value( 'A' )
)->to_column( xco_cp_xlsx=>coordinate->for_alphabetic_value( 'F' )
)->from_row( xco_cp_xlsx=>coordinate->for_numeric_value( 1 )
)->get_pattern( ).
"ヘッダの設定(すべての項目はstring型)
lt_product = VALUE #( ( ProductId = 'Product ID'
productname = 'Product Name'
price = 'Price'
currency = 'Currency'
stock = 'Stock'
unitofmeasure = 'Unit of Measure') ).
lo_worksheet->select( lo_selection_pattern
)->row_stream(
)->operation->write_from( REF #( lt_product )
)->execute( ).
DATA(lv_file_content) = lo_write_access->get_file_content( ).
result = VALUE #( FOR key IN keys (
%cid = key-%cid
%param = VALUE #( fileContent = lv_file_content
fileName = 'ProductTemplate'
fileExtension = 'xlsx'
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' )
) ).
ENDMETHOD.
データ形式について
XCO XLSXモジュールで作成したファイルデータは504B0304140000000800...
のようなバイナリ形式になっています。
DATA(lv_file_content) = lo_write_access->get_file_content( ). "504B0304140000000800...
これがGateway経由でクライアントに返されるときにはUEsDBBQAAAAIAHWeO1lFNhljHQEAAB...
のように、Base64でエンコーディングされた形式になります。ただし、エンコーディング形式に問題があり、そのままではダウンロードすることができませんでした。これについては、フロントエンド側で対処します。
フロントエンドの実装ポイント
1. 拡張によるアクションの追加
テーブルヘッダの"Upload"ボタンを押したときにファイルアップロード用のダイアログを開くため、アクションは(UIアノテーションではなく)拡張で実装します。
作成するファイルは以下の2つです。
- ext/controller/ProductListcontroller.ts(追加方法はブログ参照)
- fragment/uploadFileDialog.fragment.xml
manifest.jsonに設定された拡張の定義は以下のようになります(追加方法はブログ参照)。
...
"sap.ui5": {
...
"routing": {
...
"targets": {
"ProductList": {
"options": {
...
"controlConfiguration": {
"@com.sap.vocabularies.UI.v1.LineItem": {
"tableSettings": {
"type": "ResponsiveTable"
},
"actions": {
"uploadProduct": {
"press": ".extension.miyasuta.rapexcelupload.ext.controller.ProductList.uploadProduct",
"visible": true,
"enabled": true,
"requiresSelection": false,
"text": "Upload"
}
}
}
}
...
"extends": {
"extensions": {
"sap.ui.controllerExtensions": {
"sap.fe.templates.ListReport.ListReportController": {
"controllerName": "miyasuta.rapexcelupload.ext.controller.ProductList"
}
}
}
}
2. フラグメントの定義
元のブログの通りに実装しました。
<core:FragmentDefinition
xmlns:core="sap.ui.core"
xmlns:f="sap.ui.layout.form"
xmlns:l="sap.ui.layout"
xmlns:u="sap.ui.unified"
xmlns="sap.m">
<Dialog id="idFIleDialog" title="{i18n>uploadProductDialogTitle}">
<VBox id="idVbox" width="100%">
<core:InvisibleText id="idInvisibleText" text="{i18n>uploadProductDialogTitle}" />
<f:SimpleForm id="idSimpleForm" editable="true"
layout="ResponsiveGridLayout" maxContainerCols="2">
<f:content>
<Label id="idFileUploadlabel" required="true" text="{i18n>upladProductFile}"/>
<u:FileUploader id="idFileUpload" name="internalFileUpload" change="onFileChange"
width="100%" uploadComplete="onUploadComplete" style="Emphasized"
fileType="xls,xlsx" placeholder="{i18n>uploadProductPlaceholder}"
tooltip="{i18n>uploadProductToolTip}" sendXHR="false" />
</f:content>
</f:SimpleForm>
</VBox>
<footer>
<Toolbar id="idToolbar">
<content>
<Button id="iDownloadTempButton" text="{i18n>downloadTempButtonTxt}" press="onTempDownload"
icon="sap-icon://download-from-cloud" />
<Button id="idUploadButton" text="{i18n>uploadButtonTxt}" type="Emphasized"
press="onUploadPress" icon="sap-icon://upload-to-cloud" />
<Button id="idCancelButton" text="{i18n>cancelButtonTxt}" press="onCancelPress"
icon="sap-icon://cancel" />
</content>
</Toolbar>
</footer>
</Dialog>
</core:FragmentDefinition>
3. コントローラー拡張の実装
参考にしたブログはJavaScriptですが、以下ではTypeScriptで実装しています。JavaScriptと同じメソッドが使えなかった部分は、元の実装と変えています。
3.1. アップロード処理
3.1.1. アップロード用ダイアログを開く処理
アップロードボタンが押されたら、2.で定義したフラグメント(ダイアログ)を開きます。
export default class ProductList extends ControllerExtension<ExtensionAPI> {
dialog: Dialog
static overrides = {
...
}
uploadProduct() {
this.base.getExtensionAPI().loadFragment({
id: "uploadFileDialog",
name: "miyasuta.rapexcelupload.ext.fragment.uploadFileDialog",
controller: this
}).then(fragment => {
this.dialog = (fragment as unknown) as Dialog
this.dialog.open()
})
}
3.1.2. ファイルが選択されたときの処理
ダイアログでファイルが選択されたら、ファイル名、タイプ、コンテンツを取得します。これらの項目は、この後でアップロードボタンが押されたときに使用するので、クラスフィールドとして格納しておきます。
export default class ProductList extends ControllerExtension<ExtensionAPI> {
dialog: Dialog
fileType: string
fileName: string
fileContent: string | undefined
namespace = "com.sap.gateway.srvd.zui_yproduct_o4.v0001."
onFileChange(event:FileUploader$ChangeEvent) {
const files = event.getParameter("files")
if (files === undefined) {
return
}
const file = files[0] as File
this.fileType = file.type
this.fileName = file.name
const fileReader = new FileReader()
const that = this
const readFile = (file: File):Promise<void> => {
return new Promise(resolve => {
fileReader.onload = function (loadEvent) {
const result = loadEvent.target?.result as string
const match = result.match(/,(.*)$/)
if (match && match[1]) {
that.fileContent = match[1]
resolve()
}
}
fileReader.readAsDataURL(file)
})
}
this.base.getExtensionAPI().getEditFlow().securedExecution(() => readFile(file), {
busy: { set: true }
})
}
ポイント:securedExecution()を使用した非同期処理の実行
コントローラ拡張の中で非同期処理を行う場合、sap.fe.core.controllerextensions.EditFlowのsecuredExecution()というメソッドを使用します。メソッドの引数に非同期に行う処理および設定パラメータを渡します。
※元のブログの実装は以下のようになっていました。しかし、"Action"が何のクラスを指しているのかわからなかったため、上記の方法に変えています。
new Action(readFile(file)).executeWithBusyIndicator().then(function (result) {
fileContent = result;
})
3.1.3. ファイルアップロード処理
アップロードボタンが押されたら、fileUploadアクションを実行してファイルをアップロードします。このときに、「ファイルが選択されたときの処理」で取得しておいたファイルコンテンツなどを渡します。
onUploadPress() {
//ファイルコンテンツが存在するかチェック
const resourceBundle = (this.base.getExtensionAPI().getModel("i18n") as ResourceModel).getResourceBundle() as ResourceBundle
if (this.fileContent === undefined || this.fileContent === "") {
const fileErrorMessage = resourceBundle.getText("uploadFileErrMeg") || ""
MessageToast.show(fileErrorMessage)
return
}
//アクション呼び出しの準備
const model = this.base.getExtensionAPI().getModel()
const operation = model?.bindContext("/Product/" + this.namespace + "fileUpload(...)") as ODataContextBinding
const funSuccess = () => {
model?.refresh()
const uploadSuccessMessage = resourceBundle.getText("uploadFileSuccMsg") || ""
MessageToast.show(uploadSuccessMessage)
this.dialog.close();
//clear the file name from file Uploader
(Fragment.byId("uploadFileDialog","idFileUpload") as FileUploader).clear()
this.dialog.destroy()
this.fileContent = undefined
}
const fnError = (oError:Error) => {
this.base.getExtensionAPI().getEditFlow().securedExecution(
() => {
Messaging.addMessages(
new Message({
message: oError.message,
target: "",
persistent: true,
type: MessageType.Error,
code: oError.error.code
})
)
const errorDetails = oError.error.details
errorDetails.forEach(error => {
Messaging.addMessages(
new Message({
message: error.message,
target: "",
persistent: true,
type: MessageType.Error,
code: error.error.code
})
)
})
this.dialog.close();
//clear the file name from file Uploader
(Fragment.byId("uploadFileDialog","idFileUpload") as FileUploader).clear()
this.dialog.destroy()
this.fileContent = undefined
}
)
}
//アクションに渡すパラメータを設定
operation.setParameter("mimeType", this.fileType)
operation.setParameter("fileName", this.fileName)
operation.setParameter("fileContent", this.fileContent)
operation.setParameter("fileExtension", this.fileName.split(".")[1])
//アクション実行
operation.invoke().then(funSuccess, fnError)
}
ポイント:Staticアクションの実行方法
RAPのBehavior Definitionでアクションをstaticとして定義しているため、このアクションを実行する際はエンティティのキーを渡す必要がありません。一方で、RAPの制約(?)でアクションはすべてBound Actionとして定義されます。この結果、staticアクションはエンティティ全体に対してバインドされる形となります。
そのようなアクションを実行する際は、bindContext()に/<エンティティ>/<ネームスペース>.<アクション名>(...)
を渡します。
const operation = model?.bindContext("/Product/" + this.namespace + "fileUpload(...)") as ODataContextBinding
3.2. ダウンロード処理
テンプレートのダウンロードボタンが押されたら、fileDownloadアクションを実行してテンプレートファイルをダウンロードします。
onTempDownload () {
//アクション呼び出しの準備
const model = this.base.getExtensionAPI().getModel()
const resourceBundle = (this.base.getExtensionAPI().getModel("i18n") as ResourceModel).getResourceBundle() as ResourceBundle
const operation = model?.bindContext("/Product/" + this.namespace + "downloadFile(...)") as ODataContextBinding
const fnSuccess = () => {
const result = operation.getBoundContext().getObject() as Template
//URLセーフなBase64を標準Base64に変換
const fixedFileContent = this.convertBase64(result.fileContent)
//base64形式のファイルコンテンツをデコードしたものをバイナリデータに変換
const uint8Array = Uint8Array.from(atob(fixedFileContent), c => c.charCodeAt(0))
//Blobオブジェクトを生成
const blob = new Blob([uint8Array], {type: result.mimeType})
//ファイルをダウンロード
uFile.save(blob as unknown as string, result.fileName, result.fileExtension, result.mimeType, 'utf-8');
const downloadSuccessMessage = resourceBundle.getText("downloadTempSuccMsg") || ""
MessageToast.show(downloadSuccessMessage)
}
const fnError = (oError:Error) => {
this.base.getExtensionAPI().getEditFlow().securedExecution(
() => {
Messaging.addMessages(
new Message({
message: oError.message,
target: "",
persistent: true,
type: MessageType.Error,
code: oError.error.code
})
)
const errorDetails = oError.error.details
errorDetails.forEach(error => {
Messaging.addMessages(
new Message({
message: error.message,
target: "",
persistent: true,
type: MessageType.Error,
code: error.error.code
})
)
})
}
)
}
operation.invoke().then(fnSuccess, fnError)
}
convertBase64(urlSafeBase64: string): string {
// apply URL safe Base64 transformation pattern
let standardBase64 = urlSafeBase64
.replace(/_/g, '/') // replace '_' with '/'
.replace(/-/g, '+'); // replace '-' with '+'
return standardBase64;
}
ポイント:URLセーフなBase64を標準Base64に変換
バックエンドから返されたファイルデータは、「URLセーフなBase64」と呼ばれる形式でエンコードされています。標準Base64とURLセーフなBase64には、使われる文字に以下のような違いがあります。
標準Base64 | URLセーフなBase64 |
---|---|
/ | _ |
+ | - |
URLセーフなBase64をatob()に渡してバイナリ変換しようとすると、正しくエンコードされていないとしてエラーになってしまいます。そのため、convertBase64
というメソッドを(ChatGPTに頼んで)作成し、URLセーフなBase64を標準Base64に変換しています。