- 원문 = 중소기업중앙회 「중소제조업 직종별 임금조사 보고서」 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
173 lines
6.6 KiB
Python
173 lines
6.6 KiB
Python
# -*- 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])
|