SAP OData + Excelマクロによる購買発注の登録

OData+Excelマクロによる購買発注登録サンプルコード

前回の記事では、SAP OData V4 API「API_PURCHASEORDER_2」をExcelマクロ(VBA)から呼び出し、購買発注データを取得(GET)する方法を解説した。
GETでデータを読み込めるようになったなら、次のステップはいよいよデータの登録(POST)だ。

「SAPのデータをExcelから直接登録できたら、どれだけ業務が楽になるか」

そう感じたことのある方は多いはずだ。手入力によるミス、二重登録のリスク、そして何より時間のロス。
こうした悩みを一気に解消してくれるのが、SAP OData API + Excelマクロ(VBA) による自動登録の仕組みだ。

本記事では、SAP OData V4「API_PURCHASEORDER_2」+Excel VBAを使って、購買発注(Purchase Order)をSAPへ登録する実装方法を、実際に動作するVBAコードとともに徹底解説する。
コードの言語はVBAだが、プログラムの構造はPython・JavaScriptなど他言語でも共通しているため、言語を問わず参考になるはずだ。

SAP OData と Excel VBA によるSAPデータ登録に興味がある方は、ぜひ最後まで読んでほしい。

目次

GETの次はPOST ─ SAP OData 購買発注登録

ODataを使ったSAP連携の第一歩は「データを読む(GET)」ことだ。当ブログでも、ODataをABAPおよびExcelマクロから呼び出し、SAPシステムのでデータを取得する方法を解説した。

業務自動化の本番はここからだ。

データを書き込む(POST)ことができて初めて、SAPとExcelの間に双方向のデータフローが生まれ、実務で使える仕組みとなる。

GETPOSTでは、実装の難易度が大きく異なる。GETはURLを叩けばデータが返ってくるシンプルな仕組みだが、POSTではリクエスト本文(JSON)を正しく組み立て、さらにCSRFトークンという認証情報も付与しなければならない。

以下にGETとPOSTの主な違いをまとめた。

項目GETPOST
主目的データを取得するデータを新規登録する
SAP OData例POを読むPOを新規作成する
リクエスト本文通常なし必要(JSON形式)
CSRFトークン原則不要必要
データ変更しないする
成功時ステータス200など200 / 201 / 204など

GETと比べてPOSTは

  • リクエスト本文をJSONで組み立てる
  • CSRFトークンを取得してヘッダに付与する

という2つのステップが追加される。しかし、この2点さえ押さえてしまえば、あとは難しくない。

順を追って解説していこう。

SAP OData V4「API_PURCHASEORDER_2」とは

API_PURCHASEORDER_2」は、SAP S/4HANAが標準提供するOData V4サービスのひとつで、購買発注(Purchase Order)に関するCRUD操作(作成・読取・更新・削除)を RESTful API として公開したものだ。

SAPのAPI BusinessHubでも公開されており、外部システムやExcelマクロから標準インターフェースとして利用できる。

OData V4はOData V2と比べて、JSONファーストな設計になっており、レスポンスの構造がシンプルだ。
SAP S/4HANAのBTP(Business Technology Platform)との親和性も高く、今後の標準として積極的に採用されている。

本記事で扱うエンドポイントは以下の通りだ。

/sap/opu/odata4/iwbep/all/srvd_a2x/sap/api_purchaseorder_2/0001

このパスに対してPOSTリクエストを送ることで、SAP上に購買発注を新規登録することができる。

GETとPOSTの違いを整理する

GETはデータの取得専用であり、SAPのデータを一切変更しない安全な操作だ。

一方、POSTはSAP上にデータを新規作成する書き込み操作であるため、より厳格な認証が求められる。

特に重要なのが CSRFトークン(Cross-Site Request Forgery Token)だ。SAPの OData API は POST・PUT・DELETEなどのデータ変更操作に対して、CSRFトークンによるリクエスト正当性の確認を要求する。

これはセキュリティ上の仕組みであり、事前にGETリクエストでトークンを取得し、その値をPOSTリクエストのヘッダに付与しなければ、リクエストが弾かれてしまう。

また、POSTが成功した際のHTTPステータスコードは、サーバの設定や実装によって200・201・204のいずれかが返ってくる場合がある。GETのように「200ならOK」という単純な判定では不十分で、この点も実装上のポイントとなる。

Excelシートの準備 ─ SAP OData 購買発注登録の準備

VBAコードを書く前に、まずExcelシート上のデータ構造を把握しておこう。データの持ち方を正しく設計することが、後のJSON組み立てをスムーズにする。

今回使用するサンプルデータは以下の構成だ。

購買発注登録用Excelシートのサンプル
ヘッダ/明細項目名サンプル値
A購買発注ヘッダ購買伝票タイプNB
B仕入先勘定コードV001
C購買伝票日付2026/5/1
D購買組織A111
E購買グループA11
F会社コードA111
G通貨コードUSD
H購買発注明細明細番号10 / 20
J品目コード(18桁)RM1A111
Kテキスト(短)TEST1
L購買発注量1
M数量単位PC
NプラントA111
O保管場所1000
P納入日程行番号1 / 2
Q納入日付2026/9/25
R計画数量1
S購買発注番号(出力)5000000087

ヘッダと明細 ─ SAPデータの典型パターン

SAPのデータ構造を理解する上で外せないのが、ヘッダ(Header)と明細(Item)の親子関係だ。
購買発注に限らず、受注・出荷・請求書など、SAPの帳票系データのほぼすべてがこの構造を持っている。

