Revamp-IT에서 두 번째 삶을 얻는 모든 기기는 우선 시스템 안으로 들어와야 한다. 사소하게 들린다. 하지만 그렇지 않았다. 이 평범해 보이는 단계 — 입고 — 는 수년간 작업장이 가장 많은 시간을 잃어버린 지점이었고, 동시에 부가가치를 가장 덜 만들어내는 지점이었다. 누군가 사양을 타이핑한다고 해서 노트북이 더 좋아지지는 않는다. 이것은 우리가 15분의 수작업을 어떻게 몇 초 만에 끝나는 입고 과정으로 바꿨는지, 그리고 그 아래에 기술적으로 무엇이 있는지에 대한 이야기다.
문제: 입고는 귀찮은 일이었다
처음에는 스프레드시트가 있었다. 기기 하나를 기록하려면 순서대로 다음을 해야 했다. 무게를 재고, 줄자로 치수를 재고, 사진을 찍고, 그런 다음 사양과 그럴듯한 가격을 구글링해서 짜맞췄다 — 모델 번호, CPU, RAM, 판매 가격을 전부 손으로(대부분 복사-붙여넣기로) 개인 스프레드시트에 입력했다.
그다음 전달 사슬이 시작됐다. Heinz가 개별 시트들을 하나의 마스터 스프레드시트로 합치고 CSV로 내보냈다. Cem이 그 CSV를 우리의 Perl 기반 ERP이자 규정을 준수하는 장부인 Kivitendo에 업로드했다. 그러고도 기기는 어디에서도 보이지 않았다. 상점에 자동으로 나타나지 않았다 — 그것은 또 하나의 별개 수작업 단계였다.
기기 한 대당 순수 수작업 5분에서 15분, 그것도 여러 사람에게 나뉘어 있었다. 결과는 예측 가능했다. 팀의 모두가 입고를 하기로 되어 있었지만, 실제로는 소수만 했다. 악의가 아니라 문턱이 너무 높았기 때문이다. 아무도 즐기지 않는 과정은 병목이 되고 — 기록되지 않은 기기 더미가 창고에 쌓인다.
비전: 입고를 사실상 0으로 몰아붙이기
목표는 의도적으로 급진적이었다. 제품 하나를 시스템에 넣는 데 걸리는 시간을 99.9% 줄이는 것. "조금 더 빠르게"가 아니라 — 자릿수가 다른 차원.
논리는 이렇다. 기기 사진 한 장에는 이미 우리가 알아야 할 거의 모든 것 — 제조사, 모델, 종종 상태까지 — 이 담겨 있다. 적힌 이름("Lenovo ThinkPad T450 i5")도 마찬가지다. 이 원자재를 구조화된 필드로 바꾸는 AI와, API를 통해 서로 대화하는 정성껏 구축한 시스템이 있다면, 동작 하나면 충분해야 한다. 사진을 찍거나 타이핑하면 — 기기가 있어야 할 곳에 안착한다. 데이터베이스의 저장 위치에 연결되거나, 상점에 리스팅으로 바로 게시된다.
오늘날 작동하는 방식
하나의 입고, 네 개의 채널
Geräte-Eingang(기기 입고, 코드에서는 erfassung)은 네 개의 입력 채널을 갖춘 의도적으로 소박한 인터페이스를 가지고 있다. 텍스트, 사진, 파일(CSV/Excel), 음성. 그 뒤에 있는 설계 결정이 중요하다 — 이것들은 채널이지, 네 개의 별개 워크플로가 아니다. 데이터가 어떻게 들어오든, 동일한 제품 레코드로 수렴한다.
텍스트 채널은 영리하다. 한 줄이 단일 기기인지 아니면 전체 목록인지 자동으로 감지한다. 한 줄은 /api/admin/erfassung/text로, 여러 줄은 /api/admin/erfassung/bulk-text로 간다 — 그래서 기기 한 팔레트 전체를 한 번에 붙여넣고 일괄 검토 테이블을 돌려받을 수 있다. CSV와 Excel 파일은 bulk-upload를 거치고, 음성은 voice를 거친다.
AI 캐스케이드
핵심은 src/lib/erfassung/ai-extraction.ts에 있다. extractProductFromText는 텍스트를 세 개의 프로바이더로 이루어진 폴백 캐스케이드(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로 전사되고, 그다음 동일한 텍스트 추출을 거친다.
두 가지가 결과를 단지 인상적인 것을 넘어 실제로 쓸모 있게 만든다. 첫째, 필드별 신뢰도다. 추출된 모든 필드는 확신도를 지니며, 검토 폼은 모든 값에 백분율을 덕지덕지 붙이는 대신 정말로 다시 봐야 하는 필드(예를 들어 텍스트가 언급하지 않은 상태)만 강조한다. 둘째, 분류다. detectCategory는 기존 카테고리 코드에 매핑되는 순서가 있는 패턴 테이블이다. 순서는 의도적이다 — 액세서리, 프린터, 모니터, 네트워크 패턴이 노트북 브랜드보다 먼저 매칭되고, 내부 부품이 마지막에 매칭되어, 기기 이름이 항상 이긴다. 그래야 "Dockingstation Lenovo ThinkPad"가 노트북이 아니라 네트워크로 올바르게 분류된다.
인터페이스에서는 이렇게 보인다. 문장을 하나 입력하면, 몇 초 뒤에 채워진 폼이 나타난다 — 제조사, 모델, 풀어 쓴 짧은 설명, 카테고리. 여기서 정확히 하나의 필드만 작은 주황색 "Prüfen"(확인) 힌트를 지닌다. 바로 상태인데, 텍스트가 상태를 언급한 적이 없기 때문이다. 나머지는 모두 조용하다. 그것이 핵심이다 — AI는 모든 줄에 "95% 확실"이라고 외치지 않는다. 사람이 확인해야 할 그 한 가지를 조용히 가리킬 뿐이다.
쓰기를 위한 단일 진실 원천
채널이 아무리 다르더라도, 쓰기는 정확히 한 곳에서 일어난다. src/lib/erfassung/create-product.ts의 createErfassungProduct()다. 이 함수는 "기기가 생겨난다"에 대한 단일 진실 원천이다. 하나의 트랜잭션 안에서 사람이 읽을 수 있는 품목 번호(I-YYMMDD-NNNN)를 할당하고, 추출 레코드(ai_extracted_products)를 기록하고, 재고 항목(inventory_items, 위치·박스·수량 포함)을 생성하고, 고객 프로필을 연결하고, 이미지를 R2(오브젝트 스토리지)에 업로드해 링크하고 — 선택적으로는 리스팅을 곧바로 게시한다.
모든 것이 이 하나의 함수를 거치기 때문에, 동일한 불변식이 어디에서나 유지된다. 그것이 나중에 옛 Shopware 상점에서 197개 제품을 한 번에 마이그레이션할 수 있었던 이유이기도 하다(아래에서 더 다룬다). 임포트는 미묘하게 다른 두 번째 쓰기 방식을 발명하는 대신 정확히 createErfassungProduct를 호출한다.
품질 게이트
검토가 끝나면 유일하게 진짜 운영상의 결정이 온다. 다음은 어디로? CAPTURE_DESTINATIONS — 품질, 재고, 부품, 재활용 또는 "미검사 상점 출고" — 는 입고 등급에 매핑된다. 바로 게시하려는 검사 필수 카테고리의 기기는 안전 게이트에 가로막혀, 누군가 명시적으로 기록되는 "검사 없이 게시" 결정을 내리지 않는 한, 대신 QC 체크리스트와 함께 재정비 파이프라인에 초안으로 안착한다. 어떤 카테고리가 검사를 필요로 하는지는 별도로 관리되지 않고 체크리스트 자체에서 파생된다. 검사 필수란 해당 기기 클래스에 대해 필수 테스트 또는 보안 항목이 있음을 뜻한다.
마켓플레이스로
기기가 게시되면 publishRevampitListing이 그것을 활성 리스팅(is_revampit 플래그)으로 바꾸고, R2 이미지를 리스팅 이미지로 옮기고, 검색을 위해 항목을 Meilisearch에 색인한다. 따라서 "기록됨"에서 "상점에 표시됨"으로의 전환은 두 번째 폼을 든 두 번째 사람이 아니라 API 호출이다.
스트레스 테스트: 옛 상점의 197개 제품
이 흐름이 견고하다는 최고의 확증은 카탈로그 마이그레이션이었다. 옛 Shopware 상점에는 쓸 만한 API가 없었다 — 하지만 제품 페이지마다 깔끔한 Open Graph 메타데이터가 있었다. 작은 스크레이퍼가 /Alles/ 목록을 훑어 이름, 브랜드, 가격, 설명, 이미지 URL을 뽑아내고, 일회성 마이그레이션 엔드포인트가 197개 제품 각각을 초안으로 생성했다 — createErfassungProduct를 통해, 이미지는 서버 측에서 다운로드해 R2에 재호스팅했다. 카테고리는 detectCategory로 추론했고, 중복은 저장된 Shopware 번호로 방지했다(멱등적이며, 원하는 만큼 반복 가능). 손으로 하면 며칠 걸렸을 일이 몇 분 만에 끝났다.
그리고 마이그레이션이 여느 단일 입고와 동일한 함수를 거쳤기 때문에, 초안들은 두 번째 패스에서 게시될 수 있었다 — 각각이 활성 리스팅이 되고, R2 이미지가 자동으로 함께 따라간다. 11개의 표시 리스팅이 208개가 되었고, 각각에 이미지, 가격, 카테고리가 붙었다. 옛 상점은 단지 타이핑된 것이 아니라 옮겨진 것이다.
이것을 떠받치는 세 가지 원칙
이웃 시스템으로 넘어가기 전에, "데모에서는 작동한다"와 "프로덕션에서 견딘다"의 차이를 만드는 — 그리고 코드 전반에 반복해서 나타나는 — 세 가지 결정을 살펴볼 가치가 있다.
쓰기를 위한 단일 진실 원천. 사진이든, 음성이든, CSV든, 단일 입고든, 일괄 마이그레이션이든, 쓰기는 오로지 createErfassungProduct를 통해서만 일어난다. 마이그레이션을 위해 "빠른 두 번째, 조금 다른 경로"를 만들고 싶은 유혹이 천 가지는 있었을 것이다. 우리가 하지 않은 것이 정확히 그것이고 — 그래서 품목 번호, 이미지 처리, QC 게이트, 재고 불변식이 모든 경로에 동일하게 적용된다. 버그는 다섯 곳이 아니라 한 곳에서 고쳐진다.
확신이지 백분율이 아니다. AI는 모든 필드에 대해 신뢰도를 제공한다 — 하지만 화면에는 백분율이 보이지 않는다. 확신도가 임계값 아래로 떨어지는 곳에만 "확인" 힌트를 보여준다. "73%" 같은 숫자는 작업대 앞의 사람에게 아무런 지시가 되지 못한다. "여기 다시 봐"는 지시가 된다. 좋은 자동화는 결정을 줄이지, 늘리지 않는다.
나가는 곳마다 멱등성. Kivvi로의 모든 동기화는 멱등성 키를 지니고, 마이그레이션된 모든 레코드는 자신의 Shopware 번호를 지니며, 모든 게시 실행은 이미 리스팅이 있는 것을 건너뛴다. 사소하게 들리지만, 이것이 우리가 마이그레이션, 동기화, 재색인을 중복이나 두려움 없이 원하는 만큼 반복할 수 있는 이유다. 반복 가능성은 사치가 아니다 — 시스템이 돌아가는 도중에 그것을 고칠 수 있게 하는 전제 조건이다.
서로 대화하는 시스템들
입고는 일의 절반에 불과하다. 기기는 재고와 회계가 사는 곳에도 도착해야 한다. 그리고 여기서 아키텍처적으로 흥미로워지는데, 매우 다른 두 세계가 이것에 매달려 있기 때문이다.
Kivvi: 깨끗한 막(膜)
Kivvi는 우리가 기기를 동기화하는 현대적인 스위스 클라우드 ERP(TypeScript, Drizzle/Postgres)다. 통합 파트너에게 필요한 것을 정확히 제공하기 때문에 일을 쉽게 만든다. /api/v1/ 아래의 버전이 매겨진 REST API다. 우리의 syncToKivvi(src/lib/kivvi/client.ts)는 서버 측에 SHA-256 해시로 보관되는 베어러 토큰(kv_…)과 함께 POST /api/v1/inventory-items를 수행한다.
여기서 세 가지 속성이 결정적이다 — 그리고 Kivvi의 코드에서는 그것들에 Revamp-IT라는 이름까지 붙어 있다.
- 멱등성. 이 호출은
Idempotency-Key를 지닌다. 이중 푸시는 중복을 만들지 않는다. 바로 그래서 우리는 걱정 없이 재시도할 수 있다. - 논블로킹, 커밋 이후. 동기화는 파이어-앤-포겟이다. 입고의 데이터베이스 트랜잭션 이후에 시작되며 기록을 절대 막지 않는다. 이후 우리는
kivvi_inventory_item_id와kivvi_sync_status를 재고 항목에 되기록한다. Kivvi가 구성되어 있지 않으면(KIVVI_API_URL없음), 클라이언트는 예외를 던지는 대신{ success: false }를 깔끔하게 반환한다 — 개발 환경에서는 그냥 동기화가 없다. - 양방향. Kivvi는 서명된 웹훅을 되돌려 보낸다(
inventory_item.status_changed등). 거기서 기기가 팔리면, 우리는 폴링 없이 그것을 알게 된다.
작지만 중요한 번역 단계가 있다. RevampIT의 상태 어휘가 Kivvi의 enum에 매핑된다(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는 이를 하나의 전역 구조 $::form으로 재조립한다.
거기서의 쓰기는 load → overlay → save 패턴을 따르며, 언제나 전체 객체에 대해 이루어진다. 그것은 은근히 위험한 결과를 낳는다. 스칼라는 생략하면 보존되지만 — 컬렉션(가격, 공급자)은 삭제-후-교체다. 부분집합을 보내면 나머지를 잃는다. 그래서 완전한 상태를 먼저 로드하지 않고는 "필드 하나만 바꾸는" 일이 불가능하다.
현대 시스템을 그것에 어떻게 연결하는가? Kivitendo의 로직을 다시 만드는 것이 아니라, 얇은 번역 계층 — 브라우저를 흉내 내는 Node 서비스 — 로. 그것의 유일한 흐름은 이렇다. receive → load (SELECT) → map inward → merge → send as a $::form POST → map outward → return. 이 계층은 SQL을 절대 직접 쓰지 않는다. 쓰기는 오로지 Kivitendo 자신의 컨트롤러를 통해서만 일어나므로, 그 검증, 이력, 트랜잭션이 정확히 한 곳에 머무른다. 지침이 되는 원칙은 이렇다. 비즈니스 로직은 Kivitendo에 산다 — 우리는 번역가이지 두 번째 두뇌가 아니다.
우아한 부분은 이렇다. 필요한 엔티티별 매핑(어떤 외부 필드가 내부에서 무엇이라 불리는지, 어떤 폼 키인지, 어떤 커스텀 변수인지)이 작고 로컬한 LLM에 의해 생성된다 — Kivitendo의 Perl ORM과 컨트롤러에서 추출되고, 실제로 캡처된 폼 POST에 대해 왕복 검증된다. 매핑은 오직 Kivitendo가 받아들인 POST를 파라미터 하나하나까지 재현할 때에만 올바르다. 아무것도 추측하지 않는다. 이 조각은 아직 실험적이지만(Part 엔티티는 서 있으나, 아직 라이브 인스턴스에 대해 견고화되지는 않았다), 길은 분명하다. 바깥쪽에는 깨끗하고 버전이 매겨진 계약, 안쪽에는 변경되지 않은 Kivitendo의 진실.
정직한 유보 사항: 이 중 많은 부분이 통제된 조건에서 관찰된 것이 아니라 소수의 캡처와 소스 읽기로부터 추론된 것이다. 가장 덜 확인된 것이 하필 가장 중요한 것이다 — Kivitendo가 성공과 실패를 어떻게 신호하는지(&id=…를 실은 리다이렉트 대 오류 본문을 담은 200 응답). 그것은 우선 실행 중인 인스턴스에 대해 검증되어야 한다. 정직한 아키텍처는 자신의 열린 가정을 명시한다.
빠진 조각: 저장과 물류
그리고 여기 아직 완성되지 않은 부분이 온다 — 제품을 완성하기 위해 계획된 작업이기에 일부러 명시한다.
오늘날 기기 입고는 본질적으로 QC 체크리스트와 "어디에 있는가" 포인터를 갖춘 단일 단위 등록부다. 간결한 테이블 storage_locations(이름, 종류: 메인 저장 / 상점 / 보조 저장 / 회원 소유 / …)가 있고, 재고 항목은 storage_location_id, 자유로운 box_id, 그리고 레거시 location 필드를 지닌다. 그것이 답하는 것은 *"어느 선반에 이 기기 한 대가 있는가?"*이다.
빠진 것은 그 너머의 모든 것이다 — 그리고 그것이, 솔직히 말해, 진짜 저장 관리다.
- 재고 이동 원장 없음. 카운터
quantity_reserved/quantity_sold는 컬럼으로 존재하지만 어디에서도 기록되지 않는다. 입출고 전표도, 이동 이력도 없다. - 다중 창고 없음, 이관 없음. 평평한 위치 목록, 계층 구조 없음, 창고별 재고 없음.
- 피킹 없음, 입고 검수 없음, 보충 없음. 요컨대 창고 관리가 없고, 그저 "무엇이 어디 있는가"만 있다.
좋은 소식은, 접속점이 이미 존재한다는 것이다. Kivvi가 우리에게 부족한 바로 그 재고 원시 요소들을 가져온다 — warehouses, stockLevels(제품과 창고별 재고), 그리고 부호가 있는 수량을 갖춘 추가 전용 stockMovements 원장. 다만 운영 단위가 아니라 회계 단위에서다. Kivvi는 창고를 이름과 주소로 알지만, 빈(bin)도, 피킹 경로도, 운송사도 모른다. 따라서 미래의 RevampIT 저장 모듈에는 두 가지 깔끔한 선택지가 있다 — Kivvi의 warehouseId + location을 직접 구동하거나, 운영 계층(빈, 이동, 피킹)을 스스로 모델링하고 그 뒤에서 Kivvi를 재고 장부로 운용하는 것. 양방향 웹훅 덕분에 양쪽 모두 동기화 상태를 유지한다.
그리고 Kivitendo는? 원칙적으로 창고도 거기에 미러링될 수 있다 — 위에서 스케치한 동일한 번역 계층을 통해. Kivitendo는 모델에 창고/재고 개념을 가지고 있다. 그러면 재고 이동은 동일한 경로를 밟는 또 하나의 엔티티가 될 것이다. 로드, 병합, 적절한 컨트롤러로 $::form POST 전송. 더 큰 노력은 개념이 아니라 세심함에 있다 — 재고는 회계와 관련이 있고, Kivitendo의 "컬렉션은 교체된다" 의미론은 언제나 완전한 상태를 보낼 것을 요구한다. 장부에 대해서는, 바로 그 신중함이 정당하다.
전망
입고는 해결됐다. 사진 한 장이나 적힌 이름으로부터, 깔끔하고 분류되고 이미지가 붙은 레코드가 몇 초 만에 나타난다 — 저장 위치에 자리 잡거나 상점에 게시되고, Kivvi로 동기화된다. 거의 아무도 입고를 하지 않게 만들었던 문턱은 사라졌다.
남은 것은 그것의 물리적 대응물이다. 모든 기기가 어디에 있는지 알고, 모든 이동을 깔끔하게 전표 처리하는 것. 그것이 다음 조각이다 — 우리의 단일 단위 등록부와 Kivvi의 재고 원장 사이의 다리, 그리고 필요한 곳에서는 Kivitendo까지 이어지는 다리. 그것이 서면, 원이 닫힌다. 기기를 기록하는 동작에서부터 그것이 팔려 나가는 선반까지 — 그 사이에 아무도 스프레드시트를 관리할 필요 없이.
