diff --git a/resources/tester/fixtures/work_item_master_source_known.json b/resources/tester/fixtures/work_item_master_source_known.json index 1bb23e08..0e6fdd06 100644 --- a/resources/tester/fixtures/work_item_master_source_known.json +++ b/resources/tester/fixtures/work_item_master_source_known.json @@ -7,7 +7,10 @@ "basis: md=원문에서 읽은 밑수 [수, 단위] · master=마스터 밑수 · 둘 중 하나가 null 이면 한쪽만 있음.", "notes: md_only=원문 [주]에 있는데 마스터에 없는 줄 · master_only=마스터에만 있는 줄.", "form: 원문 표에 품 표 표지(직종 이름·소요인력·(인)·수량 단위 칸 + 숫자)가 있는데 마스터가 reference·coefficient 로 둔 표 · md_mark=그 표지 칸.", - "basis 의 12-7-1·12-7-2·12-8·12-38-3·13-16-2(F0339·F0340·F0341·F0395·F0449)는 일부러 다름 — 작업조 표라 밑수가 시공량 열(c3da333e)." + "basis 의 12-7-1·12-7-2·12-8·12-38-3·13-16-2(F0339·F0340·F0341·F0395·F0449)는 일부러 다름 — 작업조 표라 밑수가 시공량 열(c3da333e).", + "form_blind: 마스터는 품 표(requirement·productivity)인데 원문에서 품 표지를 못 찾은 표 — 시험이 성격 뒤집힘을 못 잡는 자리.", + "cost_rule: 첫 칸이 모두 비목(재료비·설치비 …)인 비목 구성표 — 모양은 잡히나 참조냐 품이냐는 원문으로 못 가림 · 사람이 봐야 함.", + "why: 사람이 적은 까닭 — 시험은 맞대기에서 뺌." ], "place": { "F0452": { @@ -539,6 +542,12 @@ "master": "reference", "md_mark": "적용시공량" }, + "F0227": { + "section": "8-6-1", + "master": "reference", + "md_mark": "유효 살포량/1회", + "why": "사람 판정 reference(Forms.py) — 기종별 희석배수별 1회 살포량 계산표" + }, "F0257": { "section": "9-11-1", "master": "coefficient", @@ -574,10 +583,184 @@ "master": "reference", "md_mark": "매" }, + "F0410": { + "section": "13-4-4", + "master": "reference", + "md_mark": "야면석(㎥) 호박돌(㎥)", + "why": "사람 판정 reference(Forms.py) — 채움 콘크리트 소요량 자재 원단위표, B08 채움표가 씀(품이면 두 번 셈) · 안티그래비티 2바퀴는 requirement 주장" + }, "F0454": { "section": "14-2", "master": "reference", "md_mark": "작업량" } + }, + "form_blind": { + "F0046": { + "section": "2-1-1", + "master": "requirement", + "why": "기계별 연료표(천공기 주연료 3ℓ·잡품 95%) — 품 낱말 없음. 같은 모양 F0044·F0045·F0047 은 사람 판정 reference 인데 이것만 requirement" + }, + "F0062": { + "section": "2-1-12", + "master": "requirement", + "why": "값 칸이 「층적부피에 따라 별도계산」·「〃」 — 숫자 없음" + }, + "F0140": { + "section": "5-26-1", + "master": "requirement", + "why": "머리 소실 — 토성×깊이 숫자만 남음(사람 판정: 100㎡당 보통인부)" + }, + "F0141": { + "section": "5-26-2", + "master": "requirement", + "why": "머리 소실 — 위와 같음" + }, + "F0142": { + "section": "5-26-3", + "master": "requirement", + "why": "머리 소실 — 위와 같음" + }, + "F0195": { + "section": "7-14", + "master": "productivity", + "why": "칸이 뭉침(「㎥ 0.06∼0.07 4.8∼6.0」) — 원문 md 가 병합 칸을 못 풂" + }, + "F0237": { + "section": "8-11", + "master": "productivity", + "why": "작업량이 식 글(「Q = 3.5 ㎥/hr」)" + }, + "F0333": { + "section": "12-1-3", + "master": "requirement", + "why": "⚠ 원문 md 의심 — 머리는 한중콘크리트 「온도 품종 | 0℃때 …」인데 몸 줄이 앞 표 F0332(시멘트·모래·자갈 kg)와 똑같음 · PDF 대조 필요" + }, + "F0342": { + "section": "12-9-1", + "master": "requirement", + "why": "수량 칸이 비어 있음 — 원문에 값 없음(「반중력식 옹벽 참조」)" + }, + "F0343": { + "section": "12-9-2", + "master": "requirement", + "why": "수량 칸이 비어 있음 — 원문에 값 없음" + }, + "F0344": { + "section": "12-9-3", + "master": "requirement", + "why": "수량 칸이 비어 있음 — 원문에 값 없음" + }, + "F0405": { + "section": "13-4-1", + "master": "requirement", + "why": "증가율(%) 표(높이별 30·40·60%) — 품이 아니라 할증률 모양 · requirement 가 맞는지 사람 확인" + } + }, + "cost_rule": { + "F0369": { + "section": "12-22", + "master": "reference", + "md_rows": [ + [ + "재료비" + ], + [ + "설치비", + "재료비의 5%" + ] + ], + "why": "설치비 = 재료비의 5% — 참조(비율 지시)로 둘지 비율 자원 줄로 세울지는 설계 방식 결정" + }, + "F0379": { + "section": "12-29", + "master": "requirement", + "md_rows": [ + [ + "재료비", + "", + "필요수량" + ], + [ + "설치비", + "재료비의 5%" + ] + ], + "why": "⚠ 같은 꼴 6표는 reference 인데 이것만 requirement — 설치비 = 재료비의 5%" + }, + "F0382": { + "section": "12-31", + "master": "reference", + "md_rows": [ + [ + "재료비" + ], + [ + "설치비", + "주재료비의 5%" + ] + ], + "why": "설치비 = 주재료비의 5% — F0369 와 같은 물음" + }, + "F0383": { + "section": "12-32", + "master": "reference", + "md_rows": [ + [ + "재료비", + "P.V.C ∅54m/m" + ], + [ + "설치비", + "주재료비의 10%" + ] + ], + "why": "설치비 = 주재료비의 10% — F0369 와 같은 물음" + }, + "F0386": { + "section": "12-34-2", + "master": "reference", + "md_rows": [ + [ + "재료비", + "1회 기준 46.1%" + ], + [ + "노무비", + "1회 기준 47.1%" + ] + ], + "why": "재료비·노무비 「1회 기준 46.1%·47.1%」 — 거푸집 사용횟수 비율" + }, + "F0388": { + "section": "12-34-4", + "master": "reference", + "md_rows": [ + [ + "재료비", + "JOINT FILLER" + ] + ], + "why": "재료비 칸에 자재 이름(JOINT FILLER)만 — 값 없음" + }, + "F0390": { + "section": "12-36", + "master": "reference", + "md_rows": [ + [ + "제작비", + "견적처리" + ], + [ + "운송비", + "견적처리" + ], + [ + "설치비", + "견적처리" + ] + ], + "why": "제작비·운송비·설치비 모두 「견적처리」 — 값 없음" + } } } diff --git a/resources/tester/test_work_item_master_source_match.py b/resources/tester/test_work_item_master_source_match.py index 1c59cd8f..5f97b79b 100644 --- a/resources/tester/test_work_item_master_source_match.py +++ b/resources/tester/test_work_item_master_source_match.py @@ -37,6 +37,15 @@ KNOWN = Path(__file__).resolve().parent / "fixtures/work_item_master_source_know TOC_TABLE = "F0001" #: 이 줄 뒤는 부록(할인·할증 공종표 · 적용 공종 · 단가산출서 예시) — 공종 품이 아니라 `orphan_tables` 몫. APPENDIX = "〔부록 1〕" +#: 알려진 목록 갈래. `form_blind`·`cost_rule` 은 어긋남이 아니라 **시험이 못 가리는 자리**를 적어 둔 것. +KIND_NAMES = { + "place": "표 자리", + "basis": "밑수", + "notes": "[주]", + "form": "표 성격", + "form_blind": "표 성격 — 품 표지를 못 알아봄", + "cost_rule": "비목 구성표 — 사람이 봐야 함", +} # ── 원문 읽기 ──────────────────────────────────────────────────────────── _SEP = re.compile(r"\s*\|(\s*:?-+:?\s*\|)+\s*") @@ -168,35 +177,68 @@ _WORKER = re.compile( r"인\s*부|기능공|운전|목\s*공|석\s*공|철근공|콘크리트공|기\s*사|벌목부|작업반장|기술자|" r"용접공|미장공|방수공|배관공|비계공" ) +#: 품 낱말 — 공백을 지운 칸에서 찾음(`주 재 료` → `주재료`). _WORK_MARK = re.compile( - r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|인/|/인|\(시간\)|\(hr\)" + r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|살포량|처리량|" + r"인력구분|인력구성|적용인부|인원\(명\)|^주재료$|인/|/인|\(시간\)|\(hr\)|[가-힣]\((kg|㎏|g|㎥|ℓ)\)" ) _QTY_UNIT = {"인", "매", "개", "본", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "대", "톤", "hr", "시간"} -#: 숫자 칸 — `0.16` · `1.5인` · `0.5ℓ` · 한 칸에 여러 값 `0.04 0.06 0.10`. -_NUMBER = re.compile(r"^\d[\d,]*(\.\d+)?(\s*(인|ℓ|시간|㎥|개|본|매|kg|m|%))?(\s+[-\d,.]+)*$") +#: 숫자 칸 — `0.16` · 범위 `3.3∼5.9` · 단위 붙음 `1.5인`·`10ℓ`·`5인/km`·`2.18Roll` · +#: 한 칸에 여러 값 `0.04 0.06 0.10`. +_NUMBER = re.compile( + r"^\d[\d,]*(\.\d+)?(\s*[~∼~-]\s*\d[\d,]*(\.\d+)?)?" + r"(\s*(인|ℓ|시간|㎥|㎡|개|본|매|kg|m|%|Roll)(/[a-z㎞㏊]+)?)?(\s+[-\d,.]+)*$" +) def md_work_mark(table: dict) -> str | None: - """품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`. + """품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`. 표에 숫자 칸이 있을 때만 봄. - ① 표 어딘가에 **짧은 칸(20자 이하)** 으로 직종 이름·품 낱말(`소요인력`·`(인)`·`작업능력` …)이 있고 - 숫자 칸도 있음 · ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음. + ① **짧은 칸(20자 이하)** 에 직종 이름·품 낱말(`소요인력`·`인력구분`·`(인)`·`시멘트(kg)` …) + ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음 + ③ 단위만 늘어선 줄(`| | ㎥ | ㎥ | ㎥ |`) — 값은 아래 줄에 있음(7-1-1 수확) 긴 설명 글 속 「보통인부」는 이름이 아님(손료계수표 적용기준 칸). - ponytail: 품 표 317 중 276 만 알아봄 — 값이 범위(`3.3∼5.9`)거나 머리가 여러 줄로 갈린 표는 못 봄. + ⚠ 못 알아보는 품 표는 알려진 목록 `form_blind` 에 까닭과 함께 둠 — 그 표는 성격이 뒤집혀도 못 잡음. """ rows = [table["headers"], *table["rows"]] cells = [c for row in rows for c in row] - if any(_NUMBER.match(c) for c in cells): - for c in cells: - if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c)): - return c + if not any(_NUMBER.match(c) for c in cells): + return None + for c in cells: + if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c.replace(" ", ""))): + return c for row in rows: unit = next((c for c in row if c in _QTY_UNIT), None) if unit and any(_NUMBER.match(c) for c in row): return unit + filled = {c for c in row[1:] if c} + if len(filled) == 1 and filled <= _QTY_UNIT: + return next(iter(filled)) return None +#: 비목 이름 — 이것만으로 첫 칸이 채워진 표가 비목 구성표(`재료비 | 설치비 재료비의 5%`). +_COST_ITEMS = {"재료비", "설치비", "노무비", "경비", "제작비", "운송비", "자재비", "주재료비"} + + +def md_cost_rows(table: dict) -> list[list[str]] | None: + """비목 구성표면 그 줄들(뒤 빈 칸 뗌), 아니면 `None`. + + ⚠ 이 꼴은 **모양은 잡히나 성격은 원문으로 못 가림** — 「설치비 = 재료비의 5%」 가 품(자원 줄)인지 + 참조(비율 지시)인지는 설계 방식의 결정이라 사람이 봐야 함(알려진 목록 `cost_rule`). + """ + rows = [r for r in table["rows"] if r and r[0].strip()] + if not rows or any(r[0].replace(" ", "") not in _COST_ITEMS for r in rows): + return None + out = [] + for r in rows: + cut = list(r) + while cut and not cut[-1]: + cut.pop() + out.append(cut) + return out + + # ── 맞대기 ────────────────────────────────────────────────────────────── def _norm(text: str) -> str: return " ".join(str(text).split()) @@ -225,7 +267,7 @@ def load_sources() -> dict: def current_mismatches(src: dict) -> dict[str, dict]: """표 자리·밑수·[주]·표 성격 어긋남 — 알려진 목록과 같은 꼴. 밑수·[주]·성격은 공종에 붙은 표만 봄.""" lines = src["lines"] - out: dict[str, dict] = {"place": {}, "basis": {}, "notes": {}, "form": {}} + out: dict[str, dict] = {kind: {} for kind in KIND_NAMES} appendix = next(i for i, x in enumerate(lines, 1) if x.strip().startswith(APPENDIX)) orphans = {o["pum_table_id"] for o in src["master"]["orphan_tables"]} for table in src["tables"]: @@ -267,12 +309,19 @@ def current_mismatches(src: dict) -> dict[str, dict]: "master_only": [x for x in entry["notes"] if x not in md_note], } - if entry["pum_form"] in ("reference", "coefficient") and (mark := md_work_mark(table)): - out["form"][table["id"]] = { + form = entry["pum_form"] + if cost_rows := md_cost_rows(table): + out["cost_rule"][table["id"]] = { "section": section, - "master": entry["pum_form"], - "md_mark": mark, + "master": form, + "md_rows": cost_rows, } + continue # 성격은 사람 몫 — 품 표지로 재지 않음 + mark = md_work_mark(table) + if form in ("reference", "coefficient") and mark: + out["form"][table["id"]] = {"section": section, "master": form, "md_mark": mark} + elif form in ("requirement", "productivity") and not mark: + out["form_blind"][table["id"]] = {"section": section, "master": form} return out @@ -280,6 +329,10 @@ def load_known() -> dict[str, dict]: return json.loads(KNOWN.read_text(encoding="utf-8")) +def _without_why(entry: dict) -> dict: + return {k: v for k, v in entry.items() if k != "why"} + + # ── 시험 ──────────────────────────────────────────────────────────────── def test_표_수와_자리(src: dict) -> None: """원문 표가 원본(`pum_forest`)에 같은 차례·같은 줄로 있고, 마스터에 빠짐없이 한 번씩 있음.""" @@ -340,14 +393,14 @@ def test_셀_값이_글자까지_같음(src: dict) -> None: assert not diffs, f"셀 어긋남 {len(diffs)}건 — 앞 30:\n" + "\n".join(diffs[:30]) -KIND_NAMES = {"place": "표 자리", "basis": "밑수", "notes": "[주]", "form": "표 성격"} - - @pytest.mark.parametrize("kind", list(KIND_NAMES)) def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None: - """표 자리·밑수·[주]·표 성격 — 알려진 목록과 똑같아야 함. 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강.""" + """알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강. + + 목록 칸 `why` 는 사람이 적은 까닭 — 맞대기에서 뺌. + """ now = current_mismatches(src)[kind] - known = load_known()[kind] + known = {k: _without_why(v) for k, v in load_known()[kind].items()} lines: list[str] = [] for tid in sorted(set(now) - set(known)): lines.append(f" 새로 어긋남 {tid}: {json.dumps(now[tid], ensure_ascii=False)}") @@ -368,6 +421,8 @@ if __name__ == "__main__" and "--prune" in sys.argv: data = load_known() for kind in KIND_NAMES: before = len(data[kind]) - data[kind] = {k: v for k, v in data[kind].items() if current[kind].get(k) == v} + data[kind] = { + k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v) + } print(f"{KIND_NAMES[kind]}: {before} → {len(data[kind])}") KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")