コンテンツにスキップ

SMILE連携プラグイン (SMILE Integration Plugin)

概要

Ritsubiの会計システム「SMILE V2」との連携を実現するVendureカスタムプラグイン。注文データ・顧客データのCSV出力(UTF-8固定、.txt拡張子)、バッチ処理機能を提供します。

得意先/納品先コードの扱い(2025-11-30更新)

  • SMILEを正とし、Vendure側で自動採番しない。
  • 得意先コードは Customer.customFields.customerCode に統一し、SMILEで発行したコードを手入力/取込する。
  • customerNumber は後方互換用に残すが、今後は customerCode を正とする。必要に応じてバッチでコピーする。
  • 納品先コード(SMILE連携の正本)は Address.customFields.deliveryPointCode をカスタムフィールドで保持する(住所のupsertキー/納品先CSV出力キー)。 shipToCode は互換/別用途のフィールドとして残し、自動同期しない

以前分割していた「SMILE連携仕様書」と本プラグインドキュメントを統合し、連携要件と実装をこの1ページで一元管理します。(更新日: 2025-11-28)

エクスポート方式(2026-06-06更新)

  • トリガー: Admin GraphQL Mutation(exportSmileOrders / exportSmileDeliveryPoints)はキックのみを担当。レスポンスは downloadUrl / filename / recordCount を返す(csvData 廃止)。
  • ダウンロード: 管理者セッションが必須のRESTエンドポイント /smile-export/download で配信。署名キーや追加環境変数は不要。
  • ZIP生成: ZIP は backend の writable filesystem を前提にせず、メモリ上の Buffer として生成してから storage provider へ保存する。
  • ストレージ: production / staging は既存の Vendure reports R2 bucket を正本とし、SmileExportLog には storageProvider / storageKey / filename を記録する。key は smile-exports/<env>/<yyyy>/<mm>/<logId>/<filename>。local / test だけ SMILE_EXPORT_DIRECTORY を fallback として扱い、未指定時は repo root output/smile/vendure-server に保存する。
  • fail-closed: production / staging で R2 bucket / endpoint / credentials が不足している場合、local filesystem へ黙って逃がさず明示エラーにする。
  • Dashboard対応: Smileエクスポート画面は downloadUrl をfetch→Blobダウンロードするフローに更新(大容量でもBase64膨張なし)。

SMILEエクスポートのトラブルシュート(2026-06-06更新)

EACCES: permission denied, mkdir '/app/output'

  • 症状: exportSmileOrders / exportSmileDeliveryPoints の実行時に 出力に失敗しました: GraphQL Request Error: EACCES: permission denied, mkdir '/app/output' が返る。
  • 原因: Fly image では /app/data/app/logs だけを writable として扱う。backend が ROOT_DIR/../../output/... のような repository 相対パスから /app/output を作ろうとすると権限エラーになる。
  • 恒久対応: production / staging の SMILE export は filesystem ではなく Vendure reports R2 bucket に保存する。apps/vendure-server/src/config/plugins.tsoutput/smile/vendure-server を hardcode しない。
  • 確認観点:
  • SmileExportLog の新規 row に storageProvider / storageKey / filename が入っていること。
  • download controller と cleanup task が storageProvider / storageKey 経由で R2 object を読取・削除していること。
  • runtime code に csvFilePath / /app/output / 旧 local path fallback が残っていないこと。
  • production / staging の runtime env に VENDURE_R2_REPORTS_BUCKET_NAME と R2 endpoint / credentials が揃っていること。
  • 監視: GraphQL interceptor / scheduled task monitor による exception capture を正本にし、SMILE 固有の失敗件数は smile.export.failed metric を見る。metric tag は export_type / execution_method / failure_class。 今回の /app/output 障害は Sentry issue B2B-COMMERCE-VENDURE-4B として記録済み(mutation ExportSmileOrders, environment=prd)。
  • ローカル確認コマンド:
rg "csvFilePath|legacyFilePath|/app/output" packages/plugins/src/system-integration/smile apps/vendure-server/src/config/plugins.ts
pnpm -C packages/plugins exec vitest run \
  src/system-integration/smile/services/csv-export-file.spec.ts \
  src/system-integration/smile/services/smile-export-storage.service.test.ts \
  src/system-integration/smile/services/__tests__/csv-export.service.test.ts \
  src/system-integration/smile/services/__tests__/csv-export-service-helpers.test.ts \
  src/system-integration/smile/services/__tests__/csv-export.service.helpers.test.ts \
  src/system-integration/smile/services/__tests__/smile-export-cleanup.service.test.ts \
  src/system-integration/smile/api/download.controller.spec.ts
pnpm -C packages/plugins build
pnpm exec nx run ritsubi-vendure-server:dashboard:build
  • deploy後確認: migration を含むため Vendure deploy は just deploy-vendure-with-migration <env> を使う。staging では SMILE export から download まで実行し、Sentry issue / smile.export.failed metric が再発していないことを確認する。production では release issue と runtime secret/env を確認し、実 export は対象期間・対象注文を決めた運用確認として扱う。

互換性レンジ

  • Vendure: 3.5.x(apps/vendure-server は ^3.5.2 を使用)
  • Node.js: >=24.7.0(apps/vendure-server/package.json の engines に準拠)

連携アーキテクチャ

システム構成

Vendure Backend ←→ SMILE会計システム
     ↓
Storefront

