diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index 0c67c8d0..550c4258 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -114,6 +114,15 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = { "ⓘ 재료(합판·못 카탈로그 없음 · 각재·철선·박리제 규격 미정)는 못 붙은 줄 — 지금 값은" " **인력 품만** · [주]③ 동바리 별도 · [주]⑥ 소형구조물 인력품 30% 할증(선택)은 안 걺.", ), + "FP-05-24": ( + "실무 역산", + "ⓘ 원문이 애매해 실무(거창 「초류종자살포(씨드스프레이)」)를 따른 자리 둘 — 뒤집을 수 있게 어긋남을" + " 나란히: ① 「트럭 4.5ton」 → 덤프트럭 4.5(원문은 트럭 · 카탈로그에 트럭 없음 · 거창이 덤프트럭 4.5Ton)" + " ② 「종자살포기 2,500-3,000ℓ」 → 취부기 11.94㎾(원문 규격은 ℓ 탱크 용량 · 카탈로그는 ㎾ · 건설 4-1-3" + "·거창이 취부기) · 취부기는 손료만(8-4 운전경비표에 줄 없음 · 거창도 손료만) · ⚠ 거창은 디젤엔진 11.19㎾" + " 를 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣음 · 물탱크 조종원은 화물차운전사(8-1-2 5호" + " 살수차) · [주]⑦ 물주기(인력) 보통인부 0.0005인은 필요시라 안 걺(2026-09-15 브레인).", + ), "FP-12-25": ( "운반거리 미정", "⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 " diff --git a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py index 7a80b66f..c214ca43 100644 --- a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py +++ b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py @@ -81,12 +81,21 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: """**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다.""" from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_MachineOperating import ( + LOSS_ONLY_MACHINES, + OPERATOR_PROVISIONAL_NOTE, load_operating_records, load_operator_wages, ) + from B09_Estimation.B09_Estimation_WoodChipping import MACHINE as CHIPPER + from B09_Estimation.B09_Estimation_WoodChipping import operating_record as chipper_record catalog = load_machine_catalog() operating = {row.machine_code: row for row in load_operating_records().records} + # 파쇄기는 8-4 칸이 「-」 라 8-11 [주]⑤ 레코드로 섬(단가와 같은 값) — 없으면 장이 「연료·조종원 없음」 으로 틀리게 뜸. + if f"X-{CHIPPER}" in build.book.titles and CHIPPER not in operating: + chipper = chipper_record() + if chipper is not None: + operating[CHIPPER] = chipper wages = load_operator_wages() loss = _loss_records() @@ -120,7 +129,8 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: money = build.book.resolve(code) gaps: list[str] = [] - attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) + loss_only = LOSS_ONLY_MACHINES.get(machine_code, "") # 8-4 에 줄 없음 · 손료만(②′) + attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) or bool(loss_only) fuel_liters = getattr(record, "fuel_liters_per_hour", None) # 연료 종류대로 그 유가(휘발유 기계가 경유값으로 보이던 자리 · 661 뒤 ②). fuel_price, fuel_meta = ( @@ -168,6 +178,13 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: str(COMBINED_MISC_PERCENT) if variant == "조합" else _money(misc_percent) ), "operator_code": occupation, + # 8-1-2 5호가 이 기종을 이름으로 안 가르면 잠정 사유(원문이 안 가르는 것을 우리가 안 가름). + "operator_note": ( + OPERATOR_PROVISIONAL_NOTE + if wage is not None + and getattr(record, "operator_mapping_is_provisional", False) + else "" + ), "operator_daily_wage": _money(wage), # 식 좌→우 순차 + 원 미만 절사(명세 7장) — 계수로 접으면 1원 틀림. "operator_krw_per_hour": _money( @@ -181,7 +198,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: "expense_krw": _money(money.expense), "total_krw": _money(money.total), "attachment": attachment, - "attachment_note": ATTACHMENT_NOTE if attachment else "", + "attachment_note": loss_only or (ATTACHMENT_NOTE if attachment else ""), "gaps": gaps, } ) diff --git a/B09_Estimation/B09_Estimation_MachineOperating.py b/B09_Estimation/B09_Estimation_MachineOperating.py index 7e4e802a..6fe7802e 100644 --- a/B09_Estimation/B09_Estimation_MachineOperating.py +++ b/B09_Estimation/B09_Estimation_MachineOperating.py @@ -47,6 +47,32 @@ _RE_FUEL_WITH_KIND = re.compile(r"^(휘발유|중유|경유)?(\d+(?:\.\d+)?)$") _OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물", "살수차") _OPERATOR_ALIAS_TRUCK = "labor_op_truck" _OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const" +_OPERATOR_ALIAS_GENERAL = "labor_op_general" + +#: ⭐ 건설품셈 8-1-2 5호 「운전사의 구분」 — **원문이 이름으로 가르는 기종만** (2026-09-15 브레인). +#: 옛 잠정 규칙이 덤프 15t 을 화물차 · 콤팩터·래머·소형믹서를 건설기계로 세우던 틀림 12(조립에 선 것 2). +#: 거창 실무 「덤프 15Ton 건설기계 · 8·4.5Ton 화물차 · 플레이트 콤팩터 일반기계운전사」 = 원문. +#: ⚠ 원문이 안 가르는 기종(콘크리트 펌프차 · 트랙터(타이어) · 믹서트럭 …)은 **우리가 가르지 않음** — 옛 규칙 + 잠정 사유. +_CLASS_CONSTRUCTION_WORDS = ( + "불도저", "굴착기", "로더", "지게차", "스크레이퍼", "기중기", "모터그레이더", "롤러", + "노상안정기", "콘크리트배치플랜트", "콘크리트피니셔", "콘크리트스프레더", "아스팔트믹싱플랜트", + "아스팔트피니셔", "아스팔트살포기", "슬러리실", "골재살포기", "쇄석기", "천공기", "사리채취기", + "노면파쇄기", +) # fmt: skip +_CLASS_TRUCK_WORDS = ("화물트럭", "살수차", "제설차", "노면청소차", "트럭탑재형크레인") +_CLASS_GENERAL_WORDS = ( + "양수기", + "윈치", + "벨트컨베이어", + "발전기", + "래머", + "콤팩터", + "콘크리트파쇄기", +) +OPERATOR_PROVISIONAL_NOTE = ( + "조종원 직종 잠정 — 건설품셈 8-1-2 5호 「운전사의 구분」 이 이 기종을 이름으로 안 가름" + " (이름에 트럭이 들면 화물차운전사 · 그 밖은 건설기계운전사로 둠)" +) #: ⚠ **셀 병합으로 줄째 버려지는 표에서 원문 값을 되살린다** (2026-09-15 초류종자살포 ③ 브레인). @@ -62,6 +88,15 @@ MERGED_CELL_OPERATING_FIXES: dict[str, str] = { "7204-0065": "9.4", "7204-0160": "12.9", } +#: **운전경비 줄이 원문에 없는 기계** — 손료만으로 층을 세움(부착 장비 길 · 2026-09-15 브레인 판정 ②′). +#: ⚠ 이 목록 밖으로 넓히지 말 것 — 연료가 빠진 채 조용히 싼 값이 섬. 줄마다 원문 없음 · 실무 실증을 적음. +LOSS_ONLY_MACHINES: dict[str, str] = { + "7750-0016": ( + "손료만 — 건설품셈 8-4 운전경비표에 취부기(7750) 줄이 없음(원문 없음) · 거창 실무 「취부기(녹생토)" + " 11.94kW」 도 손료 20,295 만 · ⚠ 거창은 디젤엔진 11.19kW 를 따로 세움 — 산림 5-24 · 건설 4-1-3" + " 표 둘 다에 없어 안 넣음" + ), +} _WATER_TANK_MISC_PERCENT = Decimal(30) _WATER_TANK_OPERATORS = Decimal(1) @@ -172,14 +207,45 @@ def _parse_person_days(token: str) -> Decimal | None: return Decimal(text) if _RE_DECIMAL.match(text) else None -def _operator_code(machine_name: str, aliases: dict[str, str]) -> str: - """기종 이름으로 운전사 직종을 고른다 — **잠정 규칙**. +def operator_class(machine_name: str, specification: str = "") -> str | None: + """8-1-2 5호가 이름·규격으로 가르는 직종 별칭 키 — 원문이 안 가르면 `None`.""" + name = re.sub(r"\s", "", machine_name) + found = re.search(r"\d+(?:\.\d+)?", str(specification).replace(",", "")) + size = Decimal(found.group(0)) if found else None + if name.startswith("덤프트럭"): + # 「덤프트럭(12ton이상)」 건설기계 · 「12ton미만의 덤프트럭」 화물차 + if size is None: + return None + return _OPERATOR_ALIAS_CONSTRUCTION if size >= 12 else _OPERATOR_ALIAS_TRUCK + if name.startswith("콘크리트믹서") and "트럭" not in name: + # 「콘크리트 믹서(0.55㎥ 이상)」 건설기계 · 「소형믹서」 일반기계 + if size is None: + return None + return _OPERATOR_ALIAS_CONSTRUCTION if size >= Decimal("0.55") else _OPERATOR_ALIAS_GENERAL + if name.startswith("공기압축기"): + # 「공기압축기(이동식, 2.83㎥/min 이상)」 건설기계 · 「소형의 공기압축기」 일반기계 + if size is None or "이동식" not in name: + return None + return _OPERATOR_ALIAS_CONSTRUCTION if size >= Decimal("2.83") else _OPERATOR_ALIAS_GENERAL + # 괄호 앞 이름의 **끝말**로 — 속에 든 글자로 보면 「크롤러드릴」 이 롤러 · 「타워크레인」 이 기중기로 걸림. + base = re.sub(r"[((].*", "", name) + if any(word in name for word in _CLASS_TRUCK_WORDS): # 「물탱크(살수차)」 — 괄호 속 이름 + return _OPERATOR_ALIAS_TRUCK + if base.endswith(_CLASS_GENERAL_WORDS): + return _OPERATOR_ALIAS_GENERAL + if base == "크레인" or base.endswith(_CLASS_CONSTRUCTION_WORDS): # 「기중기(차륜 및 무한궤도)」 + return _OPERATOR_ALIAS_CONSTRUCTION + return None + + +def _operator_code(machine_name: str, aliases: dict[str, str], specification: str = "") -> str: + """기종 이름으로 운전사 직종을 고른다 — 8-1-2 5호가 가르면 원문, 아니면 **잠정 규칙**. 품셈 8-4 표는 「조종원 인/일」 수만 주고 직종명을 안 준다. 노임표의 `aliases` 가 - 운전사 직종 셋(`labor_op_const`·`labor_op_truck`·`labor_op_general`)을 들고 있어 - 트럭 계열만 화물차운전사로, 나머지는 건설기계운전사로 **잠정** 매핑한다. + 운전사 직종 셋(`labor_op_const`·`labor_op_truck`·`labor_op_general`)을 들고 있음. + 원문이 안 가르는 기종은 트럭 계열만 화물차운전사로, 나머지는 건설기계운전사로 **잠정** 매핑한다. """ - key = ( + key = operator_class(machine_name, specification) or ( _OPERATOR_ALIAS_TRUCK if any(word in machine_name for word in _OPERATOR_TRUCK_WORDS) else _OPERATOR_ALIAS_CONSTRUCTION @@ -279,7 +345,13 @@ def enrich_with_catalog( fuel_kind=record.fuel_kind, misc_material_percent=record.misc_material_percent, operator_person_days=record.operator_person_days, - operator_occupation_code=_operator_code(machine.name, aliases), + operator_occupation_code=_operator_code( + machine.name, aliases, machine.specification or record.specification + ), + operator_mapping_is_provisional=operator_class( + machine.name, machine.specification or record.specification + ) + is None, ) ) result.records = enriched diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py index 00d1cc53..3cd10d63 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py @@ -128,14 +128,20 @@ def scoped_alias_entry( 흐려짐 — 별칭이 코드를 적었으면 그 코드가 곧 답(2026-09-14 · 9-19-3 소형브레이커). ⚠ 별칭은 **빈 곳을 채움** — 옆 칸이 규격을 적었으면 안 덮음(같은 9-19-1 성토면 「굴착기 | 0.6㎥」 가 절토면 [주]① 0.7 로 덮이던 자리 · 2026-09-14 판정 Ⓐ). + 단, 대상 규격이 옆 칸 규격과 **같으면** 덮는 것이 아님 — 이름만 이음(5-24 「트럭 | 4.5ton」 → + 덤프트럭 4.5 · 2026-09-15 판정 ①). """ - if _writes_spec(side_cells): - return None wanted = _normalize(name_cell) for row in catalog.scoped_aliases: if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): continue - return next((entry for entry in catalog.entries if entry.code == row["to"]), None) + entry = next((entry for entry in catalog.entries if entry.code == row["to"]), None) + if entry is not None and _writes_spec(side_cells): + same = any( + _RE_SIDE_SPEC.match(_normalize(c)) and same_spec(entry.spec, c) for c in side_cells + ) + return entry if same else None + return entry return None diff --git a/B09_Estimation/B09_Estimation_SeedSpray.py b/B09_Estimation/B09_Estimation_SeedSpray.py new file mode 100644 index 00000000..a3e2d49b --- /dev/null +++ b/B09_Estimation/B09_Estimation_SeedSpray.py @@ -0,0 +1,57 @@ +"""B09 원가계산 — **초류종자살포 5-24 종자**(2026-09-15 브레인 판정 ④ · 화약류 길). + +표 「종자 kg 0.025」 는 규격을 안 적음 — 자원 목록에 종자 후보가 넷(생태복원형 초본류·목본류 · 초본류 · 목본류)이라 +조인 규칙(규격이 같아야)에 걸려 못 붙었음. 배합은 [주]①③ 이 현장 판단(도로비탈면 녹화 지침 · 방향·고도·계절)으로 넘김. +⇒ 넷 모두 「자재 단가」 탭에 칸이 서고 **설계자가 넣은 하나**가 표 수량으로 붙음 · 안 넣으면 「규격 미정」 · + 둘 이상 넣으면 고르지 않고 사유(임의로 규격을 고르지 않음). +""" + +from __future__ import annotations + +from typing import Any + +CODES = ("FP-05-24-01", "FP-05-24-02") +NAME = "종자" +#: (자원 목록 코드, 규격) — 자원 목록 `resource_catalog_ext` 의 종자 넷. +SEEDS: tuple[tuple[str, str], ...] = ( + ("AR-M-f89c57d4", "생태복원형,초본류"), + ("AR-M-d52b8fc1", "생태복원형,목본류"), + ("AR-M-8f852514", "초본류"), + ("AR-M-e39ee36e", "목본류"), +) +SEED_MISSING = ( + "종자 — 규격 미정(원문 표가 규격을 안 적음 · 후보 넷 " + + " · ".join(spec for _, spec in SEEDS) + + ") · 「자재 단가」 탭에서 하나를 넣으면 붙음" +) +SEED_TWO = "종자 — 후보가 둘 이상 들어옴({specs}) · 하나만 넣을 것" + + +def attach_seed_spray(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None: + """칸 넷을 세우고(`material_uses`) · 넣은 하나만 붙이고 · 없거나 둘이면 사유로 갈아 끼움.""" + from B09_Estimation.B09_Estimation_Explosives import _table_amount + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + book = build.book + for code in CODES: + for material, _ in SEEDS: + uses = build.material_uses.setdefault(material, []) + if code not in uses: + uses.append(code) + labels = [ + label for label in build.unattached.get(code) or [] if "".join(label.split()) != NAME + ] + amount = _table_amount(nodes_by_code.get(code) or {}, NAME) + picked = [(material, spec) for material, spec in SEEDS if material in book.titles] + titles = [t for t in book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")] + if len(picked) == 1 and amount is not None and titles: + material, spec = picked[0] + for title in titles: + book.add_detail( + PriceDetail(title, material, amount, note=f"종자 {spec} — 설계자 선택") + ) + elif len(picked) > 1: + labels.append(SEED_TWO.format(specs=" · ".join(spec for _, spec in picked))) + else: + labels.append(SEED_MISSING) + build.unattached[code] = labels diff --git a/B09_Estimation/B09_Estimation_UI_MachineExpense.ts b/B09_Estimation/B09_Estimation_UI_MachineExpense.ts index cce33926..ea964667 100644 --- a/B09_Estimation/B09_Estimation_UI_MachineExpense.ts +++ b/B09_Estimation/B09_Estimation_UI_MachineExpense.ts @@ -30,6 +30,7 @@ export interface MachineExpenseDto { fuel_scope: string; misc_material_percent: string | null; operator_code: string; + operator_note: string; operator_daily_wage: string | null; operator_krw_per_hour: string | null; material_krw: string | null; @@ -110,6 +111,7 @@ function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLEl ); } if (sheet.attachment_note) box.append(note(sheet.attachment_note)); + if (sheet.operator_note) box.append(note(sheet.operator_note)); for (const gap of sheet.gaps) box.append(note(`⚠ ${gap}`)); return box; } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index da583d2f..1f8442c6 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -45,6 +45,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import ( formula_machine_codes, ) from B09_Estimation.B09_Estimation_MachineOperating import ( + LOSS_ONLY_MACHINES, load_fuel_price, load_operating_records, LABOR_RELIABILITY_LABEL, @@ -354,7 +355,8 @@ def _add_machine_layers( if machine is None or machine.loss_coefficient_per_hour is None: incomplete.append(code) continue - if record is None and not attachment: + loss_only = LOSS_ONLY_MACHINES.get(code) # 8-4 에 줄 없는 기계 — 원문 없음 · 실무 실증(②′) + if record is None and not attachment and not loss_only: incomplete.append(code) continue @@ -394,7 +396,8 @@ def _add_machine_layers( hourly_code, base_code, Decimal(0), - note=( + note=loss_only + or ( "부착용 장비 — 연료·조종원은 본체(굴착기·불도저)에 듭니다" " (품셈 제8장 [주]⑤)." ), @@ -1106,6 +1109,10 @@ def build_unit_prices( from B09_Estimation.B09_Estimation_Explosives import attach_explosives attach_explosives(build, nodes_by_code) + # 초류종자살포 5-24 종자 — 후보 넷 칸 · 넣은 하나만 붙음(2026-09-15 ④ 화약류 길). + from B09_Estimation.B09_Estimation_SeedSpray import attach_seed_spray + + attach_seed_spray(build, nodes_by_code) # 표토제거 답외구간(9-15-2) — 계수표를 불도저 식 + T 로(2026-09-14 ㉰). from B09_Estimation.B09_Estimation_TopsoilRemoval import attach_topsoil_removal diff --git a/PLAN_랩탑메인.md b/PLAN_랩탑메인.md index f9ada0c9..51655016 100644 --- a/PLAN_랩탑메인.md +++ b/PLAN_랩탑메인.md @@ -7,14 +7,16 @@ ### 남은 것 - [ ] ⚠ 자원 이름이 겹치면 남의 공종 풀이가 바뀜(명세 2장 이름 맞춤의 부작용) — 자원 목록에 「양수기」 를 넣자 12-26 물푸기 「양수기(150m/m)」 줄 풀이가 바뀌어 제목이 사라짐 → 지금은 「유인헬기방제 양수기」 로 피함(임시) · 언젠가 공종 범위·코드로 맞출 자리 -- [ ] ㉯ 나머지 — 제목 없음 32(4-1 수확베기·4-2 부모·6-2 풀베기(예취기)·13-10-2 말뚝박기 등 · 표 읽기에서 먼저 막혀 드러남) · 2장 표 읽는 한 벌(`B09_Estimation_Consumables`)로 더할 자리 +- [ ] ㉯ 나머지 — 제목 없음(지금 셈 34 · 같은 자) 가르기: ⓐ 참고 표 19(1-2 단위표준·운반 · 2장 소요재료·기계손료 17 — 다른 공종이 읽는 표라 제목이 안 서는 게 맞음) ⓑ choose_one 부모 2(4-1·4-2 — 자식이 섬 · 단어는 부록 설계예시표 F0475·F0476 에서 걸림) ⓒ 단어는 설계예시표에서만 · 자기 표는 인력뿐 3(3-1 경계표시 **사유 없음** · 3-2 작업로 선정 초급기술자 못 풂 · 3-3 작업로 설치 비율 인력 못 풂) ⓓ **예취기·분무기 인력 표 한 모양 4**(6-2-2 줄베기 · 6-2-3 모두베기 · 6-4-1 덩굴걷기 **사유 없음** · 6-4-2 덩굴 약제 — 「이름 | 도구 | 인/ha | 값 | 직종」 줄을 못 읽음 · 풀리면 2장 예취기·배부식분무기 1일 몫을 체인톱처럼) ⓔ 이름에만 드론·윈치 3(3-8 드론 영상 촬영 인력만 **사유 없음** · 7-3 아키야윈치 **사유 없음** · 8-6-2 드론방제 인력 줄 못 풂) ⓕ 거리 표 2(10-13-1 드론운반 · 10-13-2 인력운반 — 형식 미정으로 건너뜀 · **사유 없음**) ⓖ 13-10-2 나무 말뚝박기 1(뭉친 칸) · 사유 없이 버려지는 표 6(F0075 · F0082 · F0158 · F0174 · F0322 · F0323) - [ ] 막힌 셋 풀기(원문에 값이 없음 · 입력·견적 칸이 서야 함): 12-26 물푸기 가동시간 · 10-7-4 모노레일 ps·임대료·운반거리 · 10-8-3 케이블크레인 ℓ/kWh · 8-6-3 지상방제 1일 작업량·1톤 트럭 -- [ ] 손료계수 없는 기계 224종 가르기 — 파서가 놓친 것(크롤러드릴(공기식) 5401 1,548·2,506 · 크레인(무한궤도) 15 등)과 원문에 없는 것이 섞임 -- [ ] 초류종자살포 B09 둘 — 트럭 4.5ton 카탈로그 없음(덤프·크레인만) · 종자 규격 미정 후보 넷(화약류 모양 설계자 선택 칸 + 사유) +- [ ] 손료계수 없는 기계 222종 되살림(미룸 · 브레인 승인 — 지금 막는 공종 0) — 가르기 끝: 원문 없음 0 · 원천 한 줄 누락 1(3201-0003 「-0003」 표기) · 뭉친 칸이지만 열마다 개수 맞고 상각+정비+관리=계 검증 통과 196(크레인(무한궤도) 15 · 골재생산(컨베이어·크러셔·스크린·빈) 55 · 해상(준설선·예선·대선 등) 66 · 그 밖 60) · 글줄로 붙음 25(3601 콘크리트 피니셔 5 · 9020 준설선 7 · 9060 토운선 6 · 7) · 원문 md 한 줄 읽기가 기존 계수 383/383 일치 · 8-4 운전경비 버려진 줄 3 = 81기종(포장 27 · 기타 36 · 기타 18) 중 공종이 쓰는 것은 물탱크 하나(되살림 끝) +- [ ] ⚠ 조종원 직종 잠정 규칙이 8-1-2 5호와 어긋남 — 원문: 덤프트럭 12ton 이상·공기압축기(이동식 2.83㎥/min 이상) = 건설기계운전사 · 12ton 미만·살수차 = 화물차운전사 · 소형믹서·래머·콤팩터·양수기·발전기 = 일반기계운전사 ↔ 코드: 이름에 트럭 들면 화물차 · 나머지 전부 건설기계. 잼(2026-09-15): 조종원 있는 레코드 217 중 원문 또렷 맞음 167 · **틀림 12**(덤프트럭 15·20·24·32t 화물차→건설기계 4 · 콘크리트 믹서 0.10~0.45 건설기계→일반 6 · 래머 1 · 플레이트 콤팩터 1.5 1) · 원문으로 못 가름 38(콘크리트 펌프차 8 · 파일천공전용장비 6 · 트랙터(타이어) 4 · 콘크리트 믹서트럭 2 …) · **조립에 선 것 2** — 덤프트럭 15t(10-12 덤프 운반 세 갈래 · 시간당 조종원 49,778 → 59,020) · 플레이트 콤팩터 1.5(9-14-2 · 59,020 → 36,248 · 본당 노무 13,854 → 약 8,509) · 프로젝트는 덤프 운반 토사 40.44㎥ 에서 약 +1만 원 · ⭐ 거창 실무 기계경비 「덤프트럭 15Ton 건설기계운전사 · 8Ton·4.5Ton 화물차운전사 · 플레이트 콤팩터 일반기계운전사」 원문과 같음 · 앞서 적은 「공기압축기 3.5·10.3 틀림」 은 틀린 말(2.83 이상이라 건설기계 맞음) - [ ] 10-7-4 모노레일 제목의 보통인부 두 번 셈(4인) — 막힌 채 사유만 적음 · 풀리는 날 운전 단가표 2인을 빼고 셀 것 - [ ] 소형 장비 1일 소모 표(㉯) 가운데 2-1-2 경유 표(집재장비) · 천공기 · 예취기 — 쓰는 공종이 풀릴 때 같은 한 벌로 ### 검증완료(2026-09-14 밤 ~ 09-15 · 커밋 해시) +- [X] 초류종자살포 5-24-1·2 둘이 섬(브레인 판정 ①②′④) — ① 트럭 4.5ton → 덤프트럭 4.5(범위 별칭 · 옆 칸 규격이 대상 규격과 같으면 덮는 것 아님) ②′ 종자살포기 → 취부기 11.94㎾ 손료만(`LOSS_ONLY_MACHINES` · 8-4 에 줄 없음 · 거창도 손료 20,295 만 · 디젤엔진은 원문 둘 다에 없어 안 넣음) ④ 종자 후보 넷 칸 · 넣은 하나만 · 둘이면 사유(`B09_Estimation_SeedSpray`) · 어긋남 사유 나란히 · 233 → 527(③) → 744원/㎡(재료 단가 전) · 다른 공종·프로젝트 내역 변화 0. 검증완료(3d896dec) +- [X] ③ 물탱크(살수차) 운전경비 되살림 — 8-4-8 셀 병합으로 줄째 버려지던 7204 다섯 원문 값(8.2·8.6·9.3·9.4·12.9ℓ · 잡재료 30 · 1인) · 조종원 8-1-2 5호 살수차 = 화물차운전사 · 거창 5,500ℓ 일치. 검증완료(078af37d) - [X] ㉡-3 ㉠ 으로 막은 다섯 표 읽기(브레인 판정 ①②③) — 판정표 「시간 줄」(`hour_rows` · F0450·F0451): 장비 h 그대로 · 인력 h ÷ 8(총칙 L530) · ÷10본 · 굴착기 0.8㎥ 무한궤도·타이어 두 갈래(제안 없음) · 14-1 은 부착용 집게 7206-0070 과 조합(본체 `#조합`) · 사유(이름 다름 · 시간 단위 · 옛 값 8배) · 막힌 셋은 옛 값 까닭 사유(12-26 보통인부 1인뿐 · 10-7-4 같은 사람 두 번 셈 · 10-8-3 특별인부 1인뿐). 옛↔새(본당): 14-2 11,356 → 무한궤도 4,431 · 타이어 4,631 · 14-1 8,603 → 3,192 · 3,316 · 12-26 172,068 · 10-7-4 688,272 · 10-8-3 226,122 은 막힌 채 · 마스터 14-1·14-2 갈래 키만 · 전체 1721 + 392. 검증완료(42e8b4f0) - [X] 갈래마다 밑수가 다른 표 — `_match_two_row_table` 이 밑수로 안 나눠 8-6-1 유인헬기 160배·400배 부풂 → 갈래 「(Nha당)」 밑수 · 그 길 표 다섯 중 밑수 1 아닌 것 8-6-1 하나 · 전체에서 갈래마다 밑수 다른 표도 8-6-1 하나 · 소형 1,591,855 → 9,949원/ha · 대형 2,780,567 → 6,951. 검증완료(e15e9fc2) - [X] ㉡-2 유인헬기방제 8-6-1 — `AreaSet`: 양수기 1대1일 호표(휘발유 10ℓ + 잡품 95% + 손료 칸) ÷ 1일 면적 · 깃발 20ha당 1개(칸) · 헬기 임차료(견적 칸) · 소방관서 급수 단서 · 자원 이름 「유인헬기방제 양수기·깃발」 · 소형 +227원/ha · 대형 +90 · 8-6-3 은 막힌 채 사유 둘(1일 작업량 없음 · 15ha 는 연료 설명 / 1톤 트럭 카탈로그 없음). 검증완료(9563d060) diff --git a/resources/data_aliases/aliases_2026-01-01.json b/resources/data_aliases/aliases_2026-01-01.json index 90611b55..a35ee88a 100644 --- a/resources/data_aliases/aliases_2026-01-01.json +++ b/resources/data_aliases/aliases_2026-01-01.json @@ -146,6 +146,22 @@ "scope": "FP-10-12", "pum_edition": "2026-01-01", "basis": "품셈 10-12 덤프 운반 잎이 토사·암절취·발파암으로 갈리고 [주]③ 환산계수가 「암절취 1.35」(10-11 f 파쇄암 1/1.35 와 같은 값) — 우리 리핑암(리퍼 절취) = 품셈 암절취. 옛 매핑표 ground_aliases 에서 범위를 붙여 옮김(2026-09-13). ⚠ 쓰는 곳: 덤프 운반 잎 고르기(아직 없음 — 갈래 고르기형 부모 판정 대기)" + }, + { + "axis": "resource", + "from": "트럭", + "to": "0602-0045", + "scope": "FP-05-24", + "pum_edition": "2026-01-01", + "basis": "산림 5-24 표 「트럭 4.5ton」 — 카탈로그에 「트럭」 이 없음(덤프트럭·크레인만) = 원문 애매 → 실무. 거창 실무 호표 「51.초류종자살포(씨드스프레이) 나. 덤프트럭 (4.5Ton)」(건설 4-1-3 표 「트럭 4.5ton」 을 덤프트럭 4.5 로 셈). ⚠ 어긋남: 원문은 트럭(건설 4-1-3 [주]① 트럭에 종자살포기 장착) · 카탈로그는 덤프트럭. 2026-09-15 브레인 판정 ①" + }, + { + "axis": "resource", + "from": "종자살포기", + "to": "7750-0016", + "scope": "FP-05-24", + "pum_edition": "2026-01-01", + "basis": "산림 5-24 표 「종자살포기 2,500-3,000ℓ」 — 원문 규격은 ℓ(탱크 용량) · 카탈로그는 취부기(녹생토 암절개면 보호식재용) 11.94㎾ 로 같은 기계라는 원문 근거 없음 = 애매 → 실무. 5-24-1 [주]⑤ 가 건설 4-1-2 초류종자 살포를 참조 · 건설 4-1-3 표 「취부기 11.94kW」 · 거창 실무 「종자살포기 : 취부기(녹생토) (11.94kW)」. 2026-09-15 브레인 판정 ②" } ] } diff --git a/resources/tester/test_b09_operator_class.py b/resources/tester/test_b09_operator_class.py new file mode 100644 index 00000000..e68a7e7c --- /dev/null +++ b/resources/tester/test_b09_operator_class.py @@ -0,0 +1,70 @@ +"""조종원 직종 — 건설품셈 8-1-2 5호 「운전사의 구분」 (2026-09-15 브레인 · 잰 틀림 12 · 조립에 선 것 2). + +원문: 건설기계운전사 「…덤프트럭(12ton이상) … 콘크리트 믹서(0.55㎥ 이상) … 공기압축기(이동식, 2.83㎥/min 이상)」 + 화물차운전사 「12ton미만의 덤프트럭, 화물트럭, 살수차 …」 + 일반기계운전사 「소형의 공기압축기, 양수기, 소형믹서, 윈치 … 발전기, 래머, 콤팩터 …」 +⚠ 옛 잠정 규칙(이름에 트럭 들면 화물차 · 그 밖 전부 건설기계) — 덤프 15t 이 화물차 · 콤팩터·래머·소형믹서가 건설기계로 섰음. +거창 실무 기계경비 「덤프트럭 15Ton 건설기계운전사 · 8Ton·4.5Ton 화물차운전사 · 플레이트 콤팩터 일반기계운전사」 = 원문. +원문이 이름으로 안 가르는 기종(콘크리트 펌프차 · 트랙터(타이어) · 콘크리트 믹서트럭 …)은 **잠정 그대로 + 사유**. +""" + +from __future__ import annotations + +from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets +from B09_Estimation.B09_Estimation_MachineOperating import ( + _CATALOG_SUBPATH, + _read_json, + load_operating_records, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + +ALIASES = _read_json(*_CATALOG_SUBPATH, "labor_const_2026-01-01.json")["variables"]["aliases"] +CONST, TRUCK, GENERAL = ( + ALIASES[k] for k in ("labor_op_const", "labor_op_truck", "labor_op_general") +) + + +def _records(): + return {r.machine_code: r for r in load_operating_records().records} + + +def test_원문이_가르는_직종() -> None: + records = _records() + expected = { + "0602-0150": CONST, # 덤프트럭 15t — 12ton 이상 + "0602-0320": CONST, + "0602-0045": TRUCK, # 덤프트럭 4.5t — 12ton 미만 + "7204-0055": TRUCK, # 살수차 + "1730-0015": GENERAL, # 플레이트 콤팩터 + "1630-0080": GENERAL, # 래머 + "4205-0045": GENERAL, # 콘크리트 믹서 0.45㎥ — 소형믹서(0.55 미만) + "0201-0070": CONST, # 굴착기 + "5205-0035": CONST, # 공기압축기(이동식) 3.5 — 2.83 이상 + } + for code, occupation in expected.items(): + record = records[code] + assert record.operator_occupation_code == occupation, record + assert record.operator_mapping_is_provisional is False, record + + +def test_원문이_안_가르는_기종은_잠정_그대로() -> None: + records = _records() + for code in ("2602-0015", "4304-0060"): # 트랙터(타이어) · 콘크리트 믹서트럭 + assert records[code].operator_mapping_is_provisional is True, records[code] + assert records["2602-0015"].operator_occupation_code == CONST # 옛 규칙 그대로 + assert records["4304-0060"].operator_occupation_code == TRUCK + + +def test_조립에_선_둘이_원문_직종으로_섬() -> None: + build = cached_build(dump_haul_m=("164.23",)) + dump = {r.ref_code for r in build.book.details["X-0602-0150"]} + compactor = {r.ref_code for r in build.book.details["X-1730-0015"]} + assert f"{CONST}#시간" in dump, dump + assert f"{GENERAL}#시간" in compactor, compactor + + +def test_잠정_직종은_중기경비_장에_사유() -> None: + sheets = {s["machine_code"]: s for s in machine_expense_sheets(cached_build())} + assert sheets["0201-0070"]["operator_note"] == "" # 굴착기 — 원문이 가름 + note = sheets["7205-0125"]["operator_note"] # 이동식 임목파쇄기 — 원문 목록에 없음 + assert "잠정" in note and "8-1-2" in note, note diff --git a/resources/tester/test_b09_seed_spray.py b/resources/tester/test_b09_seed_spray.py new file mode 100644 index 00000000..52a9f8ab --- /dev/null +++ b/resources/tester/test_b09_seed_spray.py @@ -0,0 +1,76 @@ +"""초류종자살포 5-24-1·2 뿜어붙이기 — 2026-09-15 브레인 판정 ①②′④ (③ 물탱크는 `test_b09_water_tank_operating`). + +산림 5-24 표(㎡당): 종자 0.025 · 복합비료 0.1 · 화이버 0.18 · 합성접착제 0.1 · 색소 0.002 kg · + 종자살포기 2,500-3,000ℓ 0.0024h · 트럭 4.5ton 0.0024h · 물탱크 5,500ℓ 0.0036h · 조경공 0.0007 · 보통인부 0.0004 + [주]⑤ 「건설공사 표준품셈 공통부문 4-1-2 초류종자 살포」 참조 +① 트럭 4.5ton → 덤프트럭 4.5(0602-0045) — 카탈로그에 「트럭」 없음(원문 애매) · 거창 실무 「덤프트럭 (4.5Ton)」 +②′ 종자살포기 → 취부기 11.94㎾(7750-0016) · **손료만** — 원문 규격은 ℓ(탱크 용량) · 건설 4-1-3 「취부기 11.94kW」 · + 거창 실무 「종자살포기 : 취부기(녹생토)」 · 8-4 운전경비표에 7750 줄 없음(원문 없음) · 거창도 손료 20,295 만 + 디젤엔진 11.19㎾ 는 거창만 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣고 어긋남만 기록 +④ 종자 규격 후보 넷(생태복원형 초본·목본 · 초본류 · 목본류) — 화약류 길: 「자재 단가」 칸 · 넣은 쪽 하나가 붙음 +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, Decimal + +from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note +from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + +CODES = ("FP-05-24-01", "FP-05-24-02") +SEEDS = ("AR-M-f89c57d4", "AR-M-d52b8fc1", "AR-M-8f852514", "AR-M-e39ee36e") + + +def _rows(book, code: str) -> dict[str, Decimal]: + return {r.ref_code: r.quantity for r in book.details[f"B-{code}"]} + + +def test_트럭은_덤프트럭_종자살포기는_취부기_물탱크까지_표_시간대로() -> None: + build = cached_build() + for code in CODES: + rows = _rows(build.book, code) + assert rows["X-0602-0045"] == Decimal("0.0024"), rows + assert rows["X-7750-0016"] == Decimal("0.0024"), rows + assert rows["X-7204-0055"] == Decimal("0.0036"), rows + left = " ".join(build.unattached.get(code, [])) + assert "트 럭" not in left and "종자살포기" not in left, left + assert code not in build.component_gaps, build.component_gaps.get(code) + + +def test_취부기는_손료만으로_서고_사유가_붙음() -> None: + build = cached_build() + assert not any(m.startswith("7750-0016") for m in build.incomplete_machines) + money = build.book.resolve("X-7750-0016") + assert money.material == 0 and money.labor == 0 + assert ( + money.expense.quantize(Decimal(1), rounding=ROUND_FLOOR) == 20295 + ) # 거창 46,603,000 × 4,355 + notes = " ".join(r.note for r in build.book.details["X-7750-0016"]) + assert "8-4" in notes and "거창" in notes, notes + sheet = next(s for s in machine_expense_sheets(build) if s["code"] == "X-7750-0016") + assert sheet["gaps"] == [] and "8-4" in sheet["attachment_note"], sheet + + +def test_종자는_후보_넷_칸_넣은_하나만_붙고_둘이면_사유() -> None: + none = cached_build() + for code in CODES: + assert all(code in none.material_uses.get(seed, []) for seed in SEEDS) + left = " ".join(none.unattached.get(code, [])) + assert "종자 — 규격 미정" in left and "종 자" not in left, left + one = cached_build(material_prices=(("AR-M-8f852514", "20000", "견적"),)) + assert _rows(one.book, "FP-05-24-01")["AR-M-8f852514"] == Decimal("0.025") + assert "종자" not in " ".join(one.unattached.get("FP-05-24-01", [])) + two = cached_build( + material_prices=(("AR-M-8f852514", "20000", "견적"), ("AR-M-e39ee36e", "30000", "견적")) + ) + rows = _rows(two.book, "FP-05-24-01") + assert "AR-M-8f852514" not in rows and "AR-M-e39ee36e" not in rows + assert "하나만" in " ".join(two.unattached.get("FP-05-24-01", [])) + + +def test_사유는_어긋남을_나란히() -> None: + note = known_gap_note("FP-05-24-01") + for word in ("트럭", "덤프트럭", "ℓ", "취부기", "손료만", "디젤엔진"): + assert word in note, (word, note) + assert known_gap_note("FP-05-24-02") == note