diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index cbf78a3d..0c67c8d0 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -57,6 +57,33 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = { # 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만. # 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기. "FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."), + # ㉡-3(2026-09-15) — 시간 줄 표 둘 · 막힌 셋은 옛 값이 왜 틀렸는지(풀리는 날 두 번 세지 않게 · 브레인 ③). + "FP-14-01": ( + "원문 표", + "ⓘ 원문 「0.8㎥ 굴착기(우드그랩 부착)」 가 굴착기 형식을 안 적어 무한궤도·타이어 두 갈래 · 우드그랩 ↔ 카탈로그" + " 「부착용 집게 7206-0070(0.6∼0.8)」 — 이름이 다름 · 인력 「h」 는 시간이라 총칙 일일 작업시간 8시간으로 ÷8.", + ), + "FP-14-02": ( + "원문 표", + "ⓘ 원문 「0.8㎥ 굴착기」 가 형식을 안 적어 무한궤도·타이어 두 갈래 · 인력 「h」 는 시간이라 총칙 일일 작업시간" + " 8시간으로 ÷8 · 옛 11,356원은 0.66h 를 인으로 셈(8배) + 굴착기 줄이 빠진 값.", + ), + "FP-12-26": ( + "막힘 까닭", + "ⓘ 양수기(150㎜)·디젤엔진(15HP 11.19kW) 가동시간이 원문 표에 없음 · 카탈로그 양수기는 1.49㎾ 뿐 · 엔진(디젤)" + " 손료계수 없음 → 금액 안 세움 · 옛 172,068원은 보통인부 1인 값뿐(기계·목도 4인 빠짐).", + ), + "FP-10-07-04": ( + "막힘 까닭", + "ⓘ 운전 단가표(1일) 보통인부 2 는 적재·짐부리기 보통인부 2인(「운전조작」 포함)과 같은 사람 — 두 번 세지 말 것 ·" + " 연료 「ps × 0.253ℓ × 6h」 의 ps 입력 없음 · 본기계·레일은 임대료·견적 → 금액 안 세움 · 옛 688,272원은" + " 보통인부를 두 번 센 4인 값.", + ), + "FP-10-08-03": ( + "막힘 까닭", + "ⓘ 연료 = 윈치 기관 출력([주]② 36~73kW) × 운전시간([주]① 4.3~6.7h) × ℓ/kWh — ℓ/kWh 가 원문에 없음 · 보통인부는" + " 10-8-1 짐내리기 품 · 운반기구 손료 별도 → 금액 안 세움 · 옛 226,122원은 특별인부 1인 값뿐.", + ), "FP-08-06-01": ( "원문 [주]", "ⓘ 2-1-8 [주]② 「소방관서의 급수 지원을 받은 경우에는 휘발유(양수기)는 미반영」 — 해당하면 양수기 몫을" diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py index b1c5e78f..8d1226f4 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py @@ -96,7 +96,27 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = { "same_machine": ("봉상후렉시블(45mm)",), "why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」", }, + # 시간 줄 표 — 장비·인력이 다 「h」(2026-09-15 브레인 ㉡-3). 굴착기 형식을 원문이 안 적어 두 갈래 · 제안 없음(①). + "F0450": { + "code": "FP-14-01", + "shape": "hour_rows", + "prefix": "입목뿌리 밑막이", + "forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")), + # 원문 「우드그랩 부착」 ↔ 카탈로그 「부착용 집게 0.6∼0.8」 — 이름이 다름(②). + "attachment": "7206-0070", + "why": "원문 L7798 14-1 「0.8㎥ 굴착기(우드그랩 부착) h · 보통인부 h」(10본당)", + }, + "F0451": { + "code": "FP-14-02", + "shape": "hour_rows", + "prefix": "근주이식", + "forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")), + "why": "원문 L7809 14-2 「0.8㎥ 굴착기 h · 보통인부 h」(10본당)", + }, } +#: 총칙 L530 「본 품셈에서 제시된 품은 일일 작업시간 8시간을 기준」 — 인력 시간 ÷ 8 = 인. +HOURS_PER_DAY = Decimal(8) +_RE_SPEC_FIRST_MACHINE = re.compile(r"^\d+(?:\.\d+)?㎥굴착기") #: 줄 첫 칸이 분류 딱지인 표(12-34-1 「자재 | 콘크리트(레미콘)」) — 이름은 다음 칸. _ROW_CATEGORIES = ("자재", "인력", "기계") #: 자원이 아닌 머리 줄(12-04 「횟수별 | 재료별(%) | 노무비(%)」). @@ -206,6 +226,8 @@ def match_judged_table( staged = _use_count(code, table, rows, catalog, basis_quantity, unit) elif judged["shape"] == "remark_labor": staged = _remark_labor(code, table, judged, rows, catalog) + elif judged["shape"] == "hour_rows": + staged = _hour_rows(code, table, judged, rows, catalog, basis_quantity) elif judged["shape"] == "per_m3_rows": staged = _per_m3_rows(code, table, judged, rows, catalog) else: @@ -370,3 +392,46 @@ def _per_m3_rows(code, table, judged, rows, catalog) -> list | str: amount = amount / Decimal(found_q.group(1)) staged.append(_row(code, table, entry, amount, "㎥", index, "")) return staged + + +def _hour_rows(code, table, judged, rows, catalog, basis_quantity) -> list | str: + """장비·인력이 다 시간인 표 — 장비 h 그대로 · 인력 h ÷ 8 · ÷ 밑수 · 굴착기 형식마다 갈래.""" + entries = {entry.code: entry for entry in catalog.entries} + unit = str(table.get("basis_unit") or "") + machine: tuple[int, Decimal] | None = None + labor: list = [] + for index, cells in enumerate(rows): + if len(cells) < 3 or "".join(cells[1].split()) != "h": + continue + hours = parse_amount(cells[2]) + if hours is None: + return f"{cells[0]} 시간" + if _RE_SPEC_FIRST_MACHINE.match("".join(cells[0].split())): + machine = (index, hours) + continue + entry = _entry(catalog, cells[0], code) + if entry is None: + return f"{cells[0]} 줄" + labor.append((index, entry, hours / HOURS_PER_DAY)) + if machine is None: + return "장비 줄" + staged: list = [] + for form, machine_code in judged["forms"]: + codes = [machine_code, *([judged["attachment"]] if judged.get("attachment") else [])] + if any(c not in entries for c in codes): + return f"{form} 기종 코드" + for machine_code_ in codes: + staged.append( + _row( + code, + table, + entries[machine_code_], + machine[1] / basis_quantity, + unit, + machine[0], + form, + ) + ) + for index, entry, amount in labor: + staged.append(_row(code, table, entry, amount / basis_quantity, unit, index, form)) + return staged diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index faaf4482..45c5cf4a 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-14T23:14:39+09:00", + "generated_at": "2026-09-15T03:10:55+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,8 +12,8 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "708a6a80d016c42d3402bd2a6cb5266fa18e1e2b5301bac823e6ff79d17fb2b6", - "size_bytes": 838969 + "sha256": "480f9510cdd2fe223b54818fb7180f8c6114d6e33e45cd3c6aa829c64de27eb3", + "size_bytes": 839153 }, { "file": "form_undetermined_2026-01-01.json", diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index 08b0f120..d8ef761d 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -3,7 +3,7 @@ "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", "pum_edition": "2026-01-01", - "generated_at": "2026-09-14T23:14:39+09:00", + "generated_at": "2026-09-15T03:10:55+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -40210,7 +40210,10 @@ "formula_rows": [], "special_glyphs": [], "capacity_formula_here": false, - "variant_key": [], + "variant_key": [ + "무한궤도", + "타이어" + ], "condition_note": [ "장 비", "단 위", @@ -40233,7 +40236,10 @@ ] } ], - "variant_keys": [] + "variant_keys": [ + "무한궤도", + "타이어" + ] }, { "work_item_code": "FP-14-02", @@ -40260,7 +40266,10 @@ "formula_rows": [], "special_glyphs": [], "capacity_formula_here": false, - "variant_key": [], + "variant_key": [ + "무한궤도", + "타이어" + ], "condition_note": [ "장 비", "단 위", @@ -42099,7 +42108,10 @@ ] } ], - "variant_keys": [] + "variant_keys": [ + "무한궤도", + "타이어" + ] } ] } \ No newline at end of file diff --git a/resources/tester/test_b09_hour_rows.py b/resources/tester/test_b09_hour_rows.py new file mode 100644 index 00000000..d3e34fa8 --- /dev/null +++ b/resources/tester/test_b09_hour_rows.py @@ -0,0 +1,60 @@ +"""시간 줄 판정표 — 14-1 입목뿌리 밑막이 · 14-2 근주이식(2026-09-15 브레인 ㉡-3 판정 ①②③). + +원문(10본당): 14-2 「0.8㎥ 굴착기 | h | 0.25 · 보통인부 | h | 0.66」 · 14-1 「0.8㎥ 굴착기(우드그랩 부착) | h | 0.17 · 보통인부 | h | 0.5」 +⚠ 인력 단위가 **시간** — 옛 14-2 11,356원은 0.66 을 인으로 셈(8배) + 굴착기 줄이 말없이 빠짐. + 장비 h 그대로 · 인력 h ÷ 8(총칙 「일일 작업시간 8시간」) · ÷ 10본 +① 「0.8㎥ 굴착기」 형식을 원문이 안 적음 — 무한궤도 0201-0080 · 타이어 0211-0080 두 갈래 · 제안 없음 +② 우드그랩 = 부착용 집게 7206-0070(규격 「0.6∼0.8」 이 0.8 을 품음) — 이름이 다름을 사유에 +③ 막힌 셋(12-26 · 10-7-4 · 10-8-3)은 옛 값이 왜 틀렸는지 사유에 +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + +def _rows(book, title: str) -> dict[str, Decimal]: + return {r.ref_code: r.quantity for r in book.details[title]} + + +def test_근주이식은_두_갈래_장비_시간과_인력_시간_나누기_8() -> None: + build = cached_build() + book = build.book + crawler = _rows(book, "B-FP-14-02#무한궤도") + tire = _rows(book, "B-FP-14-02#타이어") + assert crawler["X-0201-0080"] == Decimal("0.25") / 10, crawler + assert tire["X-0211-0080"] == Decimal("0.25") / 10, tire + assert crawler["1002"] == Decimal("0.66") / 8 / 10, crawler + assert "FP-14-02" not in build.partial_ratio + assert "FP-14-02" not in build.default_variants # 제안 없음(①) + + +def test_입목뿌리_밑막이는_부착용_집게를_같은_시간만큼() -> None: + book = cached_build().book + rows = _rows(book, "B-FP-14-01#무한궤도") + # 부착용 집게와 조합 — 본체는 잡재료 16% 층(`#조합` · 품셈 제8장 [주]⑤) + assert ( + rows["X-0201-0080#조합"] == Decimal("0.17") / 10 + and rows["X-7206-0070"] == Decimal("0.17") / 10 + ) + assert rows["1002"] == Decimal("0.5") / 8 / 10 + note = known_gap_note("FP-14-01") + assert "우드그랩" in note and "부착용 집게" in note and "7206-0070" in note, note + + +def test_사유는_시간_단위와_옛_값의_잘못을_밝히고_무한궤도를_권하지_않음() -> None: + note = known_gap_note("FP-14-02") + assert "시간" in note and "8시간" in note and "8배" in note, note + assert "영월" not in note and "제안" not in note and "10-12" not in note, note + + +def test_막힌_셋은_옛_값이_왜_틀렸는지를_사유에() -> None: + build = cached_build() + assert "보통인부 1인 값뿐" in known_gap_note("FP-12-26") + assert "같은 사람" in known_gap_note("FP-10-07-04") and "두 번" in known_gap_note("FP-10-07-04") + assert "특별인부 1인 값뿐" in known_gap_note("FP-10-08-03") + for code in ("FP-12-26", "FP-10-07-04", "FP-10-08-03"): + assert code in build.partial_ratio, code diff --git a/resources/tester/test_b09_silent_machine_rows.py b/resources/tester/test_b09_silent_machine_rows.py index 9e29a13b..c690ae9e 100644 --- a/resources/tester/test_b09_silent_machine_rows.py +++ b/resources/tester/test_b09_silent_machine_rows.py @@ -15,7 +15,8 @@ from B09_Estimation.B09_Estimation_UnitPrice import cached_build def test_기계_연료_줄이_안_붙은_다섯_공종은_일부만으로_막히고_사유() -> None: build = cached_build() - for code in ("FP-12-26", "FP-14-02", "FP-14-01", "FP-10-08-03", "FP-10-07-04"): + # 14-1·14-2 는 뒤이어 시간 줄 판정표가 읽어 풀림(`test_b09_hour_rows` · 2026-09-15 ㉡-3) + for code in ("FP-12-26", "FP-10-08-03", "FP-10-07-04"): assert code in build.partial_ratio, code assert "기계·연료 줄" in (build.component_gaps.get(code) or ""), ( code, @@ -23,7 +24,7 @@ def test_기계_연료_줄이_안_붙은_다섯_공종은_일부만으로_막히 ) left = " ".join(build.unattached.get("FP-12-26", [])) assert "디젤엔진" in left and "양수기" in left, left - assert "0.8㎥ 굴착기" in " ".join(build.unattached.get("FP-14-02", [])) + assert "FP-14-02" not in build.partial_ratio and "FP-14-01" not in build.partial_ratio def test_머리_줄과_인력만인_정상_공종은_안_막힘() -> None: