diff --git a/B09_Estimation/B09_Estimation_CrewOutput.py b/B09_Estimation/B09_Estimation_CrewOutput.py new file mode 100644 index 00000000..81bc628b --- /dev/null +++ b/B09_Estimation/B09_Estimation_CrewOutput.py @@ -0,0 +1,240 @@ +"""B09 원가계산 — **작업조 + 시공량** 표 읽기 (2026-09-08). + +품셈에는 소요량을 직접 안 주고 **「작업조 몇 인이 하루 몇 ㎡」** 로 주는 표가 있다. + + 구 분 | 단 위 | 수 량 | 시 공 량 (㎡) + | | | 복잡 보통 간단 + 형틀목공 | 인 | 4 | 25 35 40 + 보통인부 | 인 | 1 | + + 1단위당 품 = 인원 ÷ 시공량 (유로폼 12-38-3: 형틀목공 4 ÷ 35 = 0.1143 인/㎡) + +**유형은 우리가 안 고른다.** 품셈 12-38-3 [유형] 표가 「보통 : 측구, 수로, 옹벽 …」로 +정해 두었으므로 **갈래(`#복잡`·`#보통`·`#간단`)로 세워 두고 고르는 것은 B08**에 맡긴다 +(철근 12-3 [주]① 과 같은 모양). + +⚠ **뭉쳐 온 칸을 짝지어 읽는다.** 이름·단위·인원이 한 칸에 붙어 온다 +(`형틀목공 보통인부` | `인 인` | `4 1`). **개수가 안 맞으면 그 표를 통째로 버린다** — +자리를 밀어 읽으면 다른 직종의 품이 붙는다(오늘 여러 번 겪은 자리). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +_RE_NUMBER = re.compile(r"^\d+(?:,\d{3})*(?:\.\d+)?$") +#: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다. +_OUTPUT_WORDS = ("시공량", "기준시공량", "적용시공량") + + +@dataclass(frozen=True) +class CrewMember: + """작업조 한 사람(또는 한 대).""" + + code: str + name: str + spec: str + kind: str + count: Decimal + + +@dataclass(frozen=True) +class CrewTable: + """작업조 표 한 장. `outputs` 는 유형별 시공량 — 갈래가 하나면 이름이 빈 문자열.""" + + members: tuple[CrewMember, ...] + outputs: tuple[tuple[str, Decimal], ...] + + def amount_of(self, member: CrewMember, output: Decimal) -> Decimal: + """1단위당 품 = 인원 ÷ 시공량.""" + return member.count / output + + +def _normalize(text: str) -> str: + return " ".join(str(text).split()) + + +def _is_output_header(cell: str) -> bool: + tight = "".join(str(cell).split()) + return any(word in tight for word in _OUTPUT_WORDS) + + +def _numbers_in(cell: str) -> list[Decimal]: + """한 칸에 뭉쳐 온 수들 — 「4 1」 → [4, 1].""" + found: list[Decimal] = [] + for token in _normalize(cell).split(" "): + if _RE_NUMBER.match(token): + found.append(Decimal(token.replace(",", ""))) + return found + + +def output_columns(table: dict[str, Any]) -> list[int]: + """시공량 열의 번호. 없으면 빈 목록 — 작업조 표가 아니다.""" + headers = table.get("condition_note") or [] + return [index for index, cell in enumerate(headers) if _is_output_header(cell)] + + +def parse_crew_table( + table: dict[str, Any], + catalog: ResourceCatalog, +) -> CrewTable | str: + """작업조 표를 읽는다. 못 읽으면 **사유 문자열**을 돌려준다(빈 표를 만들지 않는다).""" + if not output_columns(table): + return "시공량 열이 없습니다" + + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return "빈 표입니다" + + members: list[CrewMember] = [] + outputs: list[Decimal] = [] + labels: list[str] = [] + + for row in rows: + if not row or not row[0]: + continue + head = row[0] + # 첫 칸에 자원 이름이 뭉쳐 올 수 있다 — 「형틀목공 보통인부」. + # ⚠ 이름 안에 공백이 든 표가 있다 — 「조 경 공」·「비 계 공」. 공백을 구분자로만 + # 보면 그런 줄이 통째로 안 풀린다. **한 이름으로 먼저 시도**하고, 안 되면 쪼갠다. + tight = "".join(_normalize(head).split(" ")) + single = _resolve(catalog, tight) + if single is not None: + names, resolved = [tight], [single] + else: + names = [part for part in _normalize(head).split(" ") if part] + resolved = [_resolve(catalog, name) for name in names] + if not all(resolved): + # 자원이 아니면 유형 라벨 줄로 본다 — 「복 잡 | 보 통 | 간 단」. + if not _numbers_in(" ".join(row)): + if not members: + labels = [_normalize(cell) for cell in row if _normalize(cell)] + continue + # ⚠ **수가 있는데 이름을 못 푼 줄은 작업조의 한 몫**이다 — 조용히 건너뛰면 + # 그 몫이 빠진 채 단가가 선다(2026-09-08: 평떼 시비에서 「트럭 2.5ton 1대」가 + # 빠지고 노무만으로 28.6원/㎡ 이 섰다). 표를 통째로 버린다. + return f"작업조 줄 「{_normalize(head)[:20]}」을 못 풀었습니다" + + counts = _counts_of(row, len(resolved)) + if counts is None: + return f"인원 수가 이름 {len(resolved)} 개와 안 맞습니다" + for entry, count in zip(resolved, counts): + members.append( + CrewMember( + code=entry.code, + name=entry.name, + spec=entry.spec, + kind=entry.kind, + count=count, + ) + ) + # 시공량은 보통 **첫 작업조 줄**에 붙어 온다. + if not outputs: + outputs = _outputs_of(row, counts) + + if not members: + return "작업조 줄을 못 찾았습니다" + if not outputs: + return "시공량 값을 못 찾았습니다" + if labels and len(labels) != len(outputs): + # 라벨과 값의 개수가 다르면 **어느 유형인지 단정할 수 없다** — 읽지 않는다. + return f"유형 {len(labels)} 개와 시공량 {len(outputs)} 개가 안 맞습니다" + + named = tuple((labels[index] if labels else "", value) for index, value in enumerate(outputs)) + return CrewTable(members=tuple(members), outputs=named) + + +def _resolve(catalog: ResourceCatalog, name_cell: str): + name, spec = split_name_and_spec(name_cell) + entry = catalog.resolve(name, spec) + if entry is not None: + return entry + if spec: + return None + found = catalog.by_name(name) + return found[0] if len(found) == 1 else None + + +def _counts_of(row: list[str], wanted: int) -> list[Decimal] | None: + """이름 개수와 같은 만큼의 인원 수를 가진 칸을 찾는다. 없으면 `None`.""" + for cell in row[1:]: + numbers = _numbers_in(cell) + if len(numbers) == wanted: + return numbers + return None + + +def _outputs_of(row: list[str], counts: list[Decimal]) -> list[Decimal]: + """인원 칸 **뒤**에 오는 수들이 시공량이다.""" + seen_counts = False + values: list[Decimal] = [] + for cell in row[1:]: + numbers = _numbers_in(cell) + if not numbers: + continue + if not seen_counts and numbers == counts: + seen_counts = True + continue + if seen_counts: + values.extend(numbers) + return values + + +def match_crew_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, + unit: str, +) -> bool: + """작업조 표를 자원 축 줄로 바꾼다. 그런 표가 아니면 `False`.""" + if not output_columns(table): + return False + + work_item_code = node.get("work_item_code", "") + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + parsed = parse_crew_table(table, catalog) + if isinstance(parsed, str): + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=_normalize(" | ".join(str(c) for c in (table.get("condition_note") or []))), + reason=f"작업조 표를 못 읽었습니다 — {parsed}", + ) + ) + return True # 다른 길로 보내지 않는다 — 행-자원으로 읽으면 인원을 소요량으로 오해한다 + + for index, (label, output) in enumerate(parsed.outputs): + if output <= 0: + continue + for member in parsed.members: + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=member.kind, + resource_code=member.code, + resource_name=member.name, + resource_spec=member.spec, + amount=parsed.amount_of(member, output), + amount_unit=unit, + raw_row_index=index, + variant=label, + ) + ) + return True diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 2c9a4a06..9f67f8d1 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -472,6 +472,15 @@ def match_table( result: AxisResult, ) -> None: """표 하나에 자원 축을 붙인다. 값이 안 서면 `unmatched` 로 보낸다.""" + from B09_Estimation.B09_Estimation_CrewOutput import match_crew_table + + # ⚠ **작업조 표는 형태 판정보다 먼저 가른다.** 「형틀목공 4인 / 시공량 35㎡」 표는 + # 마스터에서 `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(유로폼 12-38-3). + # 그 표는 모양이 스스로를 말한다 — 시공량 열 + 작업조 줄이 있으면 그것이다. + # 행-자원으로 읽으면 **인원 4를 소요량 4로** 오해해 35배 부푼다. + if match_crew_table(node, table, catalog, result, table.get("basis_unit") or ""): + return + form = table.get("pum_form", "") if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 diff --git a/resources/data_cost_resource_axis/resource_axis_2026-01-01.json b/resources/data_cost_resource_axis/resource_axis_2026-01-01.json index d6b0bfbe..41542a88 100644 --- a/resources/data_cost_resource_axis/resource_axis_2026-01-01.json +++ b/resources/data_cost_resource_axis/resource_axis_2026-01-01.json @@ -553,34 +553,6 @@ "variant": "", "work_item_code": "FP-05-22-03" }, - { - "amount": "2", - "amount_unit": "", - "group_ratio_pct": null, - "pum_form": "requirement", - "pum_table_id": "F0132", - "raw_row_index": 0, - "resource_code": "1038", - "resource_kind": "labor", - "resource_name": "조경공", - "resource_spec": "", - "variant": "", - "work_item_code": "FP-05-22-04" - }, - { - "amount": "1", - "amount_unit": "", - "group_ratio_pct": null, - "pum_form": "requirement", - "pum_table_id": "F0132", - "raw_row_index": 1, - "resource_code": "1002", - "resource_kind": "labor", - "resource_name": "보통인부", - "resource_spec": "", - "variant": "", - "work_item_code": "FP-05-22-04" - }, { "amount": "0.0084", "amount_unit": "㎡", @@ -763,6 +735,62 @@ "variant": "", "work_item_code": "FP-05-28-01" }, + { + "amount": "0.02222222222222222222222222222", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0145", + "raw_row_index": 0, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-05-28-02" + }, + { + "amount": "0.01111111111111111111111111111", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0145", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-05-28-02" + }, + { + "amount": "0.01538461538461538461538461538", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0145", + "raw_row_index": 1, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-05-28-02" + }, + { + "amount": "0.007692307692307692307692307692", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0145", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-05-28-02" + }, { "amount": "3.00", "amount_unit": "", @@ -2023,6 +2051,34 @@ "variant": "소형구조물", "work_item_code": "FP-12-01-03" }, + { + "amount": "0.009090909090909090909090909091", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0362", + "raw_row_index": 0, + "resource_code": "1006", + "resource_kind": "labor", + "resource_name": "비계공", + "resource_spec": "", + "variant": "설 치", + "work_item_code": "FP-12-02" + }, + { + "amount": "0.006060606060606060606060606061", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0362", + "raw_row_index": 1, + "resource_code": "1006", + "resource_kind": "labor", + "resource_name": "비계공", + "resource_spec": "", + "variant": "철 거", + "work_item_code": "FP-12-02" + }, { "amount": "1.07", "amount_unit": "ton", @@ -2304,35 +2360,7 @@ "work_item_code": "FP-12-05" }, { - "amount": "1", - "amount_unit": "", - "group_ratio_pct": null, - "pum_form": "requirement", - "pum_table_id": "F0339", - "raw_row_index": 1, - "resource_code": "1003", - "resource_kind": "labor", - "resource_name": "특별인부", - "resource_spec": "", - "variant": "", - "work_item_code": "FP-12-07-01" - }, - { - "amount": "2", - "amount_unit": "", - "group_ratio_pct": null, - "pum_form": "requirement", - "pum_table_id": "F0339", - "raw_row_index": 2, - "resource_code": "1002", - "resource_kind": "labor", - "resource_name": "보통인부", - "resource_spec": "", - "variant": "", - "work_item_code": "FP-12-07-01" - }, - { - "amount": "2", + "amount": "0.002857142857142857142857142857", "amount_unit": "", "group_ratio_pct": null, "pum_form": "requirement", @@ -2346,12 +2374,12 @@ "work_item_code": "FP-12-07-02" }, { - "amount": "3", + "amount": "0.004285714285714285714285714286", "amount_unit": "", "group_ratio_pct": null, "pum_form": "requirement", "pum_table_id": "F0340", - "raw_row_index": 1, + "raw_row_index": 0, "resource_code": "1002", "resource_kind": "labor", "resource_name": "보통인부", @@ -2779,6 +2807,90 @@ "variant": "", "work_item_code": "FP-12-35" }, + { + "amount": "0.16", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0395", + "raw_row_index": 0, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-38-03" + }, + { + "amount": "0.04", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0395", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-38-03" + }, + { + "amount": "0.1142857142857142857142857143", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0395", + "raw_row_index": 1, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-38-03" + }, + { + "amount": "0.02857142857142857142857142857", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0395", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-38-03" + }, + { + "amount": "0.1", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0395", + "raw_row_index": 2, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-38-03" + }, + { + "amount": "0.025", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0395", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-38-03" + }, { "amount": "0.5", "amount_unit": "㎥", @@ -3422,6 +3534,62 @@ "resource_spec": "", "variant": "", "work_item_code": "FP-13-15-01" + }, + { + "amount": "0.02222222222222222222222222222", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0449", + "raw_row_index": 0, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-13-16-02" + }, + { + "amount": "0.01111111111111111111111111111", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0449", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-13-16-02" + }, + { + "amount": "0.01538461538461538461538461538", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0449", + "raw_row_index": 1, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-13-16-02" + }, + { + "amount": "0.007692307692307692307692307692", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "reference", + "pum_table_id": "F0449", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-13-16-02" } ], "schema_version": "1.0", @@ -3436,12 +3604,12 @@ "sha256": "fe454c56c9dc01ad7dae04a8f90d776d08c5ce33badeadb2606b35234bfce7eb" }, "stats": { - "rows": 244, + "rows": 256, "skipped_forms": { "coefficient": 19, - "reference": 98, - "undetermined": 76 + "reference": 92, + "undetermined": 75 }, - "unmatched": 312 + "unmatched": 316 } } \ No newline at end of file diff --git a/resources/data_cost_resource_axis/unmatched_2026-01-01.json b/resources/data_cost_resource_axis/unmatched_2026-01-01.json index 2926fae4..06e7a35f 100644 --- a/resources/data_cost_resource_axis/unmatched_2026-01-01.json +++ b/resources/data_cost_resource_axis/unmatched_2026-01-01.json @@ -2,6 +2,12 @@ "effective_date": "2026-01-01", "note": "못 맞춘 자원 이름. 빈칸으로 두지 않고 여기 모은다. 기계·자재 카탈로그가 아직 없어 그 계열은 전부 여기로 온다.", "rows": [ + { + "cell": "구분 | 조 건 | 적용시공량", + "pum_table_id": "F0038", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「1」을 못 풀었습니다", + "work_item_code": "FP-01-04-26" + }, { "cell": "보통휘발유 (주연료)", "pum_table_id": "F0042", @@ -417,9 +423,9 @@ "work_item_code": "FP-05-20" }, { - "cell": "트럭", + "cell": "구 분 | 규 격 | 단위 | 수량 | 시공량(㎡)", "pum_table_id": "F0132", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「트럭」을 못 풀었습니다", "work_item_code": "FP-05-22-04" }, { @@ -1370,6 +1376,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-10-10-02" }, + { + "cell": "슬 럼 프 | 기준 시공량", + "pum_table_id": "F0356", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「8 ~ 12 cm」을 못 풀었습니다", + "work_item_code": "FP-12-02" + }, { "cell": "합 판", "pum_table_id": "F0336", @@ -1407,9 +1419,21 @@ "work_item_code": "FP-12-05" }, { - "cell": "형틀목공 보통인부", + "cell": "배치인원(인) | 포장두께 | 시공량(㎥)", + "pum_table_id": "F0338", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「30㎝」을 못 풀었습니다", + "work_item_code": "FP-12-06" + }, + { + "cell": "배치인원(인) | 사용기계(1대) | 시공량(m)", + "pum_table_id": "F0339", + "reason": "작업조 표를 못 읽었습니다 — 유형 4 개와 시공량 1 개가 안 맞습니다", + "work_item_code": "FP-12-07-01" + }, + { + "cell": "배치인원(인) | 시공량(거푸집연장 m)", "pum_table_id": "F0341", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「20㎝ ≤ 포장두께 ≤ 25㎝」을 못 풀었습니다", "work_item_code": "FP-12-08" }, {