購買発注の場合、1枚の発注書(ヘッダ)に対して複数の発注品目(明細)が紐づく。さらに今回のAPI_PURCHASEORDER_2では、各明細の下に納入日程行(Schedule Line)という子レコードが存在する。

この3階層の構造をJSONで表現するのが、今回の実装における最大のポイントだ。

PurchaseOrder(ヘッダ)
└ _PurchaseOrderItem(明細)[]
└ _PurchaseOrderScheduleLineTP(納入日程行)[]

サンプルのExcelシートでは、1行目にヘッダ情報(A列〜G列)と明細の1行目(H列以降)をまとめて持ち、2行目以降に追加明細を記載する形を取っている。ヘッダは購買発注全体で1つなので、2行目の値を使い回す設計だ。

VBAコード全体の流れ──SAP OData V4 POST実装の解剖

それでは、実際に動作するVBAコード「CreatePurchaseOrder_FromExcel」の全体構造を解説しよう。コードは大きく8つのブロックに分かれている。

  1. 接続情報の取得(Configシート)
  2. ヘッダ項目の取得
  3. 明細の最終行取得
  4. OData APIのURL組み立て
  5. CSRFトークンの取得
  6. JSONボディの組み立て
  7. POSTの実行
  8. 購買発注番号の受け取り・書き戻し

この流れはSAP OData V4に対するPOST実装のテンプレートとして、購買発注以外の場面でも応用できる。

接続情報の取得──Configシートで安全に管理する

まず最初のブロックは、SAP接続に必要な情報の読み込みだ。

Set wsConfig = ThisWorkbook.Worksheets("Config")

baseUrl    = Trim$(CStr(wsConfig.Range("B1").Value))
sapClient  = Trim$(CStr(wsConfig.Range("B2").Value))
userId     = Trim$(CStr(wsConfig.Range("B3").Value))
password   = Trim$(CStr(wsConfig.Range("B4").Value))

接続情報(URL・クライアント番号・ユーザID・パスワード)を専用の「Config」シートに切り出しているのがポイントだ。これにより、接続先環境が変わっても(開発→テスト→本番など)、コードを修正することなくConfigシートの値を書き換えるだけで対応できる。

各項目が空の場合は即座にエラーを発生させ、処理を中断する。「空のまま実行してSAPに意図しないデータが入ってしまった」という事故を防ぐための防御的プログラミングだ。

本番運用では、パスワードをVBA内やExcelシート上に平文で持つことはセキュリティリスクになる。Windowsの資格情報マネージャーやVBAのInputBox経由での入力、あるいはSSO(シングルサインオン)の活用を検討してほしい。

CSRFトークン取得 ─ SAP OData POSTで必須の認証ステップ

POSTを実行する前に、必ずCSRFトークンを取得しなければならない
これは SAP OData API におけるセキュリティ仕様であり、省略するとHTTP 403 Forbiddenが返ってくる。

http.Open "GET", serviceUrl & "/PurchaseOrder?sap-client=" & EncodeUrlComponent(sapClient) & "&$top=1", False, userId, password
http.setRequestHeader "Accept", "application/json"
http.setRequestHeader "X-CSRF-Token", "Fetch"
http.Send

csrfToken    = Trim$(CStr(http.getResponseHeader("X-CSRF-Token")))
cookieHeader = ExtractCookieHeader(http.getAllResponseHeaders)

仕組みはシンプルだ。まずGETリクエストを送る際に、ヘッダに X-CSRF-Token: Fetch を付与する。するとSAPはレスポンスヘッダに実際のCSRFトークン値を乗せて返してくる。これを取り出してPOSTリクエストのヘッダ X-CSRF-Token: <取得した値> として付与する、という流れだ。

同時に、レスポンスの Set-Cookie ヘッダからCookie値も取り出し、POSTリクエストに引き継ぐ。セッション情報をPOSTにも渡すことで、SAPが同一セッションからのリクエストとして認識してくれる。

Cookie抽出には専用のプライベート関数 ExtractCookieHeader を使っている。全レスポンスヘッダを改行で分割し、set-cookie: で始まる行を探し出して、セミコロン前のキー=値ペアだけを連結する処理だ。

CSRFトークンの取得に使うGETリクエストは $top=1 を付けてレコード数を1件に絞っている。
トークン取得が目的なので大量データを取得する必要はなく、レスポンスを最小化することで処理が高速化する。

JSONボディの組み立て ─ ヘッダ・明細・納入日程行の3階層

POSTリクエストの核心部分がJSONボディの組み立てだ。API_PURCHASEORDER_2では、以下の3階層構造をJSONで表現する必要がある。

以下は http.Send する直前のJson全文である。

