Files
Aislo/resources/knowledge/original/_pipeline/kbiz_verify.py
T
eomsangdonandClaude Sonnet 5 ebc8f470e0 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
2026-09-20 18:06:13 +09:00

173 lines
6.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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])