3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RAP + List ReportでExcelデータをアップロード

Last updated at Posted at 2024-09-28

はじめに

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ファイルをアップロードして製品を登録できるアプリです。
アップロード用のダイアログからテンプレートがダウンロードできます。
image.png

ダウンロードしたテンプレートは以下のような内容になっています。
image.png

以降でバックエンド、フロントエンドそれぞれの実装ポイントについて解説します。

バックエンドの実装ポイント

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で定義します。

ZBP_R_YPRODUCT
  static action fileUpload parameter ZI_FILE_ABS;
  static action downloadFile result[1] ZI_FILE_ABS;

アクションのパラメータとして使うAbstract Entityの定義は以下のようになっています。

ZI_FILE_ABS
@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に公開します。

ZC_YPRODUCT
  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_INPUTBAPI_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

image.png

manifest.jsonに設定された拡張の定義は以下のようになります(追加方法はブログ参照)。

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. フラグメントの定義

元のブログの通りに実装しました。

uploadFileDialog.fragment.xml
<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アクションはエンティティ全体に対してバインドされる形となります。

image.png

そのようなアクションを実行する際は、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に変換しています。

参考

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?