{
	"PurchaseOrderType": "NB",
	"Supplier": "1000",
	"PurchaseOrderDate": "2026-05-01",
	"PurchasingOrganization": "A111",
	"PurchasingGroup": "A11",
	"CompanyCode": "A111",
	"DocumentCurrency": "USD",
	"_PurchaseOrderItem": [
		{
			"PurchaseOrderItem": "00010",
			"Material": "RM1A111",
			"PurchaseOrderItemText": "TEST1",
			"OrderQuantity": 1,
			"PurchaseOrderQuantityUnit": "PC",
			"Plant": "A111",
			"StorageLocation": "1000",
			"_PurchaseOrderScheduleLineTP": [
				{
					"ScheduleLine": "0001",
					"ScheduleLineDeliveryDate": "2026-09-25",
					"ScheduleLineOrderQuantity": 1,
					"PurchaseOrderQuantityUnit": "PC"
				}
			]
		},
		{
			"PurchaseOrderItem": "00020",
			"Material": "RM1A111",
			"PurchaseOrderItemText": "TEST2",
			"OrderQuantity": 2,
			"PurchaseOrderQuantityUnit": "PC",
			"Plant": "A111",
			"StorageLocation": "1000",
			"_PurchaseOrderScheduleLineTP": [
				{
					"ScheduleLine": "0002",
					"ScheduleLineDeliveryDate": "2026-09-26",
					"ScheduleLineOrderQuantity": 2,
					"PurchaseOrderQuantityUnit": "PC"
				}
			]
		}
	]
}

VBAではJSON専用ライブラリを使わず、文字列連結でJSONを組み立てている。シンプルな構造の場合はこの方法で十分だ。コードの要所を見ていこう。

まずヘッダ部分を文字列として組み立てる。

