개요
Alforse는 하나의 제조 데이터 모델을 공유하는 두 애플리케이션으로 구성됩니다.
둘 뒤에는 하나의 백엔드가 있습니다.
이 페이지를 읽는 방법
시스템 한눈에 보기로 시작한 다음 하나의 쓰기를 처음부터 끝까지 추적을 읽으십시오. 두 번째는 첫 번째를 전제로 합니다. 그 이후의 섹션은 독립적이며 어떤 순서로든 읽을 수 있습니다. 기술 용어는 페이지 끝의 용어집에 있는 짧은 정의로 연결되며, Postgres가 한 예입니다.시스템 한눈에 보기
모든 것은 pnpm 워크스페이스와 Turborepo로 실행되는 하나의 모노레포에 있으며 두 계층으로 구성됩니다.화면
두 React Router 애플리케이션apps/erp와 apps/mes가 서버에서 React를 렌더링합니다. 둘이 공통으로 사용하는 코드는 packages/web/* 아래에 패키지로 묶여 있으며 auth, database, api, jobs, planning, react, utils, workflows 패키지가 포함됩니다.
백엔드
애플리케이션 호스트는apps/backend 하나뿐입니다. API를 제공하는 것 외에 다음 네이티브 계층을 구성합니다.
모든 데이터는 기록 시스템인 하나의 Postgres 데이터베이스에 있습니다. 회사별로 행 수준 권한을 적용하며 백그라운드 작업에 입력을 공급하는 트랜잭션 아웃박스도 포함합니다. 각 부분은 계층별 설명에서 설명합니다.
모든 것을 좌우하는 다섯 가지 특성
- 화면은 데이터베이스에 직접 접근하지 않습니다. ERP와 MES의 서버 코드는
/api/web에 마운트된 백엔드 게이트웨이와 통신합니다. 게이트웨이는 화면이 처음 작성된 방식과 같은 REST, RPC, 인증, 스토리지 프로토콜을 이해하며 각 호출을kernel/,modules/,products/,packages/database/중 하나에 있는 네이티브 소유자에게 전달합니다. - 데이터베이스가 테넌트를 분리합니다. 행 수준 보안 덕분에 한 회사의 행은 다른 회사에서 접근할 수 없습니다. 방법은 인증 및 권한 부여에서 설명합니다.
- 관련 쓰기는 하나로 커밋됩니다. 네이티브 소유자는 호출자의 작업 단위에 참여하며, 소유자가 발생시키는 이벤트는 바로 그 트랜잭션 안에서 아웃박스에 들어갑니다.
- 별도의 작업 시스템은 없습니다. 백엔드 프로세스 안의 러너가 예약 및 대기열 작업을 처리하고, 전달 워커가 알림과 웹훅을 보냅니다. 자세한 내용은 쓰기 이후에 있습니다.
- CAD 처리도 백엔드에 있습니다. 지오메트리 변환과 조립 계획은 모두
/api/web/assembler뒤에서 이루어지며 이를 위해 따로 배포할 것은 없습니다.
하나의 쓰기를 처음부터 끝까지 추적
예시: 구매 담당자가 새 발주서를 저장합니다. 양식, 검증, 권한 확인, 데이터베이스 쓰기, 문서 번호 할당, 마지막으로 새로 고쳐진 화면까지 요청을 따라갑니다.전체 여정을 하나의 다이어그램으로
행이 저장되는 즉시 요청은 완료됩니다. 알림과 웹훅 같은 부수 효과는 나중에 아웃박스에서 가져와 처리됩니다.코드로 보는 단계별 과정
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로 매핑합니다. 브라우저와 서버가 이 하나의 스키마를 공유하므로 같은 규칙을 적용합니다.
4. 라우트
apps/erp/app/routes/x+/purchase-order+/new.tsx:30
apps/erp/app/routes/ 아래의 라우트 파일은 이 흐름과 관련된 세 가지를 내보낼 수 있습니다.
loader와 action 모두 브라우저 번들에 포함되지 않으므로 서버 전용 코드를 사용할 수 있습니다.
5. 서비스 함수
apps/erp/app/modules/purchasing/purchasing.service.ts:1663 (insertPurchaseOrder)
데이터 접근은 라우트에서 하지 않고 서비스 함수에 위임됩니다. 각 함수는 클라이언트를 첫 번째 매개변수로 받고 supabase-js에서 알려진 { data, error } 형태로 응답합니다. 이 클라이언트는 getAlforseServiceRole()(packages/web/auth/src/lib/supabase/client.server.ts:13)이 만듭니다. 레거시 이름과 달리 이 요청에 로그인한 사용자로서 /api/web의 네이티브 게이트웨이와 통신합니다. 클라이언트는 하나의 요청에 속하며 다른 요청에서는 사용할 수 없습니다.
6. 데이터베이스의 번호 부여
client.rpc("get_next_sequence", ...) 호출은 번호 부여 모듈 modules/master-document-numbering/가 응답하는 RPC입니다. 모듈은 제출과 같은 트랜잭션 안에서 다음 카운터 값을 확보하고 회사의 접두사, 자릿수, 날짜 토큰을 적용합니다. 형식은 채번 규칙에서 설명합니다.
7. 화면으로 돌아가기
마무리로 액션이throw redirect(...)를 실행합니다. 그러면 React Router가 목적지를 로드합니다. 해당 loader가 게이트웨이를 통해 최신 상태를 가져오고 페이지가 렌더링됩니다. 별도로 GET이 아닌 모든 제출 후 React Router는 현재 페이지의 로더를 스스로 다시 실행합니다.
계층별 설명
브라우저와 라우트 파일
두 화면 애플리케이션은 프레임워크 모드의 React Router v7로 구축되어 서버에서 React를 렌더링하므로 하나의 라우트 파일이 데이터 로딩과 UI를 함께 담을 수 있습니다. Remix와 Next.js는 ERP나 MES 화면에 사용하지 않습니다. 빌드는 Vite가, 스타일링은 Tailwind v4가 담당합니다.apps/backend만 다르며 Next.js 애플리케이션 호스트입니다.
라우트는 파일입니다
1,600개가 넘는 파일이 있는apps/erp/app/routes/는 URL 공간에 곧바로 매핑됩니다. remix-flat-routes(apps/erp/app/routes.ts에서 구성) 덕분에 폴더 구조가 곧 URL 구조입니다.
최상위 폴더는 대상에 따라 앱을 나눕니다.
데이터 읽기
페이지는 서버의loader에서 데이터를 가져오며, 로더는 서비스 함수를 호출하고 JSON을 반환합니다. 컴포넌트는 useLoaderData<typeof loader>()로 이를 받습니다. 모든 페이지에 필요한 데이터(사용자, 회사, 권한, 기능 플래그)는 apps/erp/app/routes/x+/_layout.tsx가 한 번 로드합니다. 컴포넌트는 apps/erp/app/hooks/의 훅, 예를 들어 useUser(), usePermissions(), useCompanySettings()를 통해 접근합니다.
UI에서 데이터 쓰기
양식은 추적한 쓰기와 똑같이 라우트의action으로 전송됩니다. 행 순서 변경, 인라인 편집, 삭제처럼 사용자가 페이지에 머물러야 하는 경우에는 대신 useFetcher()로 제출하며, 페이지는 이후에도 여전히 다시 검증됩니다.
공유 UI
새 컴포넌트를 작성하기 전에 다음 위치를 살펴보십시오.브라우저의 상태
브라우저는 상태를 거의 보유하지 않습니다. 어디서나 사용되는 참조 목록(품목, 고객, 공급업체, 인원)은apps/erp/app/stores/ 아래의 스토어에 보관되며 RealtimeDataProvider가 새로 고칩니다. 일회성 UI 상태는 zustand에 둡니다. 사용자와 그 권한의 출처는 항상 라우트 로더입니다.
번역
텍스트는 Lingui로 번역합니다. 문자열을t`...` 또는 <Trans>로 감싼 다음 pnpm lingui:compile을 실행하여 카탈로그를 빌드합니다.
MES
MES는 스택을 공유하지만 더 평평한 형태를 유지합니다.modules/ 폴더가 없으며 서비스는 apps/mes/app/services/에 있고, 약 100개의 라우트가 한 단계에 놓여 있으며, UI는 태블릿을 장갑 낀 손으로 사용할 수 있는 크기로 만들어졌습니다.
인증 및 권한 부여
인증과 권한 부여를 모두 처리하는requirePermissions를 건너뛰는 서버 측 진입점은 없습니다.
packages/web/auth/src/services/auth.server.ts:152에 있습니다. 호출은 네 단계를 거칩니다.
- 호출자 식별. 브라우저 요청은 네이티브 세션 쿠키를 전달하고 공개 API 요청은
alforse-key헤더를 전달합니다. 키의 경우 네이티브 키 소유자가 라우트에 필요한 권한에 대해 키를 검증하고 속도 제한을 적용합니다. 자세한 내용은 API 키에 있습니다. - 결정 요청.
view,create,update,delete와 선택적으로role중에서 가져온 라우트의 필요 권한이 백엔드의 네이티브 권한 부여 소유자에게 전송됩니다. 선택한 회사에서 사용자의 권한 부여를 바탕으로 결정합니다. 권한 부여는 세 가지 출처, 즉 직원 유형, 사용자의 그룹, 직접 배정에서 합산됩니다. - 허용되지 않는 것은 거부. 유효한 세션이 없으면(없거나 만료됨) 세션이 폐기되고 사용자는 로그인으로 이동합니다. 권한이 없으면 접근 거부 메시지가 있는 리디렉션이 발생합니다. 결정이 세션이 가진 것과 다른 사용자나 회사에 대해 돌아오면 요청은 일관성 없는 신원으로 거부됩니다.
- 컨텍스트 반환. 성공하면 라우트는 이 요청으로 범위가 지정된 클라이언트와
companyId,companyGroupId,userId를 받습니다.
멀티 테넌시와 행 수준 보안
많은 회사가 하나의 데이터베이스를 공유하므로 Alforse는 멀티 테넌트입니다. 활성 회사는 세션이 결정하며 URL은 아무 역할도 하지 않습니다. 테넌트 테이블에는 행 수준 보안(RLS)이 켜져 있고 강제됩니다. 기준선 마이그레이션packages/database/migrations/0001_baseline.sql이 ALTER TABLE ... FORCE ROW LEVEL SECURITY로 이를 수행합니다. 각 트랜잭션이 시작될 때 현재 회사와 행위자가 트랜잭션 로컬 설정으로 저장되며 모든 정책이 이 값에 대해 행을 확인합니다. 다른 회사의 레코드를 요청하면 결과는 비어 있습니다.
플랫폼과 작업 공간은 분리되어 있습니다
작업 공간 수명 주기, 플랜, 권한은 각 고객의 작업 공간과 분리된 보안 도메인인 플랫폼 제어 플레인이 관리합니다. 플랫폼 측의 권한은 고객의 업무 레코드를 읽을 암묵적인 권리를 부여하지 않습니다.다른 종류의 사용자
포털을 사용하는 고객과 공급업체는 더 좁은 권한을 받으며 모든 라우트는{ role: "employee" }를 요구할 수 있습니다. share+/ 아래의 라우트는 로그인 없이 작동합니다. 접근은 URL에 담긴 추측할 수 없는 토큰에 달려 있습니다.
데이터 쓰기
ERP와 MES의 화면은 원래 작성된 데이터 접근 방식을 유지했습니다. 백엔드는 데이터베이스 연결을 내주는 대신 각 방식을 네이티브 소유자로 변환합니다. 네 가지 경로가 있습니다.1. 게이트웨이 클라이언트
2. 이름이 있는 네이티브 작업
여러 화면이packages/web/database/src/client.ts의 getPostgresClient를 통해 Kysely로 쿼리를 기술합니다. 빌더는 SQL을 내보내지 않습니다. 쿼리의 모양을 기록하고 packages/web/database/native-operations/의 레지스트리에서 정확히 하나의 이름 있는 작업과 일치시키며, 이 레지스트리에는 purchasing.ts, inventory.ts, production-*, quality.ts 같은 파일이 있습니다. 어느 것과도 일치하지 않는 쿼리는 거부되며 거부 메시지가 해당 쿼리를 지명합니다. 드래그하여 순서 변경(apps/erp/app/modules/shared/sort-order.ts) 같은 다중 행 쓰기는 이를 통해 전부 아니면 전무로 처리됩니다.
3. 네이티브 소유자와 작업 단위
상당한 업무 쓰기는products/*와 packages/database/src/의 클래스로 구현됩니다. 출하 또는 송장 전기, 자재 불출, 생산 수량 완료가 예입니다. 각각은 작업 단위 안에서 실행되며, 이는 행위자와 회사가 설정된, 전체 업무 트랜잭션을 위한 하나의 Postgres 연결을 뜻합니다. 본문에서 어떤 실패가 일어나면 전부 롤백되며, 쓰기 성공 후에 이어지도록 만든 훅은 커밋이 이루어진 후에만 실행됩니다.
4. RPC
가장 좁은 경로는client.rpc("name", args)로 호출하는 이름 있는 함수이며, 추적한 쓰기의 get_next_sequence가 그 예입니다. 각 RPC에는 매핑된 네이티브 소유자가 있거나 매핑되지 않은 RPC의 명시적 목록에 나타납니다.
계획은 별도의 영역입니다. MRP와 생산 일정 수립은 계획 소유자와 함께 packages/web/planning에 구현되어 있습니다.
데이터베이스
데이터베이스는 PostgreSQL이며 그 앞에는 아무것도 없습니다. PostgREST도, Supabase 프로젝트도, 엣지 함수도 없습니다. 백엔드가 데이터베이스의 유일한 클라이언트이며 전용 역할, 즉 RLS의 제약을 받는 런타임 역할과 일정 수립기 및 전달 워커를 위한 별도의 역할로 연결합니다.마이그레이션
각 마이그레이션은packages/database/migrations/의 번호가 매겨진 SQL 파일입니다.
규칙은
packages/database/migrations/README.md에 문서화되어 있으며 러너는 packages/database/src/migrations.ts입니다.
스키마와 테이블
기준선에서 약 740개의 테이블이 두 스키마로 나뉘어 제공됩니다.platform은 제어 플레인과 네이티브 소유자를 위한 것이고, web은 화면이 읽는 계약 스키마입니다. 뷰가 읽기 측을 제공합니다.
식별자
ID는 Postgres가 아니라 애플리케이션이 만들며{prefix}_{base58(uuidv7)} 형태로 text 기본 키에 저장됩니다. 모든 레코드 유형은 다른 유형이 사용하지 않는 접두사를 가집니다. 결과는 생성 시각순으로 정렬되며 로그 줄이나 URL에서도 읽을 수 있습니다.
읽기와 쓰기
규칙은 소유자를 통해 쓰고 관계에서 읽는다입니다. 쓰기는 원천 데이터만 저장합니다. 합계, 이름, 썸네일처럼 화면이 그 위에 보여 주는 모든 것은 읽기 관계가 도출합니다.파일
업로드는 객체 스토리지에 기록되며 화면은/api/web/storage 라우트로 이에 접근합니다. 세 가지 공급자 유형을 지원합니다. Cloudflare R2, S3 호환 스토어, 고객 스토리지 게이트웨이입니다(apps/backend/src/production/blob-storage-provider.ts와 그 이웃 파일 참조). 각 회사는 고유한 키 네임스페이스와 할당량을 가지며 백업 복사 프로필을 추가할 수 있습니다.
쓰기 이후: 이벤트와 백그라운드 작업
쓰는 코드가 후속 작업까지 실행하지는 않습니다. 대신 소유자는 업무 변경을 만드는 트랜잭션 안에서 트랜잭션 아웃박스(platform."outbox")에 도메인 이벤트를 추가합니다. 해당 트랜잭션이 롤백되면 이벤트도 함께 사라지고, 커밋되면 이벤트가 반드시 존재합니다.
전달 보장
전달은 최소 한 번(at-least-once)이므로 핸들러는 멱등으로 작성됩니다. 일시적인 실패는 시도 횟수 제한과 상한이 있는 지수 백오프 정책으로 재시도되며, 영구적인 실패는 중단됩니다.kernel/events/에 이벤트 버스, 그 재시도 정책, 웹훅 전달이 있습니다.
네이티브 러너
백그라운드 작업은 백엔드 프로세스 자체의 일부인 러너가 실행합니다(apps/backend/src/runner/). 모든 작업은 네 단계를 거칩니다.
- 접수. 회사, 작업, 멱등 키를 지정하는 행이
platform."backgroundTaskRun"에 추가됩니다. - 확보. 러너가 행에 대한 임대를 가져갑니다.
- 실행. 해당 작업에 등록된 핸들러가 회사에 바인딩된 트랜잭션에서 실행됩니다.
- 기록. 실행은
pending,running,retrying,succeeded,deadLettered중 하나로 끝납니다.
alforse/company-export 같은 작업 이름과 packages/web/jobs 패키지가 보존하지만, 그 뒤에 외부 작업 서비스는 없습니다.
예약된 작업
apps/backend/src/runner/native-runner-schedules.ts가 일정을 선언합니다. cron 표현식은 UTC로 해석되며 모든 틱은 모든 활성 회사에 분산됩니다.
전달에는 자체 일정이 있습니다. 알림 릴레이는 매분, 유지보수 기한 도래 배차는 2분마다 실행되며, Blob 정리와 새로 추가된 파일 백업을 위한 추가 작업이 있습니다.
알림
알림은 사용자별 받은함에 도착합니다. 백엔드는 변경 피드를 게시합니다. 예를 들어 비공개 주제user:<userId>:notification입니다. 피드는 내용을 포함하지 않고 어떤 행이 변경되었는지만 ERP에 알리며 ERP가 필요한 것을 가져옵니다. 이메일은 구성된 트랜잭션 이메일 전송 수단을 통해 나갑니다.
기타 엔진
워크플로
워크플로는 고객이 드래그 앤 드롭 캔버스에서 직접 설계하는 자동화입니다. 러너와 함께 제공되는 작업과 혼동해서는 안 됩니다.
정의는
packages/web/workflows와 kernel/workflows의 코드가 기술하고 검증합니다. 각 실행과 그 안의 각 단계는 나중에 확인할 수 있도록 엔진이 기록합니다. 두 러너 일정이 이를 지원합니다. 10분마다 실행되는 워크플로 백스톱과 실행 이력 보존입니다. 기능 자체는 워크플로에서 다룹니다.
CAD 및 조립 계획
여기서 두 가지 작업이 이루어집니다. 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)
페이지가 표시하는 데이터를 수집하는 라우트 파일의 내보내기입니다. 액션처럼 서버 측 전용입니다.마이그레이션(Migration)
하나의 스키마 변경을 담은 번호가 매겨진 SQL 파일 하나입니다. 기준선과 이후 파일을 순서대로 합친 것이 곧 스키마입니다.모노레포(Monorepo)
하나로 버전 관리되고 배포되는 여러 애플리케이션과 공유 라이브러리를 위한 단일 Git 저장소입니다.MRP
자재 소요량 계획(Material Requirements Planning)의 약자입니다. 수요와 현재고로 무엇을 언제까지 구매하거나 생산할지 결정하는 것입니다.멀티 테넌시(Multi-tenancy)
하나의 데이터베이스와 하나의 실행 중인 애플리케이션으로 독립적인 여러 고객 회사를 서비스하면서 각 회사가 자신의 데이터만 보게 하는 것입니다.Postgres
Alforse가 기반으로 하는 관계형 데이터베이스인 PostgreSQL입니다.RLS
행 수준 보안(Row Level Security)입니다. Postgres가 모든 쿼리에 테이블별 규칙을 스스로 붙이며, 이 규칙이 회사들이 서로의 데이터를 보지 못하게 합니다.RPC
이 페이지에서는 애플리케이션 코드에서client.rpc("name", args)로 이름 있는 함수를 호출하는 것이며, 백엔드가 이를 네이티브 소유자로 라우팅합니다.
Turborepo
어느 패키지가 어느 것에 의존하는지 추적하여 빌드를 조율하므로pnpm build가 변경된 것만 다시 수행합니다.