전표 줄 설계: 수주 줄을 빌려 쓰는 전표가 분할 출하에서 어긋나는 이유
제조업 ERP 데모의 매출 전표 113장을 수주와 하나씩 대조했습니다. 전표 금액과 화면에 보이는 품목 줄의 합이 113장 모두 맞았습니다. 그런데 그 전표 테이블에는 품목 줄이 저장돼 있지 않았습니다.
맞았던 이유는 설계가 옳아서가 아니었습니다. 데모 데이터의 수주가 전부 한 번에 출하됐기 때문입니다. 수주 하나를 두 번에 나눠 출하하는 순간, 첫 전표부터 금액과 줄이 어긋나는 구조였습니다.
금액만 저장하고 줄은 빌려 쓰던 전표
구조는 이랬습니다. 출하를 등록하면 매출 전표를 만듭니다. 전표에는 공급가액, 부가세, 합계와 어느 수주에서 났는지만 적습니다. 공급가액은 이번 출하 줄의 수량과 단가를 곱해 더한 값입니다.
await postVoucher(ctx, { kind: "SALES", date, partnerId, amount, orderId: order.id, refNo: shipment.no });전표 화면과 세금계산서는 품목 줄이 필요합니다. 전표에 줄이 없으니 연결된 수주의 줄을 가져다 그렸습니다. 세금계산서에 품목을 채우는 코드도 같았습니다.
const lines = (v.order?.lines ?? []).map((l) => ({ name: l.item.name, qty: l.qty, unitPrice: l.unitPrice, ... }));이 구조의 논리는 단순합니다. 전표는 수주에서 나왔고, 수주에 품목이 다 있으니 같은 것을 두 번 저장할 이유가 없습니다. 정규화를 지킨 것처럼 보이는 선택입니다.
분할 출하 첫 건에서 생기는 일
수주에 품목 A 100개가 있고, 오늘 40개, 다음 주에 60개를 출하한다고 해 보겠습니다.
| 전표 금액 | 전표 화면에 보이는 줄 | |
|---|---|---|
| 첫 출하 전표 | 40개 값 | 수주 줄: A 100개 |
| 두 번째 출하 전표 | 60개 값 | 수주 줄: A 100개 |
두 전표 모두 100개짜리 줄을 보여 주고, 금액은 각각 40개와 60개 값입니다. 화면만 틀리면 그나마 낫습니다. 같은 줄이 세금계산서의 품목으로도 들어가므로, 거래처는 40개 값이 적힌 계산서에서 100개라는 수량을 받게 됩니다.
같은 문제가 단가에도 있습니다. 수주 줄의 단가를 나중에 고칠 수 있다면, 이미 발행한 전표의 화면도 새 단가로 바뀝니다. 전표는 발행 시점에 확정된 기록이어야 하는데, 수주를 빌려 쓰는 한 수주가 바뀔 때마다 지난 전표도 같이 바뀝니다.
같은 품목과 수량이어도 다른 사실인 두 줄
틀린 지점은 「같은 데이터를 두 번 저장하지 않는다」를 적용한 대상입니다.
수주 줄은 「이만큼 팔기로 약속했다」는 사실이고, 전표 줄은 「이번에 이만큼 넘기고 청구했다」는 사실입니다. 품목과 수량 칸의 모양이 같을 뿐 다른 시점의 다른 사실입니다. 분할 출하가 없으면 두 사실의 값이 우연히 같아서, 한쪽을 다른 쪽으로 대신해도 티가 나지 않습니다.
정규화가 금지하는 것은 같은 사실을 두 곳에 적는 것입니다. 수주 줄과 전표 줄은 같은 사실이 아니므로 둘 다 적는 것이 중복이 아닙니다. 주문서에 적힌 가격과 영수증에 찍힌 가격을 따로 보관하는 것과 같습니다.
전표 줄을 그때의 값으로 박는 방법
전표에 자기 줄을 두었습니다.
model VoucherLine {
id String @id @default(cuid())
voucherId String
voucher Voucher @relation(fields: [voucherId], references: [id], onDelete: Cascade)
seq Int
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
qty Int
unitPrice Int
amount Int
}전표를 만드는 함수는 이제 금액이 아니라 줄을 받습니다. 공급가액은 넘겨받는 값이 아니라 줄의 합으로 계산합니다.
const amount = v.lines.reduce((s, l) => s + l.qty * l.unitPrice, 0);이렇게 바꾸면 전표 금액과 전표 줄의 합이 어긋날 길이 만드는 시점에서는 사라집니다. 호출하는 쪽이 금액을 따로 계산해서 넘기던 구조에서는, 두 계산 중 하나만 고쳐지는 날 금액과 줄이 갈라집니다.
두 관계의 삭제 규칙도 일부러 다르게 걸었습니다. 전표 줄은 전표의 일부라 전표와 함께 지워집니다(Cascade). 품목은 전표 줄이 가리키는 한 지워지지 않습니다(Restrict). 과거 전표가 가리키는 품목이 사라지면 그 전표의 줄을 읽을 수 없게 됩니다.
세금계산서의 품목도 수주 줄이 아니라 전표 줄에서 가져오게 바꿨습니다.
줄을 가진 뒤에 따라온 일
줄을 저장하자 금액만 고치던 코드가 드러났습니다. 입고 검수에서 불합격분을 반품하면 매입 전표의 금액을 줄이는 코드가 있었는데, 줄이 생긴 뒤로는 금액만 줄이면 줄의 합과 어긋납니다. 반품 수량만큼 전표 줄의 수량도 줄이도록 고쳤습니다.
이미 쌓여 있던 데이터도 옮겨야 했습니다. 매출과 매입 전표 368건에 출하와 입고의 줄로 전표 줄을 채웠습니다. 채운 줄의 합이 전표 금액과 다르면 건너뛰게 했고, 건너뛴 건은 0건이었습니다. 처음 대조에서 113장이 모두 맞았던 것과 같은 이유입니다. 지금까지는 틀릴 기회가 없었습니다.
스모크 테스트에는 100개를 출하한 전표의 줄 수량이 100인지 확인하는 줄을 더했습니다.
다른 문서를 빌려 쓰기 전에 물을 질문
이번 일로 저는 한 문서가 다른 문서의 줄을 가져다 보여 줄 때 이렇게 묻습니다. 둘이 일대일이 아닌 날이 오는가.
수주와 출하는 분할 출하가 생기면 일대다가 됩니다. 발주와 입고도 분할 입고가 생기면 같습니다. 지금 데이터에서 일대일이라는 사실은 앞으로도 그렇다는 보증이 아닙니다. 대조 결과가 0건이라는 숫자가 설계를 증명해 주지 않는다는 것을 이번에 113장으로 확인했습니다.
함께 읽기
- ON DELETE CASCADE의 함정: 전송한 세금계산서가 출하 되돌리기에 딸려 지워지는 경로제조업 ERP 데모에 전자세금계산서 모의 발행을 붙인 날 저녁, 스키마를 다시 읽었습니다. 세금계산서 모델의 전표 연결 한 줄에 이런 주석이 붙어 있었습니다.
- ERP 공통 코드와 enum: 드롭다운 선택지를 어디에 둘지 정하는 기준생산 실적 화면에 불량 사유를 적는 칸이 있다고 해 보겠습니다. 한 달이 지나 불량 사유별로 집계를 내면 이런 줄이 나옵니다.
- 전자세금계산서 연동: 업무 시스템이 국세청 대신 발급대행 API에 붙는 이유업무 시스템 이야기에서 「세금계산서도 되나요?」라는 질문은 빠지기 어렵습니다. 이 질문에는 흔히 「시스템이 국세청에 연결되나요?」라는 기대가 섞여 있습니다. 전표를 저장하는 순간 국세청에 신고까지 끝나는 그림입니다.
- ERP 메뉴 스타일 정리: 화면을 찾아가는 여섯 가지 방식과 두 줄 툴바입사 둘째 날인 영업 담당자가 수주를 넣으려고 ERP를 엽니다. 왼쪽 메뉴에는 영업관리, 구매관리, 재고관리, 생산관리가 있고, 영업관리를 펼치니 수주등록, 수주현황, 수주마감, 수주변경이력이 또 나옵니다. 옆자리 선임은 메뉴를 쓰지 않습니다. 검색창에 「수주」를 치고 엔터를 누릅니다.
- AI 도입 전에 물어야 할 것: ERP 를 바꿀 일인가, 앞뒤를 붙일 일인가AI 기능이 들어갔다는 업무 시스템으로 갈아탔습니다. 반년이 지났는데 현장에서는 여전히 거래처 카톡을 읽어 엑셀에 옮겨 적고 있습니다.