knowledge(마스터): 인력 열 한 벌 정리 — 조사 → 구분 · 상세구분 · 상태 · 일시간 · 준용 공통 칸

- 인력.json 392 줄이 같은 열 한 벌(키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 값 · 출처 · 일시간 · 상태 · 옛이름 · 비고 · 준용)
- 건설업 미공표 14 는 4-라 산정값을 값에 · 상태 「산정」 · 근거는 비고 한 줄 · 산정·신뢰도·업종·부문·환산비·후보 등 열두 칸 없앰
- 머리 「구분」 에 원문 · 기관 · 판 · 공표일 · 원천 · 상세구분 등록 — 새 조사는 한 항목 더하고 줄만 붙이면 끝
- 엔진은 준용이 있으면 그 직종 값 · 없으면 값 · check_master 인력 열 검사 · M01 구분·상세구분 거름과 구분 목록 API · 빌더도 새 열

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-20 15:12:54 +09:00
co-authored by Claude Opus 5
parent cfd8f67471
commit 1447750e89
17 changed files with 775 additions and 653 deletions
@@ -1,14 +1,16 @@
"""노임 공표 원문 md → 인력 요소 파일 다섯 · 준용대상 짝짓기 · 건설노임 미공표 산정.
"""노임 공표 원문 md → 인력 요소 파일 다섯 · 미확보 짝짓기 · 건설노임 미공표 산정.
원문 = resources/knowledge/original/원가계산/노임단가_* (우리 md · ESTX 값 쓰지 않음).
- 모든 줄이 같은 열 한 벌(`_틀.md` 인력) · 제조업 업종·엔지니어링 부문·측량 분야는 「상세구분」.
- 인력_엔지니어링노임 · 측량노임 · 건설사업관리노임 · SW노임 · 산림노임 — md 표 그대로 · 줄 수·값을 md 와 기계 대조.
- 인력_준용대상 — 새 노임 직종과 이름이 온전히 같은 줄은 옮김(로직 참조도) · 비슷한 이름은 비고에 후보.
건설노임 원문 「통합·명칭변경 직종」 목록의 옛 직종은 현행 줄 `옛이름` 으로 옮겼음(`_틀.md` 인력 값).
- 인력_건설노임 미공표 — `산정` = {값(원 미만 처리 안 함) · 근거 · 마지막공표} · 원문 4-라.
옛이름·비고·준용은 다시 지어도 그대로 이어받음(`carry`).
- 인력_준용대상(구분 「미확보」) — 새 노임 직종과 이름이 온전히 같은 줄은 옮김(로직 참조도).
건설노임 원문 「통합·명칭변경 직종」 목록의 옛 직종은 현행 줄 `옛이름` 으로 옮겼음.
- 인력_건설노임 미공표 — 원문 4-라 산정값을 「값」 에 넣고 상태 「산정」 · 근거는 비고 한 줄.
이음 = 원문 회차의 전체직종 평균을 그대로 이음(미공표직종_추적.md 4절) · 산정값은 같은 md 5절과 대조.
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_인력_노임.py
옛 모양(원문별 파일 · 열쇠)으로 쓰고 끝에 `master_keys.adopt` 가 인력.json(키 · 조사)으로 받음.
옛 모양(원문별 파일 · 열쇠)으로 쓰고 끝에 `master_keys.adopt` 가 인력.json(키 · 구분)으로 받음.
"""
import hashlib
@@ -62,18 +64,34 @@ def origin(path: Path) -> list[dict]:
]
def row(name: str, value, source: str, **more) -> dict:
def row(name: str, value, source: str, detail=None, **more) -> dict:
"""열 한 벌(`_틀.md` 인력) — 키·원문번호·구분은 `master_keys.adopt` 가 앞에 붙임."""
return {
"열쇠": name,
"상세구분": detail,
"이름": name,
"규격": "",
"단위": WAGE,
"": value,
"출처": source,
"일시간": 6 if name == "잠수부" else 8,
"상태": "공표",
"옛이름": [],
"비고": None,
"준용": None,
**more,
}
def details(rows: list[dict]) -> list[str]:
"""구분 묶음의 상세구분 목록 — 줄에 나온 차례대로."""
out = []
for r in rows:
if r["상세구분"] and r["상세구분"] not in out:
out.append(r["상세구분"])
return out
# ── 갈래별 노임 ──────────────────────────────────────────────────────
def engineering():
d = COST / "노임단가_엔지니어링_한국엔지니어링협회"
@@ -81,7 +99,7 @@ def engineering():
t = table_after(src.read_text(encoding="utf-8"), "| 구분 |")
parts = [p.replace(r"\*", "") for p in t[0][1:]] # 「기타**」 주석 기호
rows = [
row(f"{r[0]}({p})", num(v), f"엔지니어링노임 {r[0]} {p}", 부문=p)
row(f"{r[0]}({p})", num(v), f"엔지니어링노임 {r[0]} {p}", detail=p)
for r in t[1:]
for p, v in zip(parts, r[1:])
]
@@ -90,13 +108,15 @@ def engineering():
(d / "노임단가_연도별_엔지니어링(2025).md").read_text(encoding="utf-8"), "| 년도 |"
)
xls = {(r[0], p): num(v) for r in y[2:10] for p, v in zip(y[1][-7:], r[-7:])}
assert {(r["이름"].split("(")[0], r["부문"]): r[""] for r in rows} == xls
assert {(r["이름"].split("(")[0], r["상세구분"]): r[""] for r in rows} == xls
head = {
"그룹": "인력",
"원문": "엔지니어링노임",
"기관": "한국엔지니어링협회",
"": "2026-01-01",
"공표일": "2025-12-02",
"원천": origin(src),
"상세구분": details(rows),
"비고": [
"한국엔지니어링협회 2025 엔지니어링업체 임금실태조사결과 공표(기획홍보팀-1515호 · 국가승인통계 제372001호) · 7 기술부문 × 8 기술등급 · 1인 1일 평균임금 · 월평균 근무일수 20.3일",
"이름 = 기술등급(기술부문) · 기타 = 선박·항공우주·금속·화학·광업·농림·산업·해양·수산",
@@ -130,14 +150,17 @@ def survey():
name,
num(r[3]),
f"측량노임 {' '.join(x for x in (top, field if top == '기능계' else '', grade) if x)}",
detail=f"{top} {field}" if top == "기능계" else top,
)
)
head = {
"그룹": "인력",
"원문": "측량노임",
"기관": "한국공간정보산업협회",
"": "2026-01-01",
"공표일": "2025-12-22",
"원천": origin(src),
"상세구분": details(rows),
"비고": [
"한국공간정보산업협회 2025년도 측량업체 임금실태조사 결과 공표(국가승인통계 제377001호) · 20 직종 · 1인 1일 · 월평균 근무일수 20.3일",
"이름 = 기술계 「측량<등급>기술자」·측량기술사 · 기능계 「<분야><등급>기능사」 · 기타 원문 그대로 — 원문 칸은 출처에",
@@ -155,20 +178,15 @@ def cm():
rows = []
for r in t[1:]:
grade = r[0].replace(" ", "")
rows.append(
row(
f"{grade}건설사업관리기술인",
num(r[1]),
f"건설사업관리노임 {grade}",
환산비=float(r[2]),
)
)
rows.append(row(f"{grade}건설사업관리기술인", num(r[1]), f"건설사업관리노임 {grade}"))
head = {
"그룹": "인력",
"원문": "건설사업관리노임",
"기관": "한국건설엔지니어링협회",
"": "2026-01-01",
"공표일": "2025-12-29",
"원천": origin(src),
"상세구분": [],
"비고": [
"한국건설엔지니어링협회 2026년도 건설사업관리기술인 노임가격(정책381-3804호 · 통계청승인 제378001호) · 일임금액",
"환산비 = 원문 Si(고급 = 1.000 기준 등급별 비율 · 소수 넷째 자리 반올림)",
@@ -187,16 +205,15 @@ def sw():
for r in t[1:]:
mark, name = r[0].split(" ", 1)
n = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰".index(mark) + 1
more = {"월평균임금": num(r[1]), "시간평균임금": num(r[3])}
if r[4]:
more["포함직무"] = r[4]
rows.append(row(name, num(r[2]), f"SW노임 {n}", **more))
rows.append(row(name, num(r[2]), f"SW노임 {n}"))
head = {
"그룹": "인력",
"원문": "SW노임",
"기관": "한국인공지능·소프트웨어산업협회",
"": "2026-01-01",
"공표일": "2025-12-19",
"원천": origin(src),
"상세구분": [],
"비고": [
"한국인공지능·소프트웨어산업협회 2026년 적용 SW기술자 평균임금(통계승인 제375001호) · 값 = 일평균임금(M/D) · 적용 2026-01-01 ~ 2026-12-31",
"⚠ 기본급·제수당·상여금·퇴직급여충당금·법인부담금(4대보험) 모두 포함 — 건설노임(기본급 일급)과 성격 다름",
@@ -216,9 +233,11 @@ def forest():
head = {
"그룹": "인력",
"원문": "산림노임",
"기관": "산림청",
"": "2026-01-01",
"공표일": "2026-01-06",
"원천": origin(src),
"상세구분": [],
"비고": [
"산림청 2026년 나무의사·수목치료기술자 노임단가 알림(수목진료 표준품셈 예정가격 작성용) · 2025년 소비자물가상승률 2.1% 반영(원문) · 통계 승인 노임 아님",
"판 = 2026년 적용(원문에 적용 시작일 없음 · 1월 1일로 적음)",
@@ -238,29 +257,17 @@ def check_md(src: Path, rows: list[dict], table_head: str | None = None) -> None
print(f" {src.parent.name[5:]}: {len(rows)} 줄 · 값 합 {sum(got.elements()):,} · md 대조 같음")
# ── 준용대상 짝짓기 · 통합 ───────────────────────────────────────────
def merge_list() -> dict[str, list[str]]:
"""건설노임 원문 「통합직종」 · 「직종명칭 변경」 표 — 옛 이름 → 새 이름(여럿이면 갈림)."""
text = (CAK / "2026-09-01/2. 임금적용요령.md").read_text(encoding="utf-8")
out: dict[str, list[str]] = {}
lines = [cells(x) for x in text.splitlines() if x.startswith("| ") and "---" not in x]
for r in lines:
if len(r) != 6 or not r[0].isdigit():
# ── 미확보 짝짓기 ────────────────────────────────────────────────────
def carry(rows: list[dict], kind: str, old: list[dict]) -> None:
"""관리자·근거가 채운 칸(옛이름 · 비고 · 준용)은 다시 지어도 그대로 — 이름으로 맞댐."""
had = {r["이름"]: r for r in old if r.get("구분") == kind}
for r in rows:
keep = had.get(r["이름"])
if not keep:
continue
for no, old, new in ((r[0], r[1], r[2]), (r[3], r[4], r[5])):
if not no.isdigit() or not new:
continue
for name in re.split(r"\+", old.replace("<br>", "").replace(" ", "")):
if name and name != new:
out.setdefault(name, []).append(new)
return out
def candidates(name: str, pool: dict[str, str]) -> list[str]:
"""비슷한 이름 — 새 이름이 옛 이름 뿌리(괄호 앞)와 괄호 안 말을 모두 품음."""
m = re.fullmatch(r"(.+?)\((.+)\)", name)
base, qual = (m.group(1), m.group(2)) if m else (name, "")
return [ref for n, ref in pool.items() if n != name and base in n and qual in n]
for col in ("옛이름", "비고", "준용"):
if keep.get(col) not in (None, [], ""):
r[col] = keep[col]
def main():
@@ -295,12 +302,12 @@ def main():
make = mk.old_view("인력_제조노임.json")
pool = {r["이름"]: f"인력:{b}:{r['열쇠']}" for b, (_, _, rows) in books.items() for r in rows}
old_pool = {r["이름"]: f"인력:{d['원문']}:{r['열쇠']}" for d in (wage, make) for r in d[""]}
merges = merge_list()
now = json.loads((MD / "인력.json").read_text(encoding="utf-8"))[""]
applied = { # 현행 줄이 이미 품은 옛 직종명(`_틀.md` 인력 값 · 옛이름)
name
for r in json.loads((MD / "인력.json").read_text(encoding="utf-8"))[""]
for name in (r.get("옛이름") or [])
name for r in now for name in (r.get("옛이름") or [])
} - { # 갈린 직종(절단공)은 옛이름이면서 미확보로도 남음 — 지우지 않음
r["이름"] for r in now if r.get("구분") == "미확보"
}
alias_path = MD / "인력_준용대상.json"
alias = mk.old_view(alias_path.name)
@@ -311,25 +318,18 @@ def main():
moved[f"인력:준용대상:{r['열쇠']}"] = pool[name]
continue
assert name not in old_pool, name
if name in applied: # 이미 현행 줄 「옛이름」 으로 옮긴 직종 — 준용대상에 다시 만들지 않음
if name in applied: # 이미 현행 줄 「옛이름」 으로 옮긴 직종 — 미확보로 다시 만들지 않음
continue
r = {k: v for k, v in r.items() if k not in ("통합", "후보")}
old = name.replace("스레이트", "슬레이트") # 원문 표기 「슬레이트공」
new = merges.get(old, [])
if [old_pool[n] for n in new if n in old_pool]:
r["후보"] = "재료 갈림: " + " · ".join(new) + " — 관리자가 준용으로 고름"
near = candidates(name, {**pool, **old_pool})
if near and "후보" not in r:
r["후보"] = "비슷한 이름: " + " · ".join(near)
kept.append(r)
alias["비고"] = (
"값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만 · 준용 = 관리자가 고르는 공표 직종 키(`LB000123`)"
" · 후보 = 비슷한 이름(값 안 씀) · 비고 = 이 직종을 요구한 품셈 절"
)
alias["비고"] = [
"값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만"
" · 비고 = 이 직종을 요구한 품셈 절 · 비슷한 이름도 비고에"
]
head = {k: v for k, v in alias.items() if k != ""}
dump_master(alias_path, head, kept)
for book, (src, head, rows) in books.items():
carry(rows, mk.LABOR[book], now)
dump_master(MD / f"인력_{book}.json", head, rows)
# 로직 참조 옮김 — 긴 열쇠부터
@@ -343,10 +343,7 @@ def main():
if new != text:
path.write_bytes(new.encode("utf-8"))
print(
f"준용대상 {len(alias[''])} → 옮김 {len(moved)} {sorted(moved.values())} · 남김 {len(kept)} · 로직 참조 {n_ref}"
)
print(
f" 재료 갈림 {sum(1 for r in kept if '재료 갈림' in str(r.get('후보')))} · 비슷한 이름 {sum(1 for r in kept if '비슷한' in str(r.get('후보')))}"
f"미확보 {len(alias[''])} → 옮김 {len(moved)} {sorted(moved.values())} · 남김 {len(kept)} · 로직 참조 {n_ref}"
)
estimate(wage)
@@ -366,56 +363,27 @@ def estimate(wage: dict) -> None:
until = "2026.1.1"
n = 0
for r in wage[""]:
if r.get("상태") != "미공표":
if r.get("상태") not in ("미공표", "산정"):
continue
vals = hist[r["열쇠"]]
last = max(i for i, v in enumerate(vals) if v != "-" and dates[i] != "2026.9.1")
at, base = dates[last], num(vals[last])
value, used, start = Decimal(base), [], at
value, start = Decimal(base), at
while start != until:
a, b, kinds, top, bottom = next(s for s in steps if s[0] == start)
_, b, _, top, bottom = next(s for s in steps if s[0] == start)
value = value * top / bottom
used.append(f"{top:,}/{bottom:,}({kinds}종)")
start = b
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
want = shown[r["열쇠"]]
assert str(value) == want[4].replace(",", ""), (r["열쇠"], value, want[4])
note = []
if want[5] != want[6]:
note.append(
f"다산소프트(ESTX) {want[5]} — 2020.1(123종) → 2020.9 를 127종 평균으로 이음 · 우리는 원문 회차 평균 그대로(123종끼리)"
)
after = vals[dates.index("2026.9.1")]
if after != "-":
note.append(f"2026.9.1 공표 {after} 있음(뒤 시기 · 판 밖)")
r["산정"] = {
"": value,
"근거": f"원문 4-라 · {at} 공표 {base:,} × 전체직종 평균 증가율 " + " × ".join(used),
"마지막공표": at,
**({"비고": " · ".join(note)} if note else {}),
}
r[""] = float(value)
r["상태"] = "산정"
r["비고"] = f"4-라 산정: {at.rsplit('.', 1)[0]} {base:,}× 증가율"
n += 1
notes = wage["비고"]
notes[0] = notes[0].replace("(값은 관리자 판단 — 마스터에 짓지 않음)", "(산정 칸)")
notes[:] = [x for x in notes if not x.startswith(("산정 =", "2026-09-01"))]
notes.append(
"산정 = 미공표 직종의 원문 4-라 산정값(원 미만 처리 안 함 · 소수 둘째 자리) — 엔진 차례 값 → 준용(관리자가 골랐으면) → 통합 → 산정 · 호표 출처에 「산정: …」"
)
notes.append(
"2026-09-01 적용분 확보됨(옛공표 md `2026-09-01/`) — 마스터 판은 2026-01-01 그대로"
)
head = {k: v for k, v in wage.items() if k != ""}
dump_master(MD / "인력_건설노임.json", head, [_plain(r) for r in wage[""]])
dump_master(MD / "인력_건설노임.json", head, wage[""])
print(f"건설노임 미공표 산정 {n} 줄 · 미공표직종_추적.md 5절 값과 같음")
def _plain(r: dict) -> dict:
"""Decimal → JSON 수(소수 둘째 자리 그대로)."""
out = dict(r)
if "산정" in out:
out["산정"] = {k: float(v) if isinstance(v, Decimal) else v for k, v in out["산정"].items()}
return out
if __name__ == "__main__":
main()
+34 -5
View File
@@ -70,7 +70,25 @@ ROW_KEYS = {
"품셈재료": ("", "원문번호", "이름", "규격", "단위", "요구절", "연결"),
}
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음
MERGED = {"인력": "조사", "기계": "세부분류"}
MERGED = {"인력": "구분", "기계": "세부분류"}
# 인력 줄 — 모든 줄이 같은 열 한 벌 · 상태 값
LABOR_COLS = (
"",
"원문번호",
"구분",
"상세구분",
"이름",
"규격",
"단위",
"",
"출처",
"일시간",
"상태",
"옛이름",
"비고",
"준용",
)
LABOR_STATUS = ("공표", "산정", "미공표", "미확보")
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
@@ -168,6 +186,8 @@ def check_form(name: str, data: dict) -> list[str]:
out.append(f"{where} · 칸 「{need}」 없음")
if "출처" in row and not _source_ok(row["출처"]):
out.append(f"{where} · 출처 모양 「{row['출처']}")
if data.get("그룹") == "인력":
out += _check_labor(where, row)
if kind == "품셈재료":
out += _check_linked(where, row)
if kind == "요소" and isinstance(row.get(""), dict):
@@ -181,6 +201,18 @@ def check_form(name: str, data: dict) -> list[str]:
return out
def _check_labor(where: str, row: dict) -> list[str]:
"""인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록."""
out = []
if tuple(row) != LABOR_COLS:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}")
if row.get("상태") not in LABOR_STATUS:
out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님")
if not isinstance(row.get("옛이름"), list):
out.append(f"{where} · 옛이름 목록 아님 「{row.get('옛이름')}")
return out
def _check_linked(where: str, row: dict) -> list[str]:
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 키 · 후보 조건 · null."""
out = [f"{where} · 값 칸을 두지 않음"] if "" in row else []
@@ -200,7 +232,7 @@ def _check_linked(where: str, row: dict) -> list[str]:
def check_links(whole: mf.Master) -> list[str]:
"""끊긴 키 — 품셈재료 연결 · 시중물가 조달 연결 · 인력 준용·후보가 가리키는 줄."""
"""끊긴 키 — 품셈재료 연결 · 시중물가 조달 연결 · 인력 준용 가리키는 줄."""
out = []
for key, row in whole.index.get("MP", {}).items():
link = row.get("연결")
@@ -211,9 +243,6 @@ def check_links(whole: mf.Master) -> list[str]:
ref = row.get(col)
if ref not in (None, "") and str(ref) not in whole.index.get("LB", {}):
out.append(f"{whole.label(key)} · {col}{ref}」 없는 인력 키")
for ref in mk.KEY.findall(str(row.get("후보") or "")):
if ref not in whole.index.get("LB", {}):
out.append(f"{whole.label(key)} · 후보 「{ref}」 없는 인력 키")
nara = whole.index.get("MN", {})
for key, row in whole.index.get("MT", {}).items():
source = str(row[""]["조달"].get("출처") or "")
@@ -124,7 +124,7 @@ def labor(estx: Estx, files: dict):
ours = {}
for survey, tag in (("건설업", "건설"), ("제조업", "제조")):
for r in files["인력.json"][""]:
if r["조사"] == survey:
if r["구분"] == survey:
ours.setdefault((tag, norm(r["이름"])), r)
theirs = {}
for r in rows:
@@ -260,18 +260,13 @@ class Master:
return f"{row.get('')} {row.get('이름') or row.get('원문번호')}"
def value(self, ref: str):
"""요소 값 · 출처 글 — 값이 비면 준용(관리자가 고른 키) → 산정(원문 4-라) 차례."""
"""요소 값 · 출처 글 — 준용(관리자가 고른 키)이 있으면 그 요소 값 · 없으면 제 값."""
row = self.get(ref)
if row.get("") is not None:
return row.get(""), None
alias = row.get("준용")
if alias not in (None, ""):
target = self.get(str(alias))
return target.get(""), f"준용: {target.get('이름') or alias}"
guess = row.get("산정")
if isinstance(guess, dict) and guess.get("") is not None:
return guess[""], f"산정: 원문 4-라 · 마지막 공표 {guess.get('마지막공표')}"
return None, None
return row.get(""), None
def logic(self, ref: str) -> dict:
if mk.GROUP_OF.get(mk.id_of(ref)) != "로직":
+2 -2
View File
@@ -115,10 +115,10 @@ LABOR = {
"건설사업관리노임": "건설사업관리",
"SW노임": "SW",
"산림노임": "산림",
"준용대상": "준용대상",
"준용대상": "미확보",
}
MERGED = {
"인력": ("인력.json", "조사", LABOR),
"인력": ("인력.json", "구분", LABOR),
"기계": ("기계.json", "세부분류", {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}),
}
DIVISIONS = ("공통", "토목", "건축", "기계설비", "유지관리")