From 23f9656e91fb16f0e8172bd75e0e62169be10f93 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 23:45:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EA=B0=81=EC=A2=85=20=EC=A4=91?= =?UTF-8?q?=EA=B8=B0=EA=B2=BD=EB=B9=84=EA=B3=84=EC=82=B0=EC=84=9C=20?= =?UTF-8?q?=E2=80=94=20=EA=B8=B0=EC=A2=85=EB=A7=88=EB=8B=A4=20=ED=95=9C=20?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별표2 (5)(가) 아홉째. 중기목록표가 「얼마」라면 이 장은 「왜 그 값인가」임. 원가계산 → 중기 탭의 목록표 아래에 붙음. - 기종마다 취득가·내용시간·손료계수(상각·정비·관리) → 시간당 손료 · 주연료와 유가 · 조종원 일당과 시간당 환산 · 시간당 사용료 3분할을 차례로 보임. - 내역에 실제로 선 기종만 냄(카탈로그 613 을 다 뿌리지 않음). - 부착 장비(브레이커·콤팩터·집게)는 손료만 드는 것이 정상이라 결함으로 안 셈 — 제 엔진이 없어 연료·조종원이 본체에 듦(건설품셈 제8장 [주]⑤). - 수송비는 이 장에 안 붙임 — 「회당」으로 서는 별개 공종이라 자리만 가리킴. - 계산서 합계가 일위대가가 실제로 쓰는 값과 같은지 시험으로 못 박음(두 벌 방지). ⇒ 「설계서 구성」의 중기경비계산서가 반쪽 → 있음으로 바뀜(법이 정한 13 중 8 이 섬). Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_DesignDocIndex.py | 10 +- .../B09_Estimation_MachineExpenseSheet.py | 173 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 20 ++ B09_Estimation/B09_Estimation_UI_BaseData.ts | 106 +++++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 19 ++ resources/tester/test_b09_design_doc_index.py | 8 +- resources/tester/test_b09_machine_expense.py | 77 ++++++++ 7 files changed, 406 insertions(+), 7 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MachineExpenseSheet.py create mode 100644 resources/tester/test_b09_machine_expense.py diff --git a/B09_Estimation/B09_Estimation_DesignDocIndex.py b/B09_Estimation/B09_Estimation_DesignDocIndex.py index e86cbfc2..d42a8e33 100644 --- a/B09_Estimation/B09_Estimation_DesignDocIndex.py +++ b/B09_Estimation/B09_Estimation_DesignDocIndex.py @@ -113,13 +113,13 @@ DESIGN_DOC_ITEMS: tuple[dict[str, Any], ...] = ( { "order": 9, "name": "각종 중기경비계산서", - "status": STATUS_PARTIAL, + "status": STATUS_READY, "owner": OWNER_PROGRAM, - "where": "원가계산 → 중기(중기목록표)", + "where": "원가계산 → 중기(중기목록표 + 기종별 계산서)", "note": ( - "기종별 시간당 사용료 3분할은 서 있고 줄을 누르면 무엇으로 이루어졌는지 " - "파고들 수 있다. ⚠ 다만 실무 서식의 기종마다 한 장짜리 계산서" - "(취득가·손료계수·상각비·정비비·관리비 + 운전경비 + 수송비)는 아직 없다." + "목록표가 「얼마」라면 계산서는 「왜 그 값인가」다 — 기종마다 취득가·내용시간·" + "손료계수(상각·정비·관리) · 주연료와 유가 · 조종원 일당과 환산을 그대로 보인다. " + "수송비는 「회당」으로 서는 별개 공종이라 이 장에서는 어디서 서는지만 가리킨다." ), }, { diff --git a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py new file mode 100644 index 00000000..75a66fa7 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py @@ -0,0 +1,173 @@ +"""B09 — **각종 중기경비계산서** (별표2 (5)(가) 아홉째 · 건설품셈 8-1-6). + +**무엇인가** — 기종마다 **한 장**으로 「이 기계 한 시간이 왜 이 값인가」를 보이는 표다. + + 기계경비 = 기계손료 + 운전경비 + 수송비 (건설품셈 8-1-6의 1) + ├ 손료 = 취득가격(천원) × 1,000 × 시간당 손료계수 (상각·정비·관리 계수의 합) + ├ 운전경비 = 주연료 × 유가(+잡재료 %) + 조종원 일당 ÷ 8 × 제수당 계수 + └ 수송비 = **기종에 붙지 않는다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이라 + 이 장에서는 **어디서 서는지만** 가리킨다. + +⚠ **중기목록표와 다르다.** 목록표는 「기종별 시간당 사용료 얼마」 한 줄이고, 이 장은 + **그 값이 나온 과정**이다. 별표2 가 둘을 따로 적지 않았지만 실무 서식은 계산 과정을 + 기종마다 한 장으로 남긴다. + +⚠ **못 채운 성분은 0 으로 안 때운다** — 연료·조종원이 없으면 그 사실을 줄에 남긴다 + (`HourlyMachineCost.gaps` 와 같은 규칙). +""" + +from __future__ import annotations + +import json +import os +from decimal import Decimal +from functools import lru_cache +from typing import Any + +from B09_Estimation.B09_Estimation_MachineCost import ( + OPERATOR_ALLOWANCE_FACTOR, + OPERATOR_HOURS_PER_DAY, + load_machine_catalog, +) +from B09_Estimation.B09_Estimation_PriceBook import PriceKind + +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") +_THOUSAND = Decimal(1000) +_ZERO = Decimal(0) + +#: 부착 장비 — **제 엔진이 없어** 연료·조종원이 본체에 든다(품셈 제8장 [주]⑤). +#: ⚠ 그 셋을 「성분이 빔」으로 세면 **정상인 줄을 결함으로 읽는다** — 손료만 있는 것이 맞다. +_ATTACHMENT_PREFIXES = ("0103-", "0230-", "0240-", "7206-") +ATTACHMENT_NOTE = ( + "부착 장비라 손료만 듭니다 — 제 엔진이 없어 연료·조종원이 본체(굴착기·불도저)에" + " 들어갑니다(건설품셈 제8장 [주]⑤)." +) + +TRANSPORT_NOTE = ( + "수송비는 이 장에 안 붙습니다 — 산림품셈 10-4 가 「회당」으로 세는 별개 공종이라" + " 「산출 조건」에서 거리를 넣으면 중기운반 줄로 섭니다(건설품셈 8-1-6의 2)." +) +LOSS_NOTE = ( + "손료 = 취득가격(천원) × 1,000 × 시간당 손료계수. 계수는 상각비·정비비·관리비" + " 계수의 합이며 원문이 10⁻⁷ 단위로 줍니다(건설품셈 8-1-5·8-1-6)." +) +OPERATOR_NOTE = ( + "조종원 노임 = 일당 ÷ 8시간 × 16/12 × 25/20. 공표 노임이 기본급여액뿐이라" + " 제수당·상여금·퇴직급여충당금을 따로 계상합니다." +) + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +@lru_cache(maxsize=1) +def _loss_records() -> dict[str, dict[str, Any]]: + """기종코드 → 손료계수 원문 줄(상각·정비·관리 계수까지).""" + path = os.path.join(_project_root(), *_CATALOG_SUBPATH, "mach_base_2026.json") + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + records = payload["variables"]["mach_loss_coef"]["records"] + return {str(row["machine_code"]): row for row in records} + + +def _money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + +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 ( + load_operating_records, + load_operator_wages, + ) + + catalog = load_machine_catalog() + operating = {row.machine_code: row for row in load_operating_records().records} + wages = load_operator_wages() + fuel_price, fuel_meta = load_fuel_price() + loss = _loss_records() + + sheets: list[dict[str, Any]] = [] + for code, title in sorted(build.book.titles.items()): + if title.kind is not PriceKind.MACHINE_HOURLY: + continue + machine_code = code[2:].split("#")[0] + machine = catalog.machines.get(machine_code) + if machine is None: + continue + record = operating.get(machine_code) + raw = loss.get(machine_code) or {} + money = build.book.resolve(code) + + gaps: list[str] = [] + attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) + fuel_liters = getattr(record, "fuel_liters_per_hour", None) + misc_percent = getattr(record, "misc_material_percent", None) + occupation = getattr(record, "operator_occupation_code", "") or "" + wage = wages.get(occupation) + if not attachment: + if fuel_liters is None: + gaps.append("주연료 소요량(L/hr)이 없습니다 — 재료비 성분이 비어 있습니다") + if wage is None: + gaps.append("조종원 직종·일당이 없습니다 — 노무비 성분이 비어 있습니다") + if machine.loss_coefficient_per_hour is None: + gaps.append("손료계수가 없습니다 — 취득가만 있는 기종입니다") + + sheets.append( + { + "code": code, + "machine_code": machine_code, + "name": machine.name, + "spec": machine.specification, + # ① 손료 + "price_thousand_krw": _money(machine.price_thousand_krw), + "economic_life_hours": raw.get("economic_life_hours"), + "annual_standard_hours": raw.get("annual_standard_hours"), + "depreciation_coefficient": raw.get("depreciation_coefficient_1e_minus_7"), + "maintenance_coefficient": raw.get("maintenance_coefficient_1e_minus_7"), + "management_coefficient": raw.get("management_coefficient_1e_minus_7"), + "loss_coefficient": raw.get("source_coefficient_1e_minus_7"), + "loss_krw_per_hour": _money( + machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour + if machine.loss_coefficient_per_hour is not None + else None + ), + # ② 운전경비 + "fuel_liters_per_hour": _money(fuel_liters), + "fuel_price_per_liter": _money(fuel_price), + "fuel_scope": fuel_meta.get("region_name") or "전국 공시가", + "misc_material_percent": _money(misc_percent), + "operator_code": occupation, + "operator_daily_wage": _money(wage), + "operator_krw_per_hour": _money( + (wage / Decimal(OPERATOR_HOURS_PER_DAY)) * OPERATOR_ALLOWANCE_FACTOR + if wage is not None + else None + ), + # ③ 시간당 사용료 — 조립된 값(이 장의 결론) + "material_krw": _money(money.material), + "labor_krw": _money(money.labor), + "expense_krw": _money(money.expense), + "total_krw": _money(money.total), + "attachment": attachment, + "attachment_note": ATTACHMENT_NOTE if attachment else "", + "gaps": gaps, + } + ) + return sheets + + +def machine_expense_report(build: Any) -> dict[str, Any]: + """중기경비계산서 한 벌 — 장 목록 + 근거 문구.""" + sheets = machine_expense_sheets(build) + incomplete = [sheet["name"] for sheet in sheets if sheet["gaps"]] + return { + "sheets": sheets, + "notes": [LOSS_NOTE, OPERATOR_NOTE, TRANSPORT_NOTE, ATTACHMENT_NOTE], + "summary": ( + f"내역에 선 기종 {len(sheets)} 종의 계산 과정입니다" + + (f" — 성분이 빈 것 {len(incomplete)} 종." if incomplete else ".") + ), + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 068885bb..b2d4169f 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -319,6 +319,26 @@ async def get_price_sources(project_id: UUID) -> JSONResponse: ) +@router.get("/{project_id}/estimation/machine-expense") +async def get_machine_expense(project_id: UUID) -> JSONResponse: + """**각종 중기경비계산서** — 기종마다 한 장(별표2 (5)(가) 아홉째). + + 중기목록표가 「얼마」라면 이 장은 **「왜 그 값인가」**다 — 취득가·손료계수·연료·조종원을 + 그대로 보인다. + """ + from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_report + + try: + build = await _build_for(project_id) + return JSONResponse(content={"status": "success", **machine_expense_report(build)}) + except Exception: + logger.exception("B09 중기경비계산서 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "중기경비계산서를 못 만들었습니다."}, + ) + + @router.get("/{project_id}/estimation/design-doc-index") async def get_design_doc_index(project_id: UUID) -> JSONResponse: """**설계서 구성표** — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)). diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index e506637f..72cafae8 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -240,6 +240,112 @@ export function drawDesignDocTab(body: HTMLElement, data: DesignDocDto): void { for (const line of data.notes) body.append(note(line)); } +/* ============================================================================= + * 각종 중기경비계산서 — 기종마다 한 장(별표2 (5)(가) 아홉째). + * ⚠ 목록표가 「얼마」라면 이 장은 **왜 그 값인가**다. 계산 과정을 감추지 않는다. + * ========================================================================== */ + +export interface MachineExpenseDto { + status: string; + summary: string; + notes: string[]; + sheets: Array<{ + machine_code: string; + name: string; + spec: string; + price_thousand_krw: string | null; + economic_life_hours: number | null; + annual_standard_hours: number | null; + depreciation_coefficient: number | null; + maintenance_coefficient: number | null; + management_coefficient: number | null; + loss_coefficient: number | null; + loss_krw_per_hour: string | null; + fuel_liters_per_hour: string | null; + fuel_price_per_liter: string | null; + fuel_scope: string; + misc_material_percent: string | null; + operator_code: string; + operator_daily_wage: string | null; + operator_krw_per_hour: string | null; + material_krw: string | null; + labor_krw: string | null; + expense_krw: string | null; + total_krw: string | null; + attachment: boolean; + attachment_note: string; + gaps: string[]; + }>; +} + +export async function fetchMachineExpense(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/machine-expense`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`machine-expense ${response.status}`); + return (await response.json()) as MachineExpenseDto; +} + +/** 기종 한 장 — 손료·운전경비·시간당 사용료를 차례로. */ +function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLElement { + const box = document.createElement("div"); + box.className = "b09-panel__group"; + const title = document.createElement("p"); + title.className = "b09-panel__legend"; + title.textContent = `${sheet.machine_code} ${sheet.name} ${sheet.spec}`.trim(); + box.append(title); + + const coefficient = (value: number | null) => (value === null ? "—" : String(value)); + box.append( + table( + ["구 분", "내 용", "값"], + [ + ["① 손료", "취득가격(천원)", money(sheet.price_thousand_krw)], + [ + "", + "내용시간 / 연간표준가동시간", + `${coefficient(sheet.economic_life_hours)} / ${coefficient(sheet.annual_standard_hours)}`, + ], + [ + "", + "상각비·정비비·관리비 계수 (10⁻⁷)", + `${coefficient(sheet.depreciation_coefficient)} + ${coefficient(sheet.maintenance_coefficient)} + ${coefficient(sheet.management_coefficient)} = ${coefficient(sheet.loss_coefficient)}`, + ], + ["", "시간당 손료(원)", money(sheet.loss_krw_per_hour)], + [ + "② 운전경비", + `주연료(L/hr) × 유가(${sheet.fuel_scope})`, + `${sheet.fuel_liters_per_hour ?? "—"} × ${money(sheet.fuel_price_per_liter)}`, + ], + ["", "잡재료(주연료의 %)", sheet.misc_material_percent ?? "—"], + [ + "", + `조종원(${sheet.operator_code || "—"}) 일당 → 시간당`, + `${money(sheet.operator_daily_wage)} → ${money(sheet.operator_krw_per_hour)}`, + ], + [ + "③ 시간당 사용료", + "재료비 / 노무비 / 경비", + `${money(sheet.material_krw)} / ${money(sheet.labor_krw)} / ${money(sheet.expense_krw)}`, + ], + ["", "합 계", money(sheet.total_krw)], + ], + [0, 1], + ), + ); + if (sheet.attachment_note) box.append(note(sheet.attachment_note)); + for (const gap of sheet.gaps) box.append(note(`⚠ ${gap}`)); + return box; +} + +export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto): void { + body.append(head(`각종 중기경비계산서 (${data.sheets.length})`)); + body.append(note(data.summary)); + for (const line of data.notes) body.append(note(line)); + for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet)); +} + /** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */ export function drawBaseDataError(body: HTMLElement): void { body.append(note(L("B09_Estimation_Tab_Pending"))); diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index cfe77279..01149edc 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -20,15 +20,18 @@ import { drawBaseDataTab, drawFactorChoices, drawDesignDocTab, + drawMachineExpense, drawMachineTab, drawPriceSourcesPending, drawPriceSourcesSections, fetchBaseData, fetchDesignDocIndex, + fetchMachineExpense, fetchFactorChoices, fetchPriceSources, type BaseDataDto, type DesignDocDto, + type MachineExpenseDto, type FactorChoicesDto, type PriceSourcesDto, } from "./B09_Estimation_UI_BaseData"; @@ -812,6 +815,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { let priceSources: PriceSourcesDto | null = null; let factorChoices: FactorChoicesDto | null = null; let designDoc: DesignDocDto | null = null; + let machineExpense: MachineExpenseDto | null = null; let sheet: CostSheetDto | null = null; let unitPriceList: UnitPriceListDto | null = null; let unitPriceDetail: UnitPriceDetailDto | null = null; @@ -1216,6 +1220,21 @@ export async function renderB09Estimation(root: HTMLElement): Promise { } if (activeTab === "machine") { drawMachineTab(body, baseData); + // 계산서는 목록표 **아래**에 붙는다 — 「얼마」를 보고 「왜」로 내려간다. + if (machineExpense) { + drawMachineExpense(body, machineExpense); + return; + } + if (projectId) { + void fetchMachineExpense(projectId) + .then((data) => { + machineExpense = data; + drawBody(); + }) + .catch(() => { + /* 못 받아도 목록표는 그대로 선다. */ + }); + } return; } // 산출 조건이 목록표보다 **먼저** 선다 — 값을 낳는 자리가 값보다 아래 있으면 diff --git a/resources/tester/test_b09_design_doc_index.py b/resources/tester/test_b09_design_doc_index.py index 6ba8e4be..fc3f8ccf 100644 --- a/resources/tester/test_b09_design_doc_index.py +++ b/resources/tester/test_b09_design_doc_index.py @@ -61,8 +61,12 @@ def test_설계자_몫과_우리_몫을_가른다() -> None: def test_반쪽은_반쪽이라_적는다() -> None: + """⚠ 2026-09-09 밤 — 중기경비계산서를 세우면서 **반쪽이 하나 줄었다**(기종마다 한 장). + + 남은 반쪽은 산출기초 하나 — 근거 문구는 줄마다 있으나 한 장으로 묶는 자리가 없다. + """ partial = {item["name"] for item in DESIGN_DOC_ITEMS if item["status"] == STATUS_PARTIAL} - assert partial == {"각종 중기경비계산서", "산출기초"} + assert partial == {"산출기초"} def test_서는_것은_어디서_나오는지_적혀_있다() -> None: @@ -80,7 +84,7 @@ def test_못_내는_것에는_무엇이_필요한지_적혀_있다() -> None: def test_요약이_셈과_맞는다() -> None: data = design_doc_index() - assert data["counts"][STATUS_READY] == 7 + assert data["counts"][STATUS_READY] == 8 assert sum(data["counts"].values()) == len(DESIGN_DOC_ITEMS) ours_missing = [ item["name"] diff --git a/resources/tester/test_b09_machine_expense.py b/resources/tester/test_b09_machine_expense.py new file mode 100644 index 00000000..106c69f0 --- /dev/null +++ b/resources/tester/test_b09_machine_expense.py @@ -0,0 +1,77 @@ +"""각종 중기경비계산서 — 기종마다 한 장 (2026-09-09). + +별표2 (5)(가) 가 설계서에 「각종 중기경비계산서」를 넣으라 한다. 중기목록표는 「얼마」 한 줄 +이고, 이 장은 **그 값이 나온 과정**이다. + +⚠ 겨누는 것 다섯 + ① **내역에 선 기종만** 낸다 — 카탈로그 613 을 다 뿌리지 않는다 + ② 손료 = 취득가(천원) × 1,000 × 계수 — 천원 단위를 놓치면 1,000배 틀린다 + ③ 계산서의 시간당 합계가 **일위대가가 실제로 쓰는 값과 같다**(두 벌이면 안 된다) + ④ 부착 장비(브레이커·콤팩터·집게)는 **손료만 드는 것이 정상** — 결함으로 세지 않는다 + ⑤ 수송비는 이 장에 안 붙는다 — 「회당」으로 서는 별개 공종이라 자리만 가리킨다 +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_MachineExpenseSheet import ( # noqa: E402 + machine_expense_report, +) +from B09_Estimation.B09_Estimation_PriceBook import PriceKind # noqa: E402 +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + + +def _report(): + return machine_expense_report(cached_build()) + + +def test_내역에_선_기종만_낸다() -> None: + build = cached_build() + hourly = {c for c, t in build.book.titles.items() if t.kind is PriceKind.MACHINE_HOURLY} + sheets = _report()["sheets"] + assert sheets, "계산서가 한 장도 없습니다" + assert len(sheets) <= len(hourly) + assert {sheet["code"] for sheet in sheets} <= hourly + + +def test_손료가_취득가와_계수로_맞는다() -> None: + """② 천원 단위를 놓치면 1,000배 틀린다.""" + for sheet in _report()["sheets"]: + if sheet["loss_krw_per_hour"] is None or sheet["loss_coefficient"] is None: + continue + price = Decimal(sheet["price_thousand_krw"]) * Decimal(1000) + coefficient = Decimal(sheet["loss_coefficient"]) / Decimal(10**7) + assert abs(Decimal(sheet["loss_krw_per_hour"]) - price * coefficient) < Decimal("0.01") + + +def test_계산서_합계가_일위대가와_같은_값이다() -> None: + """③ 두 벌로 세면 화면과 금액이 어긋난다.""" + build = cached_build() + for sheet in machine_expense_report(build)["sheets"]: + used = build.book.resolve(sheet["code"]).total + assert Decimal(sheet["total_krw"]) == used, sheet["code"] + + +def test_부착_장비는_결함이_아니다() -> None: + """④ 제 엔진이 없어 연료·조종원이 본체에 든다(품셈 제8장 [주]⑤).""" + sheets = _report()["sheets"] + attachments = [sheet for sheet in sheets if sheet["attachment"]] + assert attachments, "부착 장비가 한 대도 안 잡혔습니다" + for sheet in attachments: + assert sheet["gaps"] == [] + assert sheet["attachment_note"] + assert sheet["loss_krw_per_hour"] is not None # 손료는 있어야 한다 + + +def test_수송비는_이_장에_안_붙는다() -> None: + """⑤ 「회당」으로 서는 별개 공종이라 자리만 가리킨다.""" + report = _report() + assert any("수송비" in line and "회당" in line for line in report["notes"]) + for sheet in report["sheets"]: + assert "transport" not in sheet