この記事について
The Automation Initiative 第12弾「SmartOrder(在庫発注点シミュレーター)」の開発で得た、ブラウザ完結型ETL処理の実装パターンと、UIコンポーネントへのデータ受け渡しにおける型設計の話です。
marimo・WASM・Altairの導入経緯やアンチパターンは過去記事で解説済みのため、本稿では 今回直面した2つの泥臭いUX・アーキテクチャ問題 に絞って書きます。
はじめに:「在庫管理CSV」は、きれいじゃない
在庫管理や発注業務で日常的に使われるCSVデータは、往々にして「構造」を持っていません。
販売管理システムから出力されたCSVには、こんなものが混入しています。
- 多段ヘッダー(「商品名\n商品コード」が1セルに結合されている)
- 小計行(「合計」「小計」という文字列がデータ行に混在)
-
幽霊行(空白文字のみのセルが
NaNではなく""として読み込まれる) -
型の汚染(数値のはずの列が
"1,200"のように文字列として入っている)
これらを手動でExcelの「検索と置換」で消していくのが現場の実態です。
本プロジェクトでは、これを ブラウザ内で全自動化 することを目指しました。
問題1: UI Error Binding — クセのあるCSVによるクラッシュを無力化する
設計方針:「入力は信頼しない」原則と Stealth Block の回避
WASM(Pyodide)環境では、ユーザーがアップロードするCSVの品質は完全に未知数です。
不正なデータを引いた際に、安易に mo.stop() を使って処理を物理遮断してしまうと、下流のレイアウトまで芋づる式に消滅する「ステルスクラッシュ (The Stealth Block Trap)」を引き起こしてしまいます。
そこで、パイプラインのクラッシュを避けつつエラー状態を正常に下流へ流通させる 3層のガード節(UI Error Binding) で構成しました。
@app.cell
def __(cleanse_data, io, mo, pd, smart_order_inputs):
# --- Cell 3: ETL Phase (UI Error Binding & Data Cleansing) ---
_files = smart_order_inputs.value["file_upload"]
cleaned_df = None
etl_error_ui = None
# ガード節 1: ファイル未アップロード時の防衛
if not _files:
etl_error_ui = mo.md("⚠️ 過去の販売データ(CSV)をアップロードしてください。").callout(kind="warn")
else:
try:
_raw_bytes = _files[0].contents
_raw_df = pd.read_csv(io.BytesIO(_raw_bytes), dtype=str).fillna("")
# バックエンド関数へ委譲(ベクトル演算によるETL)
cleaned_df = cleanse_data(_raw_df)
# ガード節 3: クレンジング後データが0件の場合の防衛
if cleaned_df.empty:
etl_error_ui = mo.md("⚠️ 有効なデータが0件です。フォーマットを確認してください。").callout(kind="warn")
cleaned_df = None
except Exception as _e:
# ガード節 2: パース・クレンジング失敗時の防衛
etl_error_ui = mo.md(f"🚨 パースに失敗しました。エラー詳細: {_e}").callout(kind="danger")
cleaned_df = None
# DAGに正常流通させるための必須エクスポート
return cleaned_df, etl_error_ui
3層の意図を整理すると...
| ガード節 | 検出する状態 | UIへのフィードバック |
|---|---|---|
| ガード節1 | ファイル未アップロード(Noneまたは空リスト) |
warn Callout で誘導 |
| ガード節2 | CSV自体がパース不能(文字コード不一致、壊れたファイル等) |
danger Callout でエラー詳細を表示 |
| ガード節3 | パース成功したが有効行が0件(全行が小計・空白だった) |
warn Callout で再アップロードを促す |
クレンジング本体:幽霊行と小計行の駆逐
バックエンド関数 cleanse_data() の核心部分です。
動的ループを使わず、完全な Pandas のベクトル演算で処理します。
def cleanse_data(raw_df: pd.DataFrame) -> pd.DataFrame:
df = raw_df.copy()
# Step1: 列名の正規化(日本語 → 英語)
col_map = {'日付': 'Date', '商品コード': 'ItemCode', '売上数': 'SalesQty'}
df = df.rename(columns=col_map)
# Step2: 幽霊行の除去
# pd.read_csv(..., dtype=str).fillna("") で読み込んだ場合、
# 空白文字のみのセルが NaN ではなく "" や " " として残る。
# regex=True でホワイトスペースのみの文字列を NaN に変換してから dropna する。
df = df.replace(r'^\s*$', np.nan, regex=True)
df = df.dropna(how='all')
# Step3: 小計行のパージ
# isin(['小計']) はセルの値全体が '小計' と一致する行を対象とする。
# '合計' が混在する場合はリストに追加すればよい。
mask = df.isin(['小計']).any(axis=1)
df = df[~mask]
# Step4: 必須列のNaN除去と数値変換
essential_cols = [c for c in ['ItemCode', 'Date', 'SalesQty'] if c in df.columns]
if essential_cols:
df = df.dropna(subset=essential_cols)
if 'SalesQty' in df.columns:
df['SalesQty'] = pd.to_numeric(df['SalesQty'], errors='coerce')
df = df.dropna(subset=['SalesQty'])
return df
ポイント:dtype=str で全列を文字列として読み込む理由
pd.read_csv() のデフォルト挙動は、数値に見えるセルを自動的に float に変換します。
しかし「商品コード」が "001" のようにゼロ埋めされた数値文字列の場合、dtype=str を省略すると 1 に変換されてしまい、マスターデータとの突合が失敗します。
入力の多様性を吸収するためには、 全列を文字列として読み込み、変換が必要な列だけ pd.to_numeric(errors='coerce') で個別に型変換する のが安全です。
問題2: mo.ui.data_editor に浮動小数点を垂れ流してはいけない
現場の認知負荷とは何か
ROP(発注点)の計算式は次の通りです。
ROP = (1日あたり平均消費量 × リードタイム) + 安全在庫
例えば、ある商品の過去実績から算出した平均消費量が 2.666... 個/日で、リードタイムが3日、安全在庫が5個だった場合、
ROP = 2.666... × 3 + 5 = 12.999...
この 12.999... という数値を mo.ui.data_editor にそのまま表示すると、現場の担当者は困惑します。
「13個でいいの? 12個でいいの? この小数点以下は何?」
発注点は整数でなければ実運用では使えません。
浮動小数点をデータエディタに垂れ流すことは、UI/UXとして失格 です。
解決策:Pandas側で型を確定してからUIに渡す
def calculate_rop(df: pd.DataFrame, lead_time: int, safety_stock: int) -> pd.DataFrame:
if df.empty:
return pd.DataFrame(columns=['ItemCode', 'ROP'])
avg_sales = df.groupby('ItemCode')['SalesQty'].mean().reset_index()
avg_sales = avg_sales.rename(columns={'SalesQty': 'AvgDailySales'})
result_df = avg_sales.copy()
result_df['ROP'] = (result_df['AvgDailySales'] * lead_time) + safety_stock
# 【UX設計の核心】端数処理と型キャストをUIに渡す前に完結させる
# np.ceil() で「切り上げ(安全側)」を選択し、.astype(int) で整数型を確定。
# 発注点は在庫切れを防ぐための指標なので、切り捨てより切り上げが適切。
result_df['AvgDailySales'] = np.ceil(result_df['AvgDailySales']).astype(int)
result_df['ROP'] = np.ceil(result_df['ROP']).astype(int)
return result_df
mo.ui.data_editor はDataFrameをそのまま受け取ります。
つまり、 DataFrameの型がそのままUIの表示型になります 。
Pandas側で astype(int) を通しておくことで、data_editor上の表示は整数になり、担当者は迷わず発注数を確認・修正できます。
なぜ round() ではなく np.ceil() なのか
| 丸め方法 | 2.1 |
2.5 |
2.9 |
用途の適合性 |
|---|---|---|---|---|
round() |
2 | 2(banker's rounding) | 3 | 統計的精度重視 |
np.floor() |
2 | 2 | 2 | 常に切り捨て |
np.ceil() |
3 | 3 | 3 | 常に切り上げ(在庫管理向き) |
発注点は「欠品を防ぐための最低ライン」です。
切り捨てると欠品リスクが上がります。
np.ceil() による切り上げは、 在庫管理の文脈では安全側への倒し方として理にかなっています 。
まとめ:ブラウザ完結ETLのUXは「データの型」から始まる
今回のSmartOrder開発で得た教訓を2点まとめます。
教訓1: 入力は3層で防衛せよ(UI Error Binding)
WASM環境では、ユーザーがアップロードするデータの品質を制御できません。
「ファイル未選択」「パース失敗」「クレンジング後0件」を捕捉した際、安易な mo.stop() による通信切断を避け、エラー状態であっても正常なUIオブジェクトと None を下流にストリームさせることで、画面全体の消滅(ステルスクラッシュ)を防ぎます。
教訓2: UIに渡す前、Pandas側で型を確定させよ
UIコンポーネントの「見た目の問題」を、UIレイヤーで解決しようとする誘惑に抗いましょう。
データ変換は常にバックエンド(Pandas/NumPy)側で完結させることが、認知負荷の低いインターフェース設計の基本です。
浮動小数点を殺すのは、astype(int) の一行です。
しかしその一行を意識的に設計するかどうかで、現場での使われ方が劇的に変わります。
付録:今回の構成(アーキテクチャ概要)
[UI Layer] mo.ui.file() → mo.ui.number() → mo.ui.data_editor()
↓ ↓ ↑
[ETL Layer] UI Error Binding × 3 → cleanse_data() [dtype=str → to_numeric]
↓
[Sim Layer] calculate_rop() [np.ceil + astype(int)] → Altair Chart
↓
[Export] mo.download() → order_plan.csv
すべての処理はブラウザ内(Pyodide / WASM)で完結し、データはサーバーに送信されません。
NDA制約のある職場での利用を想定した、セキュリティファーストな設計です。
プロダクト一覧
開発してきたすべてのアプリはこちらからご覧いだけます。