jsonBody = "{"
jsonBody = jsonBody & """PurchaseOrderType"":""" & JsonEscape(docType) & ""","
jsonBody = jsonBody & """Supplier"":""" & JsonEscape(supplier) & ""","
...
jsonBody = jsonBody & """_PurchaseOrderItem"":["

続いてExcelの明細行をループしながら、明細配列の要素を追加していく。

For i = 4 To lastRow
    If Trim$(CStr(ws.Cells(i, "H").Value)) <> "" Then
        ' 2件目以降はカンマを付ける
        If Not isFirstItem Then
            jsonBody = jsonBody & ","
        End If
        isFirstItem = False
        ' 明細JSONを追加
        jsonBody = jsonBody & "{...}"
    End If
Next i

このループ構造がVBAによるJSON配列生成の基本パターンだ。「最初の要素の前にはカンマを付けない、2番目以降は付ける」という制御を isFirstItem フラグで実現している。

各明細の最後には、納入日程行の配列 _PurchaseOrderScheduleLineTP を入れ子にする。今回は「1明細につき1納入日程行」の前提だが、複数納入日程が必要な場合は同様のループ構造を入れ子にすれば対応できる。

数値項目(OrderQuantity・ScheduleLineOrderQuantityなど)はJSONで文字列ではなく数値として渡す必要がある。VBAでは JsonNumber 関数を使って、Excelのカンマ区切り数値などを正規化した上で、ダブルクォートなしで出力している。
文字列で渡してしまうとSAP側でエラーになる場合があるので注意が必要だ。

POSTの実行と購買発注番号の受け取り

JSONボディが完成したら、いよいよPOSTリクエストを送信する。

http.Open "POST", postUrl, False, userId, password
http.setRequestHeader "Accept", "application/json"
http.setRequestHeader "Content-Type", "application/json"
http.setRequestHeader "X-CSRF-Token", csrfToken

If cookieHeader <> "" Then
    http.setRequestHeader "Cookie", cookieHeader
End If

http.Send jsonBody

ヘッダには Content-Type: application/jsonX-CSRF-Token: <取得した値>、そしてセッションCookieを付与する。これらが揃って初めて、SAPはリクエストを正規のものとして受け付ける。

POSTが成功した場合、SAPは作成した購買発注番号(PurchaseOrder)をレスポンスに含めて返してくる。コードでは3つの方法で購買発注番号の取得を試みている。

  1. レスポンスボディのJSONから PurchaseOrder フィールドを抽出する
  2. レスポンスヘッダの Location ヘッダからURLを解析して抽出する
  3. レスポンスヘッダの OData-EntityId ヘッダから抽出する

SAPのバージョンや設定によって、どの方法で番号が返ってくるかが異なるため、3段階のフォールバックを実装しているのが実装上のポイントだ。

If statusCode = 201 Or statusCode = 200 Or statusCode = 204 Then
    createdPo = ExtractJsonStringValue(responseText, "PurchaseOrder")
    If createdPo = "" Then
        locationHeader = http.getResponseHeader("Location")
        createdPo = ExtractPurchaseOrderFromUrl(locationHeader)
    End If
    ' 取得できた番号をExcelシートへ書き戻す
    For i = 4 To lastRow
        If Trim$(CStr(ws.Cells(i, "H").Value)) <> "" Then
            ws.Cells(i, "S").Value = createdPo
        End If
    Next i
End If

購買発注番号が取得できたら、ExcelシートのS列(購買発注番号列)へ書き戻す。これにより、登録結果をそのままExcel上で確認できる。

SAP OData 購買発注登録でハマりやすいポイントと対処法

実際にAPI_PURCHASEORDER_2 のPOST実装を試みると、いくつかの壁にぶつかることがある。ここでは特にハマりやすいポイントを実体験を交えて紹介する。

JSON構造の落とし穴 ─ カンマとネストのミス

VBAで文字列としてJSONを組み立てる場合、最もよくあるミスがカンマの過不足だ。

JSONでは配列の最後の要素の後ろにカンマを付けてはいけない。しかしループで配列要素を動的に生成する場合、「最後かどうか」を判定するロジックが必要になる。今回のコードでは「最初の要素かどうか(isFirstItem)」を使ってカンマを先頭に付けるアプローチを採用している。これにより、最後の要素の後ろにカンマが付くことを防いでいる。

' 2件目以降の前にカンマを付ける(最後の後ろには付かない)
If Not isFirstItem Then
    jsonBody = jsonBody & ","
End If
isFirstItem = False

もうひとつのよくあるミスが、入れ子構造の閉じ括弧の数だ。{}[] の対応関係がひとつでもずれると、SAPはHTTP 400 Bad Requestを返す。
VBAのImmediateウィンドウ(Ctrl+G)に Debug.Print jsonBody でJSONを出力し、JSONLintなどのオンラインバリデータで構造チェックをする習慣をつけると良い。

また、JSONエスケープも見落としやすい。テキスト項目にダブルクォート(”)やバックスラッシュ(\)が含まれていると、JSONが壊れる。今回のコードでは JsonEscape 関数でこれらをエスケープしているが、独自実装する場合は忘れずに対処してほしい。

ステータスコード200・201・204の使い分け

POST成功時のHTTPステータスコードは、SAPのバージョンや設定によって異なる。

ステータスコード意味レスポンスボディ
201 Created新規リソース作成成功作成されたリソースのJSON
200 OK処理成功(一部のSAP実装)処理結果のJSON
204 No Content処理成功・ボディなしなし(Locationヘッダで番号取得)

ODataの標準的なPOSTでは201が期待されることが多いが、SAPの実装によっては200や204が返ることもある。204の場合はレスポンスボディが空なので、JSONから購買発注番号を取得できない。このため、LocationヘッダやOData-EntityIdヘッダからURLを解析して番号を取り出すフォールバックが重要になる。

コード内の ExtractPurchaseOrderFromUrl 関数は、正規表現を使って PurchaseOrder('5000000087') のようなURLパターンから発注番号を抽出する。

re.Pattern = "PurchaseOrder\('([^']+)'\)"

Tips:実装時は必ずSAP開発環境で実際のステータスコードを確認しておくこと。Immediateウィンドウの Debug.Print http.Status と Debug.Print http.responseText が最大の手がかりになる。

日付フォーマットと数値の正規化

Excelの日付値とSAP ODataが要求するISOフォーマット(yyyy-mm-dd)は異なる。
Excelでは日付をシリアル値や地域設定によって異なる文字列形式で保持するため、そのまま渡すとSAP側でエラーになる。

今回のコードでは ToIsoDate 関数で日付を正規化している。

Private Function ToIsoDate(ByVal v As Variant) As String
    If IsDate(v) Then
        ToIsoDate = Format$(CDate(v), "yyyy-mm-dd")
        Exit Function
    End If
    ' 文字列の場合はセパレータを統一してからパース
    Dim s As String
    s = Replace$(Replace$(Trim$(CStr(v)), ".", "/"), "-", "/")
    If IsDate(s) Then
        ToIsoDate = Format$(CDate(s), "yyyy-mm-dd")
    End If
End Function

同様に、Excelのカンマ区切り数値(例:1,000)をそのまま渡すとJSONが壊れる。NormalizeNumber 関数でカンマを除去し、JsonNumber 関数で数値として正しく出力している。

品目コードについては、SAP上では18桁左詰めゼロ埋めが必要なケースがある。今回のコードでは PadMaterial18 関数がその役割を担っているが、実際の環境に合わせてゼロ埋め処理を追加・調整することを推奨する。

エラーハンドリングの重要性

業務で使うツールである以上、エラーハンドリングは手を抜いてはいけない。今回のコードでは各ブロックに Err.Raise によるカスタムエラーを仕込み、最終的に ErrHandler でまとめてMsgBoxに表示する構造にしている。

エラーメッセージには「どの列のどの行が問題か」を明示することが重要だ。たとえば "J" & i & " 品目コードが空である。" というメッセージなら、ユーザはExcelのJ列のi行目を確認すればよいと即座にわかる。「登録に失敗した」だけでは原因がわからず、サポートコストが跳ね上がる。

Tips:デバッグ時はImmediateウィンドウへのDebug.Printを積極的に使おう。JSONボディの最終形、POSTのURLとステータス、レスポンスボディの全文を出力しておくと、問題の特定が格段に速くなる。

サンプルVBAコードの全文

本記事で紹介したVBAコードの全文を以下に貼り付けておく。このコードを内蔵したExcelサンプルファイルは 便利ツールと資料 にも保存してあるので、そちらも併せて活用いただきたい。

Excelサンプルファイルの場合は、「Main」シートにある「購買発注登録」ボタンを押下すればメインのプロシージャである CreatePurchaseOrder_FromExcel が実行される。

Option Explicit

Public Sub CreatePurchaseOrder_FromExcel()

    Dim ws As Worksheet
    Dim wsConfig As Worksheet

    Dim baseUrl As String
    Dim sapClient As String
    Dim userId As String
    Dim password As String

    Dim serviceUrl As String
    Dim postUrl As String

    Dim docType As String
    Dim supplier As String
    Dim docDate As String
    Dim purOrg As String
    Dim purGroup As String
    Dim companyCode As String
    Dim currency1 As String

    Dim itemNo As String
    Dim material As String
    Dim shortText As String
    Dim qty As String
    Dim unit As String
    Dim plant As String
    Dim storage As String

    Dim schedLine As String
    Dim delivDate As String
    Dim schedQty As String

    Dim csrfToken As String
    Dim cookieHeader As String
    Dim jsonBody As String

    Dim http As Object
    Dim statusCode As Long
    Dim responseText As String

    Dim createdPo As String
    Dim locationHeader As String
    Dim entityIdHeader As String

    Dim lastRow As Long
    Dim i As Long
    Dim isFirstItem As Boolean
    Dim itemCount As Long

    On Error GoTo ErrHandler

    Set ws = ThisWorkbook.Worksheets("Main")
    Set wsConfig = ThisWorkbook.Worksheets("Config")

    '========================================================
    ' 1. 接続情報を取得する
    '========================================================
    baseUrl = Trim$(CStr(wsConfig.Range("B1").Value))
    sapClient = Trim$(CStr(wsConfig.Range("B2").Value))
    userId = Trim$(CStr(wsConfig.Range("B3").Value))
    password = Trim$(CStr(wsConfig.Range("B4").Value))

    If baseUrl = "" Then Err.Raise vbObjectError + 100, , "Config!B1 の baseUrl が空である。"
    If sapClient = "" Then Err.Raise vbObjectError + 101, , "Config!B2 の sap-client が空である。"
    If userId = "" Then Err.Raise vbObjectError + 102, , "Config!B3 の userId が空である。"
    If password = "" Then Err.Raise vbObjectError + 103, , "Config!B4 の password が空である。"

    '========================================================
    ' 2. ヘッダ項目を取得する
    '    複数明細でも購買発注ヘッダは1つなので、2行目を使う
    '========================================================
    docType = Trim$(CStr(ws.Range("A4").Value))
    supplier = Trim$(CStr(ws.Range("B4").Value))
    docDate = ToIsoDate(ws.Range("C4").Value)
    purOrg = Trim$(CStr(ws.Range("D4").Value))
    purGroup = Trim$(CStr(ws.Range("E4").Value))
    companyCode = Trim$(CStr(ws.Range("F4").Value))
    currency1 = Trim$(CStr(ws.Range("G4").Value))

    If docType = "" Then Err.Raise vbObjectError + 110, , "A2 購買伝票タイプが空である。"
    If supplier = "" Then Err.Raise vbObjectError + 111, , "B2 仕入先勘定コードが空である。"
    If docDate = "" Then Err.Raise vbObjectError + 112, , "C2 購買伝票日付が不正である。"
    If purOrg = "" Then Err.Raise vbObjectError + 113, , "D2 購買組織が空である。"
    If purGroup = "" Then Err.Raise vbObjectError + 114, , "E2 購買グループが空である。"
    If companyCode = "" Then Err.Raise vbObjectError + 115, , "F2 会社コードが空である。"
    If currency1 = "" Then Err.Raise vbObjectError + 116, , "G2 通貨コードが空である。"

    '========================================================
    ' 3. 明細の最終行を取得する
    '    H列「購買伝票の明細番号」を基準にする
    '========================================================
    lastRow = ws.Cells(ws.Rows.Count, "H").End(xlUp).Row

    Debug.Print "===== lastRow ====="
    Debug.Print lastRow

    If lastRow < 4 Then
        Err.Raise vbObjectError + 117, , "登録対象の明細行が存在しない。"
    End If

    '========================================================
    ' 4. OData V4 API URLを組み立てる
    '========================================================
    serviceUrl = baseUrl & "/sap/opu/odata4/iwbep/all/srvd_a2x/sap/api_purchaseorder_2/0001"
    postUrl = serviceUrl & "/PurchaseOrder?sap-client=" & EncodeUrlComponent(sapClient)

    '========================================================
    ' 5. CSRFトークンを取得する
    '========================================================
    Set http = CreateObject("MSXML2.XMLHTTP.6.0")

    http.Open "GET", serviceUrl & "/PurchaseOrder?sap-client=" & EncodeUrlComponent(sapClient) & "&$top=1", False, userId, password
    http.setRequestHeader "Accept", "application/json"
    http.setRequestHeader "X-CSRF-Token", "Fetch"
    http.Send

    Debug.Print "===== CSRF Fetch Status ====="
    Debug.Print http.Status & " " & http.statusText
    Debug.Print "===== CSRF Fetch Response ====="
    Debug.Print http.responseText

    If http.Status <> 200 Then
        Err.Raise vbObjectError + 130, , _
            "CSRFトークン取得に失敗した。HTTP " & http.Status & " " & http.statusText & vbCrLf & http.responseText
    End If

    csrfToken = Trim$(CStr(http.getResponseHeader("X-CSRF-Token")))
    cookieHeader = ExtractCookieHeader(http.getAllResponseHeaders)

    Debug.Print "===== CSRF Token ====="
    Debug.Print csrfToken
    Debug.Print "===== Cookie Header ====="
    Debug.Print cookieHeader

    If csrfToken = "" Then
        Err.Raise vbObjectError + 131, , "レスポンスヘッダに X-CSRF-Token が存在しない。"
    End If

    '========================================================
    ' 6. JSON本文を組み立てる
    '
    ' JSON構造:
    ' PurchaseOrder
    '   └ _PurchaseOrderItem
    '        └ _PurchaseOrderScheduleLineTP
    '========================================================
    jsonBody = "{"
    jsonBody = jsonBody & """PurchaseOrderType"":""" & JsonEscape(docType) & ""","
    jsonBody = jsonBody & """Supplier"":""" & JsonEscape(supplier) & ""","
    jsonBody = jsonBody & """PurchaseOrderDate"":""" & JsonEscape(docDate) & ""","
    jsonBody = jsonBody & """PurchasingOrganization"":""" & JsonEscape(purOrg) & ""","
    jsonBody = jsonBody & """PurchasingGroup"":""" & JsonEscape(purGroup) & ""","
    jsonBody = jsonBody & """CompanyCode"":""" & JsonEscape(companyCode) & ""","
    jsonBody = jsonBody & """DocumentCurrency"":""" & JsonEscape(currency1) & ""","

    '========================================================
    ' 6-1. 発注明細配列の開始
    '--------------------------------------------------------
    ' ここから _PurchaseOrderItem の配列を作る。
    ' Excelの2行目から最終行までをループし、
    ' H列に明細番号がある行を1明細としてJSONへ追加する。
    '
    ' 例:
    ' "_PurchaseOrderItem": [
    '   { 明細10 },
    '   { 明細20 }
    ' ]
    '========================================================
    jsonBody = jsonBody & """_PurchaseOrderItem"":["

    isFirstItem = True
    itemCount = 0

    '########################################################
    '# 発注明細ループ開始
    '#-------------------------------------------------------
    '# i = 4 から lastRow まで、Excel上の明細行を順番に処理する。
    '# H列が空の行は明細ではないと判断し、スキップする。
    '########################################################
    For i = 4 To lastRow

        '----------------------------------------------------
        ' H列「購買伝票の明細番号」が空なら、この行は処理しない
        '----------------------------------------------------
        If Trim$(CStr(ws.Cells(i, "H").Value)) <> "" Then

            '------------------------------------------------
            ' 6-2. 現在処理中のExcel行から明細項目を取得する
            '------------------------------------------------
            itemNo = FormatItemNumber(ws.Cells(i, "H").Value)
            material = PadMaterial18(Trim$(CStr(ws.Cells(i, "J").Value)))
            shortText = Trim$(CStr(ws.Cells(i, "K").Value))
            qty = NormalizeNumber(ws.Cells(i, "L").Value)
            unit = Trim$(CStr(ws.Cells(i, "M").Value))
            plant = Trim$(CStr(ws.Cells(i, "N").Value))
            storage = Trim$(CStr(ws.Cells(i, "O").Value))

            schedLine = FormatScheduleLine(ws.Cells(i, "P").Value)
            delivDate = ToIsoDate(ws.Cells(i, "Q").Value)
            schedQty = NormalizeNumber(ws.Cells(i, "R").Value)

            '------------------------------------------------
            ' 6-3. 現在処理中の明細行の必須チェック
            '------------------------------------------------
            If itemNo = "" Then Err.Raise vbObjectError + 200, , "H" & i & " 明細番号が不正である。"
            If material = "" Then Err.Raise vbObjectError + 201, , "J" & i & " 品目コードが空である。"
            If qty = "" Then Err.Raise vbObjectError + 202, , "L" & i & " 購買発注量が不正である。"
            If unit = "" Then Err.Raise vbObjectError + 203, , "M" & i & " 数量単位が空である。"
            If plant = "" Then Err.Raise vbObjectError + 204, , "N" & i & " プラントが空である。"
            If storage = "" Then Err.Raise vbObjectError + 205, , "O" & i & " 保管場所が空である。"
            If schedLine = "" Then Err.Raise vbObjectError + 206, , "P" & i & " 納入日程行番号が不正である。"
            If delivDate = "" Then Err.Raise vbObjectError + 207, , "Q" & i & " 納入日付が不正である。"
            If schedQty = "" Then Err.Raise vbObjectError + 208, , "R" & i & " 計画数量が不正である。"

            Debug.Print "明細処理行 = " & i & _
                        ", ItemNo = " & itemNo & _
                        ", Material = " & material & _
                        ", Qty = " & qty & _
                        ", ScheduleLine = " & schedLine

            '------------------------------------------------
            ' 6-4. JSON配列のカンマ制御
            '------------------------------------------------
            ' JSON配列では、2件目以降の前にカンマが必要である。
            '
            ' 1件目:
            '   { 明細10 }
            '
            ' 2件目以降:
            '   ,{ 明細20 }
            '------------------------------------------------
            If Not isFirstItem Then
                jsonBody = jsonBody & ","
            End If

            isFirstItem = False
            itemCount = itemCount + 1

            '------------------------------------------------
            ' 6-5. 1明細分のJSON開始
            '------------------------------------------------
            jsonBody = jsonBody & "{"
            jsonBody = jsonBody & """PurchaseOrderItem"":""" & JsonEscape(itemNo) & ""","
            jsonBody = jsonBody & """Material"":""" & JsonEscape(material) & ""","
            jsonBody = jsonBody & """PurchaseOrderItemText"":""" & JsonEscape(shortText) & ""","
            jsonBody = jsonBody & """OrderQuantity"":" & JsonNumber(qty) & ","
            jsonBody = jsonBody & """PurchaseOrderQuantityUnit"":""" & JsonEscape(unit) & ""","
            jsonBody = jsonBody & """Plant"":""" & JsonEscape(plant) & ""","
            jsonBody = jsonBody & """StorageLocation"":""" & JsonEscape(storage) & ""","

            '------------------------------------------------
            ' 6-6. 納入日程行JSON開始
            '------------------------------------------------
            ' 今回は「1明細につき1納入日程行」の前提である。
            '------------------------------------------------
            jsonBody = jsonBody & """_PurchaseOrderScheduleLineTP"":[{"
            jsonBody = jsonBody & """ScheduleLine"":""" & JsonEscape(schedLine) & ""","
            jsonBody = jsonBody & """ScheduleLineDeliveryDate"":""" & JsonEscape(delivDate) & ""","
            jsonBody = jsonBody & """ScheduleLineOrderQuantity"":" & JsonNumber(schedQty) & ","
            jsonBody = jsonBody & """PurchaseOrderQuantityUnit"":""" & JsonEscape(unit) & """"
            jsonBody = jsonBody & "}]"
            '------------------------------------------------
            ' 6-7. 納入日程行JSON終了
            '------------------------------------------------

            jsonBody = jsonBody & "}"
            '------------------------------------------------
            ' 6-8. 1明細分のJSON終了
            '------------------------------------------------

        End If

    Next i
    '########################################################
    '# 発注明細ループ終了
    '#-------------------------------------------------------
    '# ここまでで、Excel上の全明細行が
    '# _PurchaseOrderItem 配列へ追加されている。
    '########################################################

    If itemCount = 0 Then
        Err.Raise vbObjectError + 209, , "登録対象の明細が存在しない。"
    End If

    '========================================================
    ' 6-9. 発注明細配列の終了
    '========================================================
    jsonBody = jsonBody & "]"
    jsonBody = jsonBody & "}"

    Debug.Print "===== itemCount ====="
    Debug.Print itemCount
    Debug.Print "===== POST URL ====="
    Debug.Print postUrl
    Debug.Print "===== JSON Body ====="
    Debug.Print jsonBody

    '========================================================
    ' 7. POST実行
    '========================================================
    Set http = CreateObject("MSXML2.XMLHTTP.6.0")

    http.Open "POST", postUrl, False, userId, password
    http.setRequestHeader "Accept", "application/json"
    http.setRequestHeader "Content-Type", "application/json"
    http.setRequestHeader "X-CSRF-Token", csrfToken

    If cookieHeader <> "" Then
        http.setRequestHeader "Cookie", cookieHeader
    End If

    http.Send jsonBody

    statusCode = http.Status
    responseText = http.responseText

    Debug.Print "===== POST Status ====="
    Debug.Print statusCode & " " & http.statusText
    Debug.Print "===== POST Response ====="
    Debug.Print responseText

    '========================================================
    ' 8. 成功時に購買発注番号を取得する
    '========================================================
    If statusCode = 201 Or statusCode = 200 Or statusCode = 204 Then

        createdPo = ""

        If Trim$(responseText) <> "" Then
            createdPo = ExtractJsonStringValue(responseText, "PurchaseOrder")
        End If

        If createdPo = "" Then

            locationHeader = http.getResponseHeader("Location")
            entityIdHeader = http.getResponseHeader("OData-EntityId")

            Debug.Print "===== Location Header ====="
            Debug.Print locationHeader
            Debug.Print "===== OData-EntityId Header ====="
            Debug.Print entityIdHeader

            createdPo = ExtractPurchaseOrderFromUrl(locationHeader)

            If createdPo = "" Then
                createdPo = ExtractPurchaseOrderFromUrl(entityIdHeader)
            End If

        End If

        If createdPo <> "" Then

            For i = 4 To lastRow
                If Trim$(CStr(ws.Cells(i, "H").Value)) <> "" Then
                    ws.Cells(i, "S").Value = createdPo
                End If
            Next i

            MsgBox "購買発注の登録が完了した。" & vbCrLf & _
                   "購買伝票番号: " & createdPo & vbCrLf & _
                   "明細数: " & itemCount, _
                   vbInformation, "CreatePurchaseOrder_FromExcel"

        Else

            MsgBox "登録は成功したが、購買伝票番号を取得できなかった。" & vbCrLf & _
                   "イミディエイトウィンドウの Debug.Print を確認すること。", _
                   vbExclamation, "CreatePurchaseOrder_FromExcel"

        End If

    Else

        MsgBox "登録に失敗した。" & vbCrLf & _
               "HTTP " & statusCode & " " & http.statusText & vbCrLf & _
               responseText, vbExclamation, "CreatePurchaseOrder_FromExcel"

    End If

    Exit Sub

ErrHandler:
    Debug.Print "===== Error ====="
    Debug.Print Err.Number & " / " & Err.Description
    MsgBox Err.Description, vbExclamation, "CreatePurchaseOrder_FromExcel"

End Sub

Private Function ToIsoDate(ByVal v As Variant) As String

    On Error GoTo EH

    If IsDate(v) Then
        ToIsoDate = Format$(CDate(v), "yyyy-mm-dd")
        Exit Function
    End If

    Dim s As String
    s = Trim$(CStr(v))
    s = Replace$(s, ".", "/")
    s = Replace$(s, "-", "/")

    If IsDate(s) Then
        ToIsoDate = Format$(CDate(s), "yyyy-mm-dd")
        Exit Function
    End If

EH:
    ToIsoDate = ""

End Function

Private Function NormalizeNumber(ByVal v As Variant) As String

    Dim s As String
    s = Trim$(CStr(v))

    If s = "" Then
        NormalizeNumber = ""
    Else
        NormalizeNumber = Replace$(s, ",", "")
    End If

End Function

Private Function FormatItemNumber(ByVal v As Variant) As String

    Dim s As String
    s = Trim$(CStr(v))

    If s = "" Then
        FormatItemNumber = ""
    ElseIf IsNumeric(s) Then
        FormatItemNumber = Right$("00000" & CStr(CLng(s)), 5)
    Else
        FormatItemNumber = s
    End If

End Function

Private Function FormatScheduleLine(ByVal v As Variant) As String

    Dim s As String
    s = Trim$(CStr(v))

    If s = "" Then
        FormatScheduleLine = ""
    ElseIf IsNumeric(s) Then
        FormatScheduleLine = Right$("0000" & CStr(CLng(s)), 4)
    Else
        FormatScheduleLine = s
    End If

End Function

Private Function PadMaterial18(ByVal s As String) As String

    PadMaterial18 = Trim$(s)

End Function

Private Function JsonEscape(ByVal s As String) As String

    s = Replace$(s, "\", "\\")
    s = Replace$(s, """", "\""")
    JsonEscape = s

End Function

Private Function JsonNumber(ByVal v As Variant) As String

    Dim s As String

    s = Trim$(CStr(v))
    s = Replace$(s, ",", ".")

    If s = "" Then
        Err.Raise vbObjectError + 2000, , "数値項目が空である。"
    End If

    If Not IsNumeric(s) Then
        Err.Raise vbObjectError + 2001, , "数値項目が不正である: " & s
    End If

    JsonNumber = s

End Function

Private Function EncodeUrlComponent(ByVal s As String) As String

    EncodeUrlComponent = Replace$(s, " ", "%20")

End Function

Private Function ExtractCookieHeader(ByVal allHeaders As String) As String

    Dim lines() As String
    Dim i As Long
    Dim line As String
    Dim lowerLine As String
    Dim rawCookie As String
    Dim cookiePair As String
    Dim posColon As Long
    Dim posSemi As Long
    Dim result As String

    lines = Split(allHeaders, vbCrLf)

    For i = LBound(lines) To UBound(lines)

        line = lines(i)
        lowerLine = LCase$(line)

        If Left$(lowerLine, 11) = "set-cookie:" Then

            posColon = InStr(1, line, ":", vbTextCompare)

            If posColon > 0 Then

                rawCookie = Trim$(Mid$(line, posColon + 1))
                posSemi = InStr(1, rawCookie, ";")

                If posSemi > 0 Then
                    cookiePair = Left$(rawCookie, posSemi - 1)
                Else
                    cookiePair = rawCookie
                End If

                If result <> "" Then
                    result = result & "; "
                End If

                result = result & cookiePair

            End If

        End If

    Next i

    ExtractCookieHeader = result

End Function

Private Function ExtractJsonStringValue(ByVal jsonText As String, ByVal keyName As String) As String

    Dim re As Object
    Dim matches As Object

    Set re = CreateObject("VBScript.RegExp")
    re.Global = False
    re.IgnoreCase = False
    re.Pattern = """" & keyName & """" & "\s*:\s*""([^""]*)"""

    If re.Test(jsonText) Then
        Set matches = re.Execute(jsonText)
        ExtractJsonStringValue = matches(0).SubMatches(0)
    Else
        ExtractJsonStringValue = ""
    End If

