knowledge(노임): 제조노임 옛 회차 2020.7 ~ 2026.1 원문 11회 수집 · 회차별 완전 md 화 · 미공표 16 직종 추적표
- 원문 = 중소기업중앙회 「중소제조업 직종별 임금조사 보고서」 2020 상반기 ~ 2025 하반기 (PDF 10 · HWP 1 · 2022 상반기 HWP 병행 · 직종명 대조 HWP 2) — 적용일 폴더별 보관 - 회차마다 전체본 md + 장별 md 4개 · 글자층 쪽별 글자 대조 · 조사노임표 129~130 직종 PDF 단어 좌표 재독 대조 · 회차 사이 같은 조사월 값 대조(숫자 다른 칸 0) · 수식 · 입력화면 그림 쪽 그림 판독 - 조사되지 않은(*) 직종 적용 규정 = 12 회차 원문 어디에도 없음 (기호 뜻만) — 미공표직종_추적.md 1절에 문장 그대로 - 미공표직종_추적.md = 지금 판 값 없는 16 직종 · 마지막 공표값 · 전체 평균 이음 · 산정값(원 미만 처리 없음, 참고 계산) - 직종코드_변천_대조표.md = 2024.10 적용 회차 코드 재정렬(74 직종) 포함 회차별 코드 - 도구 = _pipeline/kbiz_report_md.py · kbiz_verify.py · kbiz_track_unpublished.py - _meta.md 에 공표 기관 · 문서 이름 · 조사 기준월 · 공표일 · 적용일 · 받은 주소 · 받은 날 · 승인번호 - 마스터는 건드리지 않음 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""중소제조업 직종별 임금조사 보고서(PDF) → 전체본 md + 장별 md 4개.
|
||||
|
||||
사용: python kbiz_report_md.py <회차 폴더> [...]
|
||||
회차 폴더 = 원가계산/노임단가_제조업_중소기업중앙회/<적용일>/ (PDF 한 개 · 「…보고서.pdf」)
|
||||
|
||||
- 글은 PDF 글자층의 시각 줄 그대로(줄바꿈 보존). 표는 pymupdf find_tables 칸 그대로 — 병합 칸은 빈 칸.
|
||||
- 가로 2쪽 모아찍기(가로 841×595) PDF 는 좌우 반쪽으로 잘라 한 쪽씩 읽음(글자층 그대로 · 원문 PDF 는 안 건드림).
|
||||
- 쪽마다 인쇄 쪽 번호(「- N -」)를 머리로 붙임.
|
||||
- 검증: 쪽마다 원문 글자층 글자 집합(공백·쪽번호 뺀)과 만들어진 md 글자 집합을 대조(check_md).
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf as fitz
|
||||
|
||||
PAGE_NO = re.compile(r"^-\s*(\d+)\s*-$")
|
||||
CH = [
|
||||
("1. 조사개요", None),
|
||||
("2. 조사결과 요약", re.compile(r"Ⅱ\.\s*조사결과")),
|
||||
(
|
||||
"3. 직종별 조사노임",
|
||||
re.compile(r"제조부문\s*생산직\s*직종별\s*평균\s*조사노임\(일급\)\s*\(단위"),
|
||||
),
|
||||
("4. 직종코드 및 직종명 해설", re.compile(r"【붙임\s*2】")),
|
||||
]
|
||||
|
||||
|
||||
ROUNDS = { # 적용일: (조사 회차, 공표일, 게시글 seq, 조사기준월)
|
||||
"2020-07-01": ("2020년 상반기", "2020-06-30", 148064, "2020-03"),
|
||||
"2021-01-01": ("2020년 하반기", "2020-11-30", 149024, "2020-08"),
|
||||
"2021-07-01": ("2021년 상반기", "2021-06-25", 150311, "2021-03"),
|
||||
"2022-01-01": ("2021년 하반기", "2021-11-26", 151206, "2021-08"),
|
||||
"2022-07-01": ("2022년 상반기", "2022-06-17", 152435, "2022-03"),
|
||||
"2023-01-01": ("2022년 하반기", "2022-11-24", 153306, "2022-08"),
|
||||
"2023-07-01": ("2023년 상반기", "2023-06-26", 154773, "2023-03"),
|
||||
"2024-01-01": ("2023년 하반기", "2023-11-27", 155993, "2023-08"),
|
||||
"2024-10-01": ("2024년", "2024-09-27", 158518, "2024-06"),
|
||||
"2025-07-01": ("2025년 상반기", "2025-06-27", 160550, "2025-03"),
|
||||
"2026-01-01": ("2025년 하반기", "2025-11-28", 161708, "2025-08"),
|
||||
}
|
||||
|
||||
|
||||
def pages_of(pdf_path):
|
||||
"""(쪽 객체 목록, 문서) — 모아찍기면 반쪽으로 잘라 새 문서로."""
|
||||
src = fitz.open(pdf_path)
|
||||
if src[0].rect.width <= src[0].rect.height:
|
||||
return src, src, False
|
||||
out = fitz.open()
|
||||
for i, p in enumerate(src):
|
||||
w, h = p.rect.width, p.rect.height
|
||||
for half in (0, 1):
|
||||
np_ = out.new_page(width=w / 2, height=h)
|
||||
np_.show_pdf_page(
|
||||
np_.rect, src, i, clip=fitz.Rect(half * w / 2, 0, (half + 1) * w / 2, h)
|
||||
)
|
||||
return out, out, True
|
||||
|
||||
|
||||
def _center(r):
|
||||
return fitz.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)
|
||||
|
||||
|
||||
PUA = re.compile("[" + chr(0xE000) + "-" + chr(0xF8FF) + "]+")
|
||||
EQ, SEAL = "[수식]", "[도장기호]"
|
||||
|
||||
|
||||
def clean_pua(txt):
|
||||
"""글자층의 사용자 정의 글꼴 기호(수식 조각 · 도장 기호)는 자리표시로 바꿈 — 내용은 쪽 그림 판독 메모로 옮김."""
|
||||
txt = txt.replace(chr(0xF000), SEAL)
|
||||
return PUA.sub(EQ, txt)
|
||||
|
||||
|
||||
def raw_lines(page):
|
||||
out = []
|
||||
for blk in page.get_text("dict").get("blocks", []):
|
||||
if blk.get("type") != 0:
|
||||
continue
|
||||
for ln in blk.get("lines", []):
|
||||
txt = clean_pua("".join(sp.get("text", "") for sp in ln.get("spans", [])))
|
||||
if txt.strip():
|
||||
out.append((fitz.Rect(ln["bbox"]), txt))
|
||||
return out
|
||||
|
||||
|
||||
def cell_text(c):
|
||||
"""칸 글: 줄바꿈은 공백으로(세로로 한 글자씩 쌓인 칸은 붙임)."""
|
||||
if c is None:
|
||||
return ""
|
||||
ps = [p.strip() for p in str(c).split(chr(10)) if p.strip()]
|
||||
if len(ps) > 1 and all(len(p) <= 2 for p in ps):
|
||||
return "".join(ps)
|
||||
return " ".join(ps)
|
||||
|
||||
|
||||
def esc(s):
|
||||
return s.replace("|", "\\|")
|
||||
|
||||
|
||||
def table_md(rows):
|
||||
w = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (w - len(r)) for r in rows]
|
||||
keep = [j for j in range(w) if any(r[j] for r in rows)] # 통째로 빈 열(선 잡음)은 뺌
|
||||
rows = [[r[j] for j in keep] for r in rows]
|
||||
w = len(keep)
|
||||
o = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "---|" * w]
|
||||
for r in rows[1:]:
|
||||
o.append("| " + " | ".join(esc(c) for c in r) + " |")
|
||||
return o
|
||||
|
||||
|
||||
def page_elems(page):
|
||||
"""세로 순서대로 ('text', y, x, 줄) / ('table', y, x, md줄들, 글조각들)."""
|
||||
try:
|
||||
tabs = list(page.find_tables().tables)
|
||||
except Exception:
|
||||
tabs = []
|
||||
lines = raw_lines(page)
|
||||
final = []
|
||||
for t in tabs:
|
||||
try:
|
||||
rows = [[cell_text(c) for c in r] for r in t.extract()]
|
||||
except Exception:
|
||||
continue
|
||||
rows = [r for r in rows if any(r)]
|
||||
if len(rows) < 2 or max(len(r) for r in rows) < 2:
|
||||
continue
|
||||
b = fitz.Rect(t.bbox)
|
||||
inside = "".join(re.sub(r"\s", "", txt) for r, txt in lines if b.contains(_center(r)))
|
||||
got = "".join(re.sub(r"\s", "", c) for r in rows for c in r)
|
||||
if Counter(inside) != Counter(got):
|
||||
continue # 칸 글이 원문과 다르면 표 취급 취소 → 글 줄로
|
||||
final.append((b, rows))
|
||||
boxes = [b for b, _ in final]
|
||||
items = []
|
||||
for r, txt in lines:
|
||||
if any(b.contains(_center(r)) for b in boxes):
|
||||
continue
|
||||
items.append(("text", r.y0, r.x0, txt.rstrip()))
|
||||
for b, rows in final:
|
||||
items.append(("table", b.y0, b.x0, table_md(rows), [c for r in rows for c in r]))
|
||||
items.sort(key=lambda x: (round(x[1] / 2.5), x[2]))
|
||||
return items
|
||||
|
||||
|
||||
NOTE = "> [쪽 그림 판독] "
|
||||
INPUT_TABLE = [
|
||||
"| 직종코드 | 직종명 | 총인원 소계 | 남 | 여 | 월 정상 근로시간(①) | 인원당 월 근로시간 | 총지급액(②+③) | 기본급(②) | 통상적수당(③) | 평균일급((②+③)/①×8시간) |",
|
||||
"|---|---|---|---|---|---|---|---|---|---|---|",
|
||||
"| 30 | 종이및합성수지인쇄기계조작원 | 2 | 2 | | 416 | 208.0 | 9,650,000 | 6,400,000 | 3,250,000 | 185,577 |",
|
||||
"| 129 | 작업반장 | 5 | 5 | | 1,040 | 208.0 | 15,550,000 | 11,000,000 | 4,550,000 | 119,615 |",
|
||||
"| 106 | 제품검사및조정원 | 4 | | 4 | 832 | 208.0 | 8,357,000 | 6,670,000 | 1,687,000 | 80,356 |",
|
||||
"| 94 | 폐수처리장치조작원 | 1 | 1 | | 208 | 208.0 | 2,800,000 | 2,800,000 | | 107,692 |",
|
||||
"| 128 | 안전관리사 | 1 | 1 | | 208 | 208.0 | 2,800,000 | 2,800,000 | | 107,692 |",
|
||||
"| 125 | 내부특수차운전원 | 1 | 1 | | 208 | 208.0 | 3,200,000 | 3,200,000 | | 123,077 |",
|
||||
"| 126 | 외부특수차운전원 | 1 | 1 | | 208 | 208.0 | 3,400,000 | 3,400,000 | | 130,769 |",
|
||||
"| 127 | 단순노무종사원 | 6 | 2 | 4 | 1,248 | 208.0 | 12,535,500 | 10,005,000 | 2,530,500 | 80,356 |",
|
||||
"| 124 | 제품하역적재원 | 10 | 10 | | 2,080 | 208.0 | 20,892,500 | 16,675,000 | 4,217,500 | 80,356 |",
|
||||
"| | | | | | | | | | | |",
|
||||
]
|
||||
SAMPLE_TABLE = [
|
||||
"| 업 종 | 직종코드 및 직종명 | 2022년 8월 조사노임 | 변동계수 | 2022년 3월 조사노임 | 2021년 8월 조사노임 |",
|
||||
"|---|---|---|---|---|---|",
|
||||
"| 세척 | 27.세척원 | 92,276 | 21.5 | 89,554 | 85,656 |",
|
||||
"| 출판·인쇄 | 28.전자조판원 | **89,509 | 10.7 | **90,988 | **90,628 |",
|
||||
"| | 29.편집교정사 | 129,906 | 33.8 | 129,906 | * |",
|
||||
"| | 30.종이및합성수지인쇄기계조작원 | 112,227 | 9.4 | 112,137 | 112,478 |",
|
||||
"| | 31.인쇄기능원 | 110,092 | 15.6 | 109,328 | 108,312 |",
|
||||
"| | 32.연포장재접합원 | * | * | * | * |",
|
||||
]
|
||||
CV = "변동계수 CV(ȳ) = √var(ŷ) ÷ ȳ × 100"
|
||||
|
||||
|
||||
def page_notes(text, label, has_fig):
|
||||
"""글자층에 없는 수식·그림 내용을 쪽 그림을 보고 옮긴 메모(줄들). label = 적용일."""
|
||||
old = label <= "2022-07-01"
|
||||
out = []
|
||||
if "표본규모 산출공식" in text:
|
||||
if old:
|
||||
out += [
|
||||
NOTE + "수식 — 글자층에 수식 글자가 없어 쪽 그림을 보고 옮김: "
|
||||
"n = (C ÷ C_ȳ)² ÷ [1 + (1 ÷ N) · (C ÷ C_ȳ)²]",
|
||||
">",
|
||||
"> 여기서 n = 업종ㆍ매출액규모별 표본수 · C = S/Ȳ : 모집단 변동계수(평균 급여액) · "
|
||||
"C_ȳ : 목표오차 · N : 업종ㆍ종사자규모별 모집단 업체수",
|
||||
]
|
||||
else:
|
||||
out += [
|
||||
NOTE + "수식 — 글자층에 수식 글자가 없어 쪽 그림을 보고 옮김: "
|
||||
"n_hi = (N_hi · S_hi)² ÷ [D² + (N_hi · S_hi²)] , 단 D = X̂ · E ÷ Z",
|
||||
">",
|
||||
"> 여기서 n_hi : 업종ㆍ매출액규모별 표본수 · N_hi : 업종ㆍ매출액규모별 모집단수 · "
|
||||
"S_hi : 업종ㆍ매출액규모별 평균 급여액의 표준편차 · X̂ : 모총계(평균 급여액) · E : 허용오차 · "
|
||||
"h = 1, …, H : 업종분류를 나타내는 첨자 · i = 1, …, 5 : 매출액 규모를 나타내는 첨자",
|
||||
]
|
||||
if "설계가중치" in text and "표본추출" in text:
|
||||
head = NOTE + "수식 — 글자층에 수식 글자가 없어 쪽 그림을 보고 옮김:"
|
||||
if old:
|
||||
out += [
|
||||
head,
|
||||
"> - 설계가중치 : w_h⁰ = N_h ÷ n_h (h : 층을 나타내는 첨자(h = 1, 2, …, H) · N_h : h층의 모집단 기업체 수 · n_h : h층의 표본 기업체 수)",
|
||||
"> - 평균임금의 추정치 : ȳ = [Σ(h=1..H) Σ(i=1..n_h) w_hi · y_hi] ÷ [Σ(h=1..H) Σ(i=1..n_h) w_hi] (w_hi : h층의 i번째 표본업체의 가중치 · y_hi : h층의 i번째 표본업체로부터 얻은 측정값)",
|
||||
"> - ȳ의 분산추정치 : var(ȳ) = Σ(h=1..H) [n_h(1 − f_n) ÷ (n_h − 1)] · Σ(i=1..n_h) (e_hi − ē_h.)² (e_hi = w_hi(y_hi − ȳ) ÷ w.. , ē_h. = [Σ(i=1..n_h) e_hi] ÷ n_h , w.. = Σ(h=1..H) Σ(i=1..n_h) w_hi)",
|
||||
"> - ȳ의 상대표준오차(CV) : CV^(ȳ) = √var(ŷ) ÷ ȳ × 100",
|
||||
]
|
||||
else:
|
||||
wnote = " (다만, w_hi = 1)" if label >= "2024-01-01" else ""
|
||||
out += [
|
||||
head,
|
||||
"> - 설계가중치 : w_h¹ = N_h ÷ n_h (h : 층을 나타내는 첨자(h = 1, 2, …, H) · N_h : h층의 모집단 기업체 수 · n_h : h층의 표본 기업체 수)",
|
||||
"> - 평균임금의 추정치 : ȳ = [Σ(h=1..H) Σ(i=1..n_h) w_hi · y_hi] ÷ [Σ(h=1..H) Σ(i=1..n_h) w_hi · x_hi] "
|
||||
f"(w_hi : h층의 i번째 표본기업체의 가중치{wnote} · y_hi : h층의 i번째 표본기업체의 직종별 측정값(임금) · x_hi : h층의 i번째 표본기업체의 직종별 종사자수)",
|
||||
"> - ȳ의 분산추정치 : var(ȳ) = Σ(h=1..H) [n_h(1 − f_n) ÷ (n_h − 1)] · Σ(i=1..n_h) e²_hi (e_hi = w_hi(y_hi − ȳ · x_hi) ÷ w.. , w.. = Σ(h=1..H) Σ(i=1..n_h) w_hi)",
|
||||
"> - ȳ의 상대표준오차(CV) : CV^(ȳ) = √var(ŷ) ÷ ȳ × 100",
|
||||
]
|
||||
if re.search(r"변동계수\(" + PUA.pattern, text):
|
||||
out += [NOTE + "수식 — 「변동계수(」 뒤 수식은 글자층에 없어 쪽 그림을 보고 옮김: " + CV]
|
||||
if has_fig and "입력사례" in text:
|
||||
out += [
|
||||
NOTE
|
||||
+ "입력화면 그림(글자층 없음) — 【예시. 비금속광물 제조업체의 생산직 직종 입력현황】 「3. 입력화면」 표를 옮김. "
|
||||
"안내 글: 「※ 직종코드 삭제방법 : 조회화면에서 직종선택을 해제하거나 입력화면에서 직종코드를 지우면 됩니다.」 · [직종 추가] 단추 · (단위:명,원,시간)",
|
||||
"",
|
||||
]
|
||||
out += INPUT_TABLE
|
||||
if has_fig and "특수기호 표기 사례" in text:
|
||||
out += [
|
||||
NOTE
|
||||
+ "「< 특수기호 표기 사례 >」 그림(글자층 없음) — 표를 옮김 (그림 아래쪽은 32번 줄에서 잘려 있음):",
|
||||
"",
|
||||
]
|
||||
out += SAMPLE_TABLE
|
||||
return out
|
||||
|
||||
|
||||
def render_page(page, idx, label):
|
||||
"""→ (md 줄들, 인쇄 쪽 번호|None)."""
|
||||
items = page_elems(page)
|
||||
pn = None
|
||||
body = []
|
||||
for it in items:
|
||||
if it[0] == "text":
|
||||
m = PAGE_NO.match(it[3].strip())
|
||||
if m:
|
||||
pn = int(m.group(1))
|
||||
continue
|
||||
if it[3].strip() == EQ and body and body[-1][0] == "text" and body[-1][3].strip() == EQ:
|
||||
continue # 수식 자리 줄 연속은 하나로
|
||||
body.append(it)
|
||||
if pn is not None:
|
||||
head = f"### 쪽 {pn}"
|
||||
else:
|
||||
head = "### 표지" if idx == 0 else f"### 쪽 번호 없음 (PDF {idx + 1}째 쪽)"
|
||||
out = [head, ""]
|
||||
for it in body:
|
||||
if it[0] == "text":
|
||||
out.append(it[3].strip() + " ")
|
||||
else:
|
||||
out.append("")
|
||||
out += it[3]
|
||||
out.append("")
|
||||
has_fig = any(
|
||||
w > 250 and h > 100 and w < 700
|
||||
for w, h in (
|
||||
(i["bbox"][2] - i["bbox"][0], i["bbox"][3] - i["bbox"][1])
|
||||
for i in page.get_image_info()
|
||||
)
|
||||
)
|
||||
notes = page_notes(page.get_text(), label, has_fig)
|
||||
if notes:
|
||||
out.append("")
|
||||
out += notes
|
||||
out.append("")
|
||||
return out, pn, len(notes)
|
||||
|
||||
|
||||
def norm_chars(s):
|
||||
return re.sub(r"\s", "", s)
|
||||
|
||||
|
||||
def check_md(doc, pages_md):
|
||||
"""쪽마다 원문 글자층(공백·쪽 번호 뺀) 글자 집합 = md 글자 집합(표 표시·머리 뺀)인지."""
|
||||
bad = []
|
||||
for i, md in enumerate(pages_md):
|
||||
raw = "\n".join(ln for ln in doc[i].get_text().split("\n") if not PAGE_NO.match(ln.strip()))
|
||||
raw = PUA.sub("", raw)
|
||||
skip = set(INPUT_TABLE) | set(SAMPLE_TABLE)
|
||||
body = "\n".join(ln for ln in md[2:] if not ln.startswith(">") and ln not in skip)
|
||||
body = body.replace(EQ, "").replace(SEAL, "")
|
||||
body = body.replace("\\|", "\x00")
|
||||
body = "\n".join(
|
||||
"" if re.fullmatch(r"\|(?:---\|)+", ln.strip()) else ln for ln in body.split("\n")
|
||||
)
|
||||
body = body.replace("|", "").replace("<br>", "").replace("\x00", "|")
|
||||
a, b = Counter(norm_chars(raw)), Counter(norm_chars(body))
|
||||
if a != b:
|
||||
bad.append((i + 1, dict(a - b), dict(b - a)))
|
||||
return bad
|
||||
|
||||
|
||||
def convert(folder):
|
||||
folder = Path(folder)
|
||||
rnd = ROUNDS[folder.name]
|
||||
meta = {"공표": rnd[1], "조사": rnd[0]}
|
||||
pdf = next(p for p in folder.glob("*.pdf"))
|
||||
doc, _, twoup = pages_of(pdf)
|
||||
pages, pns = [], []
|
||||
for i in range(len(doc)):
|
||||
o, pn, nn = render_page(doc[i], i, folder.name)
|
||||
pages.append(o)
|
||||
pns.append(pn)
|
||||
bad = check_md(doc, pages)
|
||||
texts = [doc[i].get_text() for i in range(len(doc))]
|
||||
st = [0]
|
||||
for name, rx in CH[1:]:
|
||||
hits = [i for i, t in enumerate(texts) if rx.search(t) and i > st[-1]]
|
||||
if not hits:
|
||||
raise SystemExit(f"{folder.name}: 장 시작을 못 찾음 — {name}")
|
||||
st.append(hits[0])
|
||||
st.append(len(doc))
|
||||
stem = pdf.stem
|
||||
hdr = (
|
||||
f"> 원문: {pdf.name} (중소기업중앙회, 공표 {meta['공표']}, 적용 {folder.name})\n"
|
||||
"> 변환: kbiz_report_md.py — PDF 글자층을 시각 줄 그대로 · 표는 칸 그대로(병합 칸은 빈 칸). "
|
||||
"`*` = 표본 부족 미공표, `**` = 응답업체 5개 미만 · 원문 각주 참조. "
|
||||
"쪽 표제 옆 「1111」 · 「IIII」 같은 겹친 숫자는 원문 표제 그림 글자.\n\n"
|
||||
)
|
||||
title = f"{meta['조사']} 중소제조업 직종별 임금조사 보고서 — {folder.name} 적용"
|
||||
full = (
|
||||
f"# {title}\n\n> 원본: `{pdf.name}` ({len(doc)}쪽{' · 가로 모아찍기를 좌우 반쪽으로 나눔' if twoup else ''})\n\n"
|
||||
+ "\n".join("\n".join(p) for p in pages)
|
||||
+ "\n"
|
||||
)
|
||||
(folder / f"{stem}.md").write_text(full, encoding="utf-8")
|
||||
for k, (name, _) in enumerate(CH):
|
||||
a, b = st[k], st[k + 1]
|
||||
body = "\n".join("\n".join(pages[i]) for i in range(a, b))
|
||||
(folder / f"{name}.md").write_text(
|
||||
f"# {name.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8"
|
||||
)
|
||||
return bad, st, pns
|
||||
|
||||
|
||||
# ───────────────────────── HWP (pyhwp) ─────────────────────────
|
||||
HWP_CTRL = {
|
||||
"TableControl",
|
||||
"Control",
|
||||
"GShapeObjectControl",
|
||||
"ShapeComponent",
|
||||
"TableBody",
|
||||
"TableCell",
|
||||
"TableCaption",
|
||||
"TextboxParagraphList",
|
||||
"FooterParagraphList",
|
||||
"HeaderParagraphList",
|
||||
}
|
||||
|
||||
|
||||
def hwp_xml(path):
|
||||
"""pyhwp(hwp5proc xml)로 HWP5 → XML 트리. pyhwp 는 환경변수 PYHWP_PATH 폴더에 설치(pip install pyhwp --target ...)."""
|
||||
import os
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
env = dict(os.environ)
|
||||
if os.environ.get("PYHWP_PATH"):
|
||||
env["PYTHONPATH"] = os.environ["PYHWP_PATH"]
|
||||
code = (
|
||||
"import sys;from hwp5.hwp5proc import main;sys.argv=['hwp5proc','xml',sys.argv[1]];main()"
|
||||
)
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-c", code, str(path)], env=env, capture_output=True, check=True
|
||||
)
|
||||
root = ET.fromstring(r.stdout)
|
||||
for e, sc in zip(root.iter("EqEdit"), hwp_eq_scripts(path)):
|
||||
e.set("script", sc)
|
||||
return root
|
||||
|
||||
|
||||
def hwp_eq_scripts(path):
|
||||
"""HWP 본문 레코드의 수식(EQEDIT 0x58) 대본 — pyhwp XML 에는 비어 있음. 문서 순서."""
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
import olefile
|
||||
|
||||
ole = olefile.OleFileIO(str(path))
|
||||
comp = bool(struct.unpack("<I", ole.openstream("FileHeader").read()[36:40])[0] & 1)
|
||||
out = []
|
||||
names = sorted(
|
||||
(x for x in ole.listdir() if x[0] == "BodyText"), key=lambda n: int(re.sub(r"\D", "", n[1]))
|
||||
)
|
||||
for n in names:
|
||||
data = ole.openstream(n).read()
|
||||
if comp:
|
||||
data = zlib.decompress(data, -15)
|
||||
i = 0
|
||||
while i < len(data):
|
||||
h = struct.unpack_from("<I", data, i)[0]
|
||||
tag, size = h & 0x3FF, h >> 20
|
||||
i += 4
|
||||
if size == 0xFFF:
|
||||
size = struct.unpack_from("<I", data, i)[0]
|
||||
i += 4
|
||||
pl = data[i : i + size]
|
||||
i += size
|
||||
if tag == 0x58:
|
||||
ln = struct.unpack_from("<H", pl, 4)[0]
|
||||
out.append(pl[6 : 6 + ln * 2].decode("utf-16le"))
|
||||
return out
|
||||
|
||||
|
||||
def hwp_own(p):
|
||||
"""문단 자기 글(표·상자 안 글은 뺌)."""
|
||||
out = []
|
||||
|
||||
def rec(e):
|
||||
for c in e:
|
||||
if c.tag == "Text":
|
||||
out.append(c.text or "")
|
||||
elif c.tag == "ControlChar" and c.get("name") == "TAB":
|
||||
out.append(" ")
|
||||
elif c.tag in HWP_CTRL:
|
||||
if c.tag in ("Control", "GShapeObjectControl", "ShapeComponent"):
|
||||
for q in c.iter("EqEdit"):
|
||||
out.append("[수식: " + " ".join(q.get("script", "").split()) + "]")
|
||||
continue
|
||||
elif c.tag == "EqEdit":
|
||||
out.append("[수식: " + " ".join(c.get("script", "").split()) + "]")
|
||||
else:
|
||||
rec(c)
|
||||
|
||||
rec(p)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def hwp_cell_text(cell):
|
||||
ps = [hwp_own(p).strip() for p in cell.findall("Paragraph")]
|
||||
ps = [x for x in ps if x]
|
||||
for p in cell.iter("Paragraph"): # 칸 안 표·상자의 글
|
||||
pass
|
||||
return " ".join(ps)
|
||||
|
||||
|
||||
def hwp_table_rows(tc):
|
||||
tb = tc.find("TableBody")
|
||||
cells = []
|
||||
for row in tb.findall("TableRow"):
|
||||
for c in row.findall("TableCell"):
|
||||
cells.append(
|
||||
(
|
||||
int(c.get("row")),
|
||||
int(c.get("col")),
|
||||
int(c.get("rowspan")),
|
||||
int(c.get("colspan")),
|
||||
hwp_cell_text(c),
|
||||
)
|
||||
)
|
||||
nr = max(r + rs for r, c, rs, cs, t in cells)
|
||||
nc = max(c + cs for r, c, rs, cs, t in cells)
|
||||
grid = [[""] * nc for _ in range(nr)]
|
||||
for r, c, rs, cs, t in cells:
|
||||
grid[r][c] = t
|
||||
return grid
|
||||
|
||||
|
||||
def hwp_blocks(root):
|
||||
"""본문 순서대로 [('text', 줄) | ('table', 행렬) | ('page', None)]."""
|
||||
out = []
|
||||
for cs in root.iter("ColumnSet"):
|
||||
for p in cs:
|
||||
if p.tag != "Paragraph":
|
||||
continue
|
||||
if p.get("new-page") == "1":
|
||||
out.append(("page", None))
|
||||
t = hwp_own(p)
|
||||
if t.strip():
|
||||
out.append(("text", t.rstrip()))
|
||||
for box in p.iter("TextboxParagraphList"):
|
||||
for bp in box.findall("Paragraph"):
|
||||
bt = hwp_own(bp)
|
||||
if bt.strip():
|
||||
out.append(("text", bt.rstrip()))
|
||||
for tc in p.iter("TableControl"):
|
||||
grid = hwp_table_rows(tc)
|
||||
if grid and any(any(r) for r in grid):
|
||||
out.append(("table", grid))
|
||||
return out
|
||||
|
||||
|
||||
def hwp_md_lines(blocks):
|
||||
"""블록 → md 줄들. 한 줄짜리 표는 글 줄로, 여러 줄 표는 md 표로."""
|
||||
out = []
|
||||
for kind, v in blocks:
|
||||
if kind == "page":
|
||||
continue
|
||||
if kind == "text":
|
||||
out.append(v.strip() + " ")
|
||||
continue
|
||||
rows = [r for r in v if any(r)]
|
||||
if len(rows) == 1:
|
||||
out.append(" ".join(c for c in rows[0] if c) + " ")
|
||||
elif max(len(r) for r in rows) == 1:
|
||||
out += [r[0] + " " for r in rows]
|
||||
else:
|
||||
out.append("")
|
||||
out += table_md(rows)
|
||||
out.append("")
|
||||
return out
|
||||
|
||||
|
||||
def convert_hwp(folder, write=True):
|
||||
"""HWP 보고서 → 전체본 md + 장별 md 4개. HWP 에는 인쇄 쪽 구분이 없어 쪽 표기 없음."""
|
||||
folder = Path(folder)
|
||||
rnd = ROUNDS[folder.name]
|
||||
meta = {"공표": rnd[1], "조사": rnd[0]}
|
||||
hwp = next(p for p in folder.glob("*_중소기업중앙회.hwp") if "이전_직종명" not in p.name)
|
||||
blocks = hwp_blocks(hwp_xml(hwp))
|
||||
i2 = next(
|
||||
i
|
||||
for i, b in enumerate(blocks)
|
||||
if b[0] == "text" and re.match(r"Ⅱ\.\s*조사결과", b[1].strip())
|
||||
)
|
||||
i3 = next(
|
||||
i
|
||||
for i, b in enumerate(blocks)
|
||||
if i > i2 and b[0] == "table" and len(b[1]) == 1 and b[1][0][0].strip() == "4"
|
||||
)
|
||||
i4 = next(
|
||||
i
|
||||
for i, b in enumerate(blocks)
|
||||
if i > i3
|
||||
and b[0] == "text"
|
||||
and re.match(r"【붙임\s*2】", b[1].strip())
|
||||
and "\t" not in b[1]
|
||||
)
|
||||
st = [0, i2, i3, i4, len(blocks)]
|
||||
stem = hwp.stem
|
||||
hdr = (
|
||||
f"> 원문: {hwp.name} (중소기업중앙회, 공표 {meta['공표']}, 적용 {folder.name})\n"
|
||||
"> 변환: kbiz_report_md.py — HWP 본문·표 칸을 pyhwp 로 직접 읽음(표 병합 칸은 빈 칸) · 수식 개체는 "
|
||||
"HWP 수식 대본을 「[수식: …]」 로 옮김. HWP 에는 인쇄 쪽 구분이 없어 쪽 표기 없음 · 원문 PDF 없음"
|
||||
"(쪽 그림 대조 불가) — 표 값은 다른 회차 PDF 와 기계 대조(kbiz_verify.py). "
|
||||
"`*` = 표본 부족 미공표, `**` = 응답업체 5개 미만 · 원문 각주 참조.\n\n"
|
||||
)
|
||||
title = f"{meta['조사']} 중소제조업 직종별 임금조사 보고서 — {folder.name} 적용"
|
||||
full = f"# {title}\n\n> 원본: `{hwp.name}` (HWP)\n\n" + "\n".join(hwp_md_lines(blocks)) + "\n"
|
||||
parts = {}
|
||||
for k, (name, _) in enumerate(CH):
|
||||
head = f"# {name.split('. ', 1)[1]}\n\n{hdr}"
|
||||
parts[name] = head + "\n".join(hwp_md_lines(blocks[st[k] : st[k + 1]])) + "\n"
|
||||
if write:
|
||||
(folder / f"{stem}.md").write_text(full, encoding="utf-8")
|
||||
for name, body in parts.items():
|
||||
(folder / f"{name}.md").write_text(body, encoding="utf-8")
|
||||
return st, len(blocks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for f in sys.argv[1:]:
|
||||
if list(Path(f).glob("*.pdf")):
|
||||
bad, st, pns = convert(f)
|
||||
print(f, "글자대조", "이상없음" if not bad else bad, "장 시작(0기준 PDF쪽)", st[:-1])
|
||||
else:
|
||||
st, n = convert_hwp(f)
|
||||
print(f, "HWP 블록", n, "장 시작(블록)", st[:-1])
|
||||
@@ -0,0 +1,220 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""제조노임 미공표 직종 추적표 · 직종코드 변천표 생성.
|
||||
|
||||
입력: 노임단가_제조업_중소기업중앙회/<적용일>/ 회차 md 의 「3. 직종별 조사노임」 표(값은 md 에서만 읽음) + 기본 폴더(2026-07-01).
|
||||
출력: 노임단가_제조업_중소기업중앙회/미공표직종_추적.md · 직종코드_변천_대조표.md
|
||||
사용: python kbiz_track_unpublished.py
|
||||
- 미공표 직종 = 가장 최근 회차(2026-07-01 적용)에서 조사노임 칸이 「*」 인 직종.
|
||||
- 산정값 = 마지막 공표값 × (최근 조사월 전체평균 ÷ 그 공표 조사월 전체평균) — 공표 조사월 단계별 증가율을 이어 곱한 값과 같음.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from kbiz_verify import BASE, load_rounds
|
||||
|
||||
TARGET_ROUND = "2026-07-01"
|
||||
MONTH_KEY = lambda s: (int(s.split(".")[0]), int(s.split(".")[1])) # noqa: E731
|
||||
|
||||
|
||||
def num(s):
|
||||
return int(s.replace("*", "").replace(",", ""))
|
||||
|
||||
|
||||
def series(rs):
|
||||
"""(직종명 → {조사월: 값문자}) · 조사월 순서 · 조사월 → 전체평균 문자 · 조사월 → (전체평균을 실은 회차들)."""
|
||||
val, avg, avg_src = {}, {}, {}
|
||||
own = {}
|
||||
for lab, r in rs.items():
|
||||
for name, (code, vs) in r["rows"].items():
|
||||
for k, (m, v) in enumerate(zip(r["months"], vs)):
|
||||
if k == 0:
|
||||
own[(name, m)] = v # 그 조사월을 처음 낸 회차의 값(표기 그대로)
|
||||
val.setdefault(name, {}).setdefault(m, v)
|
||||
a = [x for x in (r["avg"] or []) if x != "-"]
|
||||
for m, x in zip(r["months"], a):
|
||||
avg.setdefault(m, x)
|
||||
avg_src.setdefault(m, []).append((lab, x))
|
||||
for (name, m), v in own.items():
|
||||
val[name][m] = v
|
||||
months = sorted({m for n in val for m in val[n]}, key=MONTH_KEY)
|
||||
return val, months, avg, avg_src
|
||||
|
||||
|
||||
def mlabel(m):
|
||||
y, mm = m.split(".")
|
||||
return f"{y}.{int(mm)}"
|
||||
|
||||
|
||||
def build_track(rs):
|
||||
val, months, avg, avg_src = series(rs)
|
||||
last = rs[TARGET_ROUND]
|
||||
latest_month = last["months"][0]
|
||||
un = [n for n, (c, v) in last["rows"].items() if v[0].replace("*", "").strip() in ("", "-")]
|
||||
un.sort(key=lambda n: last["rows"][n][0])
|
||||
lines = []
|
||||
A = lines.append
|
||||
A("# 제조노임 미공표 직종 추적 — 마지막 공표값 · 전체 평균 증가율 이음 (참고 계산)\n")
|
||||
A(
|
||||
"> 지식DB 근거 문서 — 값을 확정하지 않음. 원 미만 처리 · 어느 시기 값으로 이을지는 사용자 협의로 정함. "
|
||||
"마스터(`resources/master_data`)에는 반영하지 않음.\n"
|
||||
"> 생성: `original/_pipeline/kbiz_track_unpublished.py` (회차 md 에서 값을 읽음)."
|
||||
)
|
||||
A("\n## 1. 원문 규칙\n")
|
||||
A(
|
||||
"- **중소기업중앙회 「중소제조업 직종별 임금조사 보고서」 에는 조사되지 않은(`*`) 직종의 노임을 어떻게 적용하라는 규정이 없음.** "
|
||||
"2020 상반기 ~ 2026 상반기 12 회차 원문(`<적용일>/2. 조사결과 요약.md` 「조사노임 적용시 유의사항」 · `3. 직종별 조사노임.md` 표 밑 「주」)을 모두 찾아 확인함."
|
||||
)
|
||||
A("- 원문에 있는 것은 기호 뜻 뿐 (문장 그대로):")
|
||||
A(
|
||||
" - 2020.7 ~ 2023.7 적용 회차 「조사노임 적용시 유의사항」: 「‘*’ 표시는 조사업체가 3개 미만이거나 표본수가 적고 변동계수가 커 신뢰성을 확보하기 어려운 일부 직종으로 이번 조사결과 공표에서는 제외하며, ‘**’ 표시는 5개 미만인 직종으로 활용시 주의 필요함」"
|
||||
)
|
||||
A(
|
||||
" - 2024.1 ~ 2026.7 적용 회차 「특수기호의 정의」: 「‘*’ 표시는 조사업체가 3개 미만이거나 표본수가 적은 경우, 또는 변동계수가 커 신뢰성을 확보하기 어려운 직종으로 ‘공표 제외’」 · 「‘**’ 표시는 응답기업 5개 미만인 직종으로 활용시 주의가 필요함」"
|
||||
)
|
||||
A(
|
||||
" - 조사노임 표 밑 「주2」 (전 회차): 「‘-’표시는 조사되지 않음, ‘*’표시는 조사업체가 3개 미만이거나 표본수가 적고 변동계수가 커지는 등 신뢰성을 확인하기 어려운 직종, ‘**’표시는 5개 미만을 나타내는 것으로 활용시 주의 필요함」"
|
||||
)
|
||||
A(
|
||||
" - 「주3」: 「(5~7)식품가공원, (20~21)일반화학원, (23~25)배합원, (125~126)특수차운전원의 통합단가는 2019년까지만 발표하고 2020년부터는 발표하지 아니함」"
|
||||
)
|
||||
A(
|
||||
"- 건설업 임금실태조사(대한건설협회) 임금적용요령 4-라 의 「전후 시기 값 × 전체 평균 증가율」 같은 규정은 이 보고서에 **없음**. "
|
||||
"아래 산정값은 건설 쪽 방식을 같은 모양으로 대입해 본 **참고 계산**이며 원문 근거가 없음 — 채택 여부는 사용자 협의."
|
||||
)
|
||||
A(
|
||||
"- 산정식(마지막 공표 조사월 L → "
|
||||
f"{mlabel(latest_month)}): **산정값 = L 의 공표값 × Π(조사월마다 전체 평균 ÷ 앞 조사월 평균)** = "
|
||||
f"{mlabel(latest_month)} 전체 평균 ÷ L 전체 평균 (가운데 항이 지워짐). 「원 미만 처리」 규정 없음 → 처리 없이 소수 둘째 자리까지 적음."
|
||||
)
|
||||
A(
|
||||
"- 「전체 평균 조사노임」 = 「전체평균」 줄(조사노임표 맨 끝) · 조사결과 요약 「전체 조사직종(129개)의 평균 조사노임(일급)」 과 같음. "
|
||||
"원문 주: 각 직종별 평균은 단순평균값, 전체 조사직종의 평균은 가중평균."
|
||||
)
|
||||
A("\n## 2. 조사월별 전체 조사직종 평균 조사노임 (원문 인용)\n")
|
||||
A("| 조사월 | 전체 평균(원) | 실린 회차(적용일) | 회차 사이 일치 |")
|
||||
A("|---|---|---|---|")
|
||||
for m in months:
|
||||
srcs = avg_src.get(m, [])
|
||||
same = len({x for _, x in srcs}) == 1
|
||||
A(
|
||||
f"| {mlabel(m)} | {avg[m]} | {' · '.join(l for l, _ in srcs)} | {'같음' if same else '다름 ' + str(srcs)} |"
|
||||
)
|
||||
A(
|
||||
"\n- 표는 각 회차 「3. 직종별 조사노임」 표 맨 끝 「전체평균」 줄(회차마다 조사월 3 칸). "
|
||||
"같은 조사월이 여러 회차에 실려 있어 회차 사이 일치를 확인함. 직종수: 2020.3 ~ 2020.8 조사는 130 직종, 2021.3 조사부터 129 직종(90.자동차정비원 제외) — 이음 계열이 바뀌는 자리에서도 회차 사이 같은 조사월 평균은 같은 값으로 실려 있음."
|
||||
)
|
||||
A(f"\n## 3. {len(un)} 직종 공표 이력 (원문 md 인용 · `*` = 미공표 · `-` = 조사되지 않음)\n")
|
||||
hdr = "| 직종 (최근 판 코드) | " + " | ".join(mlabel(m) for m in months) + " |"
|
||||
A(hdr)
|
||||
A("|---|" + "---|" * len(months))
|
||||
for n in un:
|
||||
code = last["rows"][n][0]
|
||||
A(f"| {code} {n} | " + " | ".join(val[n].get(m, "") for m in months) + " |")
|
||||
A(
|
||||
"\n- 값 출처: 각 조사월을 처음 낸 회차 폴더의 「3. 직종별 조사노임.md」 (표기 `**` 는 그 회차 표기 그대로). 직종 코드는 2024.10 적용 회차에서 재정렬됨 — 「직종코드_변천_대조표.md」 참조. "
|
||||
"같은 조사월 값이 여러 회차에 실려 있으면 숫자는 모두 같음(`**` 표기만 회차에 따라 다를 수 있음) — 회차 사이 대조로 확인함."
|
||||
)
|
||||
A("\n## 4. 이음 단계 (조사월 전체 평균 ÷ 앞 조사월 평균)\n")
|
||||
A("| 앞 → 뒤 | 분자(뒤) | 분모(앞) | 증가율 |")
|
||||
A("|---|---|---|---|")
|
||||
step = {}
|
||||
for a, b in zip(months, months[1:]):
|
||||
r = num(avg[b]) / num(avg[a])
|
||||
step[b] = r
|
||||
A(f"| {mlabel(a)} → {mlabel(b)} | {avg[b]} | {avg[a]} | {r:.6f} |")
|
||||
A(
|
||||
"\n- 조사월 간격: 반기(3월 · 8월 조사) → 2024년은 연 1회(2024.6) → 2025.3 부터 다시 반기. "
|
||||
"원문 연혁: 「2024. 5 작성주기 변경(연2회 → 연1회)」 · 「2025. 1 작성주기 변경(연1회 → 연2회)」. "
|
||||
"이음은 실린 조사월끼리 곱함(2023.8 → 2024.6 은 두 조사월 평균의 비)."
|
||||
)
|
||||
A(f"\n## 5. {mlabel(latest_month)} 조사(2026-07-01 적용) 산정값\n")
|
||||
A("| 직종 | 마지막 공표 조사월 | 그 값(원) | 이어 곱한 증가율 | 산정값(처리 없음) | 비고 |")
|
||||
A("|---|---|---|---|---|---|")
|
||||
rows = []
|
||||
for n in un:
|
||||
code = last["rows"][n][0]
|
||||
pub = [(m, val[n][m]) for m in months if val[n].get(m, "*") not in ("*", "-", "")]
|
||||
m, v = pub[-1]
|
||||
mult = 1.0
|
||||
for k in months[months.index(m) + 1 :]:
|
||||
mult *= step[k]
|
||||
est = num(v) * mult
|
||||
ratio = num(avg[latest_month]) / num(avg[m])
|
||||
assert abs(mult - ratio) < 1e-9
|
||||
note = "값에 `**`(5개 미만) 표기" if "**" in v else ""
|
||||
rows.append((code, n, m, v, mult, est, note))
|
||||
A(f"| {code} {n} | {mlabel(m)} | {v} | {mult:.6f} | {est:,.2f} | {note} |")
|
||||
A(
|
||||
"\n- 「마지막 공표」 는 `**`(응답업체 5개 미만 · 활용시 주의) 표기가 붙은 값도 공표값으로 보아 셈 — `*` · `-` 만 값 없음."
|
||||
"\n- 「그 값」 은 마지막으로 값이 실린 조사월의 공표 표기 그대로(`**` 포함). 산정값은 `**` 를 뗀 숫자에 곱함."
|
||||
"\n- 마지막 공표값이 2020 이전인 직종은 없음 — 전 직종의 마지막 공표값이 2020.8 이후 회차에서 나와 2019 이전 회차는 받지 않음."
|
||||
"\n- 최근 조사월 전체 평균 "
|
||||
f"{avg[latest_month]}원 = 「조사결과 요약」 의 전체 조사직종(129개) 평균 조사노임."
|
||||
)
|
||||
return "\n".join(lines) + "\n", rows, un, months, val
|
||||
|
||||
|
||||
def build_codes(rs):
|
||||
labs = list(rs)
|
||||
names = {}
|
||||
for lab in labs:
|
||||
for n, (c, v) in rs[lab]["rows"].items():
|
||||
names.setdefault(n, {})[lab] = c
|
||||
o = []
|
||||
A = o.append
|
||||
A("# 직종코드 변천·대조표 — 회차별 직종코드 (조사노임표 원문 그대로)\n")
|
||||
A(
|
||||
"> 지식DB 근거 문서. 생성: `original/_pipeline/kbiz_track_unpublished.py` — 각 회차 「3. 직종별 조사노임」 표의 「직종코드 및 직종명」 칸에서 읽음."
|
||||
)
|
||||
A(
|
||||
"\n## 1. 원문에 실린 대조표 (각 회차 `3. 직종별 조사노임.md` 안 「5. 이전 직종코드·직종명으로 새 직종명 찾기」)\n"
|
||||
)
|
||||
A(
|
||||
"- 모든 회차(2020.7 ~ 2026.7 적용)가 「기존 직종코드 및 직종명(~2018)」 → 「새로운 직종코드 및 직종명(2019~)」 표(124 줄)를 싣고 있음. "
|
||||
"2020.7 ~ 2024.1 적용 회차 8 개는 표가 같고, 2024.10 · 2025.7 · 2026.1 적용 회차 3 개는 「새로운 직종코드」 칸이 재정렬 뒤 코드로 바뀐 판(두 판의 다른 줄 144 · 뒤 3 회차끼리는 같음 — 회차 사이 대조로 확인). "
|
||||
"2020.7 · 2021.1 적용 회차 게시글에는 같은 표의 HWP(`이전_직종명으로_새_직종명_찾기.hwp` → 같은 이름 md) 가 첨부돼 있음."
|
||||
)
|
||||
A(
|
||||
"- 이 HWP 첨부의 「34.유리절단및재단원」 줄은 「조사보류」 로 적혀 있고 PDF 본문 표는 「130.유리절단및재단원」 — 원문 그대로 둘 다 옮김."
|
||||
)
|
||||
A("\n## 2. 회차 사이 직종코드가 바뀐 자리\n")
|
||||
prev = None
|
||||
for lab in labs:
|
||||
if prev:
|
||||
ch = [
|
||||
n
|
||||
for n in rs[lab]["rows"]
|
||||
if n in rs[prev]["rows"] and rs[lab]["rows"][n][0] != rs[prev]["rows"][n][0]
|
||||
]
|
||||
drop = [n for n in rs[prev]["rows"] if n not in rs[lab]["rows"]]
|
||||
add = [n for n in rs[lab]["rows"] if n not in rs[prev]["rows"]]
|
||||
if ch or drop or add:
|
||||
A(
|
||||
f"- {prev} → {lab}: 코드 바뀐 직종 {len(ch)} · 빠진 직종 {len(drop)}{(' (' + ', '.join(f'{rs[prev]["rows"][n][0]}.{n}' for n in drop) + ')') if drop else ''} · 새 직종 {len(add)}"
|
||||
)
|
||||
prev = lab
|
||||
A(
|
||||
"- 코드가 바뀐 것은 2024.1 → 2024.10 적용 회차(원문 연혁 「2024. 5 … 조사직종 재정렬」) 한 번 뿐. 재정렬 전 코드 → 뒤 코드는 2024.10 적용 회차부터 1 절의 표에 반영돼 있고, 아래 3절은 회차별 조사노임표에서 이름으로 맞춘 대조."
|
||||
)
|
||||
A("\n## 3. 직종명 × 회차별 코드\n")
|
||||
A("| 직종명 | " + " | ".join(labs) + " |")
|
||||
A("|---|" + "---|" * len(labs))
|
||||
key = lambda n: rs[labs[-1]]["rows"].get(n, rs[labs[0]]["rows"].get(n, (9999,)))[0] # noqa: E731
|
||||
for n in sorted(names, key=key):
|
||||
A(f"| {n} | " + " | ".join(str(names[n].get(l, "")) for l in labs) + " |")
|
||||
A(
|
||||
"\n- 빈 칸 = 그 회차 조사노임표에 없음(90.자동차정비원은 2021.7 적용 회차부터 조사 대상에서 제외 — 원문 각주 「2년 연속 무응답으로 2021년 상반기 조사부터 조사 대상에서 제외」). "
|
||||
"2024.1 적용 회차까지 130.유리절단및재단원 · 2024.10 적용 회차부터 17."
|
||||
)
|
||||
return "\n".join(o) + "\n"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rs = load_rounds()
|
||||
md, rows, un, months, val = build_track(rs)
|
||||
(BASE / "미공표직종_추적.md").write_text(md, encoding="utf-8")
|
||||
(BASE / "직종코드_변천_대조표.md").write_text(build_codes(rs), encoding="utf-8")
|
||||
print("미공표", len(un), "직종 · 조사월", len(months))
|
||||
for r in rows:
|
||||
print(r[0], r[1], r[2], r[3], f"{r[4]:.6f}", f"{r[5]:,.2f}")
|
||||
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""중소제조업 임금조사 회차 md 표 검증 — 「3. 직종별 조사노임.md」 값을 PDF 글자층과 따로 읽어 대조 + 회차 사이 대조.
|
||||
|
||||
사용: python kbiz_verify.py (원가계산/노임단가_제조업_중소기업중앙회/<적용일>/ 전부)
|
||||
- 표 값: md 의 직종 줄(코드 · 이름 · 값 칸들) = PDF 단어 좌표로 다시 읽은 줄(코드 · 이름 · 값들) 인지.
|
||||
- 회차 사이: 같은 조사월 값(직종별)이 다른 회차 보고서 칸에도 실려 있으면 같은지.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf as fitz
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from kbiz_report_md import pages_of # noqa: E402
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent / "원가계산" / "노임단가_제조업_중소기업중앙회"
|
||||
CODE = re.compile(r"^(\d{1,3})\.")
|
||||
VAL = re.compile(r"^[ ]*\*{0,2}[\d,]+(?:\.\d+)?\**[ ]*$|^[ ]*\*{1,2}[ ]*$|^[ ]*-[ ]*$|^ $")
|
||||
|
||||
|
||||
def md_rows(md_path):
|
||||
"""조사노임표(머리에 「업 종」 · 「조사노임」) 의 직종 줄 [(코드, 이름, [값 칸들])]."""
|
||||
rows, blocks, cur = [], [], []
|
||||
for ln in Path(md_path).read_text(encoding="utf-8").split(chr(10)):
|
||||
if ln.startswith("|"):
|
||||
cur.append(ln)
|
||||
elif cur:
|
||||
blocks.append(cur)
|
||||
cur = []
|
||||
if cur:
|
||||
blocks.append(cur)
|
||||
for blk in blocks:
|
||||
head = " ".join(blk[:2])
|
||||
if not ("업 종" in head and "조사노임" in head):
|
||||
continue
|
||||
for ln in blk[2:]:
|
||||
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
|
||||
for i, c in enumerate(cells):
|
||||
m = CODE.match(c)
|
||||
if m and i <= 2:
|
||||
rows.append((int(m.group(1)), c, cells[i + 1 :]))
|
||||
break
|
||||
return rows
|
||||
|
||||
|
||||
def pdf_rows(pdf):
|
||||
doc, _, _ = pages_of(pdf)
|
||||
out = []
|
||||
for p in doc:
|
||||
ws = [(w[0], w[1], w[2], w[3], w[4]) for w in p.get_text("words")]
|
||||
for x0, y0, x1, y1, w in ws:
|
||||
if not (CODE.match(w) and x0 < 300):
|
||||
continue
|
||||
yc = (y0 + y1) / 2
|
||||
toks = sorted(
|
||||
(t for t in ws if abs((t[1] + t[3]) / 2 - yc) < 5 and t[0] >= x0),
|
||||
key=lambda t: t[0],
|
||||
)
|
||||
name, j = toks[0][4], 1
|
||||
while j < len(toks) and not VAL.match(toks[j][4]):
|
||||
name += toks[j][4]
|
||||
j += 1
|
||||
vals = [t[4] for t in toks[j:]]
|
||||
if vals and all(VAL.match(v) for v in vals):
|
||||
out.append((int(CODE.match(name).group(1)), name, vals))
|
||||
return out
|
||||
|
||||
|
||||
def wage_pages_rows(rows):
|
||||
"""값이 3~5칸인 줄만(=조사노임표 · 다른 표의 코드 줄 제외)."""
|
||||
return [(c, n, v) for c, n, v in rows if all(VAL.match(x) or x == "" for x in v)]
|
||||
|
||||
|
||||
def check_round(folder):
|
||||
pdf = next(folder.glob("*.pdf"))
|
||||
a = md_rows(folder / "3. 직종별 조사노임.md")
|
||||
b = pdf_rows(pdf)
|
||||
clean = lambda v: [x.replace(" ", "").strip() for x in v if x.replace(" ", "").strip()]
|
||||
fa = [(c, n.replace(" ", ""), clean(v)) for c, n, v in a]
|
||||
fb = [(c, n.replace(" ", ""), clean(v)) for c, n, v in b]
|
||||
# 조사노임표는 쪽 18~ 이므로 md 표 줄 수 = 직종수. PDF 쪽 전체에서 잡힌 줄 중 표 줄만 남기려면 md 코드+이름으로 맞춤
|
||||
keyb = {(c, n): v for c, n, v in fb}
|
||||
bad = []
|
||||
for c, n, v in fa:
|
||||
pv = keyb.get((c, n))
|
||||
if pv is None:
|
||||
bad.append(("PDF 에 없음", c, n))
|
||||
elif pv != v:
|
||||
bad.append(("값 다름", c, n, v, pv))
|
||||
return len(fa), bad
|
||||
|
||||
|
||||
MONTH = re.compile(r"(\d{4})\s*[년.]\s*(\d{1,2})\s*월")
|
||||
|
||||
|
||||
def load_rounds():
|
||||
"""{적용일: {'months': [조사월 3개], 'rows': {직종명: (코드, [값 3개])}, 'avg': [전체평균 3개]}} — 2026-07-01 은 기본 폴더."""
|
||||
res = {}
|
||||
dirs = [
|
||||
(d.name, d) for d in sorted(BASE.iterdir()) if d.is_dir() and re.match(r"\d{4}-", d.name)
|
||||
]
|
||||
dirs.append(("2026-07-01", BASE))
|
||||
for label, d in dirs:
|
||||
md = d / "3. 직종별 조사노임.md"
|
||||
lines = md.read_text(encoding="utf-8").split(chr(10))
|
||||
months = None
|
||||
blocks, cur = [], []
|
||||
for ln in lines:
|
||||
if ln.startswith("|"):
|
||||
cur.append(ln)
|
||||
elif cur:
|
||||
blocks.append(cur)
|
||||
cur = []
|
||||
if cur:
|
||||
blocks.append(cur)
|
||||
rows, avg = {}, None
|
||||
for blk in blocks:
|
||||
head = " ".join(blk[:3])
|
||||
if not ("업 종" in head and "조사노임" in head):
|
||||
continue
|
||||
hc = [c.strip() for c in blk[0].strip().strip("|").split("|")]
|
||||
ms = [MONTH.search(c) for c in hc]
|
||||
ms = [f"{m.group(1)}.{int(m.group(2))}" for m in ms if m]
|
||||
months = months or ms
|
||||
for ln in blk[2:]:
|
||||
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
|
||||
if cells and cells[0].startswith("전체평균"):
|
||||
avg = [c for c in cells[1:] if c]
|
||||
for i, c in enumerate(cells):
|
||||
m = CODE.match(c)
|
||||
if m and i <= 2:
|
||||
v = cells[i + 1 :]
|
||||
# 값 칸: 조사노임 · 변동계수 · 앞 조사 · 앞앞 조사 → 변동계수 뺌
|
||||
if len(v) == 4:
|
||||
v = [v[0], v[2], v[3]]
|
||||
rows[c.split(".", 1)[1].replace(" ", "")] = (int(m.group(1)), v)
|
||||
break
|
||||
res[label] = {"months": months, "rows": rows, "avg": avg}
|
||||
return res
|
||||
|
||||
|
||||
def cross_check():
|
||||
rs = load_rounds()
|
||||
seen = {}
|
||||
diffs = []
|
||||
for label, r in rs.items():
|
||||
for name, (code, vals) in r["rows"].items():
|
||||
for mth, v in zip(r["months"], vals):
|
||||
k = (name, mth)
|
||||
if k in seen and seen[k][1] != v:
|
||||
diffs.append((name, mth, seen[k], (label, v)))
|
||||
seen.setdefault(k, (label, v))
|
||||
return rs, diffs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cross":
|
||||
rs, diffs = cross_check()
|
||||
for label, r in rs.items():
|
||||
print(label, r["months"], "직종", len(r["rows"]), "전체평균", r["avg"])
|
||||
print("회차 사이 다른 칸", len(diffs))
|
||||
for d in diffs[:60]:
|
||||
print(" ", d)
|
||||
sys.exit()
|
||||
for d in sorted(p for p in BASE.iterdir() if p.is_dir() and re.match(r"\d{4}-", p.name)):
|
||||
if not list(d.glob("*.pdf")):
|
||||
continue
|
||||
n, bad = check_round(d)
|
||||
print(d.name, "직종 줄", n, "이상", len(bad), bad[:4])
|
||||
Reference in New Issue
Block a user