連携方式

  • 起動: Vendure Admin GraphQL mutation(exportSmileOrders / exportSmileDeliveryPoints / importSmileCustomers
  • データ形式: CSV ファイル(受注・納品先エクスポート、顧客取込)
  • 認証: Vendure Admin API 標準認証(API Key / セッション)

顧客/納品先コード管理

カスタムフィールド定義

  • Customer.customFields.customerCode
  • SMILEで発行された得意先コード。Vendure側での自動採番は禁止。
  • UNIQUE / NOT NULL 制約をDB側で付与。UI上は必須入力。
  • customerNumber が既存データにある場合は移行時にコピーする。
  • Address.customFields.deliveryPointCode
  • SMILE連携で使用する全体一意の納品先コード。
  • 住所のupsertキー/納品先CSV出力の参照キー。新規採番の開始値は Vendure Dashboard の SMILE同期設定で管理し、DB 初期値は required baseline seed で投入する。

入力・バリデーション運用

  • 管理画面の顧客・住所フォームで両コードが未入力の場合は保存不可にする(UIガード)。
  • コード重複時はエラーを即時表示し、SMILEコード体系の重複を防止。
  • コード発行フロー: SMILEで発行 → Vendureに手入力またはバッチ取込。Vendure側での採番は行わない。
  • SMILEインポート対象のマスタ項目は、SSOTをSMILEとするため Vendure側で編集不可を原則とする。
  • 差分修正は Vendureで直接編集せず、SMILE側更新 → 再インポートで反映する。
  • 上記を技術的に担保するため、SMILE由来の customFieldsvendure-configreadonly: true を必須とする。

Vendure Dashboard 一覧での得意先コード表示

  • 顧客一覧では Customer.customFields.customerCode得意先コード(SMILE) として列表示・列選択できる。
  • 注文一覧では Order.smileCustomerCode得意先コード(SMILE) として列表示・列選択できる。値は注文に紐づく Customer.customFields.customerCode から解決する。
  • 注文一覧の smileCustomerCode は表示専用の computed scalar field とする。Vendure Dashboard の標準注文一覧は top-level field だけを列候補にするため、nested object では 列表示メニューに出ない。
  • コードが空の場合は Dashboard 上で と表示し、SMILEコード欠落を operator が一覧上で検知できるようにする。

SMILEマスタCSVインポートのプレビュー/バリデーション(2026-03-23更新)

  • Vendure Dashboard の SMILE インポート画面は、行ごとに区切り文字を判定してプレビューする。そのため、同一ファイル内にタブ区切り行とカンマ区切り行が混在していても、商品コード・商品名・標準売上単価の列を継続して解釈できる。
  • 上記の解釈ルールは、プレビューだけでなく実際の取り込み処理にも適用する。
  • プレビューのバリデーション一覧、および取り込み実行後のエラー詳細は、先頭20件で打ち切らず全件表示する。一覧はスクロール表示とし、行番号ベースで修正対象を追跡できる状態を維持する。

SMILEマスタインポートの巻き戻し(2026-03-23更新)

  • SMILE インポート履歴には、インポート時点の巻き戻し用スナップショットを保存する。巻き戻しは履歴1件単位で実行し、行単位の部分巻き戻しは行わない。
  • 巻き戻し対象は 今後取り込む CUSTOMER / PRODUCT の履歴のみ とする。既存の古い履歴はスナップショットを持たないため、Dashboard 上でも巻き戻し対象外として扱う。
  • 顧客インポートの巻き戻しは、更新時は Customer / Address のスナップショットを復元し、新規作成時は CustomersoftDelete して取り消す。
  • 商品インポートの巻き戻しは、更新時は ProductVariant のスナップショットを復元し、新規作成時は新規 Product ごと作られていれば ProductsoftDelete、既存 Product 配下に作った SKU だけなら ProductVariantsoftDelete して取り消す。
  • 安全性を優先し、以下の条件では巻き戻しを拒否する。
  • 同じ種別(CUSTOMER / PRODUCT)の より新しいインポート履歴 が存在する
  • 巻き戻し対象エンティティの updatedAt が当該インポート完了時刻より後
  • 既に巻き戻し済み、または巻き戻しメタデータを持たない履歴である
  • Dashboard の履歴一覧には巻き戻しボタンを表示するが、最終的な実行可否判定は backend 側で行う。したがって一覧上でボタンが見えていても、後続更新や後続インポートにより mutation 側で拒否されることがある。

SMILE単価マスタ取込のバックグラウンド化/進捗%/キャンセル(2026-05-28更新)

バックグラウンド実行

  • 単価マスタ取込 (tanka.txt / suryou-tanka*.txt) は Vendure JobQueue 経由のバックグラウンド実行 が正本。同期 importSmilePriceMasters mutation は維持しているが、Dashboard 経由の通常運用では使わない。
  • Dashboard は enqueueSmilePriceMasterImport(file, input) mutation を呼ぶ。レスポンスは { logId, jobId, status: "STARTED" }。アップロードと同時に SmileImportLogEntity の row が status='STARTED' で作成され、その後 JobQueue worker が csv-import-runner.helpers.ts 経由で processing → completed/failed/cancelled へ遷移する。
  • Queue 名は rits-smile-import。実装は packages/plugins/src/system-integration/smile/services/smile-import-queue.service.ts。job payload には CSV を base64 で載せるため、Vendure JobQueue の payload size 制限を超えるサイズ (数十 MB) では分割を検討する。
  • 顧客マスタ (tokuisaki.txt) と商品マスタ (product-master) は同期取込のまま。サイズが小さく、即時応答のほうが UX が良いため。

進捗% 表示

  • SmileImportLogEntitytotalRecordCount / processedRecordCount (どちらも nullable int) を保持する。runner が CSV decode 直後に totalRecordCount を保存、1.5 秒間隔で best-effort に processedRecordCount を update、完了時に processedRecordCount = totalRecordCount で着地する。
  • 旧ログ (Queue 化前) は両カラム NULL のまま残る。Dashboard 履歴一覧では NULL を「進捗未取得」として扱い、進捗テキストを表示しない。
  • 進捗% は Math.floor(processed / total * 100)processed > total のケース (理論上は起きない) はクランプして 100% に頭打ちする。フォーマッタは packages/plugins/src/standard-extensions/admin-extensions/dashboard/smile-sync/import-progress-format.tsformatImportProgress を SSoT とする。
  • Dashboard の履歴一覧では、行の statusSTARTED / PROCESSING の間だけ setInterval(3000ms) で履歴を自動再フェッチする。COMPLETED / FAILED / CANCELLED に遷移したら polling は停止し、API 負荷を増やさない。

キャンセル(cooperative soft-cancel)

  • Dashboard 履歴の running row には「キャンセル」ボタンを表示する。クリックすると cancelSmileImport(logId) mutationSmileImportLogEntity.cancelRequestedAt に時刻を書き込む。
  • runner の進捗タイマー (1.5 秒間隔) が cancelRequestedAt を re-read し、non-null を検知すると cancelSignal.aborted = true を flip する。各 processor は processAllRecords の for-loop 先頭で if (cancelSignal?.aborted) break; を実行して打ち切る。
  • 既に処理中の 1 行は完了させる (= 行単位の SQL は強制 abort しない)。これにより DB 整合性を保ったまま停止できる。半端な行は残らない。
  • キャンセルされたログは status='CANCELLED'errorMessage='ユーザー操作によりキャンセルされました。'processedRecordCountキャンセル時点の実数 (100% にしない) で保存する。
  • CANCELLED ログも巻き戻し対象。誤投入 → キャンセル → 巻き戻し、という復旧フローが完結する。
  • cancelSmileImport mutation は active status (STARTED / PROCESSING) の log のみ受け付ける。COMPLETED / FAILED / CANCELLED / 存在しない id は UserInputError。二重押しは冪等 (re-save しない)。
  • Vendure の JobQueueService.cancelJob は実行中処理を割り込めないため、本機能では使っていない。Queue に積まれただけで未起動の job も、起動直後の polling で同じ DB flag を検知して即 abort するため、ユーザー視点では Queue 待ち / 実行中の区別なくキャンセルできる。

GraphQL スキーマ(要点)

type SmileImportLog {
  status: String! # STARTED | PROCESSING | COMPLETED | FAILED | CANCELLED
  totalRecordCount: Int # null = 進捗未取得 (旧ログ)
  processedRecordCount: Int # null = 同上
  cancelRequestedAt: DateTime # 非 null の間 runner は次の tick で abort する
  # ...
}

type SmileImportJobResult {
  logId: ID!
  jobId: ID!
  status: String! # 常に "STARTED"
}

extend type Mutation {
  enqueueSmilePriceMasterImport(
    file: Upload!
    input: SmilePriceMasterImportInput!
  ): SmileImportJobResult!
  cancelSmileImport(logId: ID!): SmileImportLog!
}

関連 migration

  • 1780400000000_add_smile_import_log_progress.tstotalRecordCount / processedRecordCount カラム追加。
  • 1780500000000_add_smile_import_log_cancel.tscancelRequestedAt カラム追加。status は既存 varchar(20) の値拡張で 'CANCELLED' を許容するため schema 変更不要。

CSVエクスポート対応

  • 得意先コードcustomer.customFields.customerCode
  • 納品先コードaddress.customFields.deliveryPointCode
  • 住所/氏名は Address 標準フィールド(会社名を使う場合は Address.company を優先)

連携要件

単価マスタ連携

単価マスタの定義

  • 用途: 通常価格とは異なる特別価格設定
  • 管理: SMILE側で管理される特別価格マスタ
  • 連携: Vendure側で単価マスタの価格を参照・適用

取り込み対象と参照元

  • 取り込み元: SMILEから出力される単価マスタ
  • テスト/リファレンス用フィクスチャ:
  • import/: SMILE → Vendure の取り込み対象データ
  • export/: Vendure → SMILE への出力で期待されるデータ
  • apps/vendure-server/tests/integration/fixtures/smile/import/tanka.txt(SMILE単価マスタ:本プロジェクトでは通常価格として扱う)
  • apps/vendure-server/tests/integration/fixtures/smile/import/suryou-tanka.txt(数量別単価マスタ/数量単価マスタ:商品単体の数量別価格)
  • apps/vendure-server/tests/integration/fixtures/smile/import/suryou-tanka-tokuisaki.txt(数量別単価マスタ/数量単価マスタ:得意先単体の数量別価格)
  • apps/vendure-server/tests/integration/fixtures/smile/import/suryou-tanka-bunrui.txt(数量別単価マスタ/数量単価マスタ:得意先分類(売上掛率分類)単位の数量別価格)
  • apps/vendure-server/tests/integration/fixtures/smile/import/tokuisaki.csv(得意先インポートの参照正本)
  • apps/vendure-server/tests/integration/fixtures/smile/import/shouhin.csv(商品インポートの参照正本)
  • apps/vendure-server/tests/integration/fixtures/smile/import/nouhinsaki.csv(納品先インポートの参照正本)
  • apps/vendure-server/tests/integration/fixtures/smile/export/nouhinsaki.txt
  • シード運用方針: 顧客のSMILE読み込みは tokuisaki.csv をシード正本として扱う。
  • 運用ルール: 統合テスト用途に加え、開発の参照データとしても使用する。重複管理を避けるため、同内容のコピーを /docs/ には置かない。
  • 格納先: Vendureに単価マスタを取り込み、価格計算で参照する
  • フィクスチャ詳細: SMILEマスタのヘッダー一覧・読み方は docs/03-implementation/smile/smile-master-data-reference.md を参照。
  • 得意先CSVの読み込み優先順位(先頭優先)・重複ヘッダー解釈(前=コード/後=label)も同ドキュメントを正とする。

商品マスタの税区分連携

  • 正本(課税商品の税率分類): shouhin.csv消費税率区分。商品ごとの課税分類はこの区分値を継承する。
  • 正本(非課税の判定): shouhin.csv非課税区分 (0:課税商品 1:非課税商品)。 非課税の最終判定はこの列が正本で、消費税率区分 に課税扱いの値が残っていても 非課税区分=1 の行は非課税として扱う。
  • Vendure 側の対応:
  • 課税分類: 税カテゴリ詳細の SMILE 消費税率区分 (TaxCategory.customFields.smileTaxRateCategoryCode) に SMILE 区分値を設定する。 例: 標準税率カテゴリに 1、軽減税率カテゴリに 2。 これは例示であり、import 実装は 1 / 2 の固定分岐ではなく、 SMILE 行の 消費税率区分 と TaxCategory 側の smileTaxRateCategoryCode が一致するカテゴリを割当先にする。
  • 非課税: 税カテゴリ詳細の SMILE 非課税フラグ (TaxCategory.customFields.smileTaxExempt: boolean) を true にした 1 つの TaxCategory が非課税の割当先になる。同フラグを複数の TaxCategory に true で設定するのは禁止(import を開始しない)。
  • import 時の動作:
  • 非課税区分=1 の行は 消費税率区分 を見ずに、smileTaxExempt=trueTaxCategoryProductVariant.taxCategoryId に割り当てる。 smileTaxExempt=true の TaxCategory が無い環境では行エラーにする。
  • 非課税区分=0 または空欄の行は 消費税率区分 から TaxCategory を解決し、 ProductVariant.taxCategoryId に設定する。解決は import 開始時点の TaxCategory.customFields.smileTaxRateCategoryCode 一覧から作った map で行い、 SMILE 行の区分値と一致した TaxCategory だけを割り当てる。 未設定・未登録の区分は行エラーにする。
  • 税率値の扱い: 新税率 は raw 取込データへ保持するだけで、Vendure の TaxRate は自動更新しない。実際の税率値と Zone 条件は Vendure の TaxRate を正本にする。
  • 設定破損の扱い:
  • 同じ SMILE 消費税率区分値が複数の TaxCategory に設定されている場合は、 誤った税カテゴリ選択を避けるため商品 import を開始しない。
  • smileTaxExempt=true が複数の TaxCategory に設定されている場合も同様に 商品 import を開始しない。

価格計算ルール(単価マスタ)

  • 金額単位: SMILE の単価は円単位で取り込み、Vendure の ProductVariant.price / priceWithTax 等へ保存・検証する境界で toVendureMoneyFromYen() を使って Vendure Money 値へ変換する。金額文脈の * 100 直書きは禁止。詳細は Vendure Money 単位運用 を参照。
  • 適用単位: 顧客 / 顧客グループ / 商品 / 単価分類(商品グループ)ごとに判定
  • 優先順位: 顧客単体への単価ルールが最優先
  • 商品マスタ連携: 商品マスタ(未入手・将来的に用意)に「エンドユーザー向け価格」と「サロン向け価格」があり、単価マスタの「単価種類」「単価種別」によって参照価格のロジックを切り替える
  • 売上掛率分類名の正本: 売上掛率分類コード / 売上掛率分類名ritsubi_smile_sales_rate_class へ upsert する。未登録 code は code/name で作成し、既存 code は最新 import の name で更新する。価格解決は分類名に依存せず、Customer.customFields.salesRateClassCode と単価マスタの code だけで判定する。

価格計算ルール(数量別単価マスタ)

  • 用途: 数量に応じて単価が変動する価格設定(数量単価)
  • 優先順位: 顧客単体の数量単価が最優先。次に顧客グループ、最後に商品単体の数量単価を参照する。
  • 適用条件: 数量別単価がヒットした場合は、通常単価(tanka.txt)より数量別単価を優先して適用する。
数量別単価の段階判定(数量単価系)

数量単価系ファイル(suryou-tanka*.txt)は、 / 数量(上限) / 数量別単価 の組で段階単価を定義する。数量(上限) は排他的上限であり、注文数量が 数量(上限) 未満となる最初の行を適用する。最終 sentinel 99999999 以上は 価格未定義として fail-closed にし、カート全体数量も最大 99999998 までに制限する。

参照キーの使い分け(数量単価)
  • 得意先単体: 取引先コード(得意先コード)+ 商品コード(SKU)
  • 得意先分類(グループ): 売上掛率分類コード + 商品コード
  • 商品単体: 商品コード

単価種類(顧客・商品の単体/グループ判定)

  • 1: 顧客は単体(取引先コード列参照)、商品も単体(商品番号列参照)
  • 2: 顧客は単体、商品はグループ(単価分類コードを参照)
  • 3: 顧客はグループ(売上掛率分類)、商品は単体
  • 4: 顧客も商品もグループ

単価種別(単価計算種別) — 実装済み (2026-05)

実装は SmilePriceMasterService.findBestPriceForVariantcomputeUnitPriceFromRow に集約。

  • 0: 金額指定 (単価列参照、0 円も valid)
  • 1: 標準売上単価×掛率 = ProductVariant.price × unitRate / 100 (実装済み)
  • 2: 標準仕入単価×掛率 (販売用途では通常使わないが、import 時には SMILE コードとして保存する)
  • 3: 上代単価×掛率 = ProductVariant.customFields.smileListPrice × unitRate / 100 (実装済み)
  • 旧 fixture (suryou-tanka*) は 単価種別 header を持たず、0 と同じ扱い

詳細フロー: docs/03-implementation/pricing/price-calculation.md の U3 セクション

単価マスタ import の責務境界(2026-05-28更新)

  • 単価マスタ importer は 単価種別 / 単価種別名 / 単価種類 / 顧客軸 / 商品軸を SMILE の正規化済みコードとして保存するだけにする。
  • import 時に「単価種別 2 は販売用途ではない」「単価種類と得意先/分類列の組み合わせが 価格計算上有効か」などの業務判断で行を skip しない。価格適用可否は SmilePriceMasterService の価格計算時に判定する。
  • header に 単価種別 がある場合、保存後の unitTypeCode は raw の 単価種別 と一致している必要がある。ここが乖離する行は、後続の価格計算で MC 系商品などが 0円 / 価格非表示に見える原因になるため import エラーにする。
  • rawRowData は監査・障害調査・migration 修復用。通常の価格計算は rawRowData単価種別名 から単価種別を推測しない。
  • 既存データ修復 migration:
  • RepairSmilePriceMasterUnitTypeFromRaw1779933100559
    • rawRowData.単価種別 / rawRowData.単価種別名 から、既存の unitTypeCode / unitTypeName を修復する。

商品マスタ入手後の参照価格切り替え

商品マスタで「エンドユーザー向け価格」「サロン向け価格」の2系統が揃った後は、単価種別 3 (掛率指定)の参照基準となる価格を商品マスタ側で切り替える。切り替え条件は、単価種別・単価種類の判定結果に従う。

連携仕様

interface UnitPriceMaster {
  productCode: string;
  specialPrice: number;
  customerGroupId: string;
  validFrom: Date;
  validTo: Date;
  isActive: boolean;
}

// SMILE → Vendure データ同期
interface UnitPriceSync {
  action: "CREATE" | "UPDATE" | "DELETE";
  unitPrice: UnitPriceMaster;
  timestamp: Date;
}

顧客グループ制限

現行システム制限

  • 制限内容: 1つの顧客は1つの顧客グループにのみ所属可能
  • SMILE制約: SMILE側で1グループ制限が実装済み
  • Vendure対応: 同様の制限をVendure側でも実装

連携仕様

interface CustomerGroupRestriction {
  customerId: string;
  currentGroupId: string;
  canChangeGroup: boolean;
  restrictionReason?: string;
}

// 顧客グループ変更時の制御
interface GroupChangeControl {
  customerId: string;
  newGroupId: string;
  requiresApproval: boolean;
  approvalWorkflow: ApprovalStep[];
}

セット商品処理

セット商品の扱い

  • 購入時: 商品セット(X=A+B+C)としてVendureで処理
  • SMILE連携時: 分裂した商品(A, B, C)として個別にエクスポート
  • データ変換: セット→個別商品への変換処理

補足: SMILEへ出力する受注データの形式(apps/vendure-server/tests/integration/fixtures/smile/export/jutyu.txt)では、セット商品に関する追加情報(元セット商品番号等)を持たせる必要がない前提とする。

連携データ形式

// Vendure側のセット商品データ
interface SetProduct {
  setCode: string;
  setName: string;
  individualProducts: {
    productCode: string;
    quantity: number;
    unitPrice: number;
  }[];
  totalPrice: number;
  bonusProduct?: {
    productCode: string;
    quantity: number;
  };
}

// SMILE連携時の個別商品データ
interface IndividualProductExport {
  orderId: string;
  products: {
    productCode: string;
    quantity: number;
    unitPrice: number;
    totalPrice: number;
  }[];
  exportTimestamp: Date;
}

価格計算連携

価格計算フロー

  1. 税抜き価格計算: すべての価格計算を税抜きで実行
  2. 消費税適用: 最終段階で消費税を適用
  3. 処理優先度: 図の下にある処理が優先される

連携仕様

interface PriceCalculation {
  basePrice: number;
  specialPrice?: number;
  unitPriceMasterPrice?: number;
  campaignPrice?: number;
  finalPrice: number;
  taxAmount: number;
  totalPrice: number;
  calculationSteps: PriceCalculationStep[];
}

interface PriceCalculationStep {
  step: string;
  price: number;
  description: string;
  priority: number;
  appliedAt: Date;
}

price_chk_campain() 関数連携

特別価格計算処理

  • 定義: システム側で自動設定される特別価格計算条件
  • 対象: セット商品(ユニーク商品)のみ
  • 連携: SMILE側の特別価格計算ロジックをVendure側で実装

連携仕様

interface PriceCheckCampaign {
  campaignId: string;
  targetProducts: string[]; // セット商品番号
  conditions: CampaignCondition[];
  pricingRules: PricingRule[];
  isActive: boolean;
}

interface CampaignCondition {
  type: "PRODUCT_COMBINATION" | "QUANTITY" | "CUSTOMER_GROUP";
  value: any;
  operator: "EQUALS" | "GREATER_THAN" | "LESS_THAN" | "IN";
}

商品ブランド連携

RCODE(商品ブランド)

  • 定義: 商品ブランドの一つ
  • 管理: SMILE側でブランド管理
  • 連携: Vendure側でブランド情報を同期

連携仕様

interface Brand {
  rcode: string;
  name: string;
  description?: string;
  pricingRules: BrandPricingRule[];
  isActive: boolean;
}

interface BrandPricingRule {
  ruleId: string;
  condition: BrandCondition;
  discount: DiscountRule;
  validFrom: Date;
  validTo: Date;
}

interface BrandCondition {
  type: "SAME_BRAND_QUANTITY" | "BRAND_COMBINATION";
  quantity?: number;
  productCodes?: string[];
}

キャンペーン・割引連携

割引パターン連携

同一ブランド商品の組み合わせ
  • パターン: 同一ブランド商品×点数の組み合わせ割引
  • 連携: SMILE側の割引ルールをVendure側で実装
セット商品割引
  • 基本パターン: A+B+C
  • おまけ付きパターン: A+B+C(+Dおまけ)
  • 亜種パターン: 対象商品1割引(特別安い設定の顧客には適用しない)

連携仕様

interface DiscountPattern {
  patternId: string;
  name: string;
  type: "SET_PRODUCT" | "BRAND_COMBINATION" | "SPECIAL_DISCOUNT";
  products: string[];
  discountRate: number;
  conditions: DiscountCondition[];
  exclusions: CustomerExclusion[];
}

interface CustomerExclusion {
  customerGroupId: string;
  reason: string;
  isActive: boolean;
}

API仕様

エンドポイント一覧

単価マスタ連携

GET    /api/smile/unit-prices           # 単価マスタ一覧取得
GET    /api/smile/unit-prices/{id}      # 単価マスタ詳細取得
POST   /api/smile/unit-prices/sync      # 単価マスタ同期
PUT    /api/smile/unit-prices/{id}      # 単価マスタ更新
DELETE /api/smile/unit-prices/{id}      # 単価マスタ削除

顧客グループ連携

GET    /api/smile/customer-groups       # 顧客グループ一覧取得
GET    /api/smile/customer-groups/{id}   # 顧客グループ詳細取得
POST   /api/smile/customer-groups/sync   # 顧客グループ同期
PUT    /api/smile/customer-groups/{id}   # 顧客グループ更新

セット商品連携

GET    /api/smile/set-products          # セット商品一覧取得
GET    /api/smile/set-products/{id}     # セット商品詳細取得
POST   /api/smile/set-products/export   # セット商品エクスポート
POST   /api/smile/set-products/convert  # セット→個別変換

価格計算連携

POST   /api/smile/price-calculation      # 価格計算実行
GET    /api/smile/price-calculation/{id} # 価格計算結果取得
POST   /api/smile/price-calculation/sync # 価格計算同期

認証・認可

API認証

interface APIAuthentication {
  apiKey: string;
  customerId: string;
  permissions: Permission[];
  expiresAt: Date;
}

interface Permission {
  resource: string;
  actions: string[];
  conditions?: PermissionCondition[];
}

顧客認証

interface CustomerAuthentication {
  customerId: string;
  customerGroup: string;
  accessLevel: AccessLevel;
  permissions: CustomerPermission[];
}

interface CustomerPermission {
  resource: "PRODUCTS" | "PRICING" | "ORDERS" | "CAMPAIGNS";
  actions: string[];
  restrictions?: PermissionRestriction[];
}

データ同期仕様

同期方式

  • リアルタイム同期: 価格・在庫・顧客情報
  • バッチ同期: 商品マスタ・顧客マスタ
  • イベント駆動同期: 注文・決済・配送情報

同期データ形式

interface SyncData {
  syncId: string;
  source: "VENDURE" | "SMILE";
  target: "VENDURE" | "SMILE";
  dataType: "PRODUCT" | "CUSTOMER" | "PRICE" | "ORDER";
  payload: any;
  timestamp: Date;
  status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED";
}

エラーハンドリング

interface SyncError {
  errorId: string;
  syncId: string;
  errorType: "VALIDATION" | "NETWORK" | "AUTHENTICATION" | "DATA";
  errorMessage: string;
  retryCount: number;
  maxRetries: number;
  nextRetryAt: Date;
}

セキュリティ要件

データ保護

  • 暗号化: すべての通信をTLS 1.3で暗号化
  • 認証: Vendure Admin API 標準認証(API Key / セッション)
  • 認可: リソースベースのアクセス制御

監査ログ

interface AuditLog {
  logId: string;
  timestamp: Date;
  userId: string;
  action: string;
  resource: string;
  details: any;
  ipAddress: string;
  userAgent: string;
}

実装優先度(フェーズ)

Phase 1: 基本連携

  1. 顧客情報の同期
  2. 商品マスタの同期
  3. 基本的な価格計算連携

Phase 2: 高度な連携

  1. 単価マスタの連携
  2. セット商品の処理
  3. 特別価格計算の連携

Phase 3: キャンペーン連携

  1. 割引パターンの連携
  2. ブランド別価格の連携
  3. 複雑な価格計算の連携

Phase 4: 最適化

  1. パフォーマンス最適化
  2. エラーハンドリング強化
  3. 監査ログの充実

パッケージ情報

  • パッケージ名: @ritsubi/smile-integration-plugin
  • パス: /packages/plugins/smile-integration
  • エントリーポイント: src/index.ts

実装機能

1. 注文データCSV出力(SMILE V2フォーマット対応)

VendureのOrderデータをSMILE V2が読み込める形式のCSVファイルに出力。

値引き(ポイント利用)の扱い

  • ポイント利用は Order のサーチャージ(負数) として保持する。
  • 注文の注文明細(OrderLine)には擬似商品を追加しない
  • SMILEエクスポート時に 擬似商品行として出力し、SMILE側で値引きとして取り込む。

出力データ項目

主要な列とデータソースを示します。全列定義は packages/plugins/src/system-integration/smile/spec/smile-order-columns.ts が正本です。

SMILE列名 説明 サンプル
受注番号 order.code 末尾の数値(NetB2B_00100477 → 100477) 100477
得意先コード order.customer.customFields.smileCustomerCode 12345
納品先コード 出荷方式による:直送出荷は空、通常出荷は order.customFields.deliveryPointCode 67890
時間帯 order.customFields.deliveryTimeSlot を配送方法ごとの SMILE 時間帯コードへ変換(指定なし99 1
商品コード スナップショット SKU 優先(OrderLine.customFields.snapshotSku)、なければ productVariant.sku に fallback EX-TRT-001
数量 OrderLine.quantity 10
単価 OrderLine.unitPricesetComponentUnitPrice カスタムフィールドで上書き可) 5000
金額 OrderLine.proratedLinePricesetComponentUnitPrice 設定時は unitPrice × quantity 50000
備考 order.customFields.specialInstructions(「最短日出荷」選択時は同文言を保存、未設定時は空欄) 最短日出荷
出荷予定日 SMILE受注CSVをエクスポートした日付。配送希望時間帯や注文側の shippingDate には依存しない 2025-11-15
定価 productVariant.customFields.smileListPrice必須、未設定時はエラー) 6000