End Function

Private Function ExtractPurchaseOrderFromUrl(ByVal s As String) As String

    Dim re As Object
    Dim matches As Object

    Set re = CreateObject("VBScript.RegExp")
    re.Global = False
    re.IgnoreCase = False
    re.Pattern = "PurchaseOrder\('([^']+)'\)"

    If re.Test(s) Then
        Set matches = re.Execute(s)
        ExtractPurchaseOrderFromUrl = matches(0).SubMatches(0)
    Else
        ExtractPurchaseOrderFromUrl = ""
    End If

End Function

まとめ:Excel VBA と SAP OData の連携で業務自動化の基礎を学ぶ

本記事では、SAP OData V4 API「API_PURCHASEORDER_2」とExcel VBAを使った購買発注の登録(POST)実装について、実際に動作するコードを交えながら徹底解説した。

改めて要点を整理しよう。

  • POSTとGETの違い:POSTではCSRFトークンの取得とJSONボディの組み立てが追加で必要になる。
  • 3階層のJSON構造:API_PURCHASEORDER_2はヘッダ→明細→納入日程行という入れ子構造で登録する。SAPの帳票データに共通するパターンだ。
  • CSRFトークン:GETリクエストで事前取得し、POSTヘッダに付与する。Cookieも引き継ぐことでセッションを維持する。
  • ステータスコードのフォールバック:200・201・204いずれも成功扱いとし、購買発注番号はJSON・Location・OData-EntityIdの3段階で取得を試みる。
  • 日付・数値の正規化:ExcelとSAP ODataの形式差異を吸収するユーティリティ関数を用意する。

GETとPOSTの両方を使いこなせるようになれば、SAP ODataを使った業務自動化の幅は一気に広がる。
Excelに入力したデータをワンクリックでSAPに登録する仕組みは、入力ミスの削減・作業時間の短縮・データ品質の向上に直結する。

本記事で紹介したVBAコードは、購買発注以外のSAP OData APIに対するPOST実装のテンプレートとしても活用できる。CSRFトークンの取得ロジック、JSON組み立てパターン、エラーハンドリング構造はそのまま流用可能だ。ぜひ自分のプロジェクトにカスタマイズして使ってほしい。

次のステップとしては、今回のPOSTをベースに、既存POの更新(PATCH)や削除(DELETE)への応用、あるいはOData V4のバッチリクエストを使った複数伝票の一括登録にも挑戦してみてほしい。SAP ODataとExcel VBA SAP連携の世界は、探れば探るほど奥が深い。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

目次