diff --git a/B09_Estimation/B09_Estimation_MachineOperating.py b/B09_Estimation/B09_Estimation_MachineOperating.py index 130dc416..cfaacc55 100644 --- a/B09_Estimation/B09_Estimation_MachineOperating.py +++ b/B09_Estimation/B09_Estimation_MachineOperating.py @@ -106,13 +106,19 @@ def _tokens(cell: str) -> list[str]: return [t for t in re.split(r"\s+", str(cell or "").strip()) if t] -def expand_codes(tokens: list[str]) -> list[str]: +def expand_codes(tokens: list[str], carry_prefix: str = "") -> list[str]: """`0201-0012 0020 0040` → `0201-0012 · 0201-0020 · 0201-0040`. 뒤 코드는 앞 코드의 **앞 네 자리를 이어받는다**. 이어받을 앞자리가 없으면 버린다. + + ⚠ `carry_prefix` — **앞 표의 마지막 앞자리**. 원문이 페이지에서 잘리면 이어지는 판이 + 앞자리를 다시 안 적는다(8-4-3 이 `0080 0100 …` 로 시작한다). 그것을 못 이어받아 + **운반·하역기계 39 기종의 운전경비가 통째로 버려지고 있었다** — 그 안에 수송비를 내는 + **트럭 트랙터 및 평판트레일러(2702)** 가 들어 있다(2026-09-09 밤 실측). + ⚠ 이어받은 코드는 **카탈로그에 있는지 확인한 뒤에만** 쓴다(`parse_operating_tables`). """ codes: list[str] = [] - prefix = "" + prefix = carry_prefix for token in tokens: full = _RE_CODE_FULL.match(token) if full: @@ -166,9 +172,16 @@ def _operator_code(machine_name: str, aliases: dict[str, str]) -> str: def parse_operating_tables( pum: dict[str, Any], aliases: dict[str, str], + known_codes: set[str] | None = None, ) -> OperatingParseResult: - """품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다.""" + """품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다. + + ⚠ `known_codes` — 기종 카탈로그의 코드. **앞 표에서 앞자리를 이어받을 때만** 쓰며, + 이어받아 만든 코드가 하나라도 카탈로그에 없으면 **그 줄을 통째로 버린다.** + 이어받기는 원문이 페이지에서 잘린 자리를 잇는 것이지 코드를 지어내는 것이 아니다. + """ result = OperatingParseResult() + carry_prefix = "" for table in pum.get("tables", []): headers = table.get("headers") or [] joined = " ".join(headers) @@ -179,6 +192,14 @@ def parse_operating_tables( if len(row) < 6: continue codes = expand_codes(_tokens(row[0])) + if not codes and carry_prefix: + carried = expand_codes(_tokens(row[0]), carry_prefix) + # ⚠ 이어받은 코드가 **전부 카탈로그에 있을 때만** 받는다 — 하나라도 없으면 + # 앞자리를 잘못 물어 온 것이므로 종전대로 버린다. + if carried and known_codes and all(code in known_codes for code in carried): + codes = carried + if codes: + carry_prefix = codes[-1][:4] names = _tokens(row[1]) specs = _tokens(row[2]) fuels = _tokens(row[3]) @@ -253,8 +274,9 @@ def load_operating_records( pum = _read_json(*_CATALOG_SUBPATH, pum_file)["variables"]["pum"] aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {}) - parsed = parse_operating_tables(pum, aliases) - return enrich_with_catalog(parsed, load_machine_catalog(), aliases) + catalog = load_machine_catalog() + parsed = parse_operating_tables(pum, aliases, set(catalog.machines)) + return enrich_with_catalog(parsed, catalog, aliases) def write_operating_records( diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 5e83ee94..7433aa1e 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -249,11 +249,14 @@ async def _build_for(project_id: UUID): ) # 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1). # 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」). + # 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]). return cached_build( ranges, machines, str(settings.get("misc_material_percent") or ""), str(settings.get("fuel_region") or ""), + str(settings.get("transport_distance_km") or ""), + str(settings.get("transport_road") or ""), ) @@ -389,9 +392,22 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse: MISC_MATERIAL_MIN_PERCENT, ) + from B09_Estimation.B09_Estimation_Transport import ASSUMPTION_TEXT as TRANSPORT_ASSUMPTION + from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS + from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS + from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS + from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE + # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. - book = (await _build_for(project_id)).book + prices = await _build_for(project_id) + book = prices.book + transport_notes = list(prices.transport_notes) + transport_prices = {} + for variant in TRANSPORT_VARIANTS: + code = f"B-{TRANSPORT_CODE}#{variant['key']}" + if code in book.titles: + transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}" with_material = sum( 1 for unit_code, unit_title in book.titles.items() @@ -423,6 +439,23 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse: " 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다." ), }, + "transport": { + "distance_km": str(settings.get("transport_distance_km") or ""), + "road": str(settings.get("transport_road") or ""), + "roads": [ + {"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS + ], + "variants": [ + { + "key": variant["key"], + "label": variant["label"], + "unit_price_krw": transport_prices.get(variant["key"], ""), + } + for variant in TRANSPORT_VARIANTS + ], + "basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION], + "notes": transport_notes, + }, "notes": [ "고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.", "바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.", @@ -446,6 +479,9 @@ class FactorChoiceBody(BaseModel): misc_material_percent: str | None = None #: 유가 지역(시도코드) — **빈 문자열이면 전국평균**으로 돌아간다. fuel_region: str | None = None + #: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.** + transport_distance_km: str | None = None + transport_road: str | None = None @router.put("/{project_id}/estimation/factors") @@ -499,6 +535,25 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe }, ) values["fuel_region"] = region + if body.transport_distance_km is not None: + from B09_Estimation.B09_Estimation_Transport import parse_distance_km + + try: + distance = parse_distance_km(body.transport_distance_km) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + values["transport_distance_km"] = "" if distance is None else str(distance) + if body.transport_road is not None: + from B09_Estimation.B09_Estimation_Transport import road_class + + road = str(body.transport_road).strip() + if road and road_class(road) is None: + # ⚠ 원문 표에 없는 도로 구분을 받아 두면 속도를 못 골라 조용히 안 선다. + return JSONResponse( + status_code=400, + content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"}, + ) + values["transport_road"] = road try: save_section(root, "estimation", values, replace_keys=tuple(values)) return JSONResponse(content={"status": "success", **values}) diff --git a/B09_Estimation/B09_Estimation_Transport.py b/B09_Estimation/B09_Estimation_Transport.py new file mode 100644 index 00000000..531a944a --- /dev/null +++ b/B09_Estimation/B09_Estimation_Transport.py @@ -0,0 +1,191 @@ +"""B09 원가계산 — **기계 수송비** (산림품셈 10-4 중기운반 · 건설품셈 8-1-6의 2). + +**무엇이 빠져 있었나** — 기계경비는 「기계손료 + 운전경비 + **수송비**」인데(건설품셈 8-1-6의 1), +우리 시간당 사용료 층에는 손료·연료·운전사만 있었다. 수송비를 내는 공종 `FP-10-04` 중기운반은 +표가 「미판정」으로 남아 **단가가 아예 안 서고 있었다**(2026-09-09 밤 실측). + +**원문이 정하는 것** (지어낸 값이 하나도 없다) + + 산림품셈 10-4 중기운반 (단위: **회당**) + 트레일러운반(20TON) t1=20min · t3=20min · t4=0.42min · t2=운반시간 참조 + 트럭운반(10.5TON) t1=10min · t3=10min · t4=5min · t2=운반시간 참조 + ㎝ = t1+t2+t3+t4 · N = 60×0.9/㎝ + [주] **자주식 기계는 제외**하며 **인근 기초자치단체와 현장중심점까지의 거리**로 계상한다. + + 건설품셈 8-1-6의 2 가 — 「가장 가까운 시·도·군·구청 소재지로부터 공사현장까지의 **왕복** + 수송비」 · 나 — 자주식 기계의 이동속도표(도로 구분별 km/hr) + +**우리가 정한 것 둘 — 원문이 안 정해 화면 근거에 그대로 적는다** + + ㉠ t2(운반시간)의 속도를 **8-1-6의 2 나 이동속도표**에서 가져온다. + 그 표는 「자주식 기계가 자주로 이동할 때」의 표이고, 운반 차량의 주행속도로 쓰라는 + 말은 원문에 없다. 다만 **같은 장에서 같은 도로 구분으로 준 유일한 속도**다. + ㉡ t2 를 **왕복**으로 본다(건설품셈이 「왕복 수송비」라 못 박으므로). + +⚠ **거리는 설계 입력이다** — 안 넣으면 **줄이 안 선다**(사토장 운반거리와 같은 자리). + 임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.** +⚠ **회수(몇 대를 몇 번 나르나)는 여기서 안 정한다** — 단가는 「회당」이고, 회수는 수량 쪽 + (설계 입력)이다. 품셈이 대수·회수를 정해 주지 않는다. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle + +_ZERO = Decimal(0) + +#: 수송 갈래 — 원문 10-4 가 준 둘. 기종 코드는 카탈로그(건설품셈 8-3) 분류번호. +TRANSPORT_VARIANTS: tuple[dict[str, Any], ...] = ( + { + "key": "트레일러20ton", + "label": "트레일러운반(20TON)", + "machine_code": "2702-0020", + "t1_min": Decimal(20), + "t3_min": Decimal(20), + "t4_min": Decimal("0.42"), + "speed_column": "trailer", + }, + { + "key": "트럭10.5ton", + "label": "트럭운반(10.5TON)", + "machine_code": "0602-0105", + "t1_min": Decimal(10), + "t3_min": Decimal(10), + "t4_min": Decimal(5), + "speed_column": "dump_truck", + }, +) + +#: 이동속도(km/hr) — 건설품셈 8-1-6의 2 나 「자주식 건설기계의 이동속도」. +#: ⚠ `None` 은 원문의 「-」다 — **그 도로 구분에 그 차량 값이 없다는 뜻**이라 지어내지 않는다. +ROAD_CLASSES: tuple[dict[str, Any], ...] = ( + {"key": "expressway_4", "label": "포장도로(고속4차선)", "dump_truck": 60, "trailer": None}, + {"key": "expressway_2", "label": "포장도로(고속2차선)", "dump_truck": 50, "trailer": 50}, + {"key": "paved", "label": "포장도로", "dump_truck": 40, "trailer": 40}, + {"key": "gravel_good", "label": "사리도로(양호)", "dump_truck": 25, "trailer": 20}, + {"key": "gravel_poor", "label": "사리도로(불량)", "dump_truck": 10, "trailer": 10}, +) + +_ROAD_BY_KEY = {row["key"]: row for row in ROAD_CLASSES} + +#: 회전율 — 원문 `N = 60 × 0.9 / ㎝`. 0.9 는 작업효율이고 60 은 시간→분이다. +_CYCLE_EFFICIENCY = Decimal("0.9") +_MINUTES_PER_HOUR = Decimal(60) + +WORK_ITEM_CODE = "FP-10-04" +UNIT = "회" + +BASIS_TEXT = ( + "산림품셈 10-4 중기운반(회당) · 건설품셈 8-1-6의 2 — 가장 가까운 시·도·군·구청 소재지에서" + " 현장까지 왕복. [주] 자주식 기계는 제외." +) +ASSUMPTION_TEXT = ( + "⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴" + "(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄." +) + + +def parse_distance_km(raw: Any) -> Decimal | None: + """설정 칸의 거리. **비면 `None`**(= 수송비 줄이 안 섬).""" + text = str(raw or "").strip().rstrip("km").rstrip("㎞").strip() + if not text: + return None + try: + value = Decimal(text) + except (ArithmeticError, ValueError): + raise ValueError(f"수송 거리를 숫자로 못 읽었습니다: {raw!r}") from None + if value <= 0: + return None + return value + + +def road_class(key: str | None) -> dict[str, Any] | None: + """도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**""" + return _ROAD_BY_KEY.get(str(key or "")) + + +def cycle_minutes(variant: dict[str, Any], distance_km: Decimal, road: dict[str, Any]) -> Decimal: + """㎝ = t1 + t2 + t3 + t4 (분). t2 는 **왕복** 운반시간이다.""" + speed = road.get(variant["speed_column"]) + if not speed: + raise ValueError( + f"{road['label']} 에는 {variant['label']} 속도가 원문에 없습니다(「-」)" + " — 지어내지 않습니다" + ) + round_trip_hours = distance_km * Decimal(2) / Decimal(str(speed)) + t2 = round_trip_hours * _MINUTES_PER_HOUR + return variant["t1_min"] + t2 + variant["t3_min"] + variant["t4_min"] + + +def hours_per_trip(variant: dict[str, Any], distance_km: Decimal, road: dict[str, Any]) -> Decimal: + """회당 시간(시간). `N = 60 × 0.9 / ㎝` 의 역수다.""" + minutes = cycle_minutes(variant, distance_km, road) + trips_per_hour = _MINUTES_PER_HOUR * _CYCLE_EFFICIENCY / minutes + return Decimal(1) / trips_per_hour + + +def attach_transport( + book: PriceBook, + names: dict[str, str], + *, + distance_km: Decimal | None, + road_key: str | None, +) -> list[str]: + """수송비 일위대가(갈래 둘)를 세운다. 돌려주는 것 — 못 세운 사유 목록. + + ⚠ **거리·도로 구분이 없으면 한 줄도 안 세운다** — 값이 없는데 금액이 서면 안 된다. + """ + notes: list[str] = [] + if distance_km is None: + notes.append( + "수송비 — 거리(인근 시·군·구청 소재지 → 현장)가 안 들어와 줄을 안 세웠습니다." + " 산출 조건에서 거리를 넣으면 섭니다." + ) + return notes + road = road_class(road_key) + if road is None: + notes.append("수송비 — 도로 구분을 안 골라 줄을 안 세웠습니다(속도가 도로로 갈립니다).") + return notes + + base_name = names.get(WORK_ITEM_CODE) or "중기운반" + for variant in TRANSPORT_VARIANTS: + hourly_code = f"X-{variant['machine_code']}" + if hourly_code not in book.titles: + notes.append(f"수송비 {variant['label']} — 그 차량의 시간당 사용료 층이 없습니다.") + continue + try: + hours = hours_per_trip(variant, distance_km, road) + except ValueError as error: + notes.append(f"수송비 {variant['label']} — {error}") + continue + + title_code = f"B-{WORK_ITEM_CODE}#{variant['key']}" + if title_code in book.titles: + continue + book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{base_name} ({variant['label']})", + spec=f"{road['label']} · 편도 {distance_km:g}㎞", + unit=UNIT, + ) + ) + minutes = cycle_minutes(variant, distance_km, road) + book.add_detail( + PriceDetail( + title_code, + hourly_code, + hours, + note=( + f"㎝ = {variant['t1_min']:g} + 왕복 {distance_km * 2:g}㎞" + f"÷{road[variant['speed_column']]}㎞/hr + {variant['t3_min']:g}" + f" + {variant['t4_min']:g} = {minutes:.2f}분 · N = 60×0.9/㎝" + f" ⇒ 회당 {hours:.4f}시간 · {BASIS_TEXT} · {ASSUMPTION_TEXT}" + ), + ) + ) + return notes diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index 310e7326..d5825662 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -536,11 +536,22 @@ export interface MiscMaterialRow { base_note: string; } +/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 선다(산림품셈 10-4). */ +export interface TransportRow { + distance_km: string; + road: string; + roads: Array<{ key: string; label: string }>; + variants: Array<{ key: string; label: string; unit_price_krw: string }>; + basis: string[]; + notes: string[]; +} + export interface FactorChoicesDto { status: string; ranges: RangeFactorRow[]; machines: MachineChoiceRow[]; misc_material?: MiscMaterialRow; + transport?: TransportRow; notes: string[]; } @@ -560,6 +571,8 @@ export async function saveFactorChoices( machine_choices?: Record; misc_material_percent?: string; fuel_region?: string; + transport_distance_km?: string; + transport_road?: string; }, ): Promise { const response = await fetch( @@ -726,5 +739,48 @@ export function drawFactorChoices( for (const line of misc.basis) body.append(note(line)); } + const transport = data.transport; + if (transport) { + body.append( + percentBox( + "기계 수송 거리 (인근 시·군·구청 → 현장, 편도 ㎞)", + transport.distance_km, + "비움", + (text) => { + void saveFactorChoices(projectId, { transport_distance_km: text }) + .then(reload) + .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); + }, + ), + ); + const roadOptions: FactorOption[] = [ + { key: "", label: "안 고름" }, + ...transport.roads.map((road) => ({ key: road.key, label: road.label })), + ]; + body.append( + picker("수송 도로 구분", roadOptions, transport.road, (key) => { + void saveFactorChoices(projectId, { transport_road: key }) + .then(reload) + .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); + }), + ); + for (const variant of transport.variants) { + body.append( + note( + variant.unit_price_krw + ? `${variant.label} — 회당 ${variant.unit_price_krw}원` + : `${variant.label} — 아직 안 섬`, + ), + ); + } + for (const line of transport.notes) body.append(note(`⚠ ${line}`)); + for (const line of transport.basis) body.append(note(line)); + body.append( + note( + "⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.", + ), + ); + } + for (const line of data.notes) body.append(note(line)); } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 32c93994..88b28fd8 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -64,6 +64,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( ) from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_Transport import parse_distance_km _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _ZERO = Decimal(0) @@ -92,6 +93,8 @@ class UnitPriceBuild: skipped: list[str] = field(default_factory=list) #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). incomplete_machines: list[str] = field(default_factory=list) + #: 수송비를 왜 못 세웠나 — 거리·도로 구분이 없으면 여기에 사유가 남는다(빈칸으로 안 둔다). + transport_notes: list[str] = field(default_factory=list) #: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구. #: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져 #: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구). @@ -542,6 +545,8 @@ def build_unit_prices( machine_picks: dict[str, str] | None = None, misc_material_percent: Decimal | None = None, fuel_region: str | None = None, + transport_distance_km: Decimal | None = None, + transport_road: str | None = None, ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -555,6 +560,9 @@ def build_unit_prices( 사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다. ⚠ `fuel_region` — 유가 시도코드(품셈 8-1-7 5호). **안 주면 전국평균**이다. + + ⚠ `transport_*` — 기계 수송비(산림품셈 10-4 · 건설품셈 8-1-6의 2). **거리·도로 구분이 + 없으면 그 줄이 안 선다** — 임의 거리로 금액을 세우지 않는다. """ from B09_Estimation.B09_Estimation_FactorChoices import ( chosen_values, @@ -658,6 +666,11 @@ def build_unit_prices( # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. machine_codes |= formula_machine_codes(master) machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows} + # 수송 차량(트레일러·트럭)은 어느 공종의 자원 줄도 아니다 — **거리가 들어왔을 때만** 세운다. + if transport_distance_km is not None: + from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS + + machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS} build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 @@ -852,6 +865,16 @@ def build_unit_prices( covered += machine_share if covered < Decimal(100): build.partial_ratio[work_item_code] = covered + # 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다. + from B09_Estimation.B09_Estimation_Transport import attach_transport + + build.transport_notes = attach_transport( + build.book, + names, + distance_km=transport_distance_km, + road_key=transport_road, + ) + # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. build.combined_swapped = _apply_combined_misc_rate( @@ -982,6 +1005,8 @@ def cached_build( machine_picks: tuple[tuple[str, str], ...] = (), misc_material_percent: str = "", fuel_region: str = "", + transport_distance_km: str = "", + transport_road: str = "", ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -1005,6 +1030,8 @@ def cached_build( machine_picks=machine_choices(settings), misc_material_percent=parse_misc_material_percent(misc_material_percent), fuel_region=fuel_region or None, + transport_distance_km=parse_distance_km(transport_distance_km), + transport_road=transport_road or None, ) diff --git a/resources/tester/test_b09_transport.py b/resources/tester/test_b09_transport.py new file mode 100644 index 00000000..671b75de --- /dev/null +++ b/resources/tester/test_b09_transport.py @@ -0,0 +1,116 @@ +"""기계 수송비 — 기계경비의 셋째 몫 (2026-09-09 밤). + +기계경비 = 기계손료 + 운전경비 + **수송비**(건설품셈 8-1-6의 1)인데 수송비가 통째로 빠져 +있었다. 수송비를 내는 공종 `FP-10-04` 중기운반은 표가 「미판정」이라 **단가가 안 서고** 있었다. + +⚠ 겨누는 것 여섯 + ① **거리가 없으면 한 줄도 안 선다** — 임의 거리로 금액이 조용히 서면 안 됨 + ② 도로 구분도 마찬가지 — 속도가 도로로 갈림 + ③ 사이클이 원문 그대로 — 트레일러 t1=20·t3=20·t4=0.42 / 트럭 t1=10·t3=10·t4=5 + ④ 운반시간은 **왕복** · 회전율 N = 60×0.9/㎝ + ⑤ 원문이 「-」로 둔 칸(트레일러 · 고속4차선)은 **지어내지 않고 사유로 냄** + ⑥ 그 차량들의 운전경비가 실제로 읽혀 있음 — 앞서 표가 페이지에서 잘려 통째로 버려졌음 +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402 + expand_codes, + load_operating_records, +) +from B09_Estimation.B09_Estimation_Transport import ( # noqa: E402 + TRANSPORT_VARIANTS, + cycle_minutes, + hours_per_trip, + parse_distance_km, + road_class, +) +from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices # noqa: E402 + +_TRAILER = next(v for v in TRANSPORT_VARIANTS if v["key"] == "트레일러20ton") +_TRUCK = next(v for v in TRANSPORT_VARIANTS if v["key"] == "트럭10.5ton") + + +def test_거리가_없으면_한_줄도_안_선다() -> None: + build = build_unit_prices() + assert not [code for code in build.book.titles if code.startswith("B-FP-10-04")] + assert any("거리" in note for note in build.transport_notes) + + +def test_도로를_안_고르면_안_선다() -> None: + build = build_unit_prices(transport_distance_km=Decimal(12)) + assert not [code for code in build.book.titles if code.startswith("B-FP-10-04")] + assert any("도로" in note for note in build.transport_notes) + + +def test_사이클이_원문_그대로다() -> None: + """③④ 트레일러 12㎞ 사리도로(양호) — 20 + (24÷20)×60 + 20 + 0.42 = 112.42분.""" + road = road_class("gravel_good") + assert road is not None + minutes = cycle_minutes(_TRAILER, Decimal(12), road) + assert minutes == Decimal(20) + Decimal("72") + Decimal(20) + Decimal("0.42") + # N = 60×0.9/㎝ 의 역수가 회당 시간이다. + hours = hours_per_trip(_TRAILER, Decimal(12), road) + assert hours == minutes / (Decimal(60) * Decimal("0.9")) + + +def test_트럭은_트럭_사이클을_쓴다() -> None: + road = road_class("gravel_good") + assert road is not None + minutes = cycle_minutes(_TRUCK, Decimal(12), road) + # 트럭은 사리도로(양호) 25㎞/hr — 10 + (24÷25)×60 + 10 + 5 + assert minutes == Decimal(10) + Decimal("57.6") + Decimal(10) + Decimal(5) + + +def test_원문이_비운_칸은_지어내지_않는다() -> None: + """⑤ 트레일러는 고속4차선 값이 원문에 「-」다.""" + road = road_class("expressway_4") + assert road is not None and road["trailer"] is None + with pytest.raises(ValueError) as caught: + cycle_minutes(_TRAILER, Decimal(12), road) + assert "원문에 없습니다" in str(caught.value) + + +def test_거리_칸은_비우면_없음이다() -> None: + assert parse_distance_km("") is None + assert parse_distance_km(" ") is None + assert parse_distance_km("0") is None + assert parse_distance_km("12.5") == Decimal("12.5") + with pytest.raises(ValueError): + parse_distance_km("멀다") + + +def test_거리를_넣으면_회당_단가가_선다() -> None: + build = build_unit_prices(transport_distance_km=Decimal(12), transport_road="gravel_good") + assert build.transport_notes == [] + for variant in TRANSPORT_VARIANTS: + code = f"B-FP-10-04#{variant['key']}" + title = build.book.titles[code] + assert title.unit == "회" + money = build.book.resolve(code) + assert money.total > 0 + # 손료(경비)·연료(재료)·운전사(노무) 셋이 다 들어야 수송비다. + assert money.material > 0 and money.labor > 0 and money.expense > 0 + + +def test_수송_차량_운전경비가_읽힌다() -> None: + """⑥ 표가 페이지에서 잘려 앞자리를 못 이어받아 39 기종이 통째로 버려지고 있었다.""" + codes = {record.machine_code for record in load_operating_records().records} + assert "2702-0020" in codes # 트럭 트랙터 및 평판트레일러 20ton + assert "0602-0105" in codes # 덤프트럭 10.5ton + + +def test_앞자리_이어받기는_이어받을_것이_있을_때만() -> None: + """⚠ 넓히면 없는 코드를 지어낸다 — 이어받을 앞자리가 없으면 그대로 버린다.""" + assert expand_codes(["0080", "0100"]) == [] + assert expand_codes(["0080", "0100"], "2101") == ["2101-0080", "2101-0100"] + assert expand_codes(["2101-0010", "0015"]) == ["2101-0010", "2101-0015"]