Python으로 다단계 BOM CSV의 공유 부품 소요량 계산하기
P1을 3개, P2를 2개 만들 때 두 제품이 함께 사용하는 부품은 얼마나
필요할까요? 중간품 A는 P1에도 P2에도 들어가고, 최종 부품 X는 A를 통해
들어가는 경로와 P1에 직접 들어가는 경로가 있습니다. 부품 이름을 한
번씩만 세면 필요한 수량이 빠집니다.
이번 실습은 세 CSV에 적힌 생산 수요와 다단계 BOM을 Python으로
전개합니다. 경로별 소요량은 6행, 최종 부품 합계는 X 35 EA와 Y 14
EA입니다. 각 경로의 곱과 최종 합계를 함께 남겨 어떤 경로에서 수량이
생겼는지 검산합니다. 입력은 모두 직접 만든 합성 자료이며 실제 생산·발주
자료를 사용하지 않습니다.
1. 부모 1개에 필요한
자식 수량부터 정합니다
BOM은 부모 품목을 만들 때 어떤 자식 품목이 몇 개 필요한지 적은
구성표입니다. 이번 예제의 qty_per는 부모 1 EA에
필요한 자식 개수입니다. P1 한 개에는 A 두 개와 X 한 개, P2 한
개에는 A 한 개와 Y 세 개가 필요합니다. A 한 개에는 X 네 개와 Y 한 개가
필요합니다.
P1 3개에서 A를 거쳐 X로 가는 경로는 3 × 2 × 4 = 24 EA입니다. P2
2개에서 같은 A를 거쳐 X로 가는 경로는 2 × 1 × 4 = 8 EA입니다. P1의 직접
X 수량 3 × 1 = 3 EA까지 합쳐 X는 35 EA가 됩니다.
공유 부품 A를 처음 만난 뒤 ’이미 처리했다’는 이유로 다른 경로에서
건너뛰면 P2의 소요량이 빠집니다. 이 코드는 순환 검사에서 쓰는
전체 방문 상태와 수량을 더하는 경로 전개를
따로 처리합니다. 같은 leaf에 여러 번 도착하면 각각의 경로 수량을 모두
더합니다.

