概要
Alforse は、1 つの製造データモデルを共有する 2 つのアプリケーションで構成されます。
どちらの背後にも、1 つのバックエンドがあります。
このページの読み方
まずシステムの全体像を読み、次に1 回の書き込みを端から端まで追うを読んでください。後者は前者を前提としています。それ以降の節は独立しており、どの順序でも読めます。 専門用語は、ページ末尾の用語集にある短い定義にリンクしています。Postgres はその一例です。システムの全体像
すべてが 1 つのモノレポにあり、pnpm ワークスペースと Turborepo で運用され、2 つの層で構成されています。画面
2 つの React Router アプリケーションapps/erp と apps/mes が、サーバー上で React を描画します。共通のコードは packages/web/* にパッケージ化されており、auth、database、api、jobs、planning、react、utils、workflows などがあります。
バックエンド
アプリケーションホストはapps/backend ただ 1 つです。API の提供に加えて、次のネイティブレイヤーを構成します。
すべてのデータは 1 つの Postgres データベース(システムオブレコード)に保存されます。これは会社ごとに行レベルの権限を適用し、バックグラウンド処理に入力を供給するトランザクショナルアウトボックスも含んでいます。各要素はレイヤーごとの解説で説明します。
すべてを形作る 5 つの性質
- 画面はデータベースに直接アクセスしません。 ERP と MES のサーバーコードは、
/api/webにマウントされたバックエンドゲートウェイと通信します。ゲートウェイは、画面が当初書かれた時と同じ REST、RPC、認証、ストレージのプロトコルを理解し、各呼び出しをkernel/、modules/、products/、packages/database/のいずれかにあるネイティブオーナーに渡します。 - データベースがテナントを分離します。 行レベルセキュリティにより、ある会社の行に別の会社からはアクセスできません。しくみは認証と認可で説明します。
- 関連する書き込みは 1 つとしてコミットされます。 ネイティブオーナーは呼び出し元の作業単位に参加し、発生したイベントはそのトランザクション内でアウトボックスに入ります。
- 別個のジョブシステムはありません。 バックエンドプロセス内のランナーが、スケジュールされたタスクとキューイングされたタスクを処理し、配信ワーカーが通知と Webhook を送信します。詳細は書き込みの後にあります。
- CAD 処理もバックエンドにあります。 ジオメトリ変換と組立計画は、どちらも
/api/web/assemblerの背後で行われ、そのために追加でデプロイするものはありません。
1 回の書き込みを端から端まで追う
例として、購買担当者が新しい発注書を保存する場合を取り上げます。リクエストがフォーム、検証、権限チェック、データベースへの書き込み、文書番号の割り当てを経て、最後に更新された画面になるまでをたどります。全行程を 1 つの図で
行が保存された時点で、リクエストは完了します。通知や Webhook などの副作用は後で、アウトボックスから拾われて処理されます。コードで順を追って
1. ボタン
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:535
permissions.can(...) が非表示にしますが、これはあくまで配慮です。実際の判断はサーバーで行われます。リンクは apps/erp/app/utils/path.ts のルートレジストリである path.to.* を経由するため、アプリケーションの URL を手で書くことはありません。
2. フォーム
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrderForm.tsx:116
フォームコンポーネントは ValidatedForm(パッケージ @alforse/web-form)です。内部は通常の HTML フォームで、その上にスキーマのエラーを、ブラウザー由来でもサーバー由来でも表示します。バリデーターと既定値は props で受け取ります。
3. スキーマ
apps/erp/app/modules/purchasing/purchasing.models.ts:146
ルールは zod オブジェクトの purchaseOrderValidator にあり、実行時チェックと対応する TypeScript の型の両方を生成します。HTML フォームの値は zod-form-data(zfd)で整えられます。たとえば zfd.text() は、空のフィールドの "" を undefined に変換します。ブラウザーとサーバーはこの 1 つのスキーマを共有するため、同じルールを適用します。
4. ルート
apps/erp/app/routes/x+/purchase-order+/new.tsx:30
apps/erp/app/routes/ 配下のルートファイルは、このフローに関わる 3 つをエクスポートできます。
loader も action もブラウザーのバンドルには含まれないため、どちらもサーバー専用のコードを使えます。
5. サービス関数
apps/erp/app/modules/purchasing/purchasing.service.ts:1663(insertPurchaseOrder)
データアクセスはサービス関数に委ねられ、ルートでは行いません。各関数は第 1 引数としてクライアントを受け取り、supabase-js でおなじみの { data, error } の形で結果を返します。このクライアントは getAlforseServiceRole()(packages/web/auth/src/lib/supabase/client.server.ts:13)で作られます。名前は従来のものですが、実際にはこのリクエストでサインインしているユーザーとして /api/web のネイティブゲートウェイと通信します。クライアントは 1 つのリクエストに属し、他のリクエストからは使えません。
6. データベースでの採番
client.rpc("get_next_sequence", ...) の呼び出しは、採番モジュール modules/master-document-numbering/ が応答する RPC です。このモジュールは送信と同じトランザクション内で次のカウンター値を確保し、会社の接頭辞、桁埋め、日付トークンを適用します。形式は採番で説明しています。
7. 画面に戻る
最後に、アクションはthrow redirect(...) を実行します。React Router は遷移先を読み込み、その loader がゲートウェイ経由で最新の状態を取得してページが描画されます。これとは別に、GET 以外の送信のたびに、React Router は現在のページの loader を自動的に再実行します。
レイヤーごとの解説
ブラウザーとルートファイル
2 つの画面アプリケーションはどちらも、フレームワークモードの React Router v7 上に構築され、サーバー上で React を描画します。これにより、1 つのルートファイルがデータ読み込みと UI をまとめて持てます。ERP と MES の画面には Remix も Next.js も使いません。ビルドは Vite、スタイリングは Tailwind v4 が担います。異なるのはapps/backend だけで、これは Next.js のアプリケーションホストです。
ルートはファイルです
apps/erp/app/routes/ は 1,600 を超えるファイルがあり、URL 空間にそのまま対応します。remix-flat-routes(apps/erp/app/routes.ts で設定)のおかげで、フォルダー構成が URL 構成そのものになります。
最上位のフォルダーは、利用者ごとにアプリを分けています。
データの読み取り
ページはサーバー上のloader からデータを受け取ります。loader はサービス関数を呼び出して JSON を返し、コンポーネントは useLoaderData<typeof loader>() で受け取ります。すべてのページが必要とするデータ(ユーザー、会社、権限、機能フラグ)は、apps/erp/app/routes/x+/_layout.tsx が一度だけ読み込みます。コンポーネントは apps/erp/app/hooks/ のフック(useUser()、usePermissions()、useCompanySettings() など)経由でこれにアクセスします。
UI からのデータの書き込み
フォームはそのルートのaction に POST され、追跡した書き込みと同じ流れになります。行の並べ替え、インライン編集、削除など、ユーザーがページにとどまるべき場合は、代わりに useFetcher() で送信します。それでも、その後ページは再検証されます。
共有 UI
新しいコンポーネントを書く前に、次の場所を確認してください。ブラウザーの状態
ブラウザーが保持する状態はごくわずかです。至る所で使う参照リスト(アイテム、得意先、仕入先、人員)はapps/erp/app/stores/ 配下のストアに保持され、RealtimeDataProvider が更新します。使い捨ての UI 状態は zustand に置きます。ユーザーとその権限の情報源は、常にルートの loader です。
翻訳
テキストの翻訳には Lingui を使います。文字列をt`...` または <Trans> で囲み、pnpm lingui:compile を実行してカタログをビルドします。
MES
MES は同じスタックを共有しますが、構造はより平坦です。modules/ フォルダーはなく、サービスは apps/mes/app/services/ にあり、約 100 のルートが 1 つの階層に並び、UI は手袋をした手でタブレットを操作することを前提としたサイズです。
認証と認可
requirePermissions を経由しないサーバー側のエントリポイントはありません。これが認証と認可の両方を処理します。
packages/web/auth/src/services/auth.server.ts:152 にあります。呼び出しは 4 つの段階を経ます。
- 呼び出し元を識別する。 ブラウザーのリクエストはネイティブのセッション Cookie を持ち、公開 API のリクエストは
alforse-keyヘッダーを持ちます。キーの場合、ネイティブのキーオーナーがルートの要求する権限に照らしてキーを検証し、レート制限を適用します。詳細は API キーにあります。 - 判断を求める。 ルートが必要とする権限(
view、create、update、delete、場合によってはroleから選択)は、バックエンドのネイティブ認可オーナーに送られます。オーナーは、選択された会社でのユーザーの付与に基づいて判断します。付与は、従業員タイプ、ユーザーのグループ、直接割り当ての 3 つの源から加算されます。 - 許可されないものを断る。 有効なセッションがない場合(なし、または期限切れ)、セッションは破棄され、ユーザーはログインに移動します。権限がない場合は、Access Denied メッセージ付きのリダイレクトになります。判断がセッションの保持するものと異なるユーザーまたは会社に対して返された場合、リクエストは ID の不整合として拒否されます。
- コンテキストを返す。 成功すると、ルートはこのリクエストにスコープされたクライアントと、
companyId、companyGroupId、userIdを受け取ります。
マルチテナンシーと行レベルセキュリティ
多数の会社が 1 つのデータベースを共有するため、Alforse はマルチテナントです。どの会社がアクティブかはセッションで決まり、URL は関与しません。 テナントのテーブルでは行レベルセキュリティ(RLS)が有効化され、強制されています。ベースラインのマイグレーションpackages/database/migrations/0001_baseline.sql が ALTER TABLE ... FORCE ROW LEVEL SECURITY でこれを行います。各トランザクションの開始時に、現在の会社と操作者がトランザクションローカルの設定として保存され、すべてのポリシーはこれらの値に照らして行を検査します。別の会社のレコードを要求しても、結果は空です。
プラットフォームとワークスペースは別です
ワークスペースのライフサイクル、プラン、権利は、プラットフォームのコントロールプレーンが管理します。これは各顧客のワークスペースとは別に保たれたセキュリティドメインです。プラットフォーム側の権限は、顧客の業務レコードを読む暗黙の権利を与えません。その他の種類のユーザー
ポータルを使う顧客と仕入先には、より限定された権限が与えられ、どのルートも{ role: "employee" } を必須にできます。share+/ 配下のルートはログインを完全に不要とし、URL に含まれる推測不能なトークンでアクセスが決まります。
データの書き込み
ERP と MES の画面は、当初書かれた時のデータアクセススタイルを保っています。バックエンドはデータベース接続を渡す代わりに、各スタイルをネイティブオーナーに変換します。経路は 4 つあります。1. ゲートウェイクライアント
2. 名前付きネイティブ操作
多くの画面は、packages/web/database/src/client.ts の getPostgresClient を通じて、Kysely でクエリを記述します。ビルダーは SQL を出力しません。クエリの形を記録し、packages/web/database/native-operations/ のレジストリ(purchasing.ts、inventory.ts、production-*、quality.ts などのファイルがあります)にある名前付き操作とちょうど 1 つ照合します。どれにも一致しないクエリは拒否され、拒否にはそのクエリが示されます。ドラッグによる並べ替え(apps/erp/app/modules/shared/sort-order.ts)のような複数行の書き込みは、この仕組みによって全部成功するか全部失敗するかが保証されます。
3. ネイティブオーナーと作業単位
出荷や請求書の転記、材料の払出し、生産数量の完了など、実質的な業務の書き込みはproducts/* と packages/database/src/ のクラスとして実装されています。それぞれが作業単位の中で実行されます。つまり、業務トランザクション全体を通して 1 つの Postgres 接続を使い、操作者と会社が設定されます。本体で何か失敗すれば、すべてがロールバックされ、書き込み成功後に実行されるべきフックは、コミットの後で初めて発火します。
4. RPC
最も狭い経路は、client.rpc("name", args) で呼び出す名前付き関数です。追跡した書き込みの get_next_sequence がその一例です。各 RPC には対応するネイティブオーナーがあるか、明示的な未マッピング RPC の一覧に載っています。
計画は独立した領域です。MRP と生産スケジューリングは、計画オーナーとともに packages/web/planning に実装されています。
データベース
データベースは PostgreSQL で、前段には何もありません。PostgREST も Supabase プロジェクトも Edge Functions もありません。バックエンドがデータベースの唯一のクライアントであり、専用のロールで接続します。具体的には、RLS に拘束されるランタイムロールと、スケジューラーおよび配信ワーカー用の別個のロールです。マイグレーション
各マイグレーションは、packages/database/migrations/ にある番号付きの SQL ファイルです。
ルールは
packages/database/migrations/README.md に記載されており、ランナーは packages/database/src/migrations.ts です。
スキーマとテーブル
ベースラインからおよそ 740 のテーブルが作られ、2 つのスキーマに分かれています。platform はコントロールプレーンとネイティブオーナーのために、web は画面が読み取る契約スキーマです。読み取り側はビューが提供します。
識別子
ID は Postgres ではなくアプリケーションが生成し、{prefix}_{base58(uuidv7)} の形式で、text の主キーに保存されます。すべてのレコードタイプは、他のタイプが使わない接頭辞を持ちます。結果は作成日時順に並び、ログの 1 行や URL でも読み取れます。
読み取りと書き込み
ルールはオーナー経由で書き込み、リレーションから読み取るです。書き込みはソースデータだけを保存します。合計、名前、サムネイルなど、画面がその上に表示するものはすべて、読み取りリレーションが導出します。ファイル
アップロードはオブジェクトストレージに書き込まれ、画面は/api/web/storage ルート経由でこれにアクセスします。3 種類のプロバイダーをサポートします。Cloudflare R2、S3 互換ストア、顧客のストレージゲートウェイです(apps/backend/src/production/blob-storage-provider.ts とその周辺のファイルを参照)。会社ごとに独自のキー名前空間とクォータがあり、バックアップ用のコピープロファイルを追加できます。
書き込みの後: イベントとバックグラウンド処理
書き込みを行うコードは、その後続の処理までは実行しません。代わりにオーナーは、業務上の変更を行うトランザクションの内側で、ドメインイベントをトランザクショナルアウトボックス(platform."outbox")に追加します。そのトランザクションがロールバックされればイベントも消え、コミットされればイベントの存在が保証されます。
配信の保証
配信は少なくとも 1 回(at-least-once)であるため、ハンドラーは冪等になるように書かれています。一時的な失敗は、試行回数の上限と上限付き指数バックオフを持つポリシーで再試行され、恒久的な失敗は停止します。kernel/events/ には、イベントバス、その再試行ポリシー、Webhook 配信があります。
ネイティブランナー
バックグラウンドタスクは、バックエンドプロセス自体の一部であるランナー(apps/backend/src/runner/)が実行します。すべてのタスクは 4 つの段階を経ます。
- 受付。 会社、タスク、冪等キーを指定した行が
platform."backgroundTaskRun"に追加されます。 - 確保。 ランナーが行のリースを取得します。
- 実行。 そのタスクに登録されたハンドラーが、会社に紐づくトランザクション内で実行されます。
- 記録。 実行は最終的に
pending、running、retrying、succeeded、deadLetteredのいずれかになります。
alforse/company-export のようなタスク名と packages/web/jobs パッケージがそれを保っていますが、その背後に外部のジョブサービスはありません。
スケジュール済みの処理
apps/backend/src/runner/native-runner-schedules.ts がスケジュールを宣言します。cron 式は UTC として読まれ、各ティックはすべてのアクティブな会社に展開されます。
配信には独自のスケジュールがあります。通知リレーは毎分、メンテナンスの期日ディスパッチは 2 分ごとに実行され、そのほかにブロブのクリーンアップと新しく追加されたファイルのバックアップ用のジョブがあります。
通知
通知はユーザーごとの受信トレイに届きます。バックエンドは変更フィードを公開します(たとえばプライベートトピックuser:<userId>:notification)。フィードは内容を含めずに、どの行が変わったかを ERP に伝え、ERP が必要なものを取得します。メールは、構成されているトランザクションメールのトランスポートを通じて送信されます。
その他のエンジン
ワークフロー
ワークフローは、顧客がドラッグ&ドロップのキャンバス上で自分で設計する自動化です。ランナーが標準で持つタスクと混同しないでください。
定義は
packages/web/workflows と kernel/workflows のコードで記述・検証されます。各実行と、その中の各ステップは、後で調べられるようエンジンが記録します。ランナーの 2 つのスケジュールがこれを支えます。10 分ごとのワークフローのバックストップと、実行履歴の保持です。機能自体はワークフローで説明しています。
CAD と組立計画
ここでは 2 つの処理が行われます。STEP 形式の CAD ファイルを、ブラウザーで表示できる 3D モデルに変換することと、組立指示の元になる、衝突を避けた分解順序を計算することです。コードはバックエンドのapps/backend/src/cad/ にあります。occt-import-js(WebAssembly 向けにビルドされた OpenCASCADE)がジオメトリを解析し、モーションプランナーはその隣にあります。
どちらのアプリケーションも /api/web/assembler でこれを呼び出し、ベアラーキーで認証し、出力は packages/web/viewer で表示します。時間のかかる変換はランナーのタスクとして実行され、呼び出し側のページは完了を待つかポーリングします。
用語集
このページからリンクされている用語の短い定義です。A から Z の順です。Action
フォーム送信を処理する、ルートファイルのエクスポート。サーバー上で実行され、ブラウザーには配信されません。Kysely
TypeScript の SQL クエリビルダー。ここでは操作を記述するだけで、バックエンドがその記述を名前付きネイティブ操作に変換します。Lingui
翻訳に使うライブラリ。コード内でマークした文字列が言語ごとのファイルに抽出され、翻訳者が埋めます。Loader
ページが表示するデータを集める、ルートファイルのエクスポート。action と同様にサーバー側専用です。Migration
1 つのスキーマ変更を保持する、番号付きの SQL ファイル 1 つ。ベースラインとその後のファイルを順に合わせたものがスキーマそのものです。Monorepo
複数のアプリケーションと共有ライブラリを、まとめてバージョン管理・出荷する 1 つの Git リポジトリ。MRP
Material Requirements Planning(資材所要量計画)の略。需要と手持在庫から、何をいつまでに購入または製造するかを決めること。Multi-tenancy
独立した多数の顧客会社を 1 つのデータベースと 1 つの稼働アプリケーションで処理しつつ、各社には自社のデータだけを見せること。Postgres
PostgreSQL。Alforse の基盤となるリレーショナルデータベース。RLS
Row Level Security(行レベルセキュリティ)。Postgres がテーブルごとのルールを各クエリに自動で付与し、このルールが会社間でデータを見られないようにします。RPC
このページでは、アプリケーションコードからclient.rpc("name", args) で名前付き関数を呼び出すこと。バックエンドがネイティブオーナーにルーティングします。
Turborepo
どのパッケージがどれに依存するかを追跡してビルドを調整し、pnpm build が変更された部分だけをやり直すようにします。