Revamp-ITで第二の人生を得るすべての機器は、まずシステムに入る必要がある。些細なことに聞こえる。だが、そうではなかった。この目立たない工程 — 受け入れ — こそ、工房が何年ものあいだ最も時間を失ってきた場所であり、そして最も付加価値の少ない場所でもある。ノートPCは、誰かがそのスペックを打ち込んだからといって良くなるわけではない。これは、15分の手作業を数秒で終わる受け入れに変えた物語であり、その裏側に技術的に何があるのかの物語である。
問題:受け入れは面倒な雑務だった
はじめにスプレッドシートがあった。機器を記録するには、次のことを順番に行った。まず計量し、メジャーで寸法を測り、写真を撮り、それからスペックと妥当な価格を寄せ集めて検索する — 型番、CPU、RAM、販売価格を、すべて手で個人のスプレッドシートに打ち込む(たいていはコピー&ペーストで)。
そこから受け渡しの連鎖が始まった。Heinzが個々のシートを1つのマスタースプレッドシートにまとめ、CSVとしてエクスポートした。CemがそのCSVを、我々のPerlベースのERPかつ法令準拠の帳簿であるKivitendoにアップロードした。そしてそれでもなお、機器はどこにも表示されなかった。ショップに自動的には現れなかった — それはまた別の、手作業の工程だったのである。
1台あたり5〜15分の純粋な手作業 — しかも複数の人に分散して。結果は予測できた。チーム全員が受け入れをするはずだったのに、実際にやったのはごく一部だけだった。悪意からではなく、障壁が高すぎたからだ。誰も楽しめない工程はボトルネックになる — そして倉庫には未登録の機器の山が育っていく。
ビジョン:受け入れを実質ゼロまで押し下げる
目標は意図的にラディカルなものだった。製品をシステムに登録するのにかかる時間を**99.9%**削減する。「少し速く」ではなく — 桁が違う。
その理由はこうだ。機器の写真には、我々が知りたいことのほとんどがすでに含まれている — メーカー、モデル、しばしば状態まで。書かれた名前(「Lenovo ThinkPad T450 i5」)も同様だ。AIがこの原材料を構造化されたフィールドに変え、注意深く構築されたシステムがAPIで互いに対話するなら、1つの動作で足りるはずだ。撮影するか入力するか — そして機器はあるべき場所に着地する。データベース内の保管場所に紐づけられるか、あるいはショップの出品として直接公開される。
現在の仕組み
1つの受け入れ、4つのチャネル
Geräte-Eingang(機器受け入れ、コード上では erfassung)は、意図的に飾り気のないインターフェースを持ち、4つの入力チャネルがある。テキスト、写真、ファイル(CSV/Excel)、そして音声である。その背後にある設計上の判断が重要だ — これらはチャネルであって、4つの別々のワークフローではない。データがどう入ってこようと、それは同じ製品レコードに収束する。
テキストチャネルは巧妙だ。1行が単一の機器なのか、それともリスト全体なのかを自動で判別する。1行は /api/admin/erfassung/text へ、複数行は /api/admin/erfassung/bulk-text へ送られる — だから機器のパレット丸ごとを一度に貼り付けて、一括レビュー用のテーブルを受け取ることができる。CSVとExcelのファイルは bulk-upload を、音声は voice を通る。
AIカスケード
心臓部は src/lib/erfassung/ai-extraction.ts にある。extractProductFromText は、テキストを3つのプロバイダのフォールバックカスケード(callWithFallback)を通す。まず Groq(llama-3.3-70b-versatile)、次に OpenRouter、そしてローカルの Ollama。すべてが失敗した場合、正規表現パーサ(fastParseProductText)が最後の網となる — 受け入れが致命的に失敗することは決してなく、精度が下がるだけである。
写真については extractProductFromImage が引き継ぐ。ここで機関室からの小さな逸話を語る価値がある。Groqは以前のビジョンモデル(Llama 4 Scout)を廃止した — リクエストが突然 404 model_not_found を返すようになり、写真解析が本番環境で死んだのだ。今日の代替は qwen/qwen3.6-27b であり、現在利用可能な唯一の画像対応Groqモデルである。しかしQwen3は推論モデルだ。答える前に <think>…</think> ブロックの中で声に出して考える。素朴な「最初の {…} をつかむ」パーサは、その思考ブロックから例示用のJSONを釣り上げてしまい、失敗した。修正は小さく地味な関数 extractJsonObject で、JSONが読まれる前に推論ブロックと json コードフェンスを取り除く。音声のほうは、Groqの whisper-large-v3-turbo で文字起こしされ、その後は同じテキスト抽出を通る。
2つの点が、結果を単に印象的なだけでなく実用的なものにしている。第一に、フィールドごとの確信度だ。抽出された各フィールドは確からしさを持ち、レビューフォームは本当に再確認が必要なフィールド(たとえばテキストが一度も述べなかった状態)だけを強調表示する — すべての値にパーセンテージを貼り付けるのではなく。第二に、カテゴリ分類だ。detectCategory は既存のカテゴリコードへ写像する順序付きパターンテーブルである。順序は意図的なものだ — アクセサリ、プリンタ、モニタ、ネットワークのパターンは、ノートPCのブランドより先にマッチし、内部コンポーネントは最後にマッチする。だから機器名が常に勝つ。こうして「Dockingstation Lenovo ThinkPad」は、ノートPCではなく正しくネットワークとして分類される。
インターフェースではこう見える。一文を入力すると、数秒後に埋まったフォームが現れる — メーカー、モデル、書き出された短い説明、カテゴリ。ここでちょうど1つのフィールドだけが、小さなオレンジ色の「Prüfen」(確認)ヒントを帯びている。状態だ。なぜならテキストがそれを一度も名指ししなかったからだ。それ以外はすべて静かである。それが要点だ — AIはあらゆる行に「95%確実」と叫ぶのではなく、人間が確認すべき唯一のことを静かに指し示す。
書き込みのための単一の信頼できる情報源
チャネルがどれほど異なっていても、書き込みが起きるのはちょうど1か所だけだ。src/lib/erfassung/create-product.ts の createErfassungProduct() である。この関数は「機器が誕生する」ことのsingle source of truthだ。1つのトランザクションの中で、人間が読める品目番号(I-YYMMDD-NNNN)を割り当て、抽出レコード(ai_extracted_products)を書き込み、在庫エントリ(inventory_items、場所・箱・数量つき)を作成し、顧客プロファイルを紐づけ、画像を R2(オブジェクトストレージ)にアップロードして紐づける — そして任意で、そのまま出品を公開する。
すべてがこの1つの関数を通るからこそ、同じ不変条件がどこでも成り立つ。だからこそ、後に旧Shopwareショップから197件の製品を一度に移行できた(これについては後述)。インポートは、微妙に異なる2番目の書き込み方法を発明する代わりに、まさに createErfassungProduct を呼ぶのである。
品質ゲート
レビューの後に来るのは、唯一の実質的な運用上の決定だ。次はどこへ? CAPTURE_DESTINATIONS — 品質、在庫、部品、リサイクル、あるいは「未検査でショップへ」 — は受け入れ階層に写像される。検査必須カテゴリの機器を直接公開しようとすると、安全ゲートが横取りし、代わりにQCチェックリストつきの下書きとしてリファービッシュのパイプラインに着地させる — 誰かが明示的にログされる「検査なしで公開」の決定を下さない限りは。あるカテゴリが検査を必要とするかどうかは、別途管理されているのではなく、チェックリストそのものから導出される。検査必須とは、その機器クラスに必須のテスト項目またはセキュリティ項目があることを意味する。
マーケットプレイスへ
機器が公開されると、publishRevampitListing がそれをアクティブな出品(is_revampit フラグ)に変え、R2画像を出品画像へ運び、そのエントリを検索のために Meilisearch にインデックスする。「記録済み」から「ショップで表示される」への移行は、こうして2人目の人が2つ目のフォームを扱うことではなく、1回のAPI呼び出しになる。
ストレステスト:旧ショップからの197製品
フローが持ちこたえることの最良の裏付けは、カタログ移行だった。旧Shopwareショップには使えるAPIがなかった — しかし、製品ページごとにきれいなOpen Graphメタデータがあった。小さなスクレイパーが /Alles/ の一覧を辿り、名前、ブランド、価格、説明、画像URLを引き出し、使い捨ての移行エンドポイントが197製品それぞれを下書きとして作成した — createErfassungProduct を介して、画像はサーバー側でダウンロードしてR2に再ホストして。カテゴリは detectCategory で推論され、重複は保存されたShopware番号によって防がれた(冪等で、好きなだけ繰り返せる)。手作業なら何日もかかっていたであろうことが、数分の問題になった。
そして移行が、任意の単一受け入れと同じ関数を通ったからこそ、下書きはその後2回目のパスで公開できた — それぞれがアクティブな出品になり、R2画像が自動的に一緒に付いていく。表示されていた11件の出品が208件になった。それぞれ画像、価格、カテゴリつきで。旧ショップは単に打ち直されたのではなく、引き継がれたのである。
それを支える3つの原則
隣接するシステムに移る前に、「デモでは動く」と「本番で持ちこたえる」を分ける3つの決定 — そしてコード全体に繰り返し現れる決定 — を見ておく価値がある。
書き込みのための単一の信頼できる情報源。 写真、音声、CSV、単一受け入れ、一括移行 — いずれであっても、書き込みは createErfassungProduct を通してのみ起きる。移行のために「手早い2番目の、少しだけ違う経路」を作りたくなる誘惑は千通りもあっただろう。それをまさに我々はしなかった — だからこそ品目番号、画像の扱い、QCゲート、在庫の不変条件が、あらゆる経路に同じように適用される。バグは5か所ではなく1か所で修正される。
確からしさ、パーセンテージではなく。 AIはすべてのフィールドに確信度を返す — だが画面にパーセンテージは表示されない。確からしさが閾値を下回ったところにだけ「確認」ヒントを表示する。「73%」のような数字は、作業台に向かう人への指示にはならない。「ここをもう一度見て」が指示だ。良い自動化は決定を減らすのであって、増やすのではない。
外へ出るところすべてに冪等性を。 Kivviへのすべての同期は冪等キーを帯び、移行された各レコードはそのShopware番号を帯び、公開の各実行はすでに出品を持つものをスキップする。これは些細なことに聞こえるが、我々が移行、同期、再インデックスを好きなだけ、重複や恐れなしに繰り返せる理由である。繰り返し可能性は贅沢ではない — システムを稼働させたまま修理できるための前提条件だ。
互いに対話するシステム
受け入れは仕事の半分にすぎない。機器は、在庫と会計が住む場所にも到着しなければならない。そしてここからアーキテクチャ的に興味深くなる。なぜなら、2つのまったく異なる世界がそこにぶら下がっているからだ。
Kivvi:きれいな膜
Kivvi は、我々が機器を同期する先の、モダンなスイス製クラウドERP(TypeScript、Drizzle/Postgres)である。統合パートナーが必要とするものをまさに提供してくれるので、話が楽だ。/api/v1/ 配下のバージョン管理されたREST APIである。我々の syncToKivvi(src/lib/kivvi/client.ts)は、サーバー側でSHA-256ハッシュとして保持されるベアラートークン(kv_…)を用いて POST /api/v1/inventory-items を行う。
ここでは3つの特性が決定的だ — そしてKivviのコードの中では、それらはRevamp-ITのために名前までつけられている。
- 冪等性。 呼び出しは
Idempotency-Keyを帯びる。二重のプッシュも重複を生まない。だからこそ心配なくリトライできる。 - ノンブロッキング、コミット後。 同期はfire-and-forgetだ。受け入れのデータベーストランザクションの後に始まり、記録を決してブロックしない。その後、我々は
kivvi_inventory_item_idとkivvi_sync_statusを在庫エントリに書き戻す。Kivviが構成されていなければ(KIVVI_API_URLがなければ)、クライアントは例外を投げる代わりにきれいに{ success: false }を返す — 開発環境では、単に同期なしだ。 - 双方向。 Kivviは署名されたwebhookを返してくる(
inventory_item.status_changedなど)。機器がそこで売れたとき、我々はポーリングなしにそれを知る。
小さいが重要な変換ステップがある。RevampITの状態語彙は、Kivviの列挙型に写像される(new → like_new、defect → parts_only、不明 → untested)。この写像がなければ、KivviのバリデーションはそのレコードをHTTP 400で拒否する。小さな契約を、明確に保つ。
Kivitendo:翻訳者であって、第二の頭脳ではない
もう一方の隣人は Kivitendo — Perl製のMVC ERPであり、我々の法令準拠の帳簿であって、意図的に保持している。難点はこうだ。KivitendoにはAPIがない。その「インターフェース」はViewだ — 人間のためのHTMLフォーム — そしてコントローラはそれらのフォームに結合されている。すべてのリクエストは、平坦なフォームフィールドの controller.pl?action=Part/save へのPOSTであり、Kivitendoはそれを1つのグローバル構造 $::form に再構成する。
そこでの書き込みはload → overlay → saveのパターンに従い、常にオブジェクト全体に対して行われる。それには厄介な帰結がある。スカラーは省略されると保持される — が、コレクション(価格、仕入先)は削除して置き換えられる。部分集合を送れば、残りは失われる。だから、まず完全な状態を読み込まずに「ただ1つのフィールドを変える」ことはできない。
そんなものにモダンなシステムをどう接続するのか? Kivitendoのロジックを作り直すのではなく、薄い変換層で — ブラウザを模倣するNodeサービスで。その単一のフローはこうだ。receive → load(SELECT) → map inward → merge → send as a $::form POST → map outward → return。この層は自身では決してSQLを書かない。書き込みはKivitendo自身のコントローラを通じてのみ起きるので、そのバリデーション、履歴、トランザクションはちょうど1か所に留まる。指針となる原則はこうだ。ビジネスロジックはKivitendoに住む — 我々は翻訳者であって、第二の頭脳ではない。
洒落た部分はこうだ。必要となるエンティティごとの写像(どの外部フィールドが内部で何と呼ばれるか、どのフォームキーか、どのカスタム変数か)は、小さなローカルLLMによって生成される — Kivitendoのperl ORMとコントローラから抽出され、実際にキャプチャされたフォームPOSTに対してラウンドトリップ検証される。写像が正しいのは、Kivitendoが受理したPOSTをパラメータ単位で再現できる場合、かつその場合に限る。何も推測されない。この部分はまだ実験的だ(Part エンティティは成立しているが、稼働中のインスタンスに対しては未硬化)が、道筋は明確だ。外側にはきれいでバージョン管理された契約、内側にはKivitendoの変わらぬ真実。
正直な但し書き。ここまでの多くは、少数のキャプチャとソースの読解から推論されたものであって、制御された条件下で観測されたものではない。最も確認できていないことが、よりによって最も重要なこと — Kivitendoがどうやって成功と失敗を示すのか(&id=… を伴うリダイレクトか、それともエラーボディを伴う200レスポンスか)である。それはまず稼働中のインスタンスに対して検証されるべきだ。正直なアーキテクチャは、その開かれた仮定を名指しする。
欠けている一片:保管と物流
そしてここに、まだ完成していない部分が来る — 意図的に名指しする。なぜなら、それは製品を仕上げるために計画された作業だからだ。
今日、機器受け入れは本質的に、QCチェックリストと「どこにあるか」ポインタを持つ単一ユニットの登録簿だ。無駄のないテーブル storage_locations(名前、種別:主保管/ショップ/副保管/メンバー所有/…)があり、在庫エントリは storage_location_id、自由入力の box_id、そしてレガシーの location フィールドを帯びる。それが答えるのは、*「どの棚がこの1台の機器を保持しているか?」*である。
欠けているのは、それを超えたすべてだ — そしてそれこそが、正直なところ、本物の保管管理なのである。
- 在庫移動台帳がない。 カウンタ
quantity_reserved/quantity_soldは列としては存在するが、どこにも書き込まれない。入出庫の記帳もなければ、移動履歴もない。 - 複数倉庫がない、転送がない。 平坦な場所リストで、階層もなければ、倉庫ごとの在庫もない。
- ピッキングがない、入荷がない、補充がない。 要するに、倉庫管理はなく、あるのは「何がどこにあるか」だけだ。
良い知らせ。ドッキングポイントはすでに存在する。Kivviは、我々に欠けている在庫プリミティブをまさに持ち込んでくれる — warehouses、stockLevels(製品と倉庫ごとの在庫)、そして符号付き数量を持つ追記専用の stockMovements 台帳だ。ただし、運用ではなく会計の粒度でだが。Kivviは倉庫を名前と住所として知っているが、棚も、ピックルートも、運送業者も知らない。したがって将来のRevampIT保管モジュールには、きれいな2つの選択肢がある — Kivviの warehouseId + location を直接駆動するか、あるいは運用層(棚、移動、ピッキング)を自前でモデル化し、その背後でKivviを在庫の帳簿として走らせるか。双方向のwebhookのおかげで、どちらの側も同期を保つ。
そしてKivitendoは? 原則的には、倉庫もそこにミラーリングできる — 上で描いた同じ変換層を介して。Kivitendoはそのモデルの中に倉庫/在庫の概念を持っている。在庫移動はそのとき、同じ経路を辿るもう1つのエンティティになる。load、merge、適切なコントローラへの $::form POSTとして送信。より大きな労力は概念にあるのではなく、注意深さにある — 在庫は会計に関わり、Kivitendoの「コレクションは置き換えられる」というセマンティクスは、常に完全な状態を送ることを要求する。帳簿にとっては、まさにその慎重さが正当なものだ。
展望
受け入れは解決された。写真からでも、書かれた名前からでも、きれいでカテゴリ分けされ、画像つきのレコードが数秒で現れる — 保管場所に位置づけられるか、ショップに公開されるかして、そしてKivviに同期される。ほとんど誰も受け入れをしなかった原因の障壁は、消えた。
残っているのは、その物理的な相方だ。すべての機器がどこにあるかを知り、すべての移動をきれいに記帳すること。 それが次の一片だ — 我々の単一ユニット登録簿とKivviの在庫台帳のあいだの橋、そして必要なところではKivitendoまでの橋。それが立ち上がれば、円環が閉じる。機器を記録する動作から、それが売られる棚まで — 途中で誰かがスプレッドシートを保つ必要もなく。