2. 품목과 BOM과 요청 CSV를
준비합니다
ZIP을 새 폴더에 풀면 실행 파일, input,
expected, 한글 README와 자동 시험이 있습니다. 먼저 정상
입력 세 파일을 텍스트 편집기로 확인하세요. 모두 UTF-8 BOM을 붙인 쉼표
CSV입니다. 실행 코드는 BOM이 없는 UTF-8도 받습니다.
input/items.csv는 품목 종류와 단위를 명시합니다. 하위
구성표가 필요한 품목은 assembly, 최종 부품은
leaf입니다.
item_id,kind,unit
P1,assembly,EA
P2,assembly,EA
A,assembly,EA
X,leaf,EA
Y,leaf,EA
input/bom.csv는 여섯 부모·자식 관계입니다.
qty_per의 2는 A 두 개라는 뜻이며 A 내부의 구성 수량까지
포함한 숫자가 아닙니다.
parent,component,qty_per
P1,A,2
P1,X,1
P2,A,1
P2,Y,3
A,X,4
A,Y,1
input/requests.csv는 만들 assembly의 수요입니다.
root,qty
P1,3
P2,2
다음 입력 계약을 코드가 검사합니다. 열 이름·순서는 표와 정확히 같아야
하며, 파일 하나당 최대 1MiB입니다.
| 파일 | 헤더 | 데이터 행 제한 |
|---|---|---|
| 품목 | item_id,kind,unit | 1~500 |
| BOM | parent,component,qty_per | 0~2,000 |
| 요청 | root,qty | 0~1,000 |
품목 코드는 첫 글자가 대문자 A~Z 또는 숫자 0~9이고, 이후
대문자·숫자·밑줄·하이픈을 써서 전체 1~40자이며 마스터에서 유일해야
합니다. 선두 하이픈·밑줄은 거부합니다. kind는 소문자
assembly 또는 leaf, 단위는 대문자
EA만 받습니다. 이 예제의 EA는 정수 개수 단위입니다. kg·m와
섞거나 소수 수량을 반올림하여 받지 않습니다.
BOM 수량은 1~1,000,000, 요청 수량은 0~1,000,000의 정수 문자열입니다.
01, +1, 1.0, 1e2,
공백이 붙은 수량은 거부합니다. BOM 수량 0은 관계를 그대로 받아야 할지
지워야 할지 업무 판단이 필요하므로 오류로 처리합니다. 요청 수량 0은
유효한 무수요로 받습니다.
parent·component·root는 마스터에 있어야 합니다. parent와 root는
assembly여야 하고, leaf는 하위 BOM을 가질 수 없습니다. 모든 assembly에
하위 BOM이 하나 이상 있어야 합니다. 같은 parent·component 쌍이 반복되면
수량이 달라도 오류입니다. 중복을 자동 합산하지 않습니다.
같은 root가 요청 CSV에 여러 번 나타나면 먼저 합산합니다. P1 1개와 P1
2개는 P1 3개로 전개하며 root별 합산 수요도 1,000,000 이하여야 합니다.
경로 결과는 합산한 root 기준이므로 개별 요청 행을 따로 추적하는 주문별
보고서는 아닙니다.
3. 새 결과 폴더 이름으로
실행합니다
이 실습은 Python 표준 라이브러리만 사용합니다. 추가 패키지 설치가
필요하지 않습니다. Python이 없으면 Python 공식 다운로드의 설치
안내를 확인하세요. 실제 검증 환경은 Linux의 CPython 3.12.14입니다. 다른
패치 버전과 Windows·macOS에서의 실제 실행은 확인하지 않았습니다.
README.md가 있는 폴더에서 터미널을 열고 다음 명령을 실행합니다.
python --version
python bom_requirements.py --items input/items.csv --bom input/bom.csv --requests input/requests.csv --out result_01
python verify_example.py result_01
python3 --version으로만 실행된다면 이후 명령의
python을 python3로 바꾸세요. Windows의 Python
실행기를 쓰는 경우 py -3가 가능한 실행 명령입니다.
운영체제별 화면이나 실행 결과를 검증한 것은 아닙니다.
result_01은 아직 존재하지 않아야 합니다. 실행 전에 직접
만들지 마세요. 기존 결과가 있으면 새 이름인 result_02를
사용합니다. 정상 계산 명령은 종료 코드 0으로 다음 한 줄을
표시합니다.
{"leaf_totals": {"X": 35, "Y": 14}, "path_rows": 6, "unit": "EA"}
정상 예제 검증기는 직접 작성한 고정 CSV·summary 기대값과 입출력
해시를 대조합니다. 통과하면 다음 문장이 표시됩니다.
PASS: X=35 EA, Y=14 EA, 6 paths, fixed CSVs and hashes
verify_example.py는 수정하지 않은 정상 입력 전용입니다.
수요나 BOM을 바꾼 자료는 원래 기대값과 달라질 수 있으므로 이 문장이
나오지 않는다고 새 계산이 곧바로 틀렸다고 판단하지 마세요. 변경 자료의
기대 결과는 별도로 검산해야 합니다.
4. 경로 6행을 먼저
확인하고 부품별로 더합니다
path_requirements.csv에는 다음 여섯 경로가 생깁니다.
실제 path 열의 구분 기호는 >입니다. root_qty는 같은 root
요청을 합산한 값입니다.
| root | root_qty | path | 계산 | qty | unit |
|---|---|---|---|---|---|
| P1 | 3 | P1>A>X | 3×2×4 | 24 | EA |
| P1 | 3 | P1>A>Y | 3×2×1 | 6 | EA |
| P1 | 3 | P1>X | 3×1 | 3 | EA |
| P2 | 2 | P2>A>X | 2×1×4 | 8 | EA |
| P2 | 2 | P2>A>Y | 2×1×1 | 2 | EA |
| P2 | 2 | P2>Y | 2×3 | 6 | EA |
표의 계산 열은 설명을 위해 추가한 것입니다. 실제 CSV 헤더는
root,root_qty,path,leaf,qty,unit이며 leaf 열에 X나 Y를
적습니다. 경로 이름이 다른 수량을 버리지 않고 leaf별로 모두
더합니다.
- X = 24 + 3 + 8 = 35 EA
- Y = 6 + 2 + 6 = 14 EA
leaf_totals.csv의 실제 전체 내용은 다음과 같습니다.
leaf,qty,unit
X,35,EA
Y,14,EA
결과 폴더에는 경로 CSV, 부품 합계 CSV, summary.json의 세
파일이 생깁니다. summary에는 품목 5행·BOM 6행·요청 2행, 합산 수요
P1=3/P2=2, 경로 6행, X/Y 합계, 실제 Python 버전과 입력·출력 SHA-256을
남깁니다. summary는 마지막에 기록합니다. complete가
true이고 두 CSV의 해시까지 맞는 결과를 사용하세요.
입력 파일은 읽기만 합니다. 입력 해시는 실제 계산에 읽은 바이트에서
계산하므로 실습 전후 원본과 대조할 수 있습니다. SHA-256은 바이트 일치
여부를 확인하는 자료입니다. 입력의 업무상 정확성이나 파일 잠금을
보증하는 표시로 사용하지 마세요.
5. 반복 수요와
0수요도 같은 규칙으로 처리합니다
ZIP의 requests_repeated.csv는 P1 1개, P1 2개, P2
2개입니다. 먼저 P1을 3개로 합산하므로 정상 예제와 같은 X=35/Y=14와 경로
6행이 나옵니다. summary의 request_rows는 원본 행 수인 3입니다.
python bom_requirements.py --items input/items.csv --bom input/bom.csv --requests input/requests_repeated.csv --out result_repeated
requests_zero.csv에는 P1=0/P2=0이 있고
requests_empty.csv는 헤더만 있습니다. 두 파일 모두 유효한
전체 BOM과 함께 쓰면 경로 0행, 부품 합계 {}로 완료합니다.
CSV 출력에는 헤더만 남습니다. 0 root는 합산 수요 기록에 남지만 경로
결과에서 제외하고, 요청하지 않은 leaf를 결과에 0행으로 만들지
않습니다.
python bom_requirements.py --items input/items.csv --bom input/bom.csv --requests input/requests_zero.csv --out result_zero
python bom_requirements.py --items input/items.csv --bom input/bom.csv --requests input/requests_empty.csv --out result_empty
수요가 0이어도 전체 품목 그래프는 검사합니다.
요청하지 않은 U와 V에 U→V→U 순환이 있으면 중단하고, 미사용 assembly에
BOM이 없어도 중단합니다. 요청 경로만 검사하면 잘못된 기준 자료가 정상
결과로 보일 수 있기 때문입니다.
최장 경로는 간선 50개, 출력 경로는 10,000행으로 제한합니다. 공유
관계가 많으면 적은 품목으로도 경로 수가 크게 늘어납니다. 이 상한은 생산
능력이나 업무 정확도의 기준이 아니라 작은 실습을 안정적으로 실행하기
위한 범위입니다.
6. 오류를 고칠 때
원본과 관계를 함께 확인합니다
잘못된 입력·그래프·경로 상한 오류는 결과 폴더를 만들기 전에 종료 코드
2와 stderr 오류를 표시합니다. ZIP의 순환 반례를 실행해 보세요.
python bom_requirements.py --items input/items.csv --bom input/bom_cycle.csv --requests input/requests.csv --out result_bad_cycle
A→P1 관계를 추가하면 P1→A→P1 순환이 생깁니다. 콘솔에는 CYCLE 토큰과
순환 경로가 표시되고 result_bad_cycle은 생기지 않습니다.
오류 메시지의 순환 경로는 검사 시작점에 따라 A>P1>A처럼 회전된
순서로 나올 수 있습니다.
| 오류 토큰 | 확인할 내용과 복구 |
|---|---|
| HEADER / WIDTH / CSV | 정확한 열 이름·순서, 열 추가·누락, 빈 행, 따옴표 확인 |
| ENCODING | 백업 사본을 UTF-8 또는 UTF-8 BOM CSV로 다시 저장 |
| ITEM_ID / KIND / UNIT | 품목 코드, assembly·leaf, EA 확인. 단위 환산은 담당자 결정 후 별도 설계 |
| DUPLICATE_ITEM / DUPLICATE_EDGE | 중복 품목 키·부모 자식 관계의 원인 확인. 근거 없이 삭제·자동 합산하지 않기 |
| UNKNOWN_ITEM / LEAF_PARENT / ROOT_KIND | 마스터 누락, leaf의 하위 관계, 요청 root 종류 대조 |
| MISSING_BOM / CYCLE | 요청이 없는 assembly까지 BOM 누락·순환 확인 |
| QUANTITY / REQUEST_LIMIT | 요청 0과 양의 BOM 정책, 정수 형식, root 합산 상한 확인 |
| FILE_LIMIT / ROW_LIMIT / DEPTH_LIMIT / PATH_LIMIT | 실습 범위 초과 확인. 연결된 BOM을 무작정 나누면 누락 위험이 있음 |
| OUTPUT_EXISTS | 기존 결과를 보존하고 result_02 등 새 이름 사용 |
| OUTPUT_PARENT / IO | 상위 폴더, 파일 접근 가능 여부, 디스크 여유 확인 |
입력 오류와 달리 디스크 부족 등 운영체제의 쓰기 오류는 새 폴더가
만들어진 뒤 발생할 수 있습니다. 이때 summary가 없거나 complete 결과와
해시가 맞지 않는 폴더는 사용하지 마세요. 원본을 유지하고 원인을 해결한
뒤 다른 새 폴더로 실행합니다. 보안 설정을 낮추거나 기존 결과를 덮어쓰는
방법으로 해결하지 않습니다.
7. 전체 실행 코드
다음 코드는 ZIP의 bom_requirements.py 전체 내용과
같습니다. CSV 형식 검사는 csv.reader에 맡기고, 열
구조·숫자·품목 관계는 코드에서 별도로 검사합니다. 전체 그래프 검사는 색
상태를 쓰는 깊이 우선 탐색입니다. 경로 전개는 leaf에 도착할 때 수량을
누적하며 전역 방문 완료 집합으로 공유 부품을 제거하지 않습니다.
"""합성 정수 BOM의 경로별 소요량과 최종 부품 합계를 계산합니다."""
import argparse
import csv
import hashlib
import io
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
MAX_BYTES = 1024 * 1024
MAX_QTY = 1_000_000
MAX_DEPTH = 50
MAX_PATHS = 10_000
ITEM_ID = re.compile(r"[A-Z0-9][A-Z0-9_-]{0,39}\Z")
INTEGER = re.compile(r"(?:0|[1-9][0-9]*)\Z")
SCHEMAS = {"items": (["item_id", "kind", "unit"], 500),
"bom": (["parent", "component", "qty_per"], 2000),
"requests": (["root", "qty"], 1000)}
class InputError(ValueError):
pass
def reject(code, detail):
raise InputError(f"{code}: {detail}")
def read_table(path, name):
with path.open("rb") as handle:
raw = handle.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
reject("FILE_LIMIT", f"{name}: 파일은 1MiB 이하여야 합니다")
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError:
reject("ENCODING", f"{name}: UTF-8 또는 UTF-8 BOM으로 저장하세요")
schema, limit = SCHEMAS[name]
try:
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
if next(reader, None) != schema:
reject("HEADER", f"{name}: 헤더와 순서는 {','.join(schema)} 이어야 합니다")
rows = []
for record, row in enumerate(reader, 2):
if len(row) != len(schema):
reject("WIDTH", f"{name} 레코드 {record}: 열 수가 맞지 않습니다")
if len(rows) >= limit:
reject("ROW_LIMIT", f"{name}: 데이터 {limit}행 이하여야 합니다")
rows.append(row)
except csv.Error as error:
reject("CSV", f"{name}: 잘못된 CSV 형식 ({error})")
return rows, hashlib.sha256(raw).hexdigest()
def check_id(value, where):
if ITEM_ID.fullmatch(value) is None:
reject("ITEM_ID", f"{where}: 품목은 첫 글자 대문자·숫자, 이후 대문자·숫자·밑줄·하이픈으로 1~40자입니다")
return value
def quantity(value, minimum, where):
if INTEGER.fullmatch(value) is None or len(value) > 7:
reject("QUANTITY", f"{where}: 공백 없는 정수 문자열이 필요합니다")
number = int(value)
if not minimum <= number <= MAX_QTY:
reject("QUANTITY", f"{where}: {minimum}~{MAX_QTY} 정수가 필요합니다")
return number
def validate_graph(items, edges):
for item, kind in items.items():
if kind == "assembly" and not edges[item]:
reject("MISSING_BOM", f"{item}: assembly의 BOM이 없습니다")
# 전체 그래프의 색 상태는 순환 검사에만 씁니다. 소요량 누적에는 쓰지 않습니다.
state, depth = {}, {}
def visit(node, path):
if state.get(node) == 1:
reject("CYCLE", " > ".join(path + [node]))
if state.get(node) == 2:
return depth[node]
state[node] = 1
longest = 0
for child, _ in edges[node]:
longest = max(longest, 1 + visit(child, path + [node]))
state[node] = 2
depth[node] = longest
if longest > MAX_DEPTH:
reject("DEPTH_LIMIT", f"{node}: 최장 경로는 간선 {MAX_DEPTH}개 이하여야 합니다")
return longest
for node in sorted(items):
visit(node, [])
def load_inputs(paths):
tables, hashes = {}, {}
for name in SCHEMAS:
tables[name], hashes[name] = read_table(paths[name], name)
if not tables["items"]:
reject("EMPTY_ITEMS", "품목 마스터는 1행 이상이어야 합니다")
items = {}
for record, (item, kind, unit) in enumerate(tables["items"], 2):
check_id(item, f"items 레코드 {record}")
if item in items:
reject("DUPLICATE_ITEM", item)
if kind not in {"assembly", "leaf"}:
reject("KIND", f"{item}: assembly 또는 leaf만 받습니다")
if unit != "EA":
reject("UNIT", f"{item}: 이 실습은 EA 정수 개수만 받습니다")
items[item] = kind
edges = defaultdict(list)
seen = set()
for record, (parent, child, value) in enumerate(tables["bom"], 2):
check_id(parent, f"bom 레코드 {record} parent")
check_id(child, f"bom 레코드 {record} component")
if parent not in items or child not in items:
reject("UNKNOWN_ITEM", f"bom 레코드 {record}: {parent} > {child}")
if items[parent] != "assembly":
reject("LEAF_PARENT", parent)
key = (parent, child)
if key in seen:
reject("DUPLICATE_EDGE", f"{parent} > {child}")
seen.add(key)
edges[parent].append((child, quantity(value, 1, f"bom 레코드 {record}")))
for children in edges.values():
children.sort()
validate_graph(items, edges)
requests = defaultdict(int)
for record, (root, value) in enumerate(tables["requests"], 2):
check_id(root, f"requests 레코드 {record}")
if root not in items:
reject("UNKNOWN_ITEM", f"requests 레코드 {record}: {root}")
if items[root] != "assembly":
reject("ROOT_KIND", f"{root}: 요청 root는 assembly여야 합니다")
requests[root] += quantity(value, 0, f"requests 레코드 {record}")
if requests[root] > MAX_QTY:
reject("REQUEST_LIMIT", f"{root}: 합산 수요가 {MAX_QTY}를 넘었습니다")
return items, edges, dict(sorted(requests.items())), hashes, tables
def explode(items, edges, requests):
traces, totals = [], defaultdict(int)
def walk(root, root_qty, node, amount, path):
if items[node] == "leaf":
if len(traces) >= MAX_PATHS:
reject("PATH_LIMIT", f"경로 결과는 {MAX_PATHS}행 이하여야 합니다")
traces.append([root, root_qty, ">".join(path), node, amount, "EA"])
totals[node] += amount
return
# 같은 부품이라도 도달 경로마다 다시 전개해 곱과 합을 보존합니다.
for child, qty_per in edges[node]:
walk(root, root_qty, child, amount * qty_per, path + [child])
for root, root_qty in requests.items():
if root_qty:
walk(root, root_qty, root, root_qty, [root])
return traces, dict(sorted(totals.items()))
def csv_bytes(header, rows):
buffer = io.StringIO(newline="")
writer = csv.writer(buffer, lineterminator="\n")
writer.writerow(header)
writer.writerows(rows)
return buffer.getvalue().encode("utf-8-sig")
def run(paths, output):
items, edges, requests, hashes, tables = load_inputs(paths)
traces, totals = explode(items, edges, requests)
payloads = {
"path_requirements.csv": csv_bytes(["root", "root_qty", "path", "leaf", "qty", "unit"], traces),
"leaf_totals.csv": csv_bytes(["leaf", "qty", "unit"], [[leaf, qty, "EA"] for leaf, qty in totals.items()])}
summary = {"complete": True, "unit": "EA", "item_rows": len(items),
"bom_rows": len(tables["bom"]), "request_rows": len(tables["requests"]),
"requests_aggregated": requests, "path_rows": len(traces),
"leaf_totals": totals, "input_sha256": hashes,
"output_sha256": {name: hashlib.sha256(raw).hexdigest() for name, raw in payloads.items()},
"runtime": {"implementation": sys.implementation.name, "version": sys.version.split()[0]},
"policy": {"request_qty": "0..1000000; repeated roots aggregated",
"bom_qty_per": "1..1000000; duplicate edges rejected",
"unit": "EA only; no conversion", "global_cycle_check": True,
"max_depth_edges": MAX_DEPTH, "max_path_rows": MAX_PATHS}}
if output.exists() or output.is_symlink():
reject("OUTPUT_EXISTS", f"{output}: 새 결과 폴더 이름을 쓰세요")
if not output.parent.is_dir():
reject("OUTPUT_PARENT", f"{output.parent}: 상위 폴더가 있어야 합니다")
# 모든 입력 검증·경로 계산·직렬화가 끝난 뒤에만 새 결과 폴더를 만듭니다.
output.mkdir(exist_ok=False)
for name, raw in payloads.items():
with (output / name).open("xb") as handle:
handle.write(raw)
with (output / "summary.json").open("x", encoding="utf-8", newline="\n") as handle:
json.dump(summary, handle, ensure_ascii=False, indent=2)
handle.write("\n")
return summary
def main():
parser = argparse.ArgumentParser(description=__doc__)
for name in SCHEMAS:
parser.add_argument(f"--{name}", required=True, type=Path)
parser.add_argument("--out", required=True, type=Path)
args = parser.parse_args()
try:
summary = run({name: getattr(args, name) for name in SCHEMAS}, args.out)
except InputError as error:
print(f"ERROR {error}", file=sys.stderr)
return 2
except (OSError, RecursionError) as error:
print(f"ERROR IO: {error}. 생성 도중 실패했다면 complete 결과를 확인하세요", file=sys.stderr)
return 2
print(json.dumps({"path_rows": summary["path_rows"], "leaf_totals": summary["leaf_totals"],
"unit": "EA"}, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
8. 자동 시험과 적용 범위를
확인합니다
압축을 푼 폴더에서 다음 명령으로 실제 CLI 시험을 실행합니다.
python -m unittest discover -s . -p "test_*.py" -v
CPython 3.12.14에서 24개 시험 메서드가 통과했습니다. 시험 안에서
순환·중복 간선·누락 BOM·음수·0수요·숫자 형식·단위·CSV 구조·파일과 경로
상한을 확인했습니다. 같은 root의 반복 요청, 새 결과 폴더 정책, 오류 시
폴더 미생성, 입력 SHA 보존, 최대 수량과 간선 50개의 큰 정수 계산도
실행했습니다.
무작위 DAG 80개는 별도의 위상 순서 누적 계산과 대조했습니다. 비교기는
경로를 재귀로 펼치지 않고 부모 품목의 누적 수량을 자식으로 전파합니다.
두 방식의 leaf 합계와 경로 수량 합계가 일치하는지 확인했습니다. ZIP은 새
검수 폴더에 다시 풀어 정상 명령과 고정 검증기, 전체 시험을
재실행했습니다.
계산 대상은 작은 합성 정수 BOM의 총소요량입니다.
재고 차감과 안전재고, 불량률·수율, 대체품, 공정별 소모,
최소주문·포장배수, 리드타임, 생산능력 계획은 포함하지 않습니다.
구매·발주를 실행하지 않습니다. CSV를 실제 Excel 앱에 가져오거나 다시
열어 자동 형식과 표시를 확인한 실습도 아닙니다.
자주 묻는 질문
공유 부품은 중복으로
제거해야 하나요
여러 생산 경로에서 생긴 수량은 모두 필요하므로 합산합니다. 같은
부모·자식 BOM 행이 두 번 적힌 입력 중복은 오류로 거부합니다. 공유 경로와
중복 입력 행을 구분해야 합니다.
같은 제품 요청이
두 행이면 어떻게 계산하나요
root별 수요를 먼저 합산합니다. P1 1개와 2개는 P1 3개로 전개합니다.
결과 경로는 합산 root 기준이며 개별 요청 행의 주문 이력 보고서를 만들지
않습니다.
요청하지 않은 부품의
순환도 오류인가요
전체 품목의 기준 BOM을 검사하므로 오류입니다. 요청이 0이거나 헤더만
있어도 순환과 assembly BOM 누락을 검사합니다.
재고를 빼서 발주량으로
사용할 수 있나요
출력은 재고 차감 전 총소요량입니다. 실제 발주량에는
재고·안전재고·불량률·대체품·공급 조건 등 별도의 규칙과 검토가
필요합니다. 이 코드는 해당 기능과 구매 실행을 포함하지 않습니다.
관련 실습과 공식 자료
입력 구조를 바꾸는 과정은 중첩
JSON 주문 항목을 CSV 행으로 꺼내는 실습을 참고할 수 있습니다. 한
CSV의 수량을 합산하고 저장값을 대조하는 기초는 CSV
수량 합계와 저장값 검산 실습에서 확인하세요. 이번 실습의 새 결과는
부모 수량의 연속 곱, 공유 leaf 누적, 전체 순환 검사와 경로별
추적입니다.
- Python csv
공식 문서: CSV 문자열 읽기, 방언과newline=''처리. 열
구조·업무 규칙은 별도 검사가 필요합니다. - Python
hashlib 공식 문서: 읽은 바이트의 SHA-256 생성 - Python
unittest 공식 문서: discover로 자동 시험 실행 - Python 공식
다운로드: 설치가 필요한 독자의 공식 안내 경로
공식 자료는 2026년 10월 13일 확인했습니다. 온라인 Python 3.12
문서에는 3.12.15가 표시되었으며, 실제 검산에 실행한 환경은 CPython
3.12.14입니다. 문서 표기 버전과 실행 버전을 구분해 기록했습니다.