케이터링 회사의 ERP는 왜 일반 ERP와 다른가
"ERP 좋은 거 하나 사서 쓰면 되지 않나요?" 케이터링 회사 대표님과 첫 미팅에서 가장 자주 받은 질문입니다. 대답하기 까다로운 질문입니다. 일반 ERP가 안 맞는 게 아니라, 일반 ERP는 다른 도메인을 모델링하고 있기 때문입니다. cadot-erp 케이스에서 우리가 결정한 도메인 모델을 정리합니다.
일반 ERP는 왜 안 맞나
대부분의 ERP는 다음을 가정합니다.
- 재고 단위 = 사용 단위. "박스 1개 = 1개 사용".
- 주문은 상품을 결정한다. "주문 = 상품 × 수량".
- 정산은 사후 작업이다. "월말에 정리".
케이터링은 이 셋을 모두 깨뜨립니다.
- 재고는 박스 단위로 들어오지만 사용은 g 단위입니다.
- 주문은 상품(완제품)을 결정하지 않습니다. 주문은 메뉴 → 레시피 → 품목의 사슬을 통해 재료 수요를 만듭니다.
- 파트너(지점)별 정산 비율이 다르고, 취소가 잦으며, 월말 수기로는 분쟁이 납니다.
"주문 100건이 들어오면 재료 20가지의 필요량을 즉시 알아야 합니다. 박스 단위로 사면 안 되고, g 단위로 떨어져야 합니다."
도메인 모델링 — 메뉴 ↔ 레시피 ↔ 품목
핵심은 주문에서 품목(재료)까지의 사슬입니다.
Order
└── OrderMenu (메뉴 + 수량)
└── Recipe (메뉴 = 레시피)
└── RecipeMaterial (레시피 = 품목 비율 × 사용량)
└── Item (품목)
└── Stock (재고)
주문이 들어오면 이 사슬을 따라 내려가서 Item.stock에서 사용량을 차감합니다. 즉, 주문이 재고를 바꿉니다. 이걸 트랜잭션으로 묶지 않으면 동시 주문이 들어왔을 때 재고가 음수가 되거나, 일부 메뉴만 차감됩니다.
# Order#calculate_order_materials (Rails 8)
def calculate_order_materials
ApplicationRecord.transaction do
self.order_materials = order_menus.flat_map do |om|
om.recipe.recipe_materials.map do |rm|
OrderMaterial.new(
item: rm.item,
required_quantity: rm.ratio * om.quantity,
unit: rm.item.usage_unit,
)
end
end
consume_stock! # 재고 차감 + low_stock 알림
end
end단위 환산이라는 작은 지옥
코드 한 줄에 많은 도메인 지식이 들어갑니다. rm.ratio * om.quantity. 그러나 Item.usage_unit(g)과 Item.stock_unit(박스)이 다르면 곱셈으로 끝나지 않습니다.
- 1박스 = 24개 = 12kg = 12000g
- 0.5kg 사용은 0.5kg / 12kg = 1/24 박스 차감
매 품목마다 환산 비율을 데이터로 저장합니다. 코드가 아니라 데이터여야 합니다. 새 품목이 추가될 때마다 코드를 고치면 안 됩니다.
class Item < ApplicationRecord
# stock_quantity는 stock_unit 기준
# 사용량은 usage_unit 기준
def consume!(usage_quantity)
stock_quantity_to_subtract = usage_quantity / conversion_rate
update!(stock_quantity: stock_quantity - stock_quantity_to_subtract)
end
end이 패턴이 없으면 운영팀이 매주 "엑셀로 다시 계산해야 한다"는 메시지를 보냅니다.
파트너 정산을 자동화한다는 것
파트너(지점)별 정산은 케이터링의 차별점입니다.
- 파트너마다 정산 비율이 다릅니다 (예: 본사 70%, 파트너 30%).
- 취소된 주문은 정산에서 빠집니다.
- 환불은 다음 달로 이월되거나 차감됩니다.
월말에 모아서 처리하면 분쟁이 납니다. 우리는 주문이 확정되는 순간 정산 row를 만들었습니다. 취소되면 그 row를 삭제하지 않고 status를 cancelled로 바꾸고 정산액을 0으로 만들었습니다 — 감사를 위해 이력을 남겨야 하기 때문입니다.
# Order#auto_generate_partner_settlement
after_update :auto_generate_partner_settlement, if: :saved_change_to_status?
def auto_generate_partner_settlement
return unless status_was != 'confirmed' && confirmed?
PartnerSettlement.create!(
partner: partner,
order: self,
rate: partner.settlement_rate, # 파트너별
amount: total_price * partner.settlement_rate,
)
end"ERP" 한 단어로 묶이지만 도메인이 다 다르다
케이터링 ERP를 만들면서 가장 크게 배운 건, "ERP"는 도메인 라벨이 아니라 장르 라벨이라는 사실입니다. 제조 ERP, 유통 ERP, 케이터링 ERP는 같은 장르(주문·재고·정산)를 공유할 뿐 모델이 다릅니다.
새 ERP 프로젝트를 검토할 때 우리가 가장 먼저 묻는 것은 "일반 ERP가 안 맞는 지점이 어디입니까"입니다. 그 지점이 명확할수록 맞춤 ERP의 ROI가 큽니다. 명확하지 않다면 SaaS ERP를 권합니다.
자세한 케이스: cadot-erp