出荷予定日 は、配送希望日・配送希望時間帯から再計算しない。SMILE 側へ取り込む受注データの出力日として、エクスポート実行日(JST)を出力する。「最短日出荷」は配送希望日ではなく備考として扱い、納期 は空のまま出力する。

CSVエンコーディング

  • デフォルト/唯一: UTF-8(SMILE V2.1以降を想定、.txt拡張子)
  • 非対応: SHIFT_JIS(運用要件により廃止)

2. 顧客データCSVインポート(SMILE得意先)

SMILE得意先CSVをVendureの顧客として取り込みます。実装は packages/plugins/src/system-integration/smile/services/csv-import.service.ts です。

対応ファイル

  • 拡張子: .csv / .txt / .tsv
  • MIMEタイプ: text/csv, text/plain, text/tab-separated-values, application/csv, application/vnd.ms-excel, application/vnd.tab-separated-values

文字コード

  • UTF-8 または AUTO
  • AUTO は BOM を優先し、UTF-8/UTF-16LE/UTF-16BE を推定して読み込む

区切り判定

  • ヘッダー行にタブが含まれる場合はタブ区切り
  • それ以外はカンマ区切り

ヘッダー正規化

  • 前後空白と全角空白を除去
  • 全角英数字を半角に正規化

必須項目

  • 得意先コード(得意先コード / 得意先コード
  • 得意先Mail(得意先Mail / 得意先メール / 得意先メール)は任意(未入力の場合は仮ID発行フロー)

取り込み時の主な扱い

  • 行データは smileRowData に JSON 文字列として保存
  • 得意先コードが空の場合は該当行をスキップし、エラーとして記録
  • 既存顧客の照合キーは Customer.customFields.customerCode(得意先コード)とし、同じ得意先コードの再インポートは新規作成せず既存顧客を更新する
  • 通常のメールアドレスは顧客照合キーに使わない。メール未入力時のみ、${customerCode}@ritsubi-platform.com 形式の仮メール顧客を再利用して更新する
  • 得意先Mailが空の場合は、仮ID発行のルールに従って仮メールアドレスを付与する
  • 顧客名は 得意先名1/2得意先名得意先名略称 の順で補完し、Customer.title/lastName に反映する
  • 請求先コード が無い/空の場合でも、billingCustomerCode = customerCode として補完する
  • 売上掛率分類コードCustomer.customFields.salesRateClassCode として保持し、CustomerGroupには同期しない。売上掛率分類名Customer.customFields.salesRateClassName に snapshot として残すが、表示正本は ritsubi_smile_sales_rate_class とする。得意先CSV import 時に code/name が来た場合は分類マスタへ upsert し、未登録 code は作成、既存 code は name 更新する
  • 住所は 納品先コード(= deliveryPointCode)をキーにupsertし、空/0 の場合は defaultBillingAddress をフォールバックとして更新する
  • 同一CSV内に同じ得意先コードが複数ある場合は、後ろの行で順次上書きされる
  • customerStatus は SMILE importer では設定・補完しない。Vendure 側の custom field default または Dashboard / 運用側の明示設定を正本にする。 SMILE の 回収方法collectionMethod として保持し、顧客分類への変換は import 時に行わない。PMCA(決済可否)と customerStatus(顧客分類)は 直交軸で、片方の更新が自動で他方に伝播しない。
  • 回収方法 は SMILE コード値のみを保存する。tokuisaki.csv の重複ヘッダー 回収方法,回収方法 は前者がコード、後者が表示名であり、general のような Vendure 内部値や 現金 / 振込 のような label を collectionMethod へ保存しない。
  • 既存データ修復 migration:
  • StopSmileCollectionMethodCustomerStatusDerivation1779934399464
    • 過去に 回収方法 から派生した customerStatus を Vendure 側の default に戻し、SMILE importer の派生責務を解消する。
  • RepairSmileCustomerCollectionMethodCode1779936361554
    • collectionMethod に入った label を raw の 回収方法 または既知 label map から SMILE コードへ戻し、DB CHECK 制約 CHK_customer_collection_method_smile_code で numeric code 以外を拒否する。
  • 正本仕様は docs/specifications/2026-03-smile-customer-import-upsert-rules.md を参照

仮ID/仮パスワード発行

  • 得意先Mailが未入力の顧客は、仮ID運用として取り込む
  • 仮IDはメールのローカルパートとして扱い、ドメインは ritsubi-platform.com に固定
  • 仮パスワードは管理用に生成し、初回ログイン時の変更を必須にする
  • 仮パスワードは Customer custom field や import log へ永続保存しない
  • Dashboard では、SMILE 取込完了直後の結果表示でのみ仮パスワードを確認できる
  • 顧客詳細では仮IDのみ参照でき、仮パスワードを忘れた場合は「パスワード設定」で再発行する
  • 記号や紛らわしい文字は使用しない(メール・口頭で共有可能な文字のみ)
  • 通知や再発行の自動フローは行わない

3. 顧客データCSV取込(SMILE → Vendure)

SMILEの顧客マスタCSVをVendureに取り込む。importSmileCustomers mutation を使用する。

取込データ項目

項目 説明
顧客コード SMILE顧客コード(得意先コード)
顧客名 法人名/個人名
住所 郵便番号・住所
電話番号 連絡先電話番号
FAX番号 FAX番号
メールアドレス 連絡先メール
掛率 基本掛率
締日 請求締日
支払条件 支払方法・期限
納品先コード 配送先ごとのSMILE納品先コード

4. 納品先コード管理

顧客の配送先住所ごとに SMILE の納品先コードを管理する。コードは SMILE 納品先CSV取込時に Address.customFields.deliveryPointCode フィールドへ保存される。import/nouhinsaki.csv は SMILE → Vendure の入力形式、export/nouhinsaki.txt は Vendure → SMILE の出力形式で、同じ納品先情報を扱うがファイル仕様は別物として扱う。

採番ルール

納品先コードは SMILE 連携全体で一意の数値コードとして扱う。
SMILE から取り込む納品先は、CSV の `納品先コード` を正本として Address.customFields.deliveryPointCode に保存する。
Storefront で新規追加された納品先は、Vendure Dashboard の SMILE同期設定にある「納品先コードの連番開始値」と既存最大値をもとに採番する。この開始値は Settings Store の保存値を正本とし、アプリコード側では未設定時の数値補完を行わない。
未設定のまま仮コードを出力する fallback は持たない。

納品先情報

  • 住所
  • 宛名
  • 電話番号
  • 配送指示
  • デフォルト納品先フラグ

5. CSV出力履歴管理

すべてのCSV出力履歴を記録し、トレーサビリティを確保。

記録情報

  • 出力日時
  • 出力ファイル名
  • 出力レコード数
  • 出力ステータス(成功/失敗)
  • エラー詳細
  • 出力実行者

6. ファイル保持期限管理

出力されたCSVファイルを一定期間保持し、期限後は自動削除。

  • デフォルト保持期間: 30日間
  • 最小保持期間: 7日間(法的要件)
  • 自動クリーンアップ: 毎日実行
  • 補足: 期限切れファイルは削除し、ログ上はダウンロード不可になる。

7. バッチ処理対応

定期的な自動CSV出力をスケジューリング。

  • デフォルトスケジュール: 毎日午前1時
  • カスタマイズ可能: Cron式で設定

ファイル保持期限管理

出力されたCSVファイルを一定期間保持し、期限後は自動削除。

  • デフォルト保持期間: 30日間
  • 最小保持期間: 7日間(法的要件)
  • 自動クリーンアップ: 毎日実行

8. エラー追跡・復旧機能

CSV出力・取込エラーを検知し、ログに記録。行ごとの詳細エラーを確認可能。

ディレクトリ構造と責務

SMILE連携プラグインは、保守性とテスト性を高めるために責務ごとにモジュールが分割されています。

バックエンド (packages/plugins/src/system-integration/smile/)

  • services/: ビジネスロジック
  • csv-export.service.ts: エクスポート全体の調整(クエリ実行、複数ファイル一括処理)
  • csv-export-assembler.ts: CSV行の組み立てに特化。VendureエンティティからSMILE形式への変換。
  • csv-export-log.service.ts: エクスポート履歴の管理。ログの作成・更新、注文へのフラグ立て。
  • csv-export-file.ts: ファイル I/O。CSV書き込み、ZIP圧縮、ファイル名生成。
  • csv-import.service.ts: インポート全体の調整(ファイル読み込み、顧客・住所のupsert、取込履歴保存)。
  • entities/: 永続化データ
  • smile-export-log.entity.ts: エクスポート実行履歴とダウンロードURL。
  • smile-import-log.entity.ts: インポート実行履歴と処理件数統計。
  • api/: GraphQL/REST エンドポイント
  • admin.resolver.ts: GraphQL Query/Mutation(履歴取得、実行キック)。
  • download.controller.ts: エクスポート済みファイルのセキュアなダウンロード。

フロントエンド (apps/vendure-server/src/plugins/ritsubi-admin-extensions/dashboard/smile-sync/)

  • components/: UIコンポーネント
  • ExportSection.tsx: 手動エクスポート実行画面。
  • ImportSection.tsx: CSVアップロード取込画面。
  • HistorySection.tsx: 実行履歴一覧。エクスポート・インポート両方の履歴をタブ切り替えで表示。
  • queries.ts: GraphQL 定義。
  • smile-sync-utils.ts: フロントエンドでのCSVパース・文字コード判定。

技術仕様

プラグイン設定オプション

export interface SmileIntegrationPluginOptions {
  /**
   * 注文データCSV出力を有効にする
   */
  enableOrderExport?: boolean;

  /**
   * 顧客データCSV出力を有効にする
   */
  enableCustomerExport?: boolean;

  /**
   * 納品先自動採番管理を有効にする
   */
  enableDeliveryPointManagement?: boolean;

  /**
   * SMILEバージョン
   */
  smileVersion?: "V1" | "V2";

  /**
   * CSV出力エンコーディング
   */
  exportFormat?: "UTF-8";

  /**
   * CSVファイル保持日数
   */
  csvFileRetentionDays?: number;

  /**
   * バッチ出力スケジュール(Cron式)
   */
  batchExportSchedule?: string;

  /**
   * CSV出力ディレクトリ
   */
  exportDirectory?: string;
}

プラグイン初期化

import { resolve } from "node:path";
import { SmileIntegrationPlugin } from "@ritsubi/smile-integration-plugin";
import { ROOT_DIR } from "./paths";

export const config: VendureConfig = {
  plugins: [
    SmileIntegrationPlugin.init({
      enableOrderExport: true,
      enableCustomerExport: true,
      enableDeliveryPointManagement: true,
      smileVersion: "V2",
      exportFormat: "UTF-8",
      csvFileRetentionDays: 30,
      batchExportSchedule: "0 1 * * *", // 毎日午前1時
      exportDirectory: resolve(ROOT_DIR, "../../output/smile/vendure-server"),
    }),
  ],
};

エンティティ

DeliveryPointEntity

納品先情報を管理するエンティティ。

@Entity()
export class DeliveryPointEntity extends VendureEntity {
  @Column()
  customerId: string;

  @Column({ unique: true })
  deliveryPointCode: string; // CUST-12345-001形式

  @Column()
  deliveryPointName: string;

  @Column()
  postalCode: string;

  @Column()
  address1: string;

  @Column({ nullable: true })
  address2: string;

  @Column()
  phoneNumber: string;

  @Column("text", { nullable: true })
  deliveryInstructions: string;

  @Column({ default: false })
  isDefault: boolean;

  @Column({ default: true })
  isActive: boolean;
}

SmileExportLogEntity

CSV出力履歴を記録するエンティティ。

@Entity()
export class SmileExportLogEntity extends VendureEntity {
  @Column("enum", { enum: ["ORDER", "CUSTOMER", "DELIVERY_POINT", "PRODUCT"] })
  exportType: ExportType;

  @Column()
  filename: string;

  @Column()
  filePath: string;

  @Column("int")
  recordCount: number;

  @Column("enum", { enum: ["PENDING", "SUCCESS", "FAILED", "PARTIAL"] })
  status: ExportStatus;

  @Column("text", { nullable: true })
  errorDetails: string;

  @Column()
  exportedAt: Date;

  @Column({ nullable: true })
  exportedBy: string; // ユーザーID

  @Column({ nullable: true })
  deletedAt: Date; // ファイル削除日時
}

サービス

CsvExportService

CSV出力のコアロジックを担当するサービス。

主要メソッド:

  • exportOrders(startDate: Date, endDate: Date): Promise<ExportResult>
  • 注文データをCSV出力
  • exportCustomers(): Promise<ExportResult>
  • 顧客データをCSV出力
  • exportDeliveryPoints(customerId: ID): Promise<ExportResult>
  • 納品先データをCSV出力
  • generateSmileV2Csv(data: Record<string, unknown>[], type: ExportType): Promise<string>
  • SMILE V2形式のCSVを生成
  • encodeUtf8(csvString: string): Promise<Buffer>
  • UTF-8エンコーディングでTXTファイルを生成

DeliveryPointService

納品先管理のロジックを担当するサービス。

主要メソッド:

  • createDeliveryPoint(customerId: ID, input: CreateDeliveryPointInput): Promise<DeliveryPointEntity>
  • 納品先を作成(自動採番)
  • getDeliveryPoints(customerId: ID): Promise<DeliveryPointEntity[]>
  • 顧客の納品先一覧を取得
  • generateDeliveryPointCode(customerId: ID): Promise<string>
  • 納品先コードを自動生成
  • setDefaultDeliveryPoint(deliveryPointId: ID): Promise<void>
  • デフォルト納品先を設定

カスタムフィールド

Customer

  • customerCode (string): SMILE顧客コード(ユニーク)
  • smileSyncStatus (string): SMILE同期ステータス
  • smileLastSyncedAt (datetime): SMILE最終同期日時

Order

  • deliveryPointCode (string): 納品先コード
  • smileExported (boolean): SMILE出力済み
  • smileExportedAt (datetime): SMILE出力日時
  • smileExportLogId (string): 関連するSMILE出力ログのID
  • shippingDate (datetime): 出荷予定日
  • specialInstructions (text): 特記事項

Product

  • smileCategory (string): SMILEカテゴリ分類

対応する要件

要件定義書との対応

  • SMILE V2連携: 会計システムとのデータ連携
  • CSV出力: UTF-8形式(.txt拡張子)での注文・顧客データ出力
  • 納品先管理: 自動採番による納品先マスタ管理
  • バッチ処理: 定期的な自動データ出力

使用例

フロントエンド(Storefront)での使用

import { useMutation } from "@tanstack/react-query";
import { graphql } from "@/__generated__/gql";
import { vendureQueryClient } from "@/lib/vendure-fetch-client";

const CREATE_DELIVERY_POINT = graphql(`
  mutation CreateDeliveryPoint(
    $customerId: ID!
    $deliveryPointName: String!
    $postalCode: String!
    $address1: String!
    $phoneNumber: String!
    $deliveryInstructions: String
    $isDefault: Boolean
  ) {
    createDeliveryPoint(
      customerId: $customerId
      deliveryPointName: $deliveryPointName
      postalCode: $postalCode
      address1: $address1
      phoneNumber: $phoneNumber
      deliveryInstructions: $deliveryInstructions
      isDefault: $isDefault
    ) {
      deliveryPointCode
    }
  }
`);

// 納品先を追加
const { mutateAsync: createDeliveryPoint } = useMutation({
  mutationFn: (variables: {
    customerId: string;
    deliveryPointName: string;
    postalCode: string;
    address1: string;
    phoneNumber: string;
    deliveryInstructions?: string;
    isDefault?: boolean;
  }) => vendureQueryClient(CREATE_DELIVERY_POINT, variables),
});

const data = await createDeliveryPoint({
  customerId: currentUser.id,
  deliveryPointName: "本社",
  postalCode: "100-0001",
  address1: "東京都千代田区...",
  phoneNumber: "03-1234-5678",
  deliveryInstructions: "平日9-17時のみ配送可",
  isDefault: true,
});

console.log(`納品先コード: ${data.createDeliveryPoint.deliveryPointCode}`);
// 例: CUST-12345-001

納品先一覧の取得

import { useQuery } from "@tanstack/react-query";
import { graphql } from "@/__generated__/gql";
import { vendureQueryClient } from "@/lib/vendure-fetch-client";

const GET_DELIVERY_POINTS = graphql(`
  query GetDeliveryPoints($customerId: ID!) {
    deliveryPoints(customerId: $customerId) {
      deliveryPointCode
      deliveryPointName
    }
  }
`);

// 顧客の納品先一覧を取得
const { data } = useQuery({
  queryKey: ["deliveryPoints", currentUser.id],
  queryFn: () => vendureQueryClient(GET_DELIVERY_POINTS, { customerId: currentUser.id }),
});

data?.deliveryPoints.forEach((point) => {
  console.log(`${point.deliveryPointCode}: ${point.deliveryPointName}`);
});

管理画面での使用

// 注文データを手動エクスポート
const result = await csvExportService.exportOrders(new Date("2025-10-01"), new Date("2025-10-31"));

console.log(`出力完了: ${result.filename}`);
console.log(`レコード数: ${result.recordCount}`);
console.log(`ファイルパス: ${result.filePath}`);

// 顧客データをエクスポート
const customerResult = await csvExportService.exportCustomers();
console.log(`顧客データ出力: ${customerResult.recordCount}件`);

// エクスポート履歴を確認
const logs = await smileExportLogRepository.find({
  where: { exportType: "ORDER" },
  order: { exportedAt: "DESC" },
  take: 10,
});

logs.forEach((log) => {
  console.log(`${log.exportedAt}: ${log.filename} - ${log.status}`);
});

バッチ処理

自動CSV出力ジョブ

毎日定時に注文データを自動出力。

@Cron('0 1 * * *') // 毎日午前1時実行
async autoExportOrders() {
  try {
    // 前日の注文を出力
    const yesterday = moment().subtract(1, 'day').startOf('day');
    const today = moment().startOf('day');

    const result = await this.csvExportService.exportOrders(
      yesterday.toDate(),
      today.toDate()
    );

    this.logger.log(`注文CSV自動出力完了: ${result.filename}`);
  } catch (error) {
    this.logger.error(`注文CSV出力エラー: ${error.message}`);
    await this.notificationService.sendAlert('SMILE CSV出力エラー', error);
  }
}

ファイルクリーンアップジョブ

期限切れのCSVファイルを自動削除。

@Cron('0 2 * * *') // 毎日午前2時実行
async cleanupOldExports() {
  const retentionDays = this.options.csvFileRetentionDays ?? 30;
  const cutoffDate = moment().subtract(retentionDays, 'days').toDate();

  const deletedCount = await this.csvExportService.deleteExportsOlderThan(cutoffDate);
  this.logger.log(`${deletedCount}件の期限切れCSVファイルを削除しました`);
}

CSV出力フロー

注文データ出力の詳細フロー

1. 出力対象の注文を取得
   ├─ 期間指定(開始日〜終了日)
   ├─ ステータス指定(確定済み注文のみ)
   └─ 未出力フラグでフィルタ

2. SMILE V2形式に変換
   ├─ 顧客コードをマッピング
   ├─ 納品先コードを取得
   ├─ 商品番号をマッピング
   └─ 金額・税額を計算

3. CSVファイル生成
   ├─ ヘッダー行を追加
   ├─ データ行を追加
   └─ UTF-8にエンコード(.txt拡張子)

4. ファイル保存
   ├─ ファイル名を生成(日付 + 連番)
   ├─ 指定ディレクトリに保存
   └─ エクスポートログを記録

5. 注文ステータスを更新
   └─ smileExported = true

データモデル

CSV出力ステータス

PENDING (処理待ち)
  ├─ 出力成功 → SUCCESS
  ├─ 一部エラー → PARTIAL
  └─ 全件エラー → FAILED

セキュリティ考慮事項

  1. アクセス制御: CSV出力は管理者権限が必要
  2. ファイル暗号化: 機密データを含むCSVは暗号化オプション
  3. 監査ログ: すべての出力操作を記録
  4. 自動削除: 保持期限経過後のファイル自動削除

パフォーマンス最適化

  • ストリーミング出力: 大量データはストリーミングで処理
  • バッチ処理: 深夜時間帯に自動実行
  • インデックス最適化: 出力クエリのパフォーマンス向上
  • 並列処理: 複数の出力タイプを並列実行

トラブルシューティング

よくある問題

問題: TXTが文字化けする

  • 原因: UTF-8以外で開いた、またはBOM除去前提のツールを使用
  • 解決: UTF-8として開くか、インポート側の設定をUTF-8に固定する

問題: 納品先コードの重複

  • 原因: 自動採番の並行実行
  • 解決: トランザクションで採番処理を保護

問題: CSVファイルがSMILEで読み込めない

  • 原因: フォーマットのバージョン不一致
  • 解決: smileVersion 設定を確認(V1 or V2)

関連ドキュメント

  • 連携要件・API仕様(本ページの「連携アーキテクチャ」「連携要件」「API仕様」セクション)
  • 配送計算プラグイン - 納品先情報との連携
  • 商流ルール - 単価・特典判定結果との連携

今後の拡張予定

  • リアルタイムSMILE連携(API連携)
  • 差分CSV出力機能
  • 複数SMILEインスタンス対応
  • CSV出力プレビュー機能