"""노임 공표 원문 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()