Files
Aislo/resources/master_data/scripts/build_인력_노임.py
T
eomsangdonandClaude Opus 5 819c5019ec knowledge(마스터): 미확보 9 직종을 사용자 지정 추정으로 · 구분 미확보 뺌
- 인력.json — 절단공·사진제판공·사진식자공·측량사·측량기사·시공측량기사·측량조수·시공측량조수·특수운전공 상태 「추정」 · 값은 준용 직종 노임 · 출처는 근거 문서 · 비고 한마디
- 구분 「미확보」 등록부·빌더 짝짓기 없앰 · check_master BOOKS 에 공간정보법
- 근거 md 에 「사용자 지정 추정」 절
- 로직 일괄 시험 계산 통과 1,109 → 1,119 · 멈춤 245 → 235

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-20 20:27:44 +09:00

417 lines
18 KiB
Python

"""노임 공표 원문 md → 인력 요소 파일 다섯 · 건설노임 미공표 산정 · 추정 줄 되채움.
원문 = resources/knowledge/original/원가계산/노임단가_* (우리 md · ESTX 값 쓰지 않음).
- 모든 줄이 같은 열 한 벌(`_틀.md` 인력) · 제조업 업종·엔지니어링 부문·측량 분야는 「상세구분」.
- 인력_엔지니어링노임 · 측량노임 · 건설사업관리노임 · SW노임 · 산림노임 — md 표 그대로 · 줄 수·값을 md 와 기계 대조.
옛이름·비고·준용은 다시 지어도 그대로 이어받음(`carry`).
- 상태 「추정」 줄(옛·폐지 직종) — 관리자가 고른 준용 직종에서 값·단위·일시간을 되채움.
- 인력_건설노임 미공표 — 원문 4-라 산정값을 「값」 에 넣고 상태 「산정」 · 근거는 비고 한 줄.
이음 = 원문 회차의 전체직종 평균을 그대로 이음(미공표직종_추적.md 4절) · 산정값은 같은 md 5절과 대조.
- 인력_제조노임 미공표 — 조사월마다 전체 평균 증가율을 곱하며 원 미만 버림(건설 4-라 방식 준용 · 제조 원문에 규정 없음).
대조는 제조 미공표직종_추적.md 5절 「단계마다 버림(채택)」 열 · 옛 회차 md 는 구분 머리 「원천」(역할 산정근거)에.
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_인력_노임.py
옛 모양(원문별 파일 · 열쇠)으로 쓰고 끝에 `master_keys.adopt` 가 인력.json(키 · 구분)으로 받음.
"""
import hashlib
import json
import re
import sys
from collections import Counter
from decimal import ROUND_FLOOR, Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
from _migrate_base import dump_master # noqa: E402
ROOT = Path(__file__).resolve().parents[3]
MD = ROOT / "resources/master_data"
COST = ROOT / "resources/knowledge/original/원가계산"
CAK = COST / "노임단가_건설업_대한건설협회"
KBIZ = COST / "노임단가_제조업_중소기업중앙회"
WAGE = "원/일"
_INT = re.compile(r"\d{1,3}(?:,\d{3})+")
def num(text: str) -> int:
return int(text.replace(",", ""))
def cells(line: str) -> list[str]:
return [c.strip() for c in line.strip().strip("|").split("|")]
def table_after(text: str, head: str) -> list[list[str]]:
"""`head` 로 시작하는 md 표 줄부터 표 끝까지 — 구분 줄 뺌."""
lines = text.splitlines()
i = next(n for n, x in enumerate(lines) if x.startswith(head))
out = []
for x in lines[i:]:
if not x.startswith("|"):
break
if not set(x) <= set("|-: "):
out.append(cells(x))
return out
def origin(path: Path) -> list[dict]:
return [
{
"경로": path.relative_to(ROOT).as_posix(),
"해시": hashlib.sha256(path.read_bytes()).hexdigest(),
"역할": "primary",
}
]
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 / "노임단가_엔지니어링_한국엔지니어링협회"
src = d / "공표문_2025 엔지니어링업체 임금실태조사결과 공표.md"
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}", detail=p)
for r in t[1:]
for p, v in zip(parts, r[1:])
]
# 같은 게시물 xls(연도별) 2025 칸과 대조
y = table_after(
(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
head = {
"그룹": "인력",
"원문": "엔지니어링노임",
"기관": "한국엔지니어링협회",
"판": "2026-01-01",
"공표일": "2025-12-02",
"원천": origin(src),
"상세구분": details(rows),
"비고": [
"한국엔지니어링협회 2025 엔지니어링업체 임금실태조사결과 공표(기획홍보팀-1515호 · 국가승인통계 제372001호) · 7 기술부문 × 8 기술등급 · 1인 1일 평균임금 · 월평균 근무일수 20.3일",
"이름 = 기술등급(기술부문) · 기타 = 선박·항공우주·금속·화학·광업·농림·산업·해양·수산",
"보고서 표 2 「전체」(기술등급 단순평균)는 공표 노임단가가 아니라 넣지 않음",
],
}
return src, head, rows
def survey():
src = (
COST
/ "노임단가_측량_한국공간정보산업협회/공표문_2025년도 측량업체 임금실태조사 결과 공표.md"
)
t = table_after(src.read_text(encoding="utf-8"), "| 구분 |")
rows, top, field = [], "", ""
for r in t[1:]:
if r[0]:
top, field = r[0], r[1]
elif r[1]:
field = r[1]
grade = r[2]
if top == "기술계":
name = "측량기술사" if grade == "기술사" else f"측량{grade}기술자"
elif top == "기능계":
name = f"{field}{grade}기능사"
else:
name = grade
rows.append(
row(
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일",
"이름 = 기술계 「측량<등급>기술자」·측량기술사 · 기능계 「<분야><등급>기능사」 · 기타 원문 그대로 — 원문 칸은 출처에",
],
}
return src, head, rows
def cm():
src = (
COST
/ "노임단가_건설사업관리_한국건설엔지니어링협회/2026년도_건설사업관리기술인_임금실태조사결과_공표_한국건설엔지니어링협회.md"
)
t = table_after(src.read_text(encoding="utf-8"), "| 구 분 |")
rows = []
for r in t[1:]:
grade = r[0].replace(" ", "")
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 기준 등급별 비율 · 소수 넷째 자리 반올림)",
],
}
return src, head, rows
def sw():
src = (
COST
/ "노임단가_SW기술자_한국인공지능소프트웨어산업협회/2026년_적용_SW기술자_평균임금_공표.md"
)
t = table_after(src.read_text(encoding="utf-8"), "| 구 분 | 월평균임금")
rows = []
for r in t[1:]:
mark, name = r[0].split(" ", 1)
n = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰".index(mark) + 1
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대보험) 모두 포함 — 건설노임(기본급 일급)과 성격 다름",
"일평균 = 월평균 ÷ 20.5일 · 시간평균 = 일평균 ÷ 8시간(원문)",
],
}
return src, head, rows
def forest():
src = COST / "노임단가_산림_산림청/2026년_나무의사_수목치료기술자_노임단가_알림.md"
text = src.read_text(encoding="utf-8")
rows = [
row(name, num(v), f"산림노임 {name}")
for name, v in re.findall(r"(나무의사|수목치료기술자) ([\d,]+)원", text)
]
head = {
"그룹": "인력",
"원문": "산림노임",
"기관": "산림청",
"판": "2026-01-01",
"공표일": "2026-01-06",
"원천": origin(src),
"상세구분": [],
"비고": [
"산림청 2026년 나무의사·수목치료기술자 노임단가 알림(수목진료 표준품셈 예정가격 작성용) · 2025년 소비자물가상승률 2.1% 반영(원문) · 통계 승인 노임 아님",
"판 = 2026년 적용(원문에 적용 시작일 없음 · 1월 1일로 적음)",
],
}
return src, head, rows
def check_md(src: Path, rows: list[dict], table_head: str | None = None) -> None:
"""md 의 그 표(없으면 본문) 안 큰 수 모음 ⊇ 파일 값 · 개수 같음."""
text = src.read_text(encoding="utf-8")
if table_head:
text = "\n".join("|".join(r) for r in table_after(text, table_head))
have = Counter(num(x) for x in _INT.findall(text))
got = Counter(r["값"] for r in rows)
assert not (got - have), (src.name, got - have)
print(f" {src.parent.name[5:]}: {len(rows)} 줄 · 값 합 {sum(got.elements()):,} · md 대조 같음")
# ── 관리자 칸 잇기 ────────────────────────────────────────────────────
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 col in ("옛이름", "비고", "준용"):
if keep.get(col) not in (None, [], ""):
r[col] = keep[col]
def main():
books = {
"엔지니어링노임": engineering(),
"측량노임": survey(),
"건설사업관리노임": cm(),
"SW노임": sw(),
"산림노임": forest(),
}
table_heads = {
"엔지니어링노임": "| 구분 |",
"측량노임": "| 구분 |",
"건설사업관리노임": "| 구 분 |",
"SW노임": "| 구 분 | 월평균임금",
}
print("갈래별 노임")
for book, (src, head, rows) in books.items():
assert len({r["열쇠"] for r in rows}) == len(rows)
check_md(src, rows, table_heads.get(book))
expect = {
"엔지니어링노임": 56,
"측량노임": 20,
"건설사업관리노임": 4,
"SW노임": 17,
"산림노임": 2,
}
assert {b: len(v[2]) for b, v in books.items()} == expect
wage = mk.old_view("인력_건설노임.json")
make = mk.old_view("인력_제조노임.json")
now = json.loads((MD / "인력.json").read_text(encoding="utf-8"))["줄"]
for book, (src, head, rows) in books.items():
carry(rows, mk.LABOR[book], now)
# 상태 「추정」 줄은 갈래 md 밖(옛 직종을 그 구분으로 옮긴 것) — 그대로 이어감
rows += [r for r in mk.old_view(f"인력_{book}.json")["줄"] if r.get("상태") == "추정"]
dump_master(MD / f"인력_{book}.json", head, rows)
estimate(wage)
estimate_make(make)
print("새 틀로 받음", mk.adopt())
refill_estimated()
def refill_estimated() -> None:
"""상태 「추정」 줄 — 값·단위·일시간을 준용 직종에서 다시 채움(공표 회차가 바뀌면 같이 바뀜)."""
path = MD / "인력.json"
data = json.loads(path.read_text(encoding="utf-8"))
by_key = {r["키"]: r for r in data["줄"]}
n = 0
for r in data["줄"]:
if r.get("상태") != "추정":
continue
target = by_key[r["준용"]]
r["값"], r["단위"], r["일시간"] = target["값"], target["단위"], target["일시간"]
n += 1
head = {k: v for k, v in data.items() if k != "줄"}
path.write_text(mk.dump_rows(head, data["줄"]), encoding="utf-8", newline="\n")
print(f"추정 {n} 줄 값 = 준용 직종 값")
# ── 건설노임 미공표 산정 ─────────────────────────────────────────────
def estimate(wage: dict) -> None:
text = (CAK / "미공표직종_추적.md").read_text(encoding="utf-8")
steps = [
(r[0].split(" → ")[0], r[0].split(" → ")[1], r[1], num(r[2]), num(r[3]))
for r in table_after(text, "| 앞 → 뒤 |")[1:]
]
hist = {r[0].split()[0]: r[1:] for r in table_after(text, "| 직종 | 2020.1.1")[1:]}
dates = table_after(text, "| 직종 | 2020.1.1")[0][1:]
shown = {r[0].split()[0]: r for r in table_after(text, "| 직종 | 마지막 공표")[1:]}
until = "2026.1.1"
n = 0
for r in wage["줄"]:
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, start = Decimal(base), at
while start != until: # 회차마다 원 미만 버림 — 다산소프트와 같은 방식
_, b, _, top, bottom = next(s for s in steps if s[0] == start)
value = (value * top / bottom).to_integral_value(rounding=ROUND_FLOOR)
start = b
want = shown[r["열쇠"]]
assert str(value) == want[6].replace(",", ""), (r["열쇠"], value, want[6])
r["값"] = int(value)
r["상태"] = "산정"
r["비고"] = f"4-라 산정: {at.rsplit('.', 1)[0]} {base:,}원 × 증가율(원 미만 버림)"
n += 1
head = {k: v for k, v in wage.items() if k != "줄"}
dump_master(MD / "인력_건설노임.json", head, wage["줄"])
print(f"건설노임 미공표 산정 {n} 줄 · 미공표직종_추적.md 5절 값과 같음")
# ── 제조노임 미공표 산정 ─────────────────────────────────────────────
def estimate_make(make: dict) -> None:
"""제조 미공표 직종 — 조사월마다 평균 증가율을 곱하며 원 미만 버림(건설 4-라 방식 준용).
이음·대조는 제조 미공표직종_추적.md 4절(단계) · 5절(「단계마다 버림(채택)」 열)."""
track = KBIZ / "미공표직종_추적.md"
text = track.read_text(encoding="utf-8")
steps = { # 뒤 조사월 → (분자, 분모)
r[0].split(" → ")[1]: (num(r[1]), num(r[2])) for r in table_after(text, "| 앞 → 뒤 |")[1:]
}
hist = table_after(text, "| 직종 (최근 판 코드) |")
months = hist[0][1:]
vals = {r[0].split()[0]: r[1:] for r in hist[1:]}
shown = {r[0].split()[0]: r for r in table_after(text, "| 직종 | 마지막 공표 조사월 |")[1:]}
n = 0
for r in make["줄"]:
if r.get("상태") not in ("미공표", "산정"):
continue
cells_ = vals[r["열쇠"]] # 원문 표기 그대로(`**` = 공표값 · `*` · `-` = 값 없음)
last = max(i for i, v in enumerate(cells_) if v.replace("*", "").strip() not in ("", "-"))
at, base = months[last], num(cells_[last].replace("*", ""))
value = Decimal(base)
for b in months[last + 1 :]:
top, bottom = steps[b]
value = (value * top / bottom).to_integral_value(rounding=ROUND_FLOOR)
want = shown[r["열쇠"]]
assert str(value) == want[5].replace(",", ""), (r["열쇠"], value, want[5])
r["값"] = int(value)
r["상태"] = "산정"
r["비고"] = (
f"산정: {at} {base:,}원 × 평균 증가율(원 미만 버림)"
" — 건설노임 4-라 방식 준용 · 제조 원문에 규정 없음"
)
n += 1
make["원천"] = [s for s in make["원천"] if s["역할"] == "primary"] + [
{
"경로": p.relative_to(ROOT).as_posix(),
"해시": hashlib.sha256(p.read_bytes()).hexdigest(),
"역할": "산정근거",
}
for p in [*sorted(KBIZ.glob("20??-??-??/3. 직종별 조사노임.md")), track]
]
head = {k: v for k, v in make.items() if k != "줄"}
dump_master(MD / "인력_제조노임.json", head, make["줄"])
print(f"제조노임 미공표 산정 {n} 줄 · 미공표직종_추적.md 5절 버림 열과 같음")
if __name__ == "__main__":
main()