From 6c4d0251dbce36948fab97f975853e7b03d29454 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:13:20 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(B09):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=ED=83=AD=20=E2=80=94=20=EB=AA=A9=EB=A1=9D=ED=91=9C?= =?UTF-8?q?+=EB=B3=B8=ED=91=9C=202=EB=8B=A8,=20=EC=9B=90=EC=B2=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C,=20=EC=B8=B5=20=ED=8C=8C=EA=B3=A0=EB=93=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **API 2종** (`B09_Estimation_Router.py`) - `GET …/estimation/unit-prices` — **목록표** + **산출 요약**. 요약을 같이 보내는 까닭은 사용자가 「무엇이 안 선 상태인가」를 화면에서 알아야 하기 때문임(자재 카탈로그 미확보로 구조물 계열이 안 섬). - `GET …/estimation/unit-prices/{code}` — **본표**. 줄마다 원천(자재5·노임6·기계경비105· 일위대가103·단가산출104)과 **파고들기 가능 여부**가 붙음. 없는 코드는 404. - 품셈 3 MB 를 요청마다 다시 안 읽게 `cached_build()` 로 한 번만 조립. **화면** (`B09_Estimation_UI_Page.ts`) - 일위대가 탭 활성화. **목록표(위) + 본표(아래) 2단** — 9-3 「제목+상세 한 쌍」이 화면에도 그대로 섬. - 본표 줄마다 `원천(번호)` 표시, **기계 줄을 누르면 그 시간당 사용료 본표로 파고듦** (거기서 취득가·연료·조종원까지 보임). 값을 못 믿을 때 사람이 하는 일이 이것임. - **재료·노무·경비 3분할 + 합계 줄**, `TC = NC + GC + JC` 성립 여부를 화면 문구로 냄. - **산출 요약을 화면에 표시** — 자재가 없어 구조물 계열이 못 선다는 것을 그 자리에 적음. locale 은 **B09 키만** 추가(16줄), 공용 파일 다른 줄 무수정. ⚠ 화면 조작 검증은 다음 단계 — `tsc` 는 통과했고(남은 오류 1건은 메인 창 B08 파일), 백엔드 재시작·클릭 검증은 이어서 함. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Router.py | 46 ++++ B09_Estimation/B09_Estimation_UI_Page.ts | 275 ++++++++++++++++++++- B09_Estimation/B09_Estimation_UnitPrice.py | 133 +++++++++- ui_template/ui_template_locale_b2.ts | 16 ++ 4 files changed, 467 insertions(+), 3 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index df636e54..b34b4c90 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -26,7 +26,14 @@ from B09_Estimation.B09_Estimation_Engine_Cost import ( proposed_profit_adjustment, ) from B09_Estimation.B09_Estimation_Rates import RateLookupError +from B09_Estimation.B09_Estimation_PriceBook import PriceBookError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS +from B09_Estimation.B09_Estimation_UnitPrice import ( + build_summary, + cached_build, + detail_of, + list_unit_prices, +) from common_util.common_util_workflow_state import complete_stage from config.config_db import get_db_pool @@ -154,6 +161,45 @@ async def list_items(project_id: UUID) -> JSONResponse: ) +@router.get("/{project_id}/estimation/unit-prices") +async def list_unit_price_titles(project_id: UUID) -> JSONResponse: + """일위대가 **목록표** — 「무엇이 있나」 한 줄씩 + 산출 요약. + + 요약을 같이 보내는 까닭은 사용자가 **「무엇이 안 선 상태인가」를 화면에서** + 알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬). + """ + try: + build = cached_build() + return JSONResponse( + content={ + "status": "success", + "summary": build_summary(build), + "rows": list_unit_prices(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/unit-prices/{code}") +async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: + """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다.""" + try: + return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)}) + except PriceBookError as error: + return JSONResponse(status_code=404, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 일위대가 본표 실패: project_id=%s, code=%s", project_id, code) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "일위대가 본표를 못 만들었습니다."}, + ) + + @router.post("/{project_id}/estimation/confirm") async def confirm_estimation(project_id: UUID) -> JSONResponse: """원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다.""" diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 7e0a684c..fc8bcbb5 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -49,6 +49,47 @@ interface CostSheetDto { suggested_profit_adjustment_krw?: string; } +interface UnitPriceRow { + code: string; + name: string; + spec: string; + unit: string; + material: string; + labor: string; + expense: string; + total: string; +} + +interface UnitPriceListDto { + status: string; + summary: { titles: number; unit_prices: number; machine_hourly: number; notes: string[] }; + rows: UnitPriceRow[]; +} + +interface UnitPriceDetailRow extends UnitPriceRow { + ref_code: string; + source_label: string; + source_index: number; + drillable: boolean; + quantity: string; + unit_total: string; + note: string; +} + +interface UnitPriceDetailDto { + status: string; + code: string; + name: string; + spec: string; + unit: string; + material: string; + labor: string; + expense: string; + total: string; + sum_matches: boolean; + rows: UnitPriceDetailRow[]; +} + /** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ interface CostFormState { direct_material_krw: string; @@ -128,6 +169,10 @@ function injectStyles(): void { .b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } .b09-sheet tr.is-adopted td { background: var(--color-surface); } .b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } +.b09-clickable { cursor: pointer; } +.b09-clickable:hover td { background: var(--color-surface); } +.b09-up-list { max-height: 45%; } +.b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; } .b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); } `; document.head.append(style); @@ -199,6 +244,152 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { return wrap; } +/** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */ +function buildUnitPriceList( + list: UnitPriceListDto, + selected: string | null, + onPick: (code: string) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet b09-up-list"; + + const caption = document.createElement("div"); + caption.className = "b09-hint"; + caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`; + wrap.append(caption); + + const table = document.createElement("table"); + const head = document.createElement("tr"); + for (const [key, left] of [ + ["B09_Estimation_Col_Name", true], + ["B09_Estimation_Col_Unit", true], + ["B09_Estimation_Col_Material", false], + ["B09_Estimation_Col_Labor", false], + ["B09_Estimation_Col_Expense", false], + ["B09_Estimation_Col_Total", false], + ] as Array<[keyof typeof ui_locales, boolean]>) { + const th = document.createElement("th"); + th.textContent = L(key); + if (left) th.className = "b09-left"; + head.append(th); + } + const thead = document.createElement("thead"); + thead.append(head); + table.append(thead); + + const body = document.createElement("tbody"); + for (const row of list.rows) { + const tr = document.createElement("tr"); + tr.className = "b09-clickable"; + if (row.code === selected) tr.classList.add("is-adopted"); + tr.addEventListener("click", () => onPick(row.code)); + + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = row.name; + const unit = document.createElement("td"); + unit.className = "b09-left"; + unit.textContent = row.unit; + tr.append(name, unit); + for (const value of [row.material, row.labor, row.expense, row.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + tr.append(cell); + } + body.append(tr); + } + table.append(body); + wrap.append(table); + return wrap; +} + +/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */ +function buildUnitPriceDetail( + detail: UnitPriceDetailDto, + onDrill: (code: string) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet b09-up-detail"; + + const caption = document.createElement("div"); + caption.className = "b09-hint"; + caption.textContent = + `${L("B09_Estimation_UP_Detail")} · ${detail.name}` + + (detail.spec ? ` (${detail.spec})` : "") + + ` · ${formatWon(detail.total)}` + + ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; + wrap.append(caption); + + const table = document.createElement("table"); + const head = document.createElement("tr"); + for (const [key, left] of [ + ["B09_Estimation_Col_Name", true], + ["B09_Estimation_Col_Spec", true], + ["B09_Estimation_Col_Source", true], + ["B09_Estimation_Col_Unit", true], + ["B09_Estimation_Col_Qty", false], + ["B09_Estimation_Col_Material", false], + ["B09_Estimation_Col_Labor", false], + ["B09_Estimation_Col_Expense", false], + ["B09_Estimation_Col_Total", false], + ] as Array<[keyof typeof ui_locales, boolean]>) { + const th = document.createElement("th"); + th.textContent = L(key); + if (left) th.className = "b09-left"; + head.append(th); + } + const thead = document.createElement("thead"); + thead.append(head); + table.append(thead); + + const body = document.createElement("tbody"); + for (const row of detail.rows) { + const tr = document.createElement("tr"); + if (row.drillable) { + tr.className = "b09-clickable"; + tr.title = L("B09_Estimation_UP_Drill"); + tr.addEventListener("click", () => onDrill(row.ref_code)); + } + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = row.drillable ? `▸ ${row.name}` : row.name; + const spec = document.createElement("td"); + spec.className = "b09-left"; + spec.textContent = row.spec; + const source = document.createElement("td"); + source.className = "b09-left"; + source.textContent = `${row.source_label} (${row.source_index})`; + const unit = document.createElement("td"); + unit.className = "b09-left"; + unit.textContent = row.unit; + tr.append(name, spec, source, unit); + for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + tr.append(cell); + } + body.append(tr); + } + + const sum = document.createElement("tr"); + sum.className = "is-total"; + const label = document.createElement("td"); + label.className = "b09-left"; + label.colSpan = 5; + label.textContent = L("B09_Estimation_Col_Total"); + sum.append(label); + for (const value of [detail.material, detail.labor, detail.expense, detail.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + sum.append(cell); + } + body.append(sum); + + table.append(body); + wrap.append(table); + return wrap; +} + /* ----------------------------------------------------------------------------- * 좌측 패널 * -------------------------------------------------------------------------- */ @@ -310,7 +501,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], ["boq", "B09_Estimation_Tab_Boq", false], - ["unit_price", "B09_Estimation_Tab_UnitPrice", false], + ["unit_price", "B09_Estimation_Tab_UnitPrice", true], ["price_basis", "B09_Estimation_Tab_PriceBasis", false], ["machine", "B09_Estimation_Tab_Machine", false], ["duration", "B09_Estimation_Tab_Duration", false], @@ -371,6 +562,27 @@ async function fetchCostSheet(projectId: string, form: CostFormState): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`unit price list failed: ${response.status}`); + return (await response.json()) as UnitPriceListDto; +} + +async function fetchUnitPriceDetail( + projectId: string, + code: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`); + return (await response.json()) as UnitPriceDetailDto; +} + async function confirmEstimationStage(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, @@ -389,6 +601,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise { const form: CostFormState = { ...INITIAL_FORM }; let activeTab = "cost_sheet"; let sheet: CostSheetDto | null = null; + let unitPriceList: UnitPriceListDto | null = null; + let unitPriceDetail: UnitPriceDetailDto | null = null; + let selectedUnitPrice: string | null = null; const main = document.createElement("div"); main.className = "b09-main"; @@ -398,8 +613,58 @@ export async function renderB09Estimation(root: HTMLElement): Promise { body.style.display = "flex"; body.style.flexDirection = "column"; + /** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */ + const openUnitPrice = async (code: string): Promise => { + if (!projectId) return; + try { + unitPriceDetail = await fetchUnitPriceDetail(projectId, code); + selectedUnitPrice = code; + drawBody(); + } catch { + showToast(L("B09_Estimation_UP_Load_Failed"), "error"); + } + }; + + const drawUnitPriceTab = (): void => { + if (!unitPriceList) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Tab_Pending"); + body.append(empty); + return; + } + // 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다. + for (const note of unitPriceList.summary.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note; + body.append(line); + } + body.append( + buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => { + void openUnitPrice(code); + }), + ); + if (unitPriceDetail) { + body.append( + buildUnitPriceDetail(unitPriceDetail, (code) => { + void openUnitPrice(code); + }), + ); + } else { + const hint = document.createElement("div"); + hint.className = "b09-empty"; + hint.textContent = L("B09_Estimation_UP_Pick"); + body.append(hint); + } + }; + const drawBody = (): void => { body.replaceChildren(); + if (activeTab === "unit_price") { + drawUnitPriceTab(); + return; + } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; @@ -428,6 +693,14 @@ export async function renderB09Estimation(root: HTMLElement): Promise { activeTab = key; drawTabs(); drawBody(); + if (key === "unit_price" && !unitPriceList && projectId) { + void fetchUnitPriceList(projectId) + .then((data) => { + unitPriceList = data; + drawBody(); + }) + .catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error")); + } }); const old = main.querySelector(".b09-tabs"); if (old) old.replaceWith(bar); diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 19c6a8b2..1aed4d1d 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -19,6 +19,7 @@ from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal +from functools import lru_cache from B09_Estimation.B09_Estimation_Guards import check_surcharge_once from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog @@ -177,8 +178,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: 공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은 공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다. """ + master = load_work_item_master() if axis is None: - axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) + axis = build_resource_axis(master, load_combined_catalog()) + # 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다. + names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])} build = UnitPriceBuild() wages = load_operator_wages() @@ -197,7 +201,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: continue unit = next((r.amount_unit for r in rows if r.amount_unit), "") build.book.add_title( - PriceTitle(code=title_code, kind=PriceKind.UNIT_PRICE, name=work_item_code, unit=unit) + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=names.get(work_item_code) or work_item_code, + spec=work_item_code, + unit=unit, + ) ) added = 0 for row in rows: @@ -230,3 +240,122 @@ def verify_surcharge_once( surcharge_rate_percent=surcharge_rate_percent, label=code, ) + + +#: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축). +SOURCE_INDEX: dict[PriceKind, int] = { + PriceKind.MATERIAL: 5, + PriceKind.LABOR: 6, + PriceKind.MACHINE_BASE: 105, + PriceKind.MACHINE_HOURLY: 105, + PriceKind.UNIT_PRICE: 103, + PriceKind.PRICE_BASIS: 104, + PriceKind.LUMPSUM: 0, +} +SOURCE_LABEL: dict[PriceKind, str] = { + PriceKind.MATERIAL: "자재", + PriceKind.LABOR: "노임", + PriceKind.MACHINE_BASE: "기계경비", + PriceKind.MACHINE_HOURLY: "기계경비", + PriceKind.UNIT_PRICE: "일위대가", + PriceKind.PRICE_BASIS: "단가산출", + PriceKind.LUMPSUM: "일식·견적", +} + +#: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다. +DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS}) + + +@lru_cache(maxsize=1) +def cached_build() -> UnitPriceBuild: + """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.""" + return build_unit_prices() + + +def build_summary(build: UnitPriceBuild) -> dict: + """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" + kinds: dict[str, int] = {} + for title in build.book.titles.values(): + kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 + return { + "titles": len(build.book.titles), + "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), + "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), + "skipped_work_items": len(build.skipped), + "incomplete_machines": len(build.incomplete_machines), + "kinds": kinds, + # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). + "notes": [ + "자재 카탈로그가 아직 없어 **구조물 계열 일위대가가 서지 않습니다** — " + "지금 선 것은 노무·기계 성분뿐입니다(연료만 자재로 섭니다).", + "사급 잡자재 단가는 미결입니다 — 값을 지어내지 않고 비워 둡니다.", + ], + } + + +def list_unit_prices(build: UnitPriceBuild) -> list[dict]: + """목록표 — 「무엇이 있나」 한 줄씩.""" + rows: list[dict] = [] + for code, title in sorted(build.book.titles.items()): + if title.kind is not PriceKind.UNIT_PRICE: + continue + money = build.book.resolve(code) + rows.append( + { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "material": str(money.material), + "labor": str(money.labor), + "expense": str(money.expense), + "total": str(money.total), + } + ) + return rows + + +def detail_of(build: UnitPriceBuild, code: str) -> dict: + """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다.""" + title = build.book.title(code) + money = build.book.resolve(code) + rows: list[dict] = [] + for detail in build.book.details.get(code, []): + child = build.book.title(detail.ref_code) + unit_money = build.book.resolve(detail.ref_code) + line = unit_money.scaled(detail.quantity) + rows.append( + { + "ref_code": detail.ref_code, + "name": child.name, + "spec": child.spec, + "unit": child.unit, + "source_index": SOURCE_INDEX.get(child.kind, 0), + "source_label": SOURCE_LABEL.get(child.kind, ""), + "drillable": child.kind in DRILLABLE_KINDS, + "quantity": str(detail.quantity), + "unit_material": str(unit_money.material), + "unit_labor": str(unit_money.labor), + "unit_expense": str(unit_money.expense), + "unit_total": str(unit_money.total), + "material": str(line.material), + "labor": str(line.labor), + "expense": str(line.expense), + "total": str(line.total), + "note": detail.note, + } + ) + return { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "kind": title.kind.value, + "material": str(money.material), + "labor": str(money.labor), + "expense": str(money.expense), + "total": str(money.total), + # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. + "sum_matches": money.total == money.material + money.labor + money.expense, + "rows": rows, + } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 75fceeee..b9ee8d06 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -669,6 +669,22 @@ export const ui_locales_b2 = { "Failed to confirm the cost estimate stage.", ], B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], + B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"], + B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"], + B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."], + B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"], + B09_Estimation_Col_Name: ["명칭", "Name"], + B09_Estimation_Col_Spec: ["규격", "Spec"], + B09_Estimation_Col_Unit: ["단위", "Unit"], + B09_Estimation_Col_Qty: ["수량", "Qty"], + B09_Estimation_Col_Source: ["원천", "Source"], + B09_Estimation_Col_Material: ["재료비", "Material"], + B09_Estimation_Col_Labor: ["노무비", "Labor"], + B09_Estimation_Col_Expense: ["경비", "Expense"], + B09_Estimation_Col_Total: ["합계", "Total"], + B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], + B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], + B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"], From 20018c6739f3e1fec260fc12d7e87e607b45b46b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:13:47 +0900 Subject: [PATCH 02/11] =?UTF-8?q?style(B09):=20=EB=9D=BC=EC=9A=B0=ED=84=B0?= =?UTF-8?q?=20import=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index b34b4c90..cdc4101d 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -25,8 +25,8 @@ from B09_Estimation.B09_Estimation_Engine_Cost import ( calculate_cost, proposed_profit_adjustment, ) -from B09_Estimation.B09_Estimation_Rates import RateLookupError from B09_Estimation.B09_Estimation_PriceBook import PriceBookError +from B09_Estimation.B09_Estimation_Rates import RateLookupError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS from B09_Estimation.B09_Estimation_UnitPrice import ( build_summary, From 29cf61f4ad8935202737986cafd92f9f0f07670a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:23:09 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(B09):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EA=B8=88=EC=95=A1=200.1=EC=9B=90=20=EB=B2=84?= =?UTF-8?q?=EB=A6=BC=20=E2=80=94=20=EC=9E=90=EB=A6=AC=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20+=20=EB=81=9D=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=B0=A8=EC=9D=B4=EB=A5=BC=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=93=9C=EB=9F=AC=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `OutputPlace.UNIT_PRICE_ROW` 추가 — **0.1원 미만 버림**(품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」). 계산은 전정밀, **표 그리는 자리에서만** 자름(단수는 출력 위치에 붙음). - **행 합계 = 자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 섬. 전정밀 합을 따로 자르면 성분과 합계가 어긋나 보임. 표 전체 합계도 **행별로 자른 값을 더함** (`단수처리_규칙.md` §2 「행별 처리(합계 후 아님)」). - ⚠ **행별 절사로 생기는 끝자리 차이를 숨기지 않음** — 화면에 「행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계: …」를 띄움. 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 듦. **화면 실측(5174)** — 일위대가 탭 → 목록 64건 → 「제근」 본표 → `▸ 굴착기(무한궤도) 0.2 | 기계경비(105) | hr | 0.8 | 8,936.5 | 28,329.7 | 11,098.5 | 48,364.7` → 기계 줄 클릭 → **시간당 사용료 본표로 파고듦**(손료 13,873.1 · 경유 11,170.6 · 건설기계운전사 35,412.1 = 60,455.9). 합계 98,292.9 / 자르기 전 98,293.2 표시. 산출 요약 2줄(자재 미확보로 구조물 계열 안 섬)도 탭 상단에 뜸. ⚠ 검증 함정 기록 — 해시 라우트에 쿼리를 붙일 때 `#/b09-estimation?v=2` 는 라우트가 안 잡힘. **`?v=2#/b09-estimation` 처럼 해시 앞에** 붙여야 함. 자체검증 — `pytest tmp/tests/ -q` 116 passed · ruff 통과 · tsc(B09) 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Rounding.py | 4 ++ B09_Estimation/B09_Estimation_UI_Page.ts | 10 ++++ B09_Estimation/B09_Estimation_UnitPrice.py | 58 +++++++++++++++------- ui_template/ui_template_locale_b2.ts | 4 ++ 4 files changed, 59 insertions(+), 17 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Rounding.py b/B09_Estimation/B09_Estimation_Rounding.py index 3447e4be..5a939ebe 100644 --- a/B09_Estimation/B09_Estimation_Rounding.py +++ b/B09_Estimation/B09_Estimation_Rounding.py @@ -36,6 +36,8 @@ class OutputPlace(str, Enum): RESOURCE_SUMMARY = "resource_summary" #: 관급자재대 총액 — **천원 올림** OWNER_MATERIAL_TOTAL = "owner_material_total" + #: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」) + UNIT_PRICE_ROW = "unit_price_row" def round_at(value: Decimal, place: OutputPlace) -> Decimal: @@ -48,6 +50,8 @@ def round_at(value: Decimal, place: OutputPlace) -> Decimal: return value.quantize(_ONE, rounding=ROUND_FLOOR) if place is OutputPlace.RESOURCE_SUMMARY: return value.quantize(_ONE, rounding=ROUND_HALF_UP) + if place is OutputPlace.UNIT_PRICE_ROW: + return value.quantize(Decimal("0.1"), rounding=ROUND_FLOOR) if place is OutputPlace.OWNER_MATERIAL_TOTAL: return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_CEILING) * _THOUSAND raise ValueError(f"단수 처리 자리를 모릅니다: {place}") diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index fc8bcbb5..813d2dd1 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -78,6 +78,7 @@ interface UnitPriceDetailRow extends UnitPriceRow { interface UnitPriceDetailDto { status: string; + precise_total: string; code: string; name: string; spec: string; @@ -320,6 +321,15 @@ function buildUnitPriceDetail( ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; wrap.append(caption); + // 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.** + // 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다. + if (detail.precise_total !== detail.total) { + const gap = document.createElement("div"); + gap.className = "b09-hint"; + gap.textContent = `${L("B09_Estimation_UP_RoundGap")} ${formatWon(detail.precise_total)}`; + wrap.append(gap); + } + const table = document.createElement("table"); const head = document.createElement("tr"); for (const [key, left] of [ diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 1aed4d1d..34351e6d 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -41,6 +41,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( load_labor_catalog, load_work_item_master, ) +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. @@ -293,6 +294,15 @@ def build_summary(build: UnitPriceBuild) -> dict: } +def _money_text(value: Decimal) -> str: + """화면에 낼 금액 — **일위대가 금액란은 0.1원 미만 버림**(품셈 1-2-2). + + 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 + (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). + """ + return str(round_at(value, OutputPlace.UNIT_PRICE_ROW)) + + def list_unit_prices(build: UnitPriceBuild) -> list[dict]: """목록표 — 「무엇이 있나」 한 줄씩.""" rows: list[dict] = [] @@ -306,10 +316,10 @@ def list_unit_prices(build: UnitPriceBuild) -> list[dict]: "name": title.name, "spec": title.spec, "unit": title.unit, - "material": str(money.material), - "labor": str(money.labor), - "expense": str(money.expense), - "total": str(money.total), + "material": _money_text(money.material), + "labor": _money_text(money.labor), + "expense": _money_text(money.expense), + "total": _money_text(money.total), } ) return rows @@ -334,28 +344,42 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "source_label": SOURCE_LABEL.get(child.kind, ""), "drillable": child.kind in DRILLABLE_KINDS, "quantity": str(detail.quantity), - "unit_material": str(unit_money.material), - "unit_labor": str(unit_money.labor), - "unit_expense": str(unit_money.expense), - "unit_total": str(unit_money.total), - "material": str(line.material), - "labor": str(line.labor), - "expense": str(line.expense), - "total": str(line.total), + "unit_material": _money_text(unit_money.material), + "unit_labor": _money_text(unit_money.labor), + "unit_expense": _money_text(unit_money.expense), + "unit_total": _money_text(unit_money.total), + "material": _money_text(line.material), + "labor": _money_text(line.labor), + "expense": _money_text(line.expense), + # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다. + # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다. + "total": str( + round_at(line.material, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW) + ), "note": detail.note, } ) + # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」 + # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다. + summed = { + key: sum((Decimal(r[key]) for r in rows), Decimal(0)) + for key in ("material", "labor", "expense", "total") + } return { "code": code, "name": title.name, "spec": title.spec, "unit": title.unit, "kind": title.kind.value, - "material": str(money.material), - "labor": str(money.labor), - "expense": str(money.expense), - "total": str(money.total), + "material": str(summed["material"]), + "labor": str(summed["labor"]), + "expense": str(summed["expense"]), + "total": str(summed["total"]), # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. - "sum_matches": money.total == money.material + money.labor + money.expense, + "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], + # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. + "precise_total": _money_text(money.total), "rows": rows, } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 076421a9..e64aa2c3 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -701,6 +701,10 @@ export const ui_locales_b2 = { B09_Estimation_Col_Total: ["합계", "Total"], B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], + B09_Estimation_UP_RoundGap: [ + "행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:", + "Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:", + ], B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], /* --- B10_Payment 결재 --- */ From f2d12afef71af8c4ba8fc9509022aa464efb35ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:26:35 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(B09):=20=E3=89=A4=20=EC=97=B4=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=E2=80=94=20=ED=96=89=20=EA=B2=80=EC=82=AC=EB=A1=9C=20=EC=95=88?= =?UTF-8?q?=20=EC=9E=A1=ED=9E=88=EB=8A=94=20=EC=9D=B4=EC=A4=91=20=ED=95=A9?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배분 창이 보고 숫자를 더해 보고 **노무 열만 5,162 어긋남**을 짚음. 확인 결과: - **값은 멀쩡했음. 내 보고가 줄 하나를 빠뜨린 것**(「제근」 본표는 보통인부 줄이 **둘**임 — 굴착기 규격 2종에 각각 붙음). 실제 열 합은 재료 20,956.3 · 노무 54,943.2 · 경비 22,393.4 · 합계 98,292.9 로 표시와 **전건 일치**. - **다만 지적한 「열 방향 검사가 비어 있다」는 실재했음.** `TC = NC + GC + JC` 는 **행 방향** 검사라, 같은 성분을 두 층에서 세는 어긋남(행마다는 다 맞고 열 합만 갈림)을 못 잡음. 예 — 기계 줄 안에 든 조종원 노무가 별도 노무 줄로도 서는 경우. - `check_column_sums()` 추가하고 `detail_of()` 에서 **실제로 호출**. 표시 합계가 상세 줄의 열별 합과 다르면 멈춤. - 테스트 둘 — ① 노무 열만 부푼 모양을 만들어 **행 검사가 통과하는데 열 검사가 잡는지** 확인 ② 실제 「제근」 본표가 열 검사를 통과하고 보통인부 줄이 둘인지 확인. ⇒ 이중계상 감시가 넷(㉠할증·㉡무대·㉢배합·㉣작업효율) + **방향 검사 ㉤** 로 늘어남. 자체검증 — 신규 2건 포함 `pytest tmp/tests/ -q` 118 passed · ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Guards.py | 26 ++++++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 5 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py index 7f07ef7d..315aeb44 100644 --- a/B09_Estimation/B09_Estimation_Guards.py +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -153,3 +153,29 @@ def check_operator_hours_basis( f"{daily_wage:,.0f} × {person_days} ÷ {hours_per_day}h = {expected:,.2f} 와 다릅니다 — " "나눗수를 줄이면 작업효율을 사용료에 넣은 것이 됩니다 (PLAN 9-6 ㉣)." ) + + +def check_column_sums( + *, + rows: list[dict], + totals: dict[str, Decimal], + columns: tuple[str, ...] = ("material", "labor", "expense", "total"), + label: str = "본표", +) -> None: + """㉤ **열 방향** 검사 — 표시된 합계가 상세 줄의 열별 합과 같은가. + + `TC = NC + GC + JC` 는 **행 방향** 검사라 「같은 성분을 두 층에서 세는」 어긋남을 + 못 잡는다(행마다는 다 맞는데 열 합만 갈리는 모양). 그래서 방향을 하나 더 둔다. + + 예 — 기계 줄 안에 든 조종원 노무가 별도 노무 줄로도 서면 노무 열만 부풀고 + 행 검사는 전부 통과한다. + """ + for column in columns: + column_sum = sum((Decimal(str(row[column])) for row in rows), Decimal(0)) + shown = Decimal(str(totals[column])) + if abs(column_sum - shown) > _TOLERANCE: + raise DoubleCountError( + f"{label}: `{column}` 열 합계가 어긋납니다 — 줄 합 {column_sum:,.2f} vs " + f"표시 {shown:,.2f}. 같은 성분을 두 층에서 셌을 수 있습니다 " + "(행 방향 `TC=NC+GC+JC` 검사로는 안 잡힘)." + ) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 34351e6d..352c4ec9 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -21,7 +21,7 @@ from dataclasses import dataclass, field from decimal import Decimal from functools import lru_cache -from B09_Estimation.B09_Estimation_Guards import check_surcharge_once +from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineOperating import ( load_fuel_price, @@ -367,6 +367,9 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: key: sum((Decimal(r[key]) for r in rows), Decimal(0)) for key in ("material", "labor", "expense", "total") } + # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다. + # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다. + check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표") return { "code": code, "name": title.name, From 3762a06b252246e6a57776c31ff6cf52eea52fc6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:33:42 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix(B09):=20=EB=84=93=EC=9D=80=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EA=B0=80=20=EC=A0=95=EC=83=81=20=EC=9E=90?= =?UTF-8?q?=EC=9B=90=2070=EA=B1=B4=EC=9D=84=20=EC=A7=80=EC=9A=B0=EA=B3=A0?= =?UTF-8?q?=20=EC=9E=88=EC=97=88=EC=9D=8C=20=E2=80=94=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인의 `막자갈`→배합 `자갈` 오탐 사례를 전해 듣고 내 필터를 재 봤더니 **같은 병이 있었음.** - **실측** — `is_non_resource_label()` 이 머리글 낱말을 **부분일치**로 보고 있어 `건설기계운전사`·`일반기계운전사`·`작업반장`·`인력운반공`·`비계공`·`계장공` 등 **정상 자원 70 / 745** 를 「자원 아님」으로 지우고 있었음(「계」·「작업」·「인력」에 걸림). 그 탓에 **매칭 14건이 조용히 없어졌음**. - **고침 둘** ① 머리글 판정을 **정확 일치 + 머리글 낱말 조합**(「단위작업별」·「위치및면적」)으로 좁힘. ② **카탈로그 조회를 필터보다 먼저** 함 — 카탈로그에 있는 이름은 **정의상 자원**이라, 필터가 넓어져도 정상 자원이 안 사라지는 구조가 됨. - **결과** — 매칭 91 → **106**(노무 100 · 기종 6). 산출 파일 다시 냄. **오탐 짝 시험을 함께 넣음** (「걸려야 한다」 + **「걸리면 안 된다」**) - 머리글 필터 — 정상 자원 8종은 안 먹고 머리글 7종은 잡는지. - ㉡ 무대 — 이름에 「운반」이 든 `도자운반`·`덤프운반` 줄은 **안 걸리는지** (판정을 이름이 아니라 `equipment` 정확 일치로 하는 근거). - ㉣ 효율 — 손료계수 0.0002085·조종원 0.125 처럼 **0~1 이지만 효율이 아닌 값**은 안 걸리는지(효율 자리로 들어올 때만 막는 근거). **덤으로 잡은 것** — 상세가 하나도 안 붙는 일위대가가 **제목만 서서** 「상세 줄이 없어 단가를 못 조립」하는 상태가 있었음. **붙을 상세를 먼저 모으고 없으면 제목도 안 세움** (일위대가 67 · 건너뜀 1). 전수 시험(`모든 일위대가 총액 > 0`)이 이걸 잡았음. PLAN 9-6 에 ㉤(열 방향 검사, 이중계상 규칙이 아니라 표 정합 검사)과 이번 필터 교훈 기록. 자체검증 — `pytest tmp/tests/ -q` **123 passed** · ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_ResourceAxis.py | 32 +- B09_Estimation/B09_Estimation_UnitPrice.py | 21 +- .../resource_axis_2026-01-01.json | 184 ++++++- .../unmatched_2026-01-01.json | 452 +++++++++++++++++- 4 files changed, 674 insertions(+), 15 deletions(-) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index ab7c6d58..881409c3 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -41,8 +41,12 @@ _CATALOG_SUBPATH = ("resources", "data_cost_input_value") _RE_SPEC = re.compile(r"[((]([^))]+)[))]|(\d+(?:\.\d+)?\s*(?:톤|ton|㎥|m3|㎡|㎜|mm|HP|kW))") _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$") -#: 표 머리글·소계 행의 첫 칸에 흔히 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다. +#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다. #: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다. +#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`· +#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이 +#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음). +#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다. _NON_RESOURCE_WORDS = ( "구분", "합계", @@ -149,7 +153,24 @@ def is_non_resource_label(cell: str) -> bool: # 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다. if len(_RE_HANGUL.findall(text)) < 2: return True - return any(word in text for word in _NON_RESOURCE_WORDS) + # ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석). + if text in _NON_RESOURCE_WORDS: + return True + # 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다. + return _is_header_composite(text) + + +def _is_header_composite(text: str) -> bool: + """머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것. + + 낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면 + 반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`). + """ + rest = text + for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True): + rest = rest.replace(word, "") + rest = rest.replace("및", "").replace("별", "").strip() + return rest == "" def _normalize(text: str) -> str: @@ -400,8 +421,6 @@ def match_table( if not cells: continue name_cell = cells[0] - if is_non_resource_label(name_cell): - continue # 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다. amount_cell = next( @@ -410,8 +429,13 @@ def match_table( if amount_cell is None: continue + # ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때 + # 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을 + # 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다. entry = _resolve_cell(catalog, name_cell, cells) if entry is None: + if is_non_resource_label(name_cell): + continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다 name, spec = split_name_and_spec(name_cell) found = catalog.by_name(name) if len(found) > 1: diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 352c4ec9..bf17bf8d 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -200,6 +200,18 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: title_code = f"B-{work_item_code}" if title_code in build.book.titles: continue + # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** + # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 + # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). + attachable = [ + (row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}") + for row in rows + ] + attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] + if not attachable: + build.skipped.append(work_item_code) + continue + unit = next((r.amount_unit for r in rows if r.amount_unit), "") build.book.add_title( PriceTitle( @@ -210,15 +222,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: unit=unit, ) ) - added = 0 - for row in rows: - ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" - if ref not in build.book.titles: - continue + for row, ref in attachable: build.book.add_detail(PriceDetail(title_code, ref, row.amount)) - added += 1 - if added == 0: - build.skipped.append(work_item_code) return build 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 48e47176..a1abf61c 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 @@ -307,6 +307,18 @@ "resource_spec": "", "work_item_code": "FP-06-05" }, + { + "amount": "2.00", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0163", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-06-05" + }, { "amount": "0.29", "amount_unit": "", @@ -355,6 +367,18 @@ "resource_spec": "", "work_item_code": "FP-06-07-02" }, + { + "amount": "0.75", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0236", + "raw_row_index": 1, + "resource_code": "7930-0001", + "resource_kind": "machine", + "resource_name": "", + "resource_spec": "0.75", + "work_item_code": "FP-08-10" + }, { "amount": "0.16", "amount_unit": "", @@ -595,6 +619,18 @@ "resource_spec": "", "work_item_code": "FP-10-06-02" }, + { + "amount": "0.35", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0305", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-10-07-01" + }, { "amount": "0.35", "amount_unit": "", @@ -607,6 +643,18 @@ "resource_spec": "", "work_item_code": "FP-10-07-01" }, + { + "amount": "2.0", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0306", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-10-07-02" + }, { "amount": "2.0", "amount_unit": "", @@ -631,6 +679,18 @@ "resource_spec": "", "work_item_code": "FP-10-07-02" }, + { + "amount": "1.0", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0307", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-10-07-03" + }, { "amount": "1.0", "amount_unit": "", @@ -667,6 +727,30 @@ "resource_spec": "", "work_item_code": "FP-10-07-04" }, + { + "amount": "2.0", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0317", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-10-10-01" + }, + { + "amount": "2.0", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0318", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-10-10-02" + }, { "amount": "0.22", "amount_unit": "", @@ -799,6 +883,18 @@ "resource_spec": "", "work_item_code": "FP-12-10" }, + { + "amount": "0.12", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0348", + "raw_row_index": 1, + "resource_code": "1050", + "resource_kind": "labor", + "resource_name": "일반기계운전사", + "resource_spec": "", + "work_item_code": "FP-12-11-02" + }, { "amount": "1.23", "amount_unit": "", @@ -943,6 +1039,30 @@ "resource_spec": "", "work_item_code": "FP-13-02-02" }, + { + "amount": "2.6", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0399", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-13-02-03" + }, + { + "amount": "0.83", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-13-06-01" + }, { "amount": "1.04", "amount_unit": "", @@ -955,6 +1075,18 @@ "resource_spec": "", "work_item_code": "FP-13-06-01" }, + { + "amount": "0.83", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-13-06-02" + }, { "amount": "1.30", "amount_unit": "", @@ -979,6 +1111,18 @@ "resource_spec": "0.8", "work_item_code": "FP-13-06-02" }, + { + "amount": "1.19", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-13-06-03" + }, { "amount": "1.86", "amount_unit": "", @@ -1003,6 +1147,18 @@ "resource_spec": "0.8", "work_item_code": "FP-13-06-03" }, + { + "amount": "0.58", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-13-07-01" + }, { "amount": "0.58", "amount_unit": "", @@ -1027,6 +1183,18 @@ "resource_spec": "0.8", "work_item_code": "FP-13-07-01" }, + { + "amount": "0.58", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-13-07-02" + }, { "amount": "1.01", "amount_unit": "", @@ -1063,6 +1231,18 @@ "resource_spec": "", "work_item_code": "FP-13-11-02" }, + { + "amount": "0.0141", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0438", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "work_item_code": "FP-13-12-02" + }, { "amount": "0.0381", "amount_unit": "", @@ -1108,12 +1288,12 @@ "sha256": "111208fd482dcfc3b8c8dd58004b153382fc46555d0d00985c19b45ebbc2e0dd" }, "stats": { - "rows": 91, + "rows": 106, "skipped_forms": { "coefficient": 19, "reference": 94, "undetermined": 83 }, - "unmatched": 269 + "unmatched": 344 } } \ 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 d8aded08..5143c743 100644 --- a/resources/data_cost_resource_axis/unmatched_2026-01-01.json +++ b/resources/data_cost_resource_axis/unmatched_2026-01-01.json @@ -44,6 +44,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-02-01-05" }, + { + "cell": "친환경 비닐랩", + "pum_table_id": "F0055", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-05" + }, { "cell": "천공기날", "pum_table_id": "F0056", @@ -68,6 +74,48 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-02-01-06" }, + { + "cell": "경비(기계경비)", + "pum_table_id": "F0456", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-02" + }, + { + "cell": "경비(기계경비)", + "pum_table_id": "F0472", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-01" + }, + { + "cell": "작업로 예정선 선정 및 표식", + "pum_table_id": "F0076", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-02" + }, + { + "cell": "경비(기계경비)", + "pum_table_id": "F0473", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-02" + }, + { + "cell": "소작업로", + "pum_table_id": "F0077", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-03" + }, + { + "cell": "대작업로", + "pum_table_id": "F0077", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-03" + }, + { + "cell": "경비(기계경비)", + "pum_table_id": "F0474", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-03" + }, { "cell": "모든 벌채산물 임내존치지역", "pum_table_id": "F0079", @@ -104,6 +152,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-03-05" }, + { + "cell": "임업기계장비 이용 정리", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, { "cell": "벌채와 동시정리지역", "pum_table_id": "F0079", @@ -128,6 +182,18 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-04-01" }, + { + "cell": "o 재료비", + "pum_table_id": "F0475", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-01" + }, + { + "cell": "o 경비(기계경비)", + "pum_table_id": "F0475", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-01" + }, { "cell": "단목", "pum_table_id": "F0083", @@ -152,6 +218,24 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-04-02" }, + { + "cell": "o 재료비", + "pum_table_id": "F0476", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-02" + }, + { + "cell": "o 경비(기계경비)", + "pum_table_id": "F0476", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-02" + }, + { + "cell": "작업보조", + "pum_table_id": "F0086", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-02-01" + }, { "cell": "굴착기+부착용집게", "pum_table_id": "F0087", @@ -230,6 +314,24 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-05-04" }, + { + "cell": "지면긁기작업", + "pum_table_id": "F0110", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-05" + }, + { + "cell": "폭 80cm × 열간거리 2m (전면적의 40%)", + "pum_table_id": "F0110", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-05" + }, + { + "cell": "맹아근주 정리작업", + "pum_table_id": "F0111", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-06" + }, { "cell": "움싹본수조절", "pum_table_id": "F0112", @@ -518,6 +620,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-06-04-02" }, + { + "cell": "작업보조", + "pum_table_id": "F0159", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-04-02" + }, { "cell": "소금 처리", "pum_table_id": "F0160", @@ -542,6 +650,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-06-04-04" }, + { + "cell": "유령림 단계", + "pum_table_id": "F0163", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-05" + }, { "cell": "병해충방제", "pum_table_id": "F0172", @@ -602,6 +716,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-07-08-02" }, + { + "cell": "작업량", + "pum_table_id": "F0188", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-10" + }, { "cell": "원목집재와 동시에 부산물 수집시", "pum_table_id": "F0191", @@ -614,6 +734,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-07-13" }, + { + "cell": "경비(기계경비)", + "pum_table_id": "F0454", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-02-03" + }, { "cell": "롤트랩 설치", "pum_table_id": "F0223", @@ -836,12 +962,24 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-08-10" }, + { + "cell": "피복작업", + "pum_table_id": "F0236", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-10" + }, { "cell": "벌목조재", "pum_table_id": "F0236", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-08-10" }, + { + "cell": "피복작업", + "pum_table_id": "F0236", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-10" + }, { "cell": "대형브레이커+ 유압식백호우 (무한궤도,0.7㎥)", "pum_table_id": "F0241", @@ -944,6 +1082,30 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-10-02" }, + { + "cell": "인력(10%)", + "pum_table_id": "F0258", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-01" + }, + { + "cell": "장비(90%)", + "pum_table_id": "F0258", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-01" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0259", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-02" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0259", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-02" + }, { "cell": "치즐소모(본/hr)", "pum_table_id": "F0259", @@ -956,6 +1118,18 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-12-02" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0260", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-03" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0260", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-03" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0260", @@ -968,6 +1142,66 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-12-03" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0261", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-01" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0261", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-01" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0262", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-02" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0263", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-03" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0264", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-04" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0264", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-04" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0265", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-05" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0266", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-06" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0267", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-07" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0267", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-07" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0267", @@ -980,18 +1214,54 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-07" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0268", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-08" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0268", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-08" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0268", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-08" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0269", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-09" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0269", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-09" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0269", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-09" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0270", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-10" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0270", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-10" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0270", @@ -1004,18 +1274,54 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-10" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0271", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-11" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0271", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-11" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0271", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-11" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0272", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-12" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0272", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-12" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0272", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-12" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0273", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-13" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0273", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-13" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0273", @@ -1028,18 +1334,54 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-13" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0274", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-14" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0274", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-14" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0274", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-14" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0275", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-15" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0275", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-15" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0275", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-15" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0276", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-16" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0276", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-16" + }, { "cell": "치즐소모량(본/hr)", "pum_table_id": "F0276", @@ -1053,17 +1395,53 @@ "work_item_code": "FP-09-13-16" }, { - "cell": "치즐소모량(본/hr)", + "cell": "인력 (10%)", + "pum_table_id": "F0277", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-17" + }, + { + "cell": "장비 (90%)", "pum_table_id": "F0277", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-17" }, { "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0277", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-17" + }, + { + "cell": "인력 (10%)", "pum_table_id": "F0278", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-18" }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0278", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-18" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0278", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-18" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0279", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-14-01" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0279", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-14-01" + }, { "cell": "모래ㆍ사질토ㆍ점토ㆍ점질토", "pum_table_id": "F0288", @@ -1238,6 +1616,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-04" }, + { + "cell": "사용고재 평가기준", + "pum_table_id": "F0336", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-04" + }, { "cell": "문양 스티로폴(자재비 포함)", "pum_table_id": "F0337", @@ -1364,6 +1748,24 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-23" }, + { + "cell": "잡재료비(재료비의)", + "pum_table_id": "F0370", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-23" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0372", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0372", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-24-02" + }, { "cell": "적사", "pum_table_id": "F0373", @@ -1382,6 +1784,18 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-26" }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0371", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0371", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-24-01" + }, { "cell": "자재", "pum_table_id": "F0376", @@ -1394,6 +1808,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-27-02" }, + { + "cell": "인력(설치비)", + "pum_table_id": "F0376", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-27-02" + }, { "cell": "재료", "pum_table_id": "F0384", @@ -1430,6 +1850,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-13-02-03" }, + { + "cell": "제 잡비 비율", + "pum_table_id": "F0399", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-02-03" + }, { "cell": "인 부", "pum_table_id": "F0401", @@ -1490,6 +1916,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-13-11-01" }, + { + "cell": "인력(인)", + "pum_table_id": "F0431", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-01" + }, { "cell": "돌 채 움", "pum_table_id": "F0431", @@ -1520,6 +1952,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-13-11-02" }, + { + "cell": "인력(인)", + "pum_table_id": "F0433", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-02" + }, { "cell": "돌 채 움", "pum_table_id": "F0433", @@ -1574,6 +2012,18 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-13-13-01" }, + { + "cell": "인력(10%)", + "pum_table_id": "F0441", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-13-02" + }, + { + "cell": "장비(90%)", + "pum_table_id": "F0441", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-13-02" + }, { "cell": "간 단 구 조", "pum_table_id": "F0443", From 0df6acb57d42fe803465b7057f5b6244080392bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:40:02 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(B09):=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=E2=80=94=20=EA=B4=80?= =?UTF-8?q?=EA=B8=89/=EC=82=AC=EA=B8=89=20=EB=B6=84=EB=A6=AC,=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=20=E2=9A=A0=20=EC=9E=84=EB=8F=84=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EA=B0=80=20=EA=B4=80=EA=B8=89=EC=97=90=20=EC=97=86?= =?UTF-8?q?=EB=8B=A4=EB=8A=94=20=EB=B0=9C=EA=B2=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`B09_Estimation_MaterialCatalog.py` 신설** — 두 창이 맞춘 이름을 그대로 씀: `supply_type` = `owner_supplied` / `contractor_supplied` · `owner_supplied_install_by` = `contractor` / `owner` / `None`. - 관급 = 나라장터 **6,999건**(`vat_basis: 부가가치세별도` → 부가세 제외 단가). - **설치 주체는 지어내지 않음** — 원천에 없으므로 전부 `None`(미지정)으로 두고 **세어서 알림**. 안전관리비 대상액이 관급 전액이 아니라 **도급자설치분**을 쓰므로(8-10) 잘못 찍으면 금액이 조용히 틀리는 자리임. - **규격 필수** — 같은 품명에 규격이 수백 개(「연돌」 410 · 「육각볼트」 323). 이름만 맞추면 엉뚱한 규격 단가가 붙음. - 매칭 카탈로그에 자재를 붙이면서 **이름 색인**을 넣음(7,700건이라 매번 훑으면 느림). **⚠ 붙여 보고 알아낸 것 — 관급 목록에 임도 자재가 거의 없음** - 나라장터 목록이 **건축·설비 자재 중심**(덕트·밸브·복공판·스피커…)이고, **시멘트·모래·자갈·레미콘·철근·파형강관이 한 건도 없음**. - 까닭 둘 — ① 철근·레미콘·아스콘은 그 파일이 `excluded_named_groups` 로 **명시적으로 빼 둠**(쇼핑몰 스냅샷 미보관) ② **시멘트·모래·자갈은 애초에 사급**임. 실무 울진에서 관급은 **파형강관 Ø800/Ø1000 뿐**이었음. - ⇒ **자재 매칭 0건.** 억지로 이름을 느슨하게 맞추지 않음 — 그렇게 하면 엉뚱한 건축 자재 단가가 임도 공종에 붙음. - **이 사실을 시험으로 못 박음**(`test_forest_road_materials_are_absent_from_owner_catalog`) — 나중에 「자재가 왜 안 붙나」를 다시 파는 일이 없도록. **화면 요약 문구를 실측대로 갈아 끼움** — 「자재 카탈로그가 없어서」가 아니라 「관급 6,999건을 붙였으나 임도 자재가 거의 없고, **사급 단가가 미결**이라 구조물 계열이 안 섬」으로. 설치 주체 미지정 건수도 함께 냄. 자체검증 — 신규 6건 포함 `pytest tmp/tests/ -q` **129 passed** · ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_MaterialCatalog.py | 157 ++++++++++++++++++ B09_Estimation/B09_Estimation_ResourceAxis.py | 35 +++- B09_Estimation/B09_Estimation_UnitPrice.py | 32 +++- 3 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MaterialCatalog.py diff --git a/B09_Estimation/B09_Estimation_MaterialCatalog.py b/B09_Estimation/B09_Estimation_MaterialCatalog.py new file mode 100644 index 00000000..8999ad19 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialCatalog.py @@ -0,0 +1,157 @@ +"""B09 원가계산 — 자재 카탈로그 (PLAN 9-3 · 9-4). + +**관급과 사급을 처음부터 가른다.** 섞어 두면 나중에 못 가른다 — 관급은 +**총원가 밖 별도 표기 + 조달수수료**라 계산 자리가 아예 다르다(PLAN 8-2 인계 6필드). + +구분 이름은 두 창이 맞춘 것을 쓴다 (2026-09-07 확정): + - `supply_type` = `owner_supplied`(관급) / `contractor_supplied`(사급) + - `owner_supplied_install_by` = `contractor`(도급자설치) / `owner` / `None` + ⚠ **모르면 `None` 으로 두고 「설치 주체 미지정」으로 드러낸다.** 안전관리비 대상액이 + **관급 전액이 아니라 도급자설치분**을 쓰므로(PLAN 8-10), 잘못 찍으면 금액이 조용히 + 틀린다. + +원천 + - 관급 = `mat_price_public_2026-08-14.json` — 나라장터 **6,999건**. + `vat_basis: "부가가치세별도"` 라 **부가세 제외 단가**이고 원가에 그대로 쓴다. + ⚠ 철근·레미콘·아스콘은 그 파일의 `excluded_named_groups` 로 **빠져 있다**. + - 사급 = **없다.** 유료 물가지 미결(No.18). **값을 지어내지 않고 공백으로 드러낸다.** + +⚠ **자재 단가는 할증 전 값이다** (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳에서만 붙인다. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") + +#: 두 창이 맞춘 구분 이름 — 값을 바꾸면 B08 자재총괄과 안 맞는다. +SUPPLY_OWNER = "owner_supplied" +SUPPLY_CONTRACTOR = "contractor_supplied" +INSTALL_BY_CONTRACTOR = "contractor" +INSTALL_BY_OWNER = "owner" + + +class MaterialCatalogError(LookupError): + """자재 단가를 못 세운 경우. 0 으로 때우지 않는다.""" + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _read_json(file_name: str) -> dict[str, Any]: + with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h: + return json.load(h) + + +@dataclass(frozen=True) +class MaterialItem: + """자재 한 줄. **단가는 할증 전·부가세 제외 값**이다.""" + + item_code: str + name: str + specification: str + unit: str + price_krw: Decimal + supply_type: str + #: 관급일 때만 뜻이 있다. `None` = **설치 주체 미지정**(안전관리비 대상액에 못 넣음). + owner_supplied_install_by: str | None = None + vat_excluded: bool = True + notice_date: str = "" + + @property + def display_name(self) -> str: + return f"{self.name} {self.specification}".strip() + + +@dataclass +class MaterialCatalog: + """자재 목록. **이름만으로는 못 고른다** — 같은 품명에 규격이 여럿이다.""" + + items: dict[str, MaterialItem] = field(default_factory=dict) + #: 채우지 못한 것 — 사급 미결·제외 품목. **빈칸이 아니라 목록으로 든다.** + gaps: list[str] = field(default_factory=list) + + def by_name(self, name: str) -> list[MaterialItem]: + return [m for m in self.items.values() if m.name == name] + + def resolve(self, name: str, specification: str) -> MaterialItem | None: + """품명 + 규격으로 한 줄을 고른다. 규격이 없으면 **고르지 않는다**. + + 6,999건 중 같은 품명이 수십 개인 것이 흔하다 — 이름만 맞추면 엉뚱한 규격의 + 단가가 조용히 붙는다. + """ + found = self.by_name(name) + if not found: + return None + if len(found) == 1 and not specification: + return found[0] + narrowed = [m for m in found if m.specification == specification] + return narrowed[0] if len(narrowed) == 1 else None + + def get(self, item_code: str) -> MaterialItem: + try: + return self.items[item_code] + except KeyError as exc: + raise MaterialCatalogError(f"자재 카탈로그에 없는 코드입니다: {item_code}") from exc + + def count_by_supply(self) -> dict[str, int]: + counts: dict[str, int] = {} + for item in self.items.values(): + counts[item.supply_type] = counts.get(item.supply_type, 0) + 1 + return counts + + +def load_material_catalog( + public_file: str = "mat_price_public_2026-08-14.json", +) -> MaterialCatalog: + """관급 자재를 읽고, 사급은 **없다는 사실을 목록으로** 남긴다.""" + payload = _read_json(public_file) + catalog = MaterialCatalog() + + for row in payload["variables"]["mat_price"]["records"]: + code = str(row["item_code"]) + catalog.items[code] = MaterialItem( + item_code=code, + name=row.get("classification_name", ""), + specification=row.get("specification", ""), + unit=row.get("unit", ""), + price_krw=Decimal(str(row.get("price_krw", 0))), + supply_type=SUPPLY_OWNER, + # ⚠ 나라장터 자료에 설치 주체가 없다 — 지어내지 않고 미지정으로 둔다. + owner_supplied_install_by=None, + vat_excluded=row.get("vat_basis", "") == "부가가치세별도", + notice_date=str(row.get("notice_datetime", ""))[:10], + ) + + # 사급 — 원천이 아직 없다. **값을 지어내지 않는다.** + catalog.gaps.append( + "사급 자재 단가 없음 — 유료 물가지 미결(No.18). 6번 슬롯(적용 단가) 수동 입력으로 채웁니다." + ) + for group in payload.get("excluded_named_groups", []): + catalog.gaps.append(f"관급 제외 품목: {group.get('group', '')} — {group.get('reason', '')}") + return catalog + + +def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]: + """화면에 낼 요약 — **무엇이 없는지**를 함께 낸다.""" + unspecified = [ + m + for m in catalog.items.values() + if m.supply_type == SUPPLY_OWNER and m.owner_supplied_install_by is None + ] + return { + "items": len(catalog.items), + "by_supply": catalog.count_by_supply(), + "owner_supplied_install_unspecified": len(unspecified), + "gaps": list(catalog.gaps), + "notes": [ + "자재 단가는 **할증 전·부가세 제외** 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", + "관급 자재의 **설치 주체가 미지정**이라 안전관리비 대상액에 자동으로 넣지 않습니다.", + ], + } diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 881409c3..09b5f57b 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -110,10 +110,16 @@ class ResourceCatalog: entries: list[CatalogEntry] = field(default_factory=list) aliases: dict[str, str] = field(default_factory=dict) + #: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다. + _index: dict[str, list[CatalogEntry]] | None = None def by_name(self, name: str) -> list[CatalogEntry]: - cleaned = _normalize(name) - return [e for e in self.entries if _normalize(e.name) == cleaned] + if self._index is None: + index: dict[str, list[CatalogEntry]] = {} + for entry in self.entries: + index.setdefault(_normalize(entry.name), []).append(entry) + self._index = index + return self._index.get(_normalize(name), []) def resolve(self, name: str, spec: str) -> CatalogEntry | None: """이름(+규격)으로 한 줄을 고른다. 못 고르면 None — 0 으로 안 때운다.""" @@ -205,11 +211,32 @@ def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list ] +def load_material_catalog_entries( + file_name: str = "mat_price_public_2026-08-14.json", +) -> list[CatalogEntry]: + """관급 자재 6,999건을 매칭용 항목으로 편다. + + ⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323). + **규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog + + catalog = load_material_catalog(file_name) + return [ + CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification) + for m in catalog.items.values() + ] + + def load_combined_catalog() -> ResourceCatalog: - """노임 + 기종을 한 벌로. 자재는 카탈로그가 아직 없다.""" + """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다.""" labor = load_labor_catalog() return ResourceCatalog( - entries=[*labor.entries, *load_machine_catalog_entries()], + entries=[ + *labor.entries, + *load_machine_catalog_entries(), + *load_material_catalog_entries(), + ], aliases=labor.aliases, ) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index bf17bf8d..bb1485e8 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -278,6 +278,32 @@ def cached_build() -> UnitPriceBuild: return build_unit_prices() +def _status_notes() -> list[str]: + """화면에 낼 「지금 무엇이 안 선 상태인가」. + + 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가 + 거의 없다는 것이 이 자리의 진짜 공백이다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import ( + catalog_summary, + load_material_catalog, + ) + + summary = catalog_summary(load_material_catalog()) + return [ + f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " + "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " + "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", + "**사급 자재 단가가 미결**이라 구조물 계열 일위대가가 아직 서지 않습니다 — " + "값을 지어내지 않고 6번 슬롯(적용 단가) 수동 입력으로 채웁니다.", + ( + f"관급 자재 **설치 주체가 미지정**" + f"({summary['owner_supplied_install_unspecified']:,}건)이라 " + "안전관리비 대상액에 자동으로 넣지 않습니다." + ), + ] + + def build_summary(build: UnitPriceBuild) -> dict: """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" kinds: dict[str, int] = {} @@ -291,11 +317,7 @@ def build_summary(build: UnitPriceBuild) -> dict: "incomplete_machines": len(build.incomplete_machines), "kinds": kinds, # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). - "notes": [ - "자재 카탈로그가 아직 없어 **구조물 계열 일위대가가 서지 않습니다** — " - "지금 선 것은 노무·기계 성분뿐입니다(연료만 자재로 섭니다).", - "사급 잡자재 단가는 미결입니다 — 값을 지어내지 않고 비워 둡니다.", - ], + "notes": _status_notes(), } From 2faca7ac092fdce8a1581417161cc86bf63cb778 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:45:56 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(B09):=20=E2=91=A4=E2=86=94=E2=91=A2?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20=E2=80=94=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=203=EB=B6=84=ED=95=A0=EC=9D=84=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=EB=B9=84=EB=A1=9C=20=EC=A0=91=EC=96=B4=20=EB=84=A3=EC=9D=8C=20?= =?UTF-8?q?(=EB=AD=89=EC=B9=98=EC=A7=80=20=EC=95=8A=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **⚠ 일위대가 합계를 순공사비로 뭉쳐 넣으면 ⑤ 의 밑수가 전부 틀림**(8-9 규칙 2) — 산재·고용은 노무비, 건강·연금은 직접노무비, 기타경비는 재료비+노무비를 봄. 일위대가가 3분할을 이미 들고 있으므로 **성분별로 접어 넣음**. - `direct_cost_from_quantities({공종코드: 수량})` → **직접비 3분할**. 단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남김 — 수량이 있는데 단가가 없으면 그 공종이 총액에서 조용히 빠짐. - `cost_input_from_quantities()` → 성분이 그대로 `direct_material/labor/expense_krw` 로 감. - **뭉치면 틀린다는 것을 수치로 보이는 시험**을 둠 — 같은 총액을 노무비 한 덩어리로 넣으면 건강보험료가 커짐(`test_lumping_would_break_the_bases`). - ⇒ ④예산내역서 없이도 **수량만 있으면 ⑤ 가 실물로 도는 경로**가 생김. **자재 잠정(㉡) 화면 표시** — 「사급 자재 단가는 설계자가 직접 넣습니다(6번 슬롯 「적용 단가」) — 유료 물가지 미구독 … (잠정 — 구독하면 1~5번 슬롯에 꽂습니다)」. 화면 실측으로 3줄 다 뜨는 것 확인(일위대가 67건). - 곁가지 — 화면은 평문이라 `**강조**` 별표가 그대로 보였음. 내보내기 직전에 벗김. 자체검증 — 신규 4건 포함 `pytest tmp/tests/ -q` **133 passed** · ruff 통과 · 백엔드 재시작 후 화면 클릭 검증. Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_MaterialCatalog.py | 4 +- B09_Estimation/B09_Estimation_UnitPrice.py | 107 ++++++++++++++++-- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MaterialCatalog.py b/B09_Estimation/B09_Estimation_MaterialCatalog.py index 8999ad19..8fe5721b 100644 --- a/B09_Estimation/B09_Estimation_MaterialCatalog.py +++ b/B09_Estimation/B09_Estimation_MaterialCatalog.py @@ -151,7 +151,7 @@ def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]: "owner_supplied_install_unspecified": len(unspecified), "gaps": list(catalog.gaps), "notes": [ - "자재 단가는 **할증 전·부가세 제외** 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", - "관급 자재의 **설치 주체가 미지정**이라 안전관리비 대상액에 자동으로 넣지 않습니다.", + "자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", + "관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.", ], } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index bb1485e8..d4ae5643 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -17,6 +17,7 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from decimal import Decimal from functools import lru_cache @@ -43,6 +44,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( ) from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. FUEL_CODE_PREFIX = "M-FUEL-" @@ -278,6 +280,11 @@ def cached_build() -> UnitPriceBuild: return build_unit_prices() +def _plain(text: str) -> str: + """화면용 평문 — 마크다운 강조 표시를 벗긴다.""" + return _RE_EMPHASIS.sub(lambda match: match.group(1), text) + + def _status_notes() -> list[str]: """화면에 낼 「지금 무엇이 안 선 상태인가」. @@ -290,17 +297,22 @@ def _status_notes() -> list[str]: ) summary = catalog_summary(load_material_catalog()) + # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다. return [ - f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " - "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " - "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", - "**사급 자재 단가가 미결**이라 구조물 계열 일위대가가 아직 서지 않습니다 — " - "값을 지어내지 않고 6번 슬롯(적용 단가) 수동 입력으로 채웁니다.", - ( - f"관급 자재 **설치 주체가 미지정**" - f"({summary['owner_supplied_install_unspecified']:,}건)이라 " - "안전관리비 대상액에 자동으로 넣지 않습니다." - ), + _plain(note) + for note in [ + f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " + "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " + "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", + "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — " + "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 " + "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)", + ( + f"관급 자재 **설치 주체가 미지정**" + f"({summary['owner_supplied_install_unspecified']:,}건)이라 " + "안전관리비 대상액에 자동으로 넣지 않습니다." + ), + ] ] @@ -322,7 +334,7 @@ def build_summary(build: UnitPriceBuild) -> dict: def _money_text(value: Decimal) -> str: - """화면에 낼 금액 — **일위대가 금액란은 0.1원 미만 버림**(품셈 1-2-2). + """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2). 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). @@ -413,3 +425,76 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "precise_total": _money_text(money.total), "rows": rows, } + + +@dataclass +class DirectCostBreakdown: + """⑤ 공사원가계산서가 받는 **직접비 3분할**. + + ⚠ **일위대가 합계를 순공사비로 뭉쳐 넣으면 안 된다.** ⑤ 의 밑수는 항목마다 갈리고 + (산재·고용 = 노무비 / 건강·연금 = 직접노무비 / 기타경비 = 재료비+노무비 …), + 뭉쳐 넣으면 그 밑수가 전부 틀린다(PLAN 8-9 규칙 2). 일위대가는 3분할을 이미 + 들고 있으니 **성분별로 접어 넣는다.** + """ + + material: Decimal = _ZERO + labor: Decimal = _ZERO + expense: Decimal = _ZERO + #: 값을 못 세운 공종 — 수량이 있는데 단가가 없으면 여기 남는다(0 으로 안 때운다). + missing: list[str] = field(default_factory=list) + + @property + def total(self) -> Decimal: + return self.material + self.labor + self.expense + + +def direct_cost_from_quantities( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, +) -> DirectCostBreakdown: + """공종별 수량을 일위대가에 곱해 **직접비 3분할**을 만든다. + + `quantities` = `{공종코드: 수량}`. 공종코드는 `FP-09-21` 처럼 마스터 코드를 쓰거나 + `B-FP-09-21` 처럼 일위대가 코드를 그대로 써도 된다. + + 단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남긴다 — 수량이 있는데 단가가 + 없으면 그 공종이 총액에서 조용히 빠진다. + """ + book = (build or cached_build()).book + result = DirectCostBreakdown() + + for raw_code, quantity in quantities.items(): + code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}" + if code not in book.titles: + result.missing.append(raw_code) + continue + unit_money = book.resolve(code) + line = unit_money.scaled(Decimal(str(quantity))) + result.material += line.material + result.labor += line.labor + result.expense += line.expense + return result + + +def cost_input_from_quantities( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, + **cost_input_kwargs, +): + """직접비 3분할을 ⑤ 엔진 입력으로 접어 넣는다. + + 성분이 그대로 `direct_material_krw`·`direct_labor_krw`·`direct_expense_krw` 로 간다 — + **뭉치지 않는다.** + """ + from B09_Estimation.B09_Estimation_Engine_Cost import CostInput + + breakdown = direct_cost_from_quantities(quantities, build) + return ( + CostInput( + direct_material_krw=breakdown.material, + direct_labor_krw=breakdown.labor, + direct_expense_krw=breakdown.expense, + **cost_input_kwargs, + ), + breakdown, + ) From 991f7e31b1fba7d3b37927443cdceff1d9facd49 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:55:09 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(B09):=20=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=EB=94=B1=EC=A7=80=20=ED=96=89=EC=97=90=EC=84=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20=EB=91=98=EC=A7=B8=20=EC=B9=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BD=EC=9D=8C=20=E2=80=94=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=C2=B7=EC=9E=A5=EB=B9=84=EA=B0=80=20=ED=86=B5=EC=A7=B8=EB=A1=9C?= =?UTF-8?q?=20=EB=B9=A0=EC=A7=80=EB=8D=98=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배분 창이 「초류종자살포 배합이 네 일위대가에 있나」를 물어 확인하다 **구멍을 찾음.** - 품셈 표 중 **첫 칸이 분류 딱지**(「자재」·「장비」)이고 **이름이 둘째 칸**인 것이 있음: `['자재', '종 자', '', 'kg', '0.025']` · `['장비', '종자살포기', '2,500-3,000ℓ', …]`. 첫 칸만 보고 읽어 **그 표의 자재·장비가 통째로 빠지고 있었음** — 씨앗뿜어붙이기에서 종자·비료·피복제·침식안정제·색소·장비 3종이 다 빠지고 **보통인부 한 줄만** 남았음. - 첫 칸이 분류 딱지면 이름을 둘째 칸에서 읽고, 값도 그 뒤 칸에서 찾게 고침. - **매칭 106 → 116**(노무 100 → 110). 산출 파일 다시 냄. **⚠ 그래도 배합은 아직 못 실림 — 원인은 사급 자재 단가 미결임** - 종자·복합비료·화이버·합성접착제·색소가 **관급 카탈로그에 없음**(임도 자재가 관급에 없다는 앞선 발견과 같은 자리). 장비 3종(종자살포기·트럭·물탱크)도 기종 카탈로그에 없음. - **다만 이제 못 맞춘 목록에는 남음** — 구멍을 목록으로 드러내는 규칙 그대로. 시험으로 못 박음(`test_seeding_materials_are_listed_not_dropped`). - ⇒ 사면 4계열의 배합이 **B09 일위대가 몫**인 것은 맞으나, **사급 단가가 들어오기 전에는 못 세움.** 「자재총괄에 이을 것이 없다」는 메인 판정은 유지되고, 그 자재는 **㉡ 6번 슬롯 수동 입력 목록**으로 감(야면석·막자갈·고임돌·물구멍에 이어). 자체검증 — 신규 2건 포함 `pytest tmp/tests/ -q` **136 passed** · ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_ResourceAxis.py | 16 ++- .../resource_axis_2026-01-01.json | 124 +++++++++++++++++- .../unmatched_2026-01-01.json | 74 +++++++---- 3 files changed, 185 insertions(+), 29 deletions(-) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 09b5f57b..1c9e4525 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -41,6 +41,13 @@ _CATALOG_SUBPATH = ("resources", "data_cost_input_value") _RE_SPEC = re.compile(r"[((]([^))]+)[))]|(\d+(?:\.\d+)?\s*(?:톤|ton|㎥|m3|㎡|㎜|mm|HP|kW))") _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$") +#: 첫 칸이 **분류 딱지**이고 이름이 둘째 칸에 오는 표가 있다. +#: 예 — `['자재', '종 자', '', 'kg', '0.025']` · `['장비', '종자살포기', …]`. +#: 이 표를 첫 칸만 보고 읽으면 **자재·장비가 통째로 빠진다**(2026-09-07 실측 — +#: 씨앗뿜어붙이기에서 종자·비료·피복제·침식안정제·색소·장비 3종이 다 빠지고 +#: 보통인부 한 줄만 남았다). +_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계") + #: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다. #: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다. #: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`· @@ -447,11 +454,16 @@ def match_table( cells = [str(c) for c in row] if not cells: continue + # 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다. name_cell = cells[0] + value_cells = cells[1:] + if _normalize(name_cell) in _GROUP_LABELS and len(cells) > 1: + name_cell = cells[1] + value_cells = cells[2:] # 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다. amount_cell = next( - (parse_amount(c) for c in cells[1:] if parse_amount(c) is not None), None + (parse_amount(c) for c in value_cells if parse_amount(c) is not None), None ) if amount_cell is None: continue @@ -459,7 +471,7 @@ def match_table( # ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때 # 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을 # 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다. - entry = _resolve_cell(catalog, name_cell, cells) + entry = _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) if entry is None: if is_non_resource_label(name_cell): continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다 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 a1abf61c..5774d43a 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 @@ -211,6 +211,18 @@ "resource_spec": "", "work_item_code": "FP-05-23-03" }, + { + "amount": "0.0007", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0135", + "raw_row_index": 8, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "work_item_code": "FP-05-24-01" + }, { "amount": "0.0004", "amount_unit": "", @@ -223,6 +235,18 @@ "resource_spec": "", "work_item_code": "FP-05-24-01" }, + { + "amount": "0.0007", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0136", + "raw_row_index": 8, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "work_item_code": "FP-05-24-02" + }, { "amount": "0.0004", "amount_unit": "", @@ -235,6 +259,18 @@ "resource_spec": "", "work_item_code": "FP-05-24-02" }, + { + "amount": "0.002", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0139", + "raw_row_index": 1, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "work_item_code": "FP-05-25" + }, { "amount": "0.0007", "amount_unit": "", @@ -415,6 +451,18 @@ "resource_spec": "", "work_item_code": "FP-09-05-01" }, + { + "amount": "0.02", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0251", + "raw_row_index": 0, + "resource_code": "1012", + "resource_kind": "labor", + "resource_name": "용접공", + "resource_spec": "", + "work_item_code": "FP-09-08-01" + }, { "amount": "0.08", "amount_unit": "", @@ -559,6 +607,30 @@ "resource_spec": "", "work_item_code": "FP-09-13-18" }, + { + "amount": "0.019", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0290", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-19-02" + }, + { + "amount": "0.0328", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0291", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-19-03" + }, { "amount": "0.80", "amount_unit": "", @@ -943,6 +1015,18 @@ "resource_spec": "", "work_item_code": "FP-12-18" }, + { + "amount": "0.034", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0368", + "raw_row_index": 1, + "resource_code": "1026", + "resource_kind": "labor", + "resource_name": "방수공", + "resource_spec": "", + "work_item_code": "FP-12-21" + }, { "amount": "0.04", "amount_unit": "", @@ -955,6 +1039,18 @@ "resource_spec": "", "work_item_code": "FP-12-21" }, + { + "amount": "0.003", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0370", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-12-23" + }, { "amount": "4.0", "amount_unit": "", @@ -991,6 +1087,30 @@ "resource_spec": "", "work_item_code": "FP-12-24-01" }, + { + "amount": "0.004", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0384", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-12-33" + }, + { + "amount": "3.8", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0387", + "raw_row_index": 1, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "work_item_code": "FP-12-34-03" + }, { "amount": "2.2", "amount_unit": "", @@ -1288,12 +1408,12 @@ "sha256": "111208fd482dcfc3b8c8dd58004b153382fc46555d0d00985c19b45ebbc2e0dd" }, "stats": { - "rows": 106, + "rows": 116, "skipped_forms": { "coefficient": 19, "reference": 94, "undetermined": 83 }, - "unmatched": 344 + "unmatched": 348 } } \ 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 5143c743..915d08fc 100644 --- a/resources/data_cost_resource_axis/unmatched_2026-01-01.json +++ b/resources/data_cost_resource_axis/unmatched_2026-01-01.json @@ -417,7 +417,7 @@ "work_item_code": "FP-05-22-04" }, { - "cell": "자재", + "cell": "종 자", "pum_table_id": "F0135", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-05-24-01" @@ -446,6 +446,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-05-24-01" }, + { + "cell": "종자살포기", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, { "cell": "트 럭", "pum_table_id": "F0135", @@ -459,7 +465,7 @@ "work_item_code": "FP-05-24-01" }, { - "cell": "자재", + "cell": "종 자", "pum_table_id": "F0136", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-05-24-02" @@ -488,6 +494,12 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-05-24-02" }, + { + "cell": "종자살포기", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, { "cell": "트 럭", "pum_table_id": "F0136", @@ -501,7 +513,7 @@ "work_item_code": "FP-05-24-02" }, { - "cell": "자재", + "cell": "거 적", "pum_table_id": "F0139", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-05-25" @@ -999,7 +1011,7 @@ "work_item_code": "FP-09-04-01" }, { - "cell": "자재", + "cell": "폭 약", "pum_table_id": "F0243", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-05-01" @@ -1016,6 +1028,18 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-05-01" }, + { + "cell": "화 약 공", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, + { + "cell": "착 암 기", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, { "cell": "공기압축기", "pum_table_id": "F0243", @@ -1047,7 +1071,7 @@ "work_item_code": "FP-09-06-01" }, { - "cell": "자재", + "cell": "아세틸렌", "pum_table_id": "F0251", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-08-01" @@ -1079,7 +1103,7 @@ { "cell": "아스팔트", "pum_table_id": "F0255", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", "work_item_code": "FP-09-10-02" }, { @@ -1478,6 +1502,18 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-19-01" }, + { + "cell": "유압식백호우 (무한궤도,0.7㎥)", + "pum_table_id": "F0290", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-02" + }, + { + "cell": "공기압축기(3.5㎥/min)", + "pum_table_id": "F0291", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-03" + }, { "cell": "소형브레이커", "pum_table_id": "F0291", @@ -1601,19 +1637,19 @@ { "cell": "각 재", "pum_table_id": "F0336", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", "work_item_code": "FP-12-04" }, { "cell": "철 선", "pum_table_id": "F0336", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", "work_item_code": "FP-12-04" }, { "cell": "박 리 제", "pum_table_id": "F0336", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", "work_item_code": "FP-12-04" }, { @@ -1737,13 +1773,13 @@ "work_item_code": "FP-12-18" }, { - "cell": "자재", + "cell": "아스팔트(㏊-500)", "pum_table_id": "F0368", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", "work_item_code": "FP-12-21" }, { - "cell": "자재", + "cell": "부 직 포", "pum_table_id": "F0370", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-23" @@ -1796,12 +1832,6 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-24-01" }, - { - "cell": "자재", - "pum_table_id": "F0376", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-12-27-02" - }, { "cell": "접착제", "pum_table_id": "F0376", @@ -1815,13 +1845,7 @@ "work_item_code": "FP-12-27-02" }, { - "cell": "재료", - "pum_table_id": "F0384", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-12-33" - }, - { - "cell": "자재", + "cell": "결속선(R-0.9mm)", "pum_table_id": "F0387", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-34-03" From ca881fbb18fb444308f3679f2756b54086c2389e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:09:27 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B3=B5=EC=A2=85=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공종 수량(코드=수량)을 일위대가에 곱해 직접비 3분할 생성, ⑤ 원가계산서 밑수로 투입 — 뭉치지 않고 재료·노무·경비 성분 그대로 전달 - 단가 없는 공종은 0 으로 안 때우고 `missing_unit_prices` 로 화면 노출 - 계산 원천을 화면에 표시 (「수량 원천: 손입력 / 직접 입력」) - ⑤ 표에 찍히는 값은 자원 집계표 규칙(반올림)으로 절단 — 소수점 유출 제거 - 일위대가 요약에 총액 분포(최소·중앙·최대)·의심 저가 목록 추가 검증: pytest 136 통과, 공용 브라우저(5174) 실측 — 수량 입력 후 재계산 시 재료비 10,478,189 · 순공사원가 84,454,045 · 총공사비 113,652,213, 미매칭 공종 1건 화면 표시 확인 Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Router.py | 31 ++- B09_Estimation/B09_Estimation_UI_Page.ts | 154 ++++++++++++-- B09_Estimation/B09_Estimation_UnitPrice.py | 37 +++- ui_template/ui_template_locale_b2.ts | 232 +++++++++++++++++---- 4 files changed, 386 insertions(+), 68 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index cdc4101d..762d581f 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +from dataclasses import replace as dataclass_replace from decimal import Decimal from typing import Any from uuid import UUID @@ -28,10 +29,12 @@ from B09_Estimation.B09_Estimation_Engine_Cost import ( from B09_Estimation.B09_Estimation_PriceBook import PriceBookError from B09_Estimation.B09_Estimation_Rates import RateLookupError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( build_summary, cached_build, detail_of, + direct_cost_from_quantities, list_unit_prices, ) from common_util.common_util_workflow_state import complete_stage @@ -70,6 +73,11 @@ class CostRequest(BaseModel): rate_file_name: str = "rates_2026.json" + #: 공종별 수량 `{공종코드: 수량}`. 주면 **직접비 3분할을 여기서 만들어** 쓴다. + #: ⚠ 일위대가 합계를 뭉쳐 넣지 않는다 — 밑수가 항목마다 갈린다(PLAN 8-9 규칙 2). + #: 지금 원천은 **손입력**이고, B08 인계(9번)가 나오면 **원천만 바꿔 끼운다**. + quantities: dict[str, Decimal] | None = None + #: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**. #: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다. target_contract_amount_krw: Decimal | None = None @@ -125,8 +133,25 @@ def _serialize(result: CostResult) -> dict[str, Any]: @router.post("/{project_id}/estimation/cost") async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: """공사원가계산서 한 장을 계산해 돌려준다 (저장 없음).""" + direct_source = "manual" + missing_unit_prices: list[str] = [] try: - result = calculate_cost(payload.to_engine_input()) + data = payload.to_engine_input() + if payload.quantities: + # 수량이 오면 **일위대가에서 직접비 3분할을 만들어** 갈아 끼운다. + breakdown = direct_cost_from_quantities(payload.quantities) + # ⑤ 표에 찍히는 자리라 **자원 집계표 규칙(반올림)** 으로 자른다 — + # 안 자르면 원가계산서에 소수점이 그대로 흘러나온다. + summary = OutputPlace.RESOURCE_SUMMARY + data = dataclass_replace( + data, + direct_material_krw=round_at(breakdown.material, summary), + direct_labor_krw=round_at(breakdown.labor, summary), + direct_expense_krw=round_at(breakdown.expense, summary), + ) + direct_source = "quantities" + missing_unit_prices = breakdown.missing + result = calculate_cost(data) except RateLookupError as error: # 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다. logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error) @@ -139,6 +164,10 @@ async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: ) body = _serialize(result) + # 어느 값으로 계산했는지 화면이 알아야 한다 — 안 보이면 나중에 못 가른다. + body["direct_cost_source"] = direct_source + # 수량은 있는데 단가가 없는 공종 — **화면에 반드시 보인다**. + body["missing_unit_prices"] = missing_unit_prices if payload.target_contract_amount_krw is not None: # 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다. body["suggested_profit_adjustment_krw"] = str( diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 813d2dd1..01c4ca1e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -14,11 +14,18 @@ * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + showToast, +} from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, +} from "../A00_Common/b_workflow_nav"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -42,6 +49,8 @@ interface CostLineDto { interface CostSheetDto { status: string; + direct_cost_source: "manual" | "quantities"; + missing_unit_prices: string[]; lines: CostLineDto[]; totals: Record; rate_version: { dataset_id: string; effective_date: string; sha256: string }; @@ -62,7 +71,12 @@ interface UnitPriceRow { interface UnitPriceListDto { status: string; - summary: { titles: number; unit_prices: number; machine_hourly: number; notes: string[] }; + summary: { + titles: number; + unit_prices: number; + machine_hourly: number; + notes: string[]; + }; rows: UnitPriceRow[]; } @@ -101,6 +115,8 @@ interface CostFormState { procurement_fee_krw: string; profit_adjustment_krw: string; target_contract_amount_krw: string; + /** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */ + quantities_text: string; } const INITIAL_FORM: CostFormState = { @@ -112,6 +128,7 @@ const INITIAL_FORM: CostFormState = { procurement_fee_krw: "0", profit_adjustment_krw: "0", target_contract_amount_krw: "", + quantities_text: "", }; /** 총계 성격의 줄 — 표에서 굵게 띄운다. */ @@ -170,6 +187,7 @@ function injectStyles(): void { .b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } .b09-sheet tr.is-adopted td { background: var(--color-surface); } .b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } +.b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); } .b09-clickable { cursor: pointer; } .b09-clickable:hover td { background: var(--color-surface); } .b09-up-list { max-height: 45%; } @@ -216,8 +234,10 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { for (const line of sheet.lines) { const tr = document.createElement("tr"); if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total"); - if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted"); - if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped"); + if (line.note === L("B09_Estimation_Adopted")) + tr.classList.add("is-adopted"); + if (line.note === L("B09_Estimation_NotAdopted")) + tr.classList.add("is-dropped"); const name = document.createElement("td"); name.className = "b09-left"; @@ -227,7 +247,8 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { amount.textContent = formatWon(line.amount_krw); const rate = document.createElement("td"); - rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`; + rate.textContent = + line.rate_percent === null ? "" : `${line.rate_percent}%`; const basis = document.createElement("td"); basis.className = "b09-left"; @@ -373,7 +394,13 @@ function buildUnitPriceDetail( unit.className = "b09-left"; unit.textContent = row.unit; tr.append(name, spec, source, unit); - for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) { + for (const value of [ + row.quantity, + row.material, + row.labor, + row.expense, + row.total, + ]) { const cell = document.createElement("td"); cell.textContent = formatWon(value); tr.append(cell); @@ -388,7 +415,12 @@ function buildUnitPriceDetail( label.colSpan = 5; label.textContent = L("B09_Estimation_Col_Total"); sum.append(label); - for (const value of [detail.material, detail.labor, detail.expense, detail.total]) { + for (const value of [ + detail.material, + detail.labor, + detail.expense, + detail.total, + ]) { const cell = document.createElement("td"); cell.textContent = formatWon(value); sum.append(cell); @@ -470,6 +502,25 @@ function buildSidePanel( ["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"], ]); + // 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다. + const quantityGroup = document.createElement("div"); + quantityGroup.className = "b09-panel__group"; + const quantityLegend = document.createElement("span"); + quantityLegend.className = "b09-panel__legend"; + quantityLegend.textContent = L("B09_Estimation_Group_Quantity"); + const quantityLabel = document.createElement("label"); + quantityLabel.className = "ui-field__label"; + quantityLabel.textContent = L("B09_Estimation_Field_Quantities"); + const quantityInput = document.createElement("textarea"); + quantityInput.className = "ui-input b09-qty"; + quantityInput.rows = 4; + quantityInput.placeholder = "FP-09-21=500"; + quantityInput.addEventListener("input", () => { + form.quantities_text = quantityInput.value; + }); + quantityGroup.append(quantityLegend, quantityLabel, quantityInput); + root.append(quantityGroup); + const hintBox = document.createElement("div"); hintBox.className = "b09-hint"; root.append(hintBox); @@ -477,8 +528,15 @@ function buildSidePanel( const actions = document.createElement("div"); actions.className = "b09-panel__actions"; actions.append( - createButton({ label: L("B09_Estimation_Btn_Recalc"), variant: "filled", onClick: onRecalc }), - createButton({ label: L("B09_Estimation_Btn_Confirm"), onClick: onConfirm }), + createButton({ + label: L("B09_Estimation_Btn_Recalc"), + variant: "filled", + onClick: onRecalc, + }), + createButton({ + label: L("B09_Estimation_Btn_Confirm"), + onClick: onConfirm, + }), ); root.append(actions); @@ -490,7 +548,12 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { if (!sheet) return; const rows: Array<[string, string]> = [ ["적용일", sheet.rate_version.effective_date || "—"], - ["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"], + [ + "지문", + sheet.rate_version.sha256 + ? `${sheet.rate_version.sha256.slice(0, 8)}…` + : "—", + ], ]; for (const [label, value] of rows) { const row = document.createElement("div"); @@ -519,7 +582,10 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["base_data", "B09_Estimation_Tab_BaseData", false], ]; -function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { +function buildTabs( + active: string, + onSelect: (key: string) => void, +): HTMLElement { const bar = document.createElement("div"); bar.className = "b09-tabs"; for (const [key, labelKey, enabled] of TAB_KEYS) { @@ -541,8 +607,24 @@ function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement * API * -------------------------------------------------------------------------- */ +/** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */ +function parseQuantities(text: string): Record { + const out: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [code, value] = trimmed.split(/[=\t,]/); + if (!code || !value) continue; + const qty = value.trim(); + if (!/^\d+(\.\d+)?$/.test(qty)) continue; + out[code.trim()] = qty; + } + return out; +} + function toRequestBody(form: CostFormState): Record { - const num = (value: string): string => (value.trim() === "" ? "0" : value.trim()); + const num = (value: string): string => + value.trim() === "" ? "0" : value.trim(); const body: Record = { direct_material_krw: num(form.direct_material_krw), direct_labor_krw: num(form.direct_labor_krw), @@ -555,10 +637,15 @@ function toRequestBody(form: CostFormState): Record { if (form.target_contract_amount_krw.trim() !== "") { body.target_contract_amount_krw = form.target_contract_amount_krw.trim(); } + const quantities = parseQuantities(form.quantities_text); + if (Object.keys(quantities).length > 0) body.quantities = quantities; return body; } -async function fetchCostSheet(projectId: string, form: CostFormState): Promise { +async function fetchCostSheet( + projectId: string, + form: CostFormState, +): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`, { @@ -568,16 +655,20 @@ async function fetchCostSheet(projectId: string, form: CostFormState): Promise { +async function fetchUnitPriceList( + projectId: string, +): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`, { credentials: "include" }, ); - if (!response.ok) throw new Error(`unit price list failed: ${response.status}`); + if (!response.ok) + throw new Error(`unit price list failed: ${response.status}`); return (await response.json()) as UnitPriceListDto; } @@ -589,7 +680,8 @@ async function fetchUnitPriceDetail( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`, { credentials: "include" }, ); - if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`); + if (!response.ok) + throw new Error(`unit price detail failed: ${response.status}`); return (await response.json()) as UnitPriceDetailDto; } @@ -598,7 +690,8 @@ async function confirmEstimationStage(projectId: string): Promise { `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, { method: "POST", credentials: "include" }, ); - if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`); + if (!response.ok) + throw new Error(`estimation confirm failed: ${response.status}`); } /* ----------------------------------------------------------------------------- @@ -689,6 +782,23 @@ export async function renderB09Estimation(root: HTMLElement): Promise { body.append(empty); return; } + // 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다. + const source = document.createElement("div"); + source.className = "b09-hint"; + source.textContent = + sheet.direct_cost_source === "quantities" + ? L("B09_Estimation_Src_Quantities") + : L("B09_Estimation_Src_Manual"); + body.append(source); + + // 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**. + if (sheet.missing_unit_prices.length > 0) { + const missing = document.createElement("div"); + missing.className = "b09-hint"; + missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`; + body.append(missing); + } + body.append(buildCostSheetTable(sheet)); for (const note of sheet.notes) { const line = document.createElement("div"); @@ -725,7 +835,8 @@ export async function renderB09Estimation(root: HTMLElement): Promise { sheet = await fetchCostSheet(projectId, form); renderRateVersion(panel.rateVersionBox, sheet); panel.hintBox.textContent = - sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" + sheet.suggested_profit_adjustment_krw && + sheet.suggested_profit_adjustment_krw !== "0" ? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}` : ""; drawBody(); @@ -757,7 +868,8 @@ export async function renderB09Estimation(root: HTMLElement): Promise { mainContent: main, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { - if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + if (projectId) + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index d4ae5643..c34a3a0f 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -48,6 +48,8 @@ _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. FUEL_CODE_PREFIX = "M-FUEL-" +#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다. +SUSPICIOUSLY_LOW_KRW = Decimal(100) def _slots(value: Decimal) -> list[Decimal | None]: @@ -321,8 +323,34 @@ def build_summary(build: UnitPriceBuild) -> dict: kinds: dict[str, int] = {} for title in build.book.titles.values(): kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 + # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다. + # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다 + # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07). + totals = sorted( + build.book.resolve(code).total + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + ) + stats: dict[str, str] = {} + low: list[dict[str, str]] = [] + if totals: + stats = { + "min": _money_text(totals[0]), + "median": _money_text(totals[len(totals) // 2]), + "max": _money_text(totals[-1]), + } + low = [ + {"code": code, "name": title.name, "total": _money_text(money)} + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW + ] + return { "titles": len(build.book.titles), + "unit_price_totals": stats, + # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다. + "suspiciously_low": low, "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), "skipped_work_items": len(build.skipped), @@ -489,11 +517,14 @@ def cost_input_from_quantities( from B09_Estimation.B09_Estimation_Engine_Cost import CostInput breakdown = direct_cost_from_quantities(quantities, build) + # ⑤ 표에 들어가는 자리이므로 여기서 자른다 — 자원 집계표는 **반올림**이다 + # (`B09_Estimation_Rounding` 참조). `breakdown` 자체는 전정밀 값으로 남긴다. + summary = OutputPlace.RESOURCE_SUMMARY return ( CostInput( - direct_material_krw=breakdown.material, - direct_labor_krw=breakdown.labor, - direct_expense_krw=breakdown.expense, + direct_material_krw=round_at(breakdown.material, summary), + direct_labor_krw=round_at(breakdown.labor, summary), + direct_expense_krw=round_at(breakdown.expense, summary), **cost_input_kwargs, ), breakdown, diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index e64aa2c3..49035ddf 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -37,7 +37,10 @@ export const ui_locales_b2 = { "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.", ], - B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"], + B05_Drainage_Btn_DeleteSelected: [ + "선택한 관 삭제", + "Delete selected culvert", + ], B05_Drainage_Btn_DeleteSelected_Tip: [ "지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.", "Removes the culvert selected on the map. Select a culvert marker first.", @@ -69,19 +72,34 @@ export const ui_locales_b2 = { "노선을 확정하면 배수유역도가 표시됩니다.", "The drainage map appears once the route is confirmed.", ], - B05_Drainage_Status_Analyzing: ["세부유역을 산정하는 중…", "Computing sub-basins…"], - B05_Drainage_Status_NoBasin: ["산정된 배수유역이 없습니다.", "No drainage basin was computed."], + B05_Drainage_Status_Analyzing: [ + "세부유역을 산정하는 중…", + "Computing sub-basins…", + ], + B05_Drainage_Status_NoBasin: [ + "산정된 배수유역이 없습니다.", + "No drainage basin was computed.", + ], B05_Drainage_Status_AnalyzeFailed: [ "세부유역 산정에 실패했습니다.", "Failed to compute sub-basins.", ], - B05_Drainage_Status_LoadingBase: ["배경도를 불러오는 중…", "Loading the basemap…"], - B05_Drainage_Status_LoadingSheets: ["도엽 레이어를 불러오는 중…", "Loading map sheet layers…"], + B05_Drainage_Status_LoadingBase: [ + "배경도를 불러오는 중…", + "Loading the basemap…", + ], + B05_Drainage_Status_LoadingSheets: [ + "도엽 레이어를 불러오는 중…", + "Loading map sheet layers…", + ], B05_Drainage_Status_NoSheets: [ "도엽 레이어가 없습니다. B04에서 임포트하세요.", "No map sheet layer found. Import them in B04.", ], - B05_Drainage_Status_LoadFailed: ["배경도를 불러오지 못했습니다.", "Failed to load the basemap."], + B05_Drainage_Status_LoadFailed: [ + "배경도를 불러오지 못했습니다.", + "Failed to load the basemap.", + ], B05_Drainage_Basin_Undecided: ["미정", "TBD"], /* 관 최대 규격 초과 계류 유역 — 관이 아니라 세월교 대상. 유효직경은 앞머리가 적는다 */ B05_Drainage_Basin_Bridge: ["세월교 제안", "Ford bridge proposal"], @@ -96,7 +114,10 @@ export const ui_locales_b2 = { "Tc {tc}min · I {i}mm/hr · Qd {q}m³/s (100yr, ×2.0)", ], /* {chainage}=측점 누가거리(m) */ - B05_Drainage_Basin_Chainage: ["측점 누가거리 {chainage}m", "Station chainage {chainage}m"], + B05_Drainage_Basin_Chainage: [ + "측점 누가거리 {chainage}m", + "Station chainage {chainage}m", + ], /* {d}=규격 스냅 관경(mm). 유효직경 이상인 가장 작은 레지스트리 선택지 */ B05_Drainage_Basin_RecPipe: ["Ø{d} 배관 제안", "Ø{d} pipe proposal"], /* 유효직경 Ø1,500 초과 — 교본 BOX암거 전환 유량 조건 */ @@ -121,7 +142,10 @@ export const ui_locales_b2 = { B05_Route_Field_Filter: ["지면 필터", "Ground filter"], B05_Route_Field_Method: ["지표면 표현", "Surface method"], B05_Route_Field_SurfaceId: ["지표면 모델 ID", "Surface model ID"], - B05_Route_Surface_Confirmed: ["확정 모델 #{id} · {method}", "Confirmed model #{id} · {method}"], + B05_Route_Surface_Confirmed: [ + "확정 모델 #{id} · {method}", + "Confirmed model #{id} · {method}", + ], B05_Route_Surface_NotConfirmed: [ "WF1에서 지표면 모델을 확정하세요.", "Confirm a surface model in WF1.", @@ -156,27 +180,45 @@ export const ui_locales_b2 = { ], B05_Route_Reset_Failed: ["초기화에 실패했습니다.", "Failed to reset."], B05_Route_Result_Title: ["경로 탐색 결과", "Route Result"], - B05_Route_Result_Empty: ["아직 계산된 경로가 없습니다.", "No route computed yet."], + B05_Route_Result_Empty: [ + "아직 계산된 경로가 없습니다.", + "No route computed yet.", + ], B05_Route_Result_Length: ["총 연장(m)", "Total length (m)"], B05_Route_Result_MinSlope: ["최소 경사", "Min slope"], B05_Route_Result_MaxSlope: ["최대 경사", "Max slope"], B05_Route_Result_MeanSlope: ["평균 경사", "Mean slope"], B05_Route_Result_Cost: ["비용 점수", "Cost score"], B05_Route_Result_Path: ["경로 파일", "Route file"], - B05_Route_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], + B05_Route_Error_Project: [ + "먼저 프로젝트를 선택하세요.", + "Select a project first.", + ], B05_Route_Error_Points: [ "시점과 종점 좌표를 모두 입력하세요.", "Enter both begin and end coordinates.", ], - B05_Route_Error_Filter: ["지면 필터 키를 입력하세요.", "Enter a ground filter key."], + B05_Route_Error_Filter: [ + "지면 필터 키를 입력하세요.", + "Enter a ground filter key.", + ], B05_Route_Solve_Success: ["경로 탐색을 완료했습니다.", "Route solved."], B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."], B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."], - B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."], - B05_Route_Group_SectionOptions: ["시작 측점 및 샘플링 설정", "Start Station & Sampling Settings"], + B05_Route_Confirm_Failed: [ + "경로 확정에 실패했습니다.", + "Route confirm failed.", + ], + B05_Route_Group_SectionOptions: [ + "시작 측점 및 샘플링 설정", + "Start Station & Sampling Settings", + ], B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"], B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"], - B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], + B05_Route_Field_CrossSample: [ + "횡단 샘플 간격(m)", + "Cross sample interval (m)", + ], B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"], B05_Route_Field_StationLabels: ["측점 라벨", "Station labels"], @@ -189,7 +231,10 @@ export const ui_locales_b2 = { B06_Profile_Field_Method: ["지표면 표현", "Surface method"], B06_Profile_Field_Crs: ["좌표계", "CRS"], B06_Profile_Group_Display: ["표시 옵션", "Display Options"], - B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"], + B06_Profile_Field_VerticalExaggeration: [ + "높이 배율", + "Vertical exaggeration", + ], B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], B06_Profile_Smooth_On: ["사용", "On"], B06_Profile_Smooth_Off: ["미사용", "Off"], @@ -216,9 +261,18 @@ export const ui_locales_b2 = { B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"], B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"], B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"], - B06_Profile_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], - B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."], - B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."], + B06_Profile_Error_Project: [ + "먼저 프로젝트를 선택하세요.", + "Select a project first.", + ], + B06_Profile_Confirm_Success: [ + "종·횡단을 확정했습니다.", + "Sections confirmed.", + ], + B06_Profile_Confirm_Failed: [ + "종·횡단 확정에 실패했습니다.", + "Section confirm failed.", + ], B06_Profile_Detail_Failed: [ "종·횡단 도면 데이터를 불러오지 못했습니다.", "Failed to load section drawing data.", @@ -244,9 +298,18 @@ export const ui_locales_b2 = { B06_Cross_Revet_Pipe: ["관 길이", "Pipe length"], B06_Cross_Revet_Outward: ["바깥", "outward"], B06_Cross_Revet_Inward: ["안쪽", "inward"], - B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"], - B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"], - B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"], + B06_Cross_Revet_Left: [ + "왼쪽으로 — 관 길이 1m 단위", + "Move left — 1m of pipe length", + ], + B06_Cross_Revet_Right: [ + "오른쪽으로 — 관 길이 1m 단위", + "Move right — 1m of pipe length", + ], + B06_Cross_Revet_Reset: [ + "기슭막이 자동 자리로 초기화", + "Reset revetment to solved position", + ], B06_Cross_Revet_Inlet: ["기슭막이(유입)", "Revetment (inlet)"], B06_Cross_Revet_Outlet: ["기슭막이(유출)", "Revetment (outlet)"], /* 배관과 무관한 독립 기슭막이(구조물 정본 D군) — 2026-08-28. */ @@ -267,7 +330,10 @@ export const ui_locales_b2 = { "Cannot move further down the slope", ], B06_Cross_Height_Label: ["높이", "Height"], - B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"], + B06_Cross_Move_Label: [ + "이동(좌우·사면 상하)", + "Move (lateral / along slope)", + ], B06_Cross_Lateral_Label: ["좌우", "Lateral"], B06_Cross_Slope_Label: ["상하(사면)", "Along slope"], B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"], @@ -276,7 +342,10 @@ export const ui_locales_b2 = { "{mat} 높이 한계 {limit}m — 더 올리려면 재질을 변경하세요", "{mat} height limit {limit}m — change material to go higher", ], - B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"], + B06_Cross_Height_Floor: [ + "최소 높이라 더 낮출 수 없습니다", + "Already at the minimum height", + ], B06_Cross_Basin_Limit_Pipe: [ "여기까지입니다 — 더 옮기면 배관 길이가 달라집니다(I형은 관을 감싸는 구조)", "Limit reached — moving further changes the pipe length (type I wraps the pipe)", @@ -444,7 +513,10 @@ export const ui_locales_b2 = { B06_Design_Area_Total: ["계", "Total"], /* 단위는 값 칸마다 붙이지 않고 표 좌상단(행제목 × 열제목 교차) 칸에 한 번만 적는다. */ B06_Design_Area_Unit: ["㎡", "㎡"], - B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"], + B06_Design_Area_Highlight: [ + "누르면 해당 면적을 강조합니다", + "Click to highlight this area", + ], B06_Design_Fill_Area: ["성토", "Fill"], B06_Design_Unset: ["미지정", "Not set"], B06_Design_DitchType_Legend: ["측구형식", "Ditch type"], @@ -485,8 +557,14 @@ export const ui_locales_b2 = { B06_Design_RockBoundary_Legend: ["암 경계", "Rock boundary"], B06_Design_RockBoundary_Up: ["암 경계선 올림", "Raise rock boundary"], B06_Design_RockBoundary_Down: ["암 경계선 내림", "Lower rock boundary"], - B06_Design_RockBoundary_Reset: ["암 경계선 기본값 복원", "Reset rock boundary"], - B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."], + B06_Design_RockBoundary_Reset: [ + "암 경계선 기본값 복원", + "Reset rock boundary", + ], + B06_Design_Failed: [ + "횡단 설계 계산에 실패했습니다.", + "Failed to compute cross-section design.", + ], B06_Profile_Confirm_NeedDesign: [ "지반유형이 지정되지 않은 측점이 있습니다.", "Some stations have no ground type assigned.", @@ -499,17 +577,29 @@ export const ui_locales_b2 = { "표시 반폭만 바로 반영합니다(측점 설계 재계산 없음). 계산 반폭(20m)을 넘는 값만 재생성이 필요해 시간이 걸립니다.", "Applies the display half-width only (no per-station redesign). Only values beyond the sampled 20 m need regeneration.", ], - B06_View_Apply_Success: ["표시 반폭을 반영했습니다.", "Display half-width applied."], + B06_View_Apply_Success: [ + "표시 반폭을 반영했습니다.", + "Display half-width applied.", + ], /* --- B06 표준 횡단면 설정 패널 --- */ B06_Std_Title: ["표준 횡단면 설정", "Standard cross-section"], B06_Std_Group_Soil: ["토사 구간", "Soil section"], - B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"], + B06_Std_Group_Rock: [ + "암 구간 (리핑/발파)", + "Rock section (ripping/blasting)", + ], B06_Std_Group_Paved: ["포장 구간", "Paved section"], B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"], B06_Std_Section_Common: ["공통", "Common"], - B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"], - B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"], + B06_Std_Section_RockOnly: [ + "암 구간 — 다른 값만", + "Rock section - differing values", + ], + B06_Std_Section_PavedOnly: [ + "포장 구간 — 다른 값만", + "Paved section - differing values", + ], B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"], B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"], B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"], @@ -532,7 +622,10 @@ export const ui_locales_b2 = { "패널 설정을 전체 측점에 반영했습니다.", "Applied panel settings to all stations.", ], - B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"], + B06_Std_Load_Title: [ + "다른 프로젝트에서 불러오기", + "Load from another project", + ], B06_Std_Load_Select: ["프로젝트 선택", "Select project"], B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"], B06_Std_Load_Empty: [ @@ -542,7 +635,10 @@ export const ui_locales_b2 = { B06_Std_Load_Loading: ["불러오는 중…", "Loading…"], B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"], B06_Std_Load_Applied: ["적용되었습니다.", "Applied."], - B06_Std_Load_Failed: ["설계값을 불러오지 못했습니다.", "Failed to load design values."], + B06_Std_Load_Failed: [ + "설계값을 불러오지 못했습니다.", + "Failed to load design values.", + ], B06_Std_Load_None: [ "선택한 프로젝트에 저장된 설계값이 없습니다.", "The selected project has no saved design values.", @@ -569,7 +665,10 @@ export const ui_locales_b2 = { "Side panel will be configured after the upstream data spec is finalized.", ], B07_Cad_Loading: ["도면을 불러오는 중...", "Loading drawing..."], - B07_Cad_Load_Failed: ["도면을 불러오지 못했습니다.", "Failed to load drawing."], + B07_Cad_Load_Failed: [ + "도면을 불러오지 못했습니다.", + "Failed to load drawing.", + ], B07_Info_Ground_Title: ["지반정보", "Ground info"], B07_Info_Plan_Title: ["계획정보", "Plan info"], B07_Info_GroundType: ["지반유형", "Ground type"], @@ -584,7 +683,10 @@ export const ui_locales_b2 = { B07_Info_FillArea: ["성토 단면적", "Fill area"], B07_Info_Provisional: ["잠정", "Provisional"], B07_Info_Confirmed: ["확정", "Confirmed"], - B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."], + B07_Info_NoDesign: [ + "지반·계획 지정 데이터가 없습니다.", + "No ground/plan designation data.", + ], B07_Info_Station: ["측점", "Station"], /* 장(여러 측점을 담은 횡단 도면)은 측점 단위 지반·계획 정보를 갖지 않는다 — 제목을 「측점」으로 달면 어느 측점 값인지 오해된다(2026-09-03 정리). */ @@ -610,7 +712,10 @@ export const ui_locales_b2 = { "Failed to confirm the quantity stage.", ], B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"], - B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], + B08_Quantity_Grid_Loading: [ + "토적표를 만드는 중입니다…", + "Building the earthwork table…", + ], B08_Quantity_Grid_Empty: [ "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", "No cross-section areas yet. Finish the cross-section design first.", @@ -630,15 +735,24 @@ export const ui_locales_b2 = { B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"], B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"], B08_Quantity_Btn_Save: ["저장", "Save"], - B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."], - B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."], + B08_Quantity_Save_Success: [ + "산출 조건을 저장했습니다.", + "Calculation settings saved.", + ], + B08_Quantity_Save_Failed: [ + "산출 조건을 저장하지 못했습니다.", + "Failed to save the settings.", + ], B08_Quantity_Unsaved: [ "저장하지 않은 변경이 있습니다.", "You have unsaved changes.", ], B08_Quantity_Side_Method: ["산출법", "Method"], B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], - B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], + B08_Quantity_Side_Factors: [ + "토량환산계수(다짐)", + "Conversion factors (compacted)", + ], /* --- B09_Estimation 원가계산 --- */ B09_Estimation_Title: ["원가계산", "Cost Estimate"], @@ -676,7 +790,10 @@ export const ui_locales_b2 = { "목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.", "To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.", ], - B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."], + B09_Estimation_Calc_Failed: [ + "원가계산에 실패했습니다.", + "Cost calculation failed.", + ], B09_Estimation_Confirm_Success: [ "원가계산 단계를 확정했습니다.", "Cost estimate stage confirmed.", @@ -688,7 +805,10 @@ export const ui_locales_b2 = { B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"], B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"], - B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."], + B09_Estimation_UP_Pick: [ + "목록에서 항목을 고르세요.", + "Pick an item from the index.", + ], B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"], B09_Estimation_Col_Name: ["명칭", "Name"], B09_Estimation_Col_Spec: ["규격", "Spec"], @@ -700,12 +820,35 @@ export const ui_locales_b2 = { B09_Estimation_Col_Expense: ["경비", "Expense"], B09_Estimation_Col_Total: ["합계", "Total"], B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], - B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], + B09_Estimation_UP_SumBad: [ + "⚠ 합계가 재료+노무+경비와 다릅니다", + "⚠ Total ≠ M+L+E", + ], B09_Estimation_UP_RoundGap: [ "행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:", "Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:", ], - B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], + B09_Estimation_Group_Quantity: ["수량", "Quantities"], + B09_Estimation_Field_Quantities: [ + "공종별 수량 (한 줄에 「공종코드=수량」)", + 'Quantities (one "code=qty" per line)', + ], + B09_Estimation_Src_Manual: [ + "수량 원천: 손입력(직접비 직접 입력)", + "Source: manual direct costs", + ], + B09_Estimation_Src_Quantities: [ + "수량 원천: 손입력 공종 수량 × 일위대가", + "Source: manual quantities × unit prices", + ], + B09_Estimation_Missing_UP: [ + "수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:", + "Quantities without a unit price — excluded from the total:", + ], + B09_Estimation_UP_Load_Failed: [ + "일위대가를 못 불러왔습니다.", + "Failed to load unit prices.", + ], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"], @@ -723,7 +866,10 @@ export const ui_locales_b2 = { B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"], B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"], B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"], - B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"], + B10_Payment_Deposit_Pending: [ + "견적 확정 후 표시", + "Shown after estimate confirmation", + ], B10_Payment_Deposit_Note: [ "입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.", "Design documents and DWG downloads are enabled after the deposit is confirmed.", From 01691d4c14d3f6a2c5c5bfa99547ac5afdb48a76 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:14:02 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat(B09):=20=EC=9E=90=EC=9B=90=20?= =?UTF-8?q?=EC=B6=95=20=EC=9E=AC=EC=83=9D=EC=84=B1=20+=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=ED=8C=8C=EC=9D=BC=20=EC=A7=80=EB=AC=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B08 공종 마스터 재생성(ab39c174) 반영 — 자원 축 116 → 119 줄, 미판정 83 → 76. 새로 붙은 것: 강관동바리(형틀목공·보통인부) · 신구 BOX접합(미장공) - 기존 119 줄 중 **수치가 바뀐 줄 0건** — 밑수 판정 교정이 기존 소요량을 건드리지 않았음. 일위대가 총액 분포도 그대로 (최소 233.4 · 중앙 86,034.0 · 최대 1,916,466.0, 의심 저가 0건) - 산출물에 `source_master_file`(마스터 파일 sha256) 추가 — 기존 `source_dataset_version` 은 품셈 원판 지문이라 마스터를 다시 생성해도 안 움직여, 낡은 파생물을 드러내지 못했음 검증: pytest 136 통과, 새 공종 2건 일위대가 적층 확인 (강관동바리 27,908.7 · 신구 BOX접합 33,273.1) Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_ResourceAxis.py | 21 ++++++++ .../resource_axis_2026-01-01.json | 48 +++++++++++++++++-- .../unmatched_2026-01-01.json | 42 ++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 1c9e4525..84b72ba5 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -20,6 +20,7 @@ from __future__ import annotations +import hashlib import json import os import re @@ -526,6 +527,23 @@ def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> Axi OUTPUT_SUBPATH = ("resources", "data_cost_resource_axis") +def _master_file_fingerprint(master: dict[str, Any]) -> dict[str, str]: + """공종 마스터 **파일 자체**의 지문. 낡은 파생물을 드러내는 유일한 근거다. + + `dataset_version`(품셈 원판 지문)은 마스터가 다시 생성돼도 그대로라, 그것만 + 적어 두면 「내 자원 축이 옛 마스터에서 나왔다」는 사실이 안 보인다. + """ + effective_date = master.get("effective_date", "") + file_name = f"work_item_master_{effective_date}.json" + path = os.path.join(_project_root(), *_MASTER_SUBPATH, file_name) + try: + with open(path, "rb") as handle: + digest = hashlib.sha256(handle.read()).hexdigest() + except OSError: + return {"file": file_name, "sha256": ""} + return {"file": file_name, "sha256": digest} + + def write_resource_axis( result: AxisResult, master: dict[str, Any], @@ -546,6 +564,9 @@ def write_resource_axis( "effective_date": effective_date, # 어느 공종 축 판에 붙인 것인지 — 세 쪽을 그대로 옮겨 적는다(PLAN 9-2). "source_dataset_version": master.get("dataset_version", {}), + # ⚠ 위 지문은 **품셈 원판**의 것이라 B08 이 마스터를 다시 생성해도 안 움직인다. + # 낡음을 실제로 드러내려면 **마스터 파일 자체의 지문**이 있어야 한다. + "source_master_file": _master_file_fingerprint(master), "policy": { "axis": "resource_only", "work_item_axis_owner": "B08", 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 5774d43a..7b3a2e23 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 @@ -1015,6 +1015,30 @@ "resource_spec": "", "work_item_code": "FP-12-18" }, + { + "amount": "0.07", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0367", + "raw_row_index": 3, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "work_item_code": "FP-12-20" + }, + { + "amount": "0.05", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0367", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-12-20" + }, { "amount": "0.034", "amount_unit": "", @@ -1087,6 +1111,18 @@ "resource_spec": "", "work_item_code": "FP-12-24-01" }, + { + "amount": "0.12", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0378", + "raw_row_index": 2, + "resource_code": "1027", + "resource_kind": "labor", + "resource_name": "미장공", + "resource_spec": "", + "work_item_code": "FP-12-28" + }, { "amount": "0.004", "amount_unit": "", @@ -1407,13 +1443,17 @@ "file": "pum_forest_2026.json", "sha256": "111208fd482dcfc3b8c8dd58004b153382fc46555d0d00985c19b45ebbc2e0dd" }, + "source_master_file": { + "file": "work_item_master_2026-01-01.json", + "sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0" + }, "stats": { - "rows": 116, + "rows": 119, "skipped_forms": { "coefficient": 19, - "reference": 94, - "undetermined": 83 + "reference": 98, + "undetermined": 76 }, - "unmatched": 348 + "unmatched": 355 } } \ 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 915d08fc..b2414650 100644 --- a/resources/data_cost_resource_axis/unmatched_2026-01-01.json +++ b/resources/data_cost_resource_axis/unmatched_2026-01-01.json @@ -1772,6 +1772,24 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-18" }, + { + "cell": "강관 동바리", + "pum_table_id": "F0367", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-20" + }, + { + "cell": "외관(60.6mm×2.3mm)", + "pum_table_id": "F0367", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-20" + }, + { + "cell": "잡재료비(재료비의)", + "pum_table_id": "F0367", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-20" + }, { "cell": "아스팔트(㏊-500)", "pum_table_id": "F0368", @@ -1844,6 +1862,30 @@ "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-27-02" }, + { + "cell": "실런트", + "pum_table_id": "F0377", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-27-03" + }, + { + "cell": "인력(설치비)", + "pum_table_id": "F0377", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-27-03" + }, + { + "cell": "에폭시 접착제", + "pum_table_id": "F0378", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", + "work_item_code": "FP-12-28" + }, + { + "cell": "시너", + "pum_table_id": "F0378", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", + "work_item_code": "FP-12-28" + }, { "cell": "결속선(R-0.9mm)", "pum_table_id": "F0387", From a71031b1f004de602164d283d15994c37f53025f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:31:17 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat(B09):=20=E2=91=A3=20=EC=98=88?= =?UTF-8?q?=EC=82=B0=EB=82=B4=EC=97=AD=EC=84=9C=20=EC=A1=B0=ED=8C=90=20+?= =?UTF-8?q?=20=EB=B0=B0=EB=B6=84=EC=9C=A8=20=ED=91=9C=20=EC=98=A4=EB=8F=85?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조판 - B08 인계 응답을 계층 선 내역서로 접음. 계층·정렬은 공종 마스터의 `parent_code`·`sort_order`(256 간격)에서 옴 — 코드 글자수로 깊이 안 셈 - ITEM NO. 를 가지치기 나무에서 매김. 머리글 줄은 수량·금액 없음 - `in_bill=false`(보정량계)는 수량만 보이고 금액 안 붙임. `check_excluded_rows_not_priced()` 가 수치로 막음 (㉡ 확장) - 단가 없는 줄·공급 구분 미정 자재는 0 으로 안 때우고 `missing` 에 이름째 남김 - 잎에 일위대가가 없고 하위에 있으면 **후보만 보임** — 임의로 고르지 않음 - 반영률은 적기만 함(B08 이 이미 곱함) — 여기서 또 곱하면 두 배 배분율 표 오독 차단 (2026-09-08 실측으로 발견) - 분류 딱지가 「인력(10%)」처럼 비율을 달고 오면 판정이 빗나가 자원이 통째로 빠지고 있었음. 측구터파기(FP-09-12-01)는 자원 줄이 0 개였음 - 비율 꼬리표만 떼고 정확 일치 유지 — 「보통인부(인)」·「인력운반공」은 안 걸림 - 자원 축 119 → 144 줄, 일위대가 73 → 85. 기존 줄 변경 0·삭제 0 - ⚠ 그 표들은 인력 몫만 붙음(장비 몫은 시공능력 공식). 그대로 두면 인력 10 % 몫 단가가 전량에 곱해져 **조용히 틀림** — 25 공종을 `partial_ratio` 로 표시하고 내역서에서 금액을 안 붙임 검증: pytest 146 통과(신규 10). 가드는 일부러 어겨 멈추는 것까지 확인, 오탐 짝 시험 포함. 실물 인계자료(프로젝트 5cff3920)로 조판 실행 — 14 줄·검산줄 1·미확보 14 건이 이름째 뜸 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 505 ++++++++++++++++++ B09_Estimation/B09_Estimation_Guards.py | 26 + B09_Estimation/B09_Estimation_ResourceAxis.py | 40 +- B09_Estimation/B09_Estimation_UnitPrice.py | 32 ++ .../resource_axis_2026-01-01.json | 304 ++++++++++- .../unmatched_2026-01-01.json | 192 +------ 6 files changed, 925 insertions(+), 174 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_BillOfQuantities.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py new file mode 100644 index 00000000..b52bce61 --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -0,0 +1,505 @@ +"""B09 원가계산 — ④ 예산내역서 조판 (PLAN 9-5 「B08 출력을 붙이는 자리」). + +**하는 일** — B08 인계 응답(공종 수량 + 자재)을 받아 **계층이 선 내역서 한 장**으로 +접는다. 수량은 B08 것을 그대로 쓰고(다시 세지 않는다), 단가는 ③ 일위대가에서 가져오며, +금액은 이 자리에서 `수량 × 단가` 로 만든다. + +**계층은 코드에 안 박는다** (PLAN 9-3 · STmate `BOQ11` 해부 결과). 공종 마스터가 이미 +`parent_code` · `level` · `sort_order`(256 간격)를 들고 있으므로, 쓰인 공종의 **조상만 +남긴 가지치기 나무**를 세우고 거기서 ITEM NO. 를 매긴다. 깊이를 코드 글자수로 세지 않는다 — +`FP-09-03-02` 가 3층이라는 보장이 없다. + +**빈칸을 지어내지 않는다** — 단가가 없는 줄, 관급/사급이 안 갈린 자재는 금액을 0 으로 +때우지 않고 `missing` 에 이름째 남긴다. 화면이 그것을 그대로 보인다. + +⚠ **`in_bill=false` 줄에는 단가를 붙이지 않는다** (PLAN 8-7 ㉡ 와 같은 성격). +보정량계·무대 같은 검산용 줄이라 금액을 매기면 같은 것을 두 번 세게 된다. 주석으로 +막지 않고 `check_excluded_rows_not_priced()` 가 수치로 멈춘다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_Guards import check_excluded_rows_not_priced +from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build + +_ZERO = Decimal(0) + +#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인). +#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. +SUPPLY_UNKNOWN = "unknown" + + +class BillError(ValueError): + """내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다.""" + + +@dataclass(frozen=True) +class HandoffWorkItem: + """B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다.""" + + work_item_code: str | None + name: str + spec: str + unit: str + quantity: Decimal + in_bill: bool + in_bill_reason: str = "" + origin: str = "" + ground_class: str = "" + #: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**. + application_ratio_pct: Decimal | None = None + + @property + def display_name(self) -> str: + return f"{self.name} {self.spec}".strip() + + +@dataclass(frozen=True) +class HandoffMaterial: + """B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정).""" + + material_name: str + spec: str + unit: str + net_amount: Decimal + total_amount: Decimal + supply_type: str + surcharge_pct: Decimal | None = None + surcharge_note: str = "" + install_by: str | None = None + source_structure: tuple[str, ...] = () + + @property + def display_name(self) -> str: + return f"{self.material_name} {self.spec}".strip() + + +@dataclass +class BillRow: + """내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다.""" + + item_no: str + level: int + code: str | None + name: str + spec: str = "" + unit: str = "" + quantity: Decimal | None = None + unit_price_krw: Decimal | None = None + amount_krw: Decimal | None = None + #: 3분할 — ⑤ 로 넘길 때 **뭉치지 않고** 성분 그대로 간다(PLAN 8-9 규칙 2). + material_krw: Decimal = _ZERO + labor_krw: Decimal = _ZERO + expense_krw: Decimal = _ZERO + is_group: bool = False + in_bill: bool = True + note: str = "" + + def as_dict(self) -> dict[str, Any]: + def money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + return { + "item_no": self.item_no, + "level": self.level, + "code": self.code, + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "quantity": money(self.quantity), + "unit_price_krw": money(self.unit_price_krw), + "amount_krw": money(self.amount_krw), + "material_krw": str(self.material_krw), + "labor_krw": str(self.labor_krw), + "expense_krw": str(self.expense_krw), + "is_group": self.is_group, + "in_bill": self.in_bill, + "note": self.note, + } + + +@dataclass +class BillResult: + """④ 예산내역서 한 장.""" + + rows: list[BillRow] = field(default_factory=list) + #: 금액을 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다**. + missing: list[dict[str, str]] = field(default_factory=list) + #: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다. + excluded: list[BillRow] = field(default_factory=list) + #: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다. + material_rows: list[BillRow] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + @property + def direct_material_krw(self) -> Decimal: + return sum((r.material_krw for r in self.rows if not r.is_group), _ZERO) + + @property + def direct_labor_krw(self) -> Decimal: + return sum((r.labor_krw for r in self.rows if not r.is_group), _ZERO) + + @property + def direct_expense_krw(self) -> Decimal: + return sum((r.expense_krw for r in self.rows if not r.is_group), _ZERO) + + @property + def body_total_krw(self) -> Decimal: + """내역서 **본체** 합계 — 줄마다 절사한 금액의 합. + + ⚠ 집계표(반올림) 합계와 원 단위로 어긋나는 것이 정상이다 + (`B09_Estimation_Rounding.SUMMARY_MISMATCH_NOTE`). + """ + return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO) + + +def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None: + if value is None or value == "": + return default + return Decimal(str(value)) + + +def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]: + """인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**""" + if "work_items" not in payload or "materials" not in payload: + raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.") + + work_items = [ + HandoffWorkItem( + work_item_code=row.get("work_item_code"), + name=row.get("name", ""), + spec=row.get("spec") or "", + unit=row.get("unit") or "", + quantity=_decimal(row.get("quantity")) or _ZERO, + in_bill=bool(row.get("in_bill", True)), + in_bill_reason=row.get("in_bill_reason") or "", + origin=row.get("origin") or "", + ground_class=row.get("ground_class") or "", + # ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①). + application_ratio_pct=_decimal(row.get("application_ratio_pct"), None), + ) + for row in payload["work_items"] + ] + materials = [ + HandoffMaterial( + material_name=row.get("material_name", ""), + spec=row.get("spec") or "", + unit=row.get("unit") or "", + net_amount=_decimal(row.get("net_amount")) or _ZERO, + total_amount=_decimal(row.get("total_amount")) or _ZERO, + supply_type=row.get("supply_type") or SUPPLY_UNKNOWN, + surcharge_pct=_decimal(row.get("surcharge_pct"), None), + surcharge_note=row.get("surcharge_note") or "", + install_by=row.get("install_by"), + source_structure=tuple(row.get("source_structure") or ()), + ) + for row in payload["materials"] + ] + return work_items, materials + + +@dataclass(frozen=True) +class _MasterNode: + code: str + name: str + level: int + parent_code: str | None + sort_order: int + + +def _master_index(master: dict[str, Any]) -> dict[str, _MasterNode]: + return { + node["work_item_code"]: _MasterNode( + code=node["work_item_code"], + name=node.get("name", ""), + level=int(node.get("level", 1)), + parent_code=node.get("parent_code"), + sort_order=int(node.get("sort_order", 0)), + ) + for node in master.get("work_items", []) + if node.get("work_item_code") + } + + +def _ancestor_chain(code: str, index: dict[str, _MasterNode]) -> list[_MasterNode]: + """뿌리 → 자기 순서의 조상 사슬. **코드 글자수로 깊이를 세지 않는다.**""" + chain: list[_MasterNode] = [] + seen: set[str] = set() + cursor: str | None = code + while cursor and cursor in index and cursor not in seen: + seen.add(cursor) + node = index[cursor] + chain.append(node) + cursor = node.parent_code + chain.reverse() + return chain + + +def _number_of(path: tuple[int, ...]) -> str: + """ITEM NO. — 자리마다 1 부터. 「1」·「1-2」·「1-2-3」 모양.""" + return "-".join(str(n) for n in path) + + +def build_bill( + payload: dict[str, Any], + *, + build: UnitPriceBuild | None = None, + master: dict[str, Any] | None = None, +) -> BillResult: + """인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.""" + work_items, materials = parse_handoff(payload) + unit_prices = build or cached_build() + index = _master_index(master or load_work_item_master()) + result = BillResult() + + # ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ──────────────────────────────── + # 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다. + used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = [] + orphans: list[HandoffWorkItem] = [] + for item in work_items: + if not item.in_bill: + # ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라 + # **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할 + # 줄」로 잘못 읽힌다. + result.excluded.append(_excluded_row(item)) + continue + if not item.work_item_code or item.work_item_code not in index: + orphans.append(item) + continue + used.append(((), item, _ancestor_chain(item.work_item_code, index))) + + def sort_key(entry: tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]) -> tuple: + return tuple(node.sort_order for node in entry[2]) + + used.sort(key=sort_key) + + emitted: dict[str, str] = {} # 코드 → ITEM NO. + counters: dict[str, int] = {} # 부모 ITEM NO. → 마지막 번호 + + def next_number(parent_no: str) -> str: + counters[parent_no] = counters.get(parent_no, 0) + 1 + return f"{parent_no}-{counters[parent_no]}" if parent_no else str(counters[parent_no]) + + for _, item, chain in used: + parent_no = "" + # 조상 줄(머리글)을 먼저 세운다 — 이미 선 것은 다시 안 세운다. + for node in chain[:-1]: + if node.code in emitted: + parent_no = emitted[node.code] + continue + parent_no = next_number(parent_no) + emitted[node.code] = parent_no + result.rows.append( + BillRow( + item_no=parent_no, + level=node.level, + code=node.code, + name=node.name, + is_group=True, + ) + ) + leaf = chain[-1] + item_no = emitted.get(leaf.code) or next_number(parent_no) + emitted[leaf.code] = item_no + result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result)) + + # ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ──────────────────────────────────── + for item in orphans: + result.missing.append( + { + "name": item.display_name, + "unit": item.unit, + "quantity": str(item.quantity), + "reason": "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다.", + } + ) + + # ── 3) 자재 벌 ──────────────────────────────────────────────────────────── + for material in materials: + result.material_rows.append(_material_row(material, result)) + + # ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ────────────────────── + check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded]) + + if any(m.surcharge_pct is None for m in materials): + result.notes.append( + "자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. " + "할증은 자재총괄에서 한 번만 붙습니다 (PLAN 8-7 ㉠)." + ) + return result + + +def _excluded_row(item: HandoffWorkItem) -> BillRow: + """검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**""" + return BillRow( + item_no="", + level=1, + code=item.work_item_code, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=False, + note=item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.", + ) + + +def _leaf_row( + item_no: str, + node: _MasterNode, + item: HandoffWorkItem, + unit_prices: UnitPriceBuild, + result: BillResult, +) -> BillRow: + """세부 공종 한 줄. 단가가 없으면 **금액을 비우고** `missing` 에 남긴다.""" + row = BillRow( + item_no=item_no, + level=node.level, + code=node.code, + name=item.name or node.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=item.in_bill, + ) + if item.application_ratio_pct is not None: + # ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다. + row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량" + + if not item.in_bill: + # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). + row.quantity = item.quantity + row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다." + result.excluded.append(row) + return row + + price_code = f"B-{node.code}" + if price_code not in unit_prices.book.titles: + # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 + # (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도). + children = sorted( + code + for code in unit_prices.book.titles + if code.startswith(f"{price_code}-") and code.count("-") == price_code.count("-") + 1 + ) + if children: + names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) + row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}" + reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" + else: + row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." + reason = "일위대가 없음" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": reason, + "candidates": ", ".join(children), + } + ) + return row + + covered = unit_prices.partial_ratio.get(node.code) + if covered is not None: + # ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만 + # 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다. + row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)." + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": f"단가 일부만 섬(붙은 몫 {covered}%)", + } + ) + return row + + unit_money = unit_prices.book.resolve(price_code) + line = unit_money.scaled(item.quantity) + row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) + # 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상. + row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) + # 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다. + row.material_krw = line.material + row.labor_krw = line.labor + row.expense_krw = line.expense + return row + + +def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: + """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" + row = BillRow( + item_no="", + level=1, + code=None, + name=material.material_name, + spec=material.spec, + unit=material.unit, + quantity=material.total_amount, + note=material.surcharge_note, + ) + if material.supply_type == SUPPLY_UNKNOWN: + row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": "공급 구분 미정(unknown)", + } + ) + return row + # 사급 자재 단가는 아직 원천이 없다(미결 No.18) — 여기서도 지어내지 않는다. + row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": "자재 단가 없음(미결 No.18)", + } + ) + return row + + +def bill_summary(result: BillResult) -> dict[str, Any]: + """화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다.""" + return { + "rows": len(result.rows), + "detail_rows": sum(1 for r in result.rows if not r.is_group), + "group_rows": sum(1 for r in result.rows if r.is_group), + "excluded_rows": len(result.excluded), + "material_rows": len(result.material_rows), + "missing": result.missing, + "body_total_krw": str(result.body_total_krw), + "direct_material_krw": str(result.direct_material_krw), + "direct_labor_krw": str(result.direct_labor_krw), + "direct_expense_krw": str(result.direct_expense_krw), + "notes": result.notes, + } + + +def cost_input_from_bill(result: BillResult, **cost_input_kwargs): + """④ 내역서 합계를 ⑤ 원가계산서 입력으로 접어 넣는다. + + **뭉치지 않는다** — 재료·노무·경비 성분이 그대로 간다(PLAN 8-9 규칙 2). + ⑤ 표에 찍히는 자리이므로 **자원 집계표 규칙(반올림)** 으로 자른다. + """ + from B09_Estimation.B09_Estimation_Engine_Cost import CostInput + + summary = OutputPlace.RESOURCE_SUMMARY + return CostInput( + direct_material_krw=round_at(result.direct_material_krw, summary), + direct_labor_krw=round_at(result.direct_labor_krw, summary), + direct_expense_krw=round_at(result.direct_expense_krw, summary), + **cost_input_kwargs, + ) diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py index 315aeb44..e36d1f69 100644 --- a/B09_Estimation/B09_Estimation_Guards.py +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -179,3 +179,29 @@ def check_column_sums( f"표시 {shown:,.2f}. 같은 성분을 두 층에서 셌을 수 있습니다 " "(행 방향 `TC=NC+GC+JC` 검사로는 안 잡힘)." ) + + +def check_excluded_rows_not_priced( + *, + rows: list[dict], + amount_field: str = "amount_krw", + unit_price_field: str = "unit_price_krw", + label: str = "내역서", +) -> None: + """㉡ 확장 — `in_bill=false` 줄(보정량계·무대 등)에 금액이 붙지 않았는가. + + B08 은 검산용 줄도 **수량을 그대로 실어 보낸다**(계약 확정). 수량이 있으니 + 조판이 무심코 단가를 붙이면 같은 것을 두 번 세게 된다 — 합계 줄과 그 아래 + 상세 줄이 함께 더해지는 모양이라 **행 검사로는 안 잡힌다**. + """ + for row in rows: + for field_name in (amount_field, unit_price_field): + value = row.get(field_name) + if value in (None, ""): + continue + if Decimal(str(value)) != 0: + raise DoubleCountError( + f"{label}: 합계·검산용 줄(`in_bill=false`)에 {field_name} " + f"{Decimal(str(value)):,.0f} 이 붙었습니다 — 그 줄은 수량만 보이고 " + "금액을 매기지 않습니다 (PLAN 8-7 ㉡ 와 같은 성격)." + ) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 84b72ba5..f48679dd 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -366,6 +366,10 @@ class ResourceRow: amount: Decimal amount_unit: str raw_row_index: int + #: 분류 딱지가 달고 온 배분율 — 「인력(10%)」이면 `10`. 없으면 `None`. + #: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다 + #: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다). + group_ratio_pct: Decimal | None = None def as_dict(self) -> dict[str, Any]: return { @@ -379,6 +383,7 @@ class ResourceRow: "amount": str(self.amount), "amount_unit": self.amount_unit, "raw_row_index": self.raw_row_index, + "group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct), } @@ -458,7 +463,9 @@ def match_table( # 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다. name_cell = cells[0] value_cells = cells[1:] - if _normalize(name_cell) in _GROUP_LABELS and len(cells) > 1: + group_ratio = None + if _group_label_of(name_cell) is not None and len(cells) > 1: + group_ratio = _group_ratio_of(name_cell) name_cell = cells[1] value_cells = cells[2:] @@ -509,10 +516,41 @@ def match_table( amount=amount, amount_unit=unit, raw_row_index=index, + group_ratio_pct=group_ratio, ) ) +#: 분류 딱지가 **비율을 달고 오는** 모양 — 「인력(10%)」·「장비(90%)」. +#: 2026-09-08 실측: 측구터파기(FP-09-12-01) 표가 이 모양이라 딱지 판정이 빗나가 +#: **보통인부 0.23인이 통째로 빠지고 있었다**(그 공종의 자원 줄이 0 개였다). +#: 괄호 안이 **숫자·%·소수점뿐일 때만** 떼어 낸다 — 「보통인부(인)」 같은 단위 표기는 +#: 떼면 안 되므로 넓게 잡지 않는다. +_RE_RATIO_SUFFIX = re.compile(r"[((][\d.\s]*%?[))]$") + + +def _group_label_of(cell: str) -> str | None: + """첫 칸이 분류 딱지면 그 딱지를, 아니면 `None` 을 돌려준다. + + ⚠ 딱지 목록은 **정확 일치**를 유지한다(부분일치가 정상 자원을 지운 전례 — + `_NON_RESOURCE_WORDS` 주석). 비율 꼬리표만 떼고 다시 정확 일치로 본다. + """ + text = _normalize(cell) + if text in _GROUP_LABELS: + return text + stripped = _normalize(_RE_RATIO_SUFFIX.sub("", text)) + return stripped if stripped in _GROUP_LABELS else None + + +def _group_ratio_of(cell: str) -> Decimal | None: + """분류 딱지에 붙은 배분율. 「인력(10%)」 → `10`, 「자재」 → `None`.""" + found = _RE_RATIO_SUFFIX.search(_normalize(cell)) + if found is None: + return None + digits = found.group(0).strip("()()%").strip() + return Decimal(digits) if digits else None + + def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> AxisResult: """공종 축 전체를 훑어 자원 축을 만든다.""" result = AxisResult() diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index c34a3a0f..61219251 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -66,6 +66,8 @@ class UnitPriceBuild: skipped: list[str] = field(default_factory=list) #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). incomplete_machines: list[str] = field(default_factory=list) + #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). + partial_ratio: dict[str, Decimal] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: @@ -228,9 +230,39 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: ) for row, ref in attachable: build.book.add_detail(PriceDetail(title_code, ref, row.amount)) + + # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** + # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 + # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ + # 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다. + covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build) + if covered is not None and covered < Decimal(100): + build.partial_ratio[work_item_code] = covered return build +def _covered_ratio_pct( + rows: list, attached_refs: set[str], build: UnitPriceBuild +) -> Decimal | None: + """배분율 표에서 **실제로 붙은 몫**의 합계(%). 배분율이 없는 표면 `None`.""" + ratios = { + row.group_ratio_pct for row in rows if getattr(row, "group_ratio_pct", None) is not None + } + if not ratios: + return None + covered = Decimal(0) + seen: set[Decimal] = set() + for row in rows: + ratio = getattr(row, "group_ratio_pct", None) + if ratio is None or ratio in seen: + continue + ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" + if ref in attached_refs: + seen.add(ratio) + covered += ratio + return covered + + def material_total_before_surcharge(build: UnitPriceBuild, code: str) -> Decimal: """일위대가 한 줄의 **할증 전** 재료비 합계 — ㉠ 가드에 넘길 값.""" return build.book.resolve(code).material 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 7b3a2e23..6a986ebe 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 @@ -475,6 +475,30 @@ "resource_spec": "", "work_item_code": "FP-09-08-01" }, + { + "amount": "0.23", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0258", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-12-01" + }, + { + "amount": "1.6", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0259", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-12-02" + }, { "amount": "0.8", "amount_unit": "", @@ -487,6 +511,18 @@ "resource_spec": "", "work_item_code": "FP-09-12-02" }, + { + "amount": "2.8", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0260", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-12-03" + }, { "amount": "1.266", "amount_unit": "", @@ -499,6 +535,90 @@ "resource_spec": "", "work_item_code": "FP-09-12-03" }, + { + "amount": "0.23", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0261", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-01" + }, + { + "amount": "0.31", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0262", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-02" + }, + { + "amount": "0.39", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0263", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-03" + }, + { + "amount": "0.345", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0264", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-04" + }, + { + "amount": "0.465", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0265", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-05" + }, + { + "amount": "0.585", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0266", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-06" + }, + { + "amount": "1.6", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0267", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-07" + }, { "amount": "0.8", "amount_unit": "", @@ -511,6 +631,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-07" }, + { + "amount": "1.8", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0268", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-08" + }, { "amount": "0.9", "amount_unit": "", @@ -523,6 +655,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-08" }, + { + "amount": "2.0", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0269", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-09" + }, { "amount": "1.0", "amount_unit": "", @@ -535,6 +679,54 @@ "resource_spec": "", "work_item_code": "FP-09-13-09" }, + { + "amount": "1.2", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0270", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-10" + }, + { + "amount": "1.35", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0271", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-11" + }, + { + "amount": "1.5", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0272", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-13-12" + }, + { + "amount": "2.8", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0273", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-13" + }, { "amount": "1.266", "amount_unit": "", @@ -547,6 +739,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-13" }, + { + "amount": "3.5", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0274", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-14" + }, { "amount": "1.566", "amount_unit": "", @@ -559,6 +763,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-14" }, + { + "amount": "4.2", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0275", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-15" + }, { "amount": "1.866", "amount_unit": "", @@ -571,6 +787,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-15" }, + { + "amount": "4.20", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0276", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-16" + }, { "amount": "1.899", "amount_unit": "", @@ -583,6 +811,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-16" }, + { + "amount": "5.25", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0277", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-17" + }, { "amount": "2.349", "amount_unit": "", @@ -595,6 +835,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-17" }, + { + "amount": "6.3", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0278", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "work_item_code": "FP-09-13-18" + }, { "amount": "2.799", "amount_unit": "", @@ -607,6 +859,18 @@ "resource_spec": "", "work_item_code": "FP-09-13-18" }, + { + "amount": "0.10", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0279", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-09-14-01" + }, { "amount": "0.019", "amount_unit": "", @@ -1075,6 +1339,18 @@ "resource_spec": "", "work_item_code": "FP-12-23" }, + { + "amount": "0.2", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0372", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "work_item_code": "FP-12-24-02" + }, { "amount": "4.0", "amount_unit": "", @@ -1099,6 +1375,18 @@ "resource_spec": "", "work_item_code": "FP-12-26" }, + { + "amount": "0.2", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0371", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "work_item_code": "FP-12-24-01" + }, { "amount": "4.0", "amount_unit": "", @@ -1423,6 +1711,18 @@ "resource_spec": "", "work_item_code": "FP-13-12-02" }, + { + "amount": "0.20", + "amount_unit": "", + "pum_form": "requirement", + "pum_table_id": "F0441", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "work_item_code": "FP-13-13-02" + }, { "amount": "0.0027", "amount_unit": "", @@ -1448,12 +1748,12 @@ "sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0" }, "stats": { - "rows": 119, + "rows": 144, "skipped_forms": { "coefficient": 19, "reference": 98, "undetermined": 76 }, - "unmatched": 355 + "unmatched": 330 } } \ 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 b2414650..119aa796 100644 --- a/resources/data_cost_resource_axis/unmatched_2026-01-01.json +++ b/resources/data_cost_resource_axis/unmatched_2026-01-01.json @@ -1107,25 +1107,13 @@ "work_item_code": "FP-09-10-02" }, { - "cell": "인력(10%)", + "cell": "유압식백호우 (무한궤도,0.7㎥)", "pum_table_id": "F0258", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-12-01" }, { - "cell": "장비(90%)", - "pum_table_id": "F0258", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-12-01" - }, - { - "cell": "인력 (10%)", - "pum_table_id": "F0259", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-12-02" - }, - { - "cell": "장비 (90%)", + "cell": "대형브레이커(㎥/hr)", "pum_table_id": "F0259", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-12-02" @@ -1143,13 +1131,7 @@ "work_item_code": "FP-09-12-02" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0260", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-12-03" - }, - { - "cell": "장비 (90%)", + "cell": "대형브레이커(㎥/hr)", "pum_table_id": "F0260", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-12-03" @@ -1167,61 +1149,19 @@ "work_item_code": "FP-09-12-03" }, { - "cell": "인력 (10%)", + "cell": "유압식백호우 (무한궤도,0.7㎥)", "pum_table_id": "F0261", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-01" }, { - "cell": "장비 (90%)", - "pum_table_id": "F0261", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-01" - }, - { - "cell": "인력 (10%)", - "pum_table_id": "F0262", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-02" - }, - { - "cell": "인력 (10%)", - "pum_table_id": "F0263", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-03" - }, - { - "cell": "인력 (10%)", + "cell": "유압식백호우 (무한궤도,0.7㎥)", "pum_table_id": "F0264", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-04" }, { - "cell": "장비 (90%)", - "pum_table_id": "F0264", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-04" - }, - { - "cell": "인력 (10%)", - "pum_table_id": "F0265", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-05" - }, - { - "cell": "인력 (10%)", - "pum_table_id": "F0266", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-06" - }, - { - "cell": "인력 (10%)", - "pum_table_id": "F0267", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-07" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0267", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-07" @@ -1239,13 +1179,7 @@ "work_item_code": "FP-09-13-07" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0268", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-08" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0268", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-08" @@ -1257,13 +1191,7 @@ "work_item_code": "FP-09-13-08" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0269", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-09" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0269", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-09" @@ -1275,13 +1203,7 @@ "work_item_code": "FP-09-13-09" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0270", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-10" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0270", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-10" @@ -1299,13 +1221,7 @@ "work_item_code": "FP-09-13-10" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0271", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-11" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0271", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-11" @@ -1317,13 +1233,7 @@ "work_item_code": "FP-09-13-11" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0272", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-12" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0272", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-12" @@ -1335,13 +1245,7 @@ "work_item_code": "FP-09-13-12" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0273", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-13" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0273", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-13" @@ -1359,13 +1263,7 @@ "work_item_code": "FP-09-13-13" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0274", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-14" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0274", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-14" @@ -1377,13 +1275,7 @@ "work_item_code": "FP-09-13-14" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0275", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-15" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0275", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-15" @@ -1395,13 +1287,7 @@ "work_item_code": "FP-09-13-15" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0276", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-16" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0276", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-16" @@ -1419,13 +1305,7 @@ "work_item_code": "FP-09-13-16" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0277", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-17" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0277", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-17" @@ -1437,13 +1317,7 @@ "work_item_code": "FP-09-13-17" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0278", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-13-18" - }, - { - "cell": "장비 (90%)", + "cell": "깨기", "pum_table_id": "F0278", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-13-18" @@ -1455,13 +1329,7 @@ "work_item_code": "FP-09-13-18" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0279", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-09-14-01" - }, - { - "cell": "장비 (90%)", + "cell": "유압식백호우 (무한궤도,0.7㎥)", "pum_table_id": "F0279", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-09-14-01" @@ -1809,13 +1677,7 @@ "work_item_code": "FP-12-23" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0372", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-12-24-02" - }, - { - "cell": "장비 (90%)", + "cell": "부설", "pum_table_id": "F0372", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-24-02" @@ -1839,13 +1701,7 @@ "work_item_code": "FP-12-26" }, { - "cell": "인력 (10%)", - "pum_table_id": "F0371", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-12-24-01" - }, - { - "cell": "장비 (90%)", + "cell": "부설", "pum_table_id": "F0371", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-12-24-01" @@ -2079,13 +1935,7 @@ "work_item_code": "FP-13-13-01" }, { - "cell": "인력(10%)", - "pum_table_id": "F0441", - "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", - "work_item_code": "FP-13-13-02" - }, - { - "cell": "장비(90%)", + "cell": "굴착기 (0.2㎥)", "pum_table_id": "F0441", "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", "work_item_code": "FP-13-13-02"