Reports

TL;DR 納品書や出荷表を 写真/PDF で投げると、vision(gpt-4o)が明細表と伝票ヘッダー(取引先・日付・伝票番号)を抽出する。 抽出後は在庫CSVと同じ会話UIを mode で使い回し、できた表は aiTableToParsedRows で既存の入荷/出荷パイプラインに無改修注入される。 伝票ヘッダーは「列」でなく「1伝票=全行共通の値」として扱い、再利用は取引先(clientId)キーで誤マッチを防ぐ。

目次 §1 在庫との違い §2 画像→data URL §3 vision抽出 §4 無改修注入 §5 振り分けフック §6 入荷と出荷の差 §7 同じUIの使い回し
§1 在庫CSVとの違い(要点)

会話UIや列カードの仕組みは在庫と完全に共有です。入荷/出荷で増えるのは次の3点だけ。

観点在庫CSV入荷/出荷
入口CSV/ExcelCSV/Excel + 写真/PDF
伝票ヘッダー(documentFields)常に空(無視)取引先・伝票番号・日付を1伝票=全行共通で使う
再利用キー列名だけ列名 + 取引先ID(誤マッチ防止)

「同じものを作り直さない」が徹底されていて、入荷/出荷は在庫の会話エンジンに橋渡しの薄い層を足しただけです。

§2 画像 → data URL(送る前に圧縮)
JPEGにリサイズ圧縮してから base64 data URL 化
表の文字が読める解像度は残しつつ、送信サイズを抑える。長辺1600px・品質0.7。圧縮に失敗しても元ファイルにフォールバックして致命的にしない。
imageFileToDataUrl(file)(src/util/imageToDataUrl.ts)。対応は jpg/png/webp のみ、HEIC非対応(呼び出し側で事前に弾く)。
data URL(data:image/...)でインライン送信するのは、任意URLをOpenAIのフェッチャに渡すSSRF踏み台化を防ぐため(サーバー側でも data:image/ 以外を拒否)。複数ページ/複数枚は images: [{url}, ...] で一度に渡せる。
§3 vision抽出(extract API)

サーバーの extractDocumentTable が会社確認→権限→日次クォータ(200)→vision LLM→矯正、の順で処理します。

vision LLM が明細表+伝票ヘッダーを返す
出力は { headers, rows, documentFields }。「見えない値は創作しない」「合計・小計行は明細に含めない」「品目と無関係な個人情報は出さない」をプロンプトで指示。
aiClient.structured(..., ExtractedDocumentSchema, {tier:'strong', schemaName:'document_extract'})。
矯正(行幅をヘッダー数に揃える)
ヘッダー数を上限で切り詰め、各行をヘッダー列数に強制整形(不足は空・超過は切詰)、全空行は捨てる、各セルtrim/上限。
sanitizeExtractedDocument(上限: 50列 / 2000行 / セル2000字)。
セッションを作らない/保存しないのがextractの特徴。抽出は「読み取るだけ」で、セッション・クォータ管理・テレメトリは後続のanalyze側が持つ。取引先名などのPIIは抽出段では保存しない設計。
§4 既存パイプラインへの無改修注入

AI由来のデータを、既存の受払処理(プレビュー→実行)に手を入れずに流し込むための変換層。3つの純関数が橋渡しします。

関数役割
aiTableToParsedRows(cleanedTable, documentFields)正規化済みの表を、既存パーサが食べる ParsedRow[] に変換。取引先/伝票番号/日付は「列」でなく documentFields から全行に当てる。アイテム名空・数量不正はエラー行に。
aiTableToHeaderMapping(headers)入荷/出荷が使う HeaderMapping を生成。単価・税区分・取引先・日付・伝票番号の列インデックスは全て -1(=documentFieldsと後段導出で扱う)。
detectAiTableColumns(headers)アイテム名列(無ければエラー)・在庫数列(=数量)・nancoID列を特定し、残りをフォルダ列/属性列に分類。
単価(unitPrice)をここで設定しないのは、後段の useImportAttributePricing が「価格ロール属性」から導出するため。価格の出どころを1箇所に統一して二重計算を防ぐ。
§5 振り分けフック useAiTradeImportFlow

在庫の CSVImportModal が持つAIライフサイクルを、入荷/出荷向けに切り出した共有フック。責務はドロップの振り分けと、抽出・同意・取込成功・ロールバックの管理です。

ドロップの振り分け(startFromFile)

画像なら vision 抽出経路
同意未取得なら先に同意ダイアログ。data URL 化 → extract → 表と伝票ヘッダーをセット。
取引先名から resolveClientIdByName で clientId 解決(既存取引先のみ)→ seedClientId。
CSV/Excel は従来 or AI に振り分け
parse して detectTradeTemplateMatch。テンプレ適合・空データ・サイズ超過は従来パースへ。不適合だけ空列を落としてスレッドへ。
detectTradeTemplateMatch(在庫の detectStockTemplateMatch の入荷出荷版)。
世代カウンタ extractIdRef
close や別ドロップで遅れて解決した古い抽出/パース結果を捨てる(レース対策)。await後に「自分が最新か」を判定。
markImported
取込成功時にロールバック抑止+再利用を取引先キーで保存(取引先が解決できる時だけ)。
rollbackCreatedAttrs
取込せず閉じたら、AIが作った新規アイテム情報を削除し孤児を残さない。
handleConsentAgree
AI同意を保存。失敗時はトーストしてAIを開始しない fail-safe。
§6 入荷と出荷の差(価格導出)

会話・抽出・注入は対称(ほぼ同じコード)。本質的に違うのは価格ロールの向きだけです。

入荷(receiving)

  • 価格ロール = 仕入値(PURCHASE)
  • skipMatched: true = 新規行のみ仕入値を導出
  • モーダル: ReceivingCsvImportMultipleModal

出荷(shipping)

  • 価格ロール = 売価(SELLING)
  • skipMatched: false = 既存ヒット行も売価で上書き
  • モーダル: ShippingCsvImportMultipleModal

どちらのモーダルも useAiTradeImportFlow を mode 違いで使い、aiPhase(thread / extracting / consent)で表示を切り替えます。AI注入口 loadFromAiTable は aiTableToHeaderMapping + aiTableToParsedRows をプレビューに流すだけ。

§7 同じ会話UIを mode で使い回す

AiImportThread は mode プロップ(stock / receiving / shipping)を取りますが、UIの構造は変わりません。mode は主にデータ経路の引数として効きます。

mergeDocumentFields は画像seedとCSV解析結果をフィールド単位で ?? 合成する。文書全体に seed ?? result すると、空(全null but 非nullオブジェクト)で解析結果が丸ごと落ち、取引先・日付が消えるため。