From 564cb11103d0506a321f53d65ba5514eedcf113d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:18:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(B08):=20=EC=88=98=EB=9F=89=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=ED=83=AD=203?= =?UTF-8?q?=EC=9E=A5=C2=B7=EC=82=B0=EC=B6=9C=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=C2=B7[=EC=A0=80=EC=9E=A5]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 4·5 화면 마무리. 앞 `auto:` 커밋(4756f0fe)은 토적표 그리드·로케일 배선분임. 우측 — 실무 산출서 시트를 탭으로 토적표 · 토공집계 · 운반거리. 탭을 눌러 갈아 끼움. 토공집계 열은 거창 실무 시트 그대로(구분·공종·규격·단위·계·비고). 운반계획이 아직 없으면 빈 표 대신 「종단설계에서 확정하면 생김」을 보임. 좌측 — 산출 조건 입력 ⚠ 암 갈래 칸 수를 코드에 안 박음 — 프로젝트 세트가 정함(지금 거창5 = 5칸). 반영률 4칸, 기본 100 %. 실무 관측 80/50/80 은 기본값 아님(PLAN 8-11 · 법대로). 반영률 이름을 사람 말로 보임 — 서버 키(fill_slope_compaction)를 그대로 띄우면 설계자가 못 읽음. 로케일 키 4개 추가. [저장] 신설 (CLAUDE.md 5장 데이터 3층) 조작은 캐시에만 쌓이고 [저장]에서만 정본(project_settings.json)으로 감. 자동저장 안 만듦. quantity 구획만 감 — estimation 은 B09 것이고 서버가 막고 있음. 저장 뒤 표를 다시 받아 그림 — 조건이 바뀌면 집계·운반 값이 달라짐. 저장 안 한 변경이 있으면 나갈 때 알림(beforeunload). ⚠ [초기화]는 안 달았음 — 5장의 [초기화]는 초기값을 작업본에 덮어쓰는 것인데 설정에는 대응 초기값이 없어 재계산 단추로 오해될 자리임. TODO(미결) 주석으로 남김. 검증 — 공용 브라우저 실화면, 타입검사 0. 탭 3장 · 입력칸 9개(암 5 + 반영률 4) · 단추 [저장][확정]. 토공집계 10행, 값 일치 — 흙깎기 토사 2,526.99 · 측구 90.51 · 보정량계 6,511.06. [저장] 실제로 눌러 확인 — 연암 60·보통암 40 을 넣으니 암 3,512.09 가 2,107.25 / 1,404.83 으로 갈리고(60:40 안분), 성토면다짐 80 % 를 넣으니 13,518.6 → 10,814.9 로 바뀌며 비고에 「성토면 80 % 반영」이 남음. ⚠ 넣었던 값은 전부 원래대로(0·0·100) 되돌려 놓았고 화면도 사용자가 보던 주소로 복귀. ⚠ 개발 중 발견 — vite 가 옛 모듈을 물고 있어 첫 확인이 옛 화면이었음. 쿼리를 붙여 새로 물린 뒤 재확인(계획서 7-2 의 그 함정). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_UI_Page.ts | 30 ++++++++++++++------- B08_Quantity/B08_Quantity_UI_SummaryGrid.ts | 18 +++++++++++-- ui_template/ui_template_locale_b2.ts | 10 ++++--- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index ece0199e..4d07414f 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -78,12 +78,21 @@ function field(label: string, value: string): HTMLElement { return row; } +/** 반영률 키 → 사람이 읽는 이름. 서버 키를 그대로 보이면 설계자가 못 읽는다. */ +const RATIO_LABEL_KEYS: Record = { + fill_slope_compaction: "B08_Quantity_Ratio_FillCompaction", + seed_spray_fill: "B08_Quantity_Ratio_SeedFill", + seed_spray_cut: "B08_Quantity_Ratio_SeedCut", + obstacle_removal: "B08_Quantity_Ratio_TreeRemoval", +}; + +function ratioLabel(key: string): string { + const localeKey = RATIO_LABEL_KEYS[key]; + return localeKey ? L(localeKey) : key; +} + /** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(5장). */ -function numberField( - label: string, - value: number, - onInput: (value: number) => void, -): HTMLElement { +function numberField(label: string, value: number, onInput: (value: number) => void): HTMLElement { const row = document.createElement("label"); row.className = "b08-quantity__field"; const name = document.createElement("span"); @@ -149,7 +158,7 @@ function buildQuantitySidePanel( panel.append(field(L("B08_Quantity_Side_Ratios"), "")); for (const key of Object.keys(ratios)) { panel.append( - numberField(key, draft.application_ratios_pct[key] ?? 100, (value) => { + numberField(ratioLabel(key), draft.application_ratios_pct[key] ?? 100, (value) => { draft.application_ratios_pct[key] = value; draft.dirty = true; }), @@ -203,7 +212,10 @@ function buildQuantitySidePanel( const actions = document.createElement("div"); actions.className = "b08-quantity__actions ui-sidebar-actions"; - actions.append(confirmButton); + // [초기화]는 이번에 달지 않는다 — 5장의 [초기화]는 초기값(`initial_snapshot/`)을 작업본에 + // 덮어쓰는 것인데 설정에는 대응하는 초기값이 아직 없다. 재계산 단추로 오해될 자리다. + // TODO(미결) — 설정의 초기값을 무엇으로 볼지 사용자 확인 뒤에 붙인다. + actions.append(saveButton, confirmButton); panel.append(actions); return panel; } @@ -239,9 +251,7 @@ function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLE { label: L("B08_Quantity_Tab_Summary"), build: () => - table.summary - ? renderSummaryGrid(table.summary) - : message(L("B08_Quantity_Grid_Empty")), + table.summary ? renderSummaryGrid(table.summary) : message(L("B08_Quantity_Grid_Empty")), }, { label: L("B08_Quantity_Tab_Haul"), diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts index 439817e6..317784e5 100644 --- a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -43,7 +43,14 @@ export interface HaulRow { export interface HaulTable { rows: HaulRow[]; - legs: { equipment: string; ground: string; volume_m3: number; distance_m: number; from_m: number; to_m: number }[]; + legs: { + equipment: string; + ground: string; + volume_m3: number; + distance_m: number; + from_m: number; + to_m: number; + }[]; bill_row_count: number; } @@ -141,7 +148,14 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen const head = document.createElement("thead"); const headRow = document.createElement("tr"); - for (const label of ["운반수단", "지반유형", "토량(㎥)", "평균운반거리(m)", "근거 구간", "비고"]) { + for (const label of [ + "운반수단", + "지반유형", + "토량(㎥)", + "평균운반거리(m)", + "근거 구간", + "비고", + ]) { const th = document.createElement("th"); th.textContent = label; headRow.append(th); diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 123f55c1..8e4c7cea 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -627,15 +627,17 @@ export const ui_locales_b2 = { ], B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"], B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"], + /* 반영률 항목 이름 — 서버 키를 사람이 읽는 말로. 거창 실무 시트 문구를 따른다. */ + B08_Quantity_Ratio_FillCompaction: ["성토면다짐", "Fill slope compaction"], + B08_Quantity_Ratio_SeedFill: ["초류종자살포(성토면)", "Seed spray (fill)"], + B08_Quantity_Ratio_SeedCut: ["초류종자살포(절토면)", "Seed spray (cut)"], + B08_Quantity_Ratio_TreeRemoval: ["지장목제거", "Obstacle removal"], 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_Unsaved: [ - "저장하지 않은 변경이 있습니다.", - "You have unsaved changes.", - ], + 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)"], From fc40265dbe82d600ad9c6935077a9e7e3c867dfa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:25:34 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(B08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=9B=90=EB=8B=A8=EC=9C=84=20=EC=A0=84=EA=B0=9C=EC=8B=9D=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=98=EC=88=98=EC=97=90=EC=84=9C=20=EC=84=B1?= =?UTF-8?q?=EB=B6=84=20=EB=AC=BC=EB=9F=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 6 엔진. PLAN 8-6·8-8·8-15. 식을 발명하지 않음 — 실무 원본을 옮김 울진 설계원본 5. 구조도(기번3).xlsx 에 구조물 31종 계산식이 살아 있는 수식으로 남아 있음(8-15). 그 식을 옮기되 식 안에 상수로 박혀 있던 값(돌 뒷길이 0.45 · 고임돌 0.15 · 돌중량 0.88 등)은 **계수표로 뺌** — 뒷길이가 바뀌어도 식을 안 고침. 실무 방식의 약점을 여기서 고친 자리. 치수 정본은 하나 — structures.json 의 type_id·options 를 읽어 계산. 자기 치수표를 따로 들지 않음(도면은 H=1.5 인데 수량은 옛 치수로 도는 사고 방지). ⚠⚠ 이중계상 셋을 코드로 막음 ㉢ 배합을 분해하지 않음 — 콘크리트 ㎥·모르터 ㎥ 에서 멈춤. 시멘트·모래로 쪼개는 것은 B09 일위대가 몫이고 양쪽이 쪼개면 시멘트가 두 배가 됨. verify_no_mix_components() 가 산출물에 배합 성분이 나타나면 알림. ⚠ 정확히 같은 이름으로만 봄 — 부분문자열로 재니 「막자갈」(뒤채움 재료)이 배합 「자갈」로 오탐됐음. 개발 중 실제로 걸린 자리라 시험에도 박음. ㉠ 할증을 안 붙임 — surcharge_applied=False 로 응답에 못 박음. 할증은 자재총괄 한 곳뿐. · 성분마다 destination 을 담 — earthwork(토공 대분류로 합산) / material(자재총괄) / unit_price(일위대가 재료비). 울진 토적집계 D12~D14 가 구조물 터파기 1,248/30㎥ · 되메우기 739㎥ 를 개별 줄이 아니라 토공으로 합산한 것이 근거. ⚠ 공제 규칙을 주석에 원문째 — 품셈 1-2-1 「물구멍·이음줄눈·모따기·철근은 공제하지 않음」. 치수를 곧이곧대로 빼면 실무값과 어긋남. 빼는 것은 관 통과 단면처럼 실제로 비어 있는 자리뿐. 원본에 없는 칸은 지어내지 않음 — 뒷길이 60㎝ 돌중량이 원본 표에 비어 있어 야면석을 내지 않고 그 사실을 notes 로 알림(8-8 ㉮). ⚠ 실무값과 0.012 차이 — 우리 쪽이 맞음 터파기 1.5375 vs 실무 1.55. 평균두께가 0.825 인데 실무 시트는 표기값 0.83 으로 다시 계산해 1.545 가 됨. 품셈 1-2-2 는 표기 규칙이고 계산은 전정밀(8-16). 맞추려고 식을 비틀지 않고 허용오차를 그 차이만큼 둠. 검증 — 17건 통과. 배합 금지·할증 금지·행선지 표시·계수표 교체·빈 칸 미생성을 각각 못 박음. 실데이터(route 150 의 돌쌓기(찰) H=2.5·L=10)로 확인 — 성분 10개, 배합 위반 0. 전체 회귀 450 passed (실패 1건은 B05 코리도 기존 깨짐). Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_UnitQuantity.py diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py new file mode 100644 index 00000000..6a107634 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -0,0 +1,337 @@ +"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (B08 일감 6 · PLAN 8-6·8-8·8-15). + +식을 발명하지 않는다 — 실무 원본을 옮긴다 + 울진 설계원본 `5. 구조도(기번3).xlsx` 에 구조물 31종의 계산식이 **살아 있는 수식**으로 + 남아 있다(PLAN 8-15). 여기 옮긴 것은 그 식이며, 식 안에 상수로 박혀 있던 값 + (돌 뒷길이 0.45 · 공극률 0.77 · 돌 비중 2.65 · 고임돌 0.15 등)은 **계수표로 뺐다**. + 그래야 뒷길이가 바뀔 때 식을 안 고친다 — 실무 방식의 약점을 여기서 고친다. + +치수 정본은 하나다 (PLAN 8-6 ② 필수 조건) + 전개식은 **저장된 구조물 제원**(`structures.json` 의 `type_id`·`options`)을 읽어 계산한다. + 자기 치수표를 따로 들지 않는다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다. + +⚠⚠ 이중계상 셋 — 이 파일이 지켜야 할 규칙 + ㉢ **배합을 분해하지 않는다.** 산출물은 `콘크리트 ㎥` · `모르터 ㎥` 에서 **멈춘다**. + 시멘트·모래·자갈로 쪼개는 것은 B09 일위대가 몫이다. 양쪽이 쪼개면 시멘트가 두 배가 된다. + 실무 원단위 라이브러리에 배합이 이미 분해돼 있어도 **그 줄은 버린다**(PLAN 8-8 ②). + `verify_no_mix_components()` 가 이 규칙을 코드로 지킨다. + ㉠ **할증을 붙이지 않는다.** 여기 값은 전부 할증 **전**이다. 할증은 자재총괄 한 곳뿐(PLAN 8-7). + · **터파기·되메우기·잔토는 토공으로 합산된다.** 내역 줄의 실체는 작업 공종 + (`돌쌓기(찰) H=1.5 · 70m`)이고 그 전개인 터파기는 토공 대분류로 합쳐진다 + (울진 토적집계 D12~D14 실증). 둘 다 내역에 올리면 이중계상이다 — + 그래서 성분마다 `destination` 을 달아 어디로 갈 값인지 표시한다. + +⚠ 공제 규칙 (품셈 1-2-1 원문) + 「말뚝머리, 볼트 구멍, 모따기ㆍ물구멍, 이음줄눈 간격, 포장 1개소당 0.1 ㎡ 이하 구조물 자리, + 리벳 구멍, **철근콘크리트 중의 철근** 등」은 **공제하지 않는다.** + 치수를 곧이곧대로 빼면 실무값과 어긋난다. 전개식에서 빼는 것은 **관 통과 단면**처럼 + 실제로 비어 있는 자리뿐이다. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any, Iterable + +# ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── +# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. +# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). +STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { + 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, + 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, + 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, + 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, +} +DEFAULT_BACK_LENGTH_CM = 45 + +# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. +STONE_MASONRY = { + "face_to_slope_factor": 1.04, # 돌쌓기 면적 = 정면적 × 1.04 (비탈 기울기 몫) + "thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께 + "thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H) + "thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H) + "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 + "weep_hole_length_m": 0.5, # 물구멍 1개소당 관 길이 + "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) + "excavation_extra_m": 0.2, # 터파기 폭 여유 + "backfill_thickness_m": 0.2, # 되메우기 두께 +} + +# 성분이 어디로 가는가 — 이중계상을 막는 표시. +# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) +# `material` = 자재총괄로 감(할증은 거기서 한 번만) +# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) +DESTINATION = { + "터파기": "earthwork", + "되메우기": "earthwork", + "잔토처리": "earthwork", + "돌쌓기": "unit_price", + "돌붙임": "unit_price", + "깬돌": "material", + "야면석": "material", + "고임돌": "material", + "막자갈": "material", + "콘크리트": "unit_price", + "채움콘크리트": "unit_price", + "모르터": "unit_price", + "거푸집": "unit_price", + "물구멍": "material", +} + +# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. +# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 +# 오탐된다 — 개발 중 실제로 걸렸던 자리다. +MIX_COMPONENTS = frozenset( + {"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"} +) + + +@dataclass(slots=True) +class Component: + """전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다.""" + + name: str + unit: str + amount: float + destination: str + basis: str = "" + + +@dataclass(slots=True) +class StructureQuantity: + """구조물 하나의 원단위 전개 결과.""" + + structure_id: str | None + type_id: str + name: str + length_m: float = 0.0 + height_m: float = 0.0 + components: list[Component] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +def _back_length(options: dict[str, Any]) -> int: + raw = options.get("stone_back_length_cm") + if isinstance(raw, (int, float)) and int(raw) in STONE_BACK_LENGTH_TABLE: + return int(raw) + return DEFAULT_BACK_LENGTH_CM + + +def _num(value: Any, fallback: float = 0.0) -> float: + return float(value) if isinstance(value, (int, float)) else fallback + + +def stone_masonry( + height_m: float, length_m: float, options: dict[str, Any], wet: bool +) -> tuple[list[Component], list[str]]: + """돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식. + + 실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3): + 정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245 + 터파기 1.55 · 되메우기 0.30 · 잔토 1.25 + """ + notes: list[str] = [] + if height_m <= 0 or length_m <= 0: + return [], ["높이·연장이 없어 전개하지 않음"] + + back_cm = _back_length(options) + table = STONE_BACK_LENGTH_TABLE[back_cm] + slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 전면 기울기 1:0.3 (교본 7-3) + constants = STONE_MASONRY + + face_area = height_m * length_m # 정면적 + # 비탈면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. + slope_area = face_area * math.hypot(1.0, slope_ratio) + masonry_area = slope_area * constants["face_to_slope_factor"] + thickness = ( + (constants["thickness_base_m"] + constants["thickness_top_coeff"] * height_m) + + (constants["thickness_base_m"] + constants["thickness_bottom_coeff"] * height_m) + ) / 2.0 + volume = face_area * thickness # 입적 + + components = [ + Component("돌쌓기", "㎡", masonry_area, DESTINATION["돌쌓기"], "비탈면적 × 1.04"), + Component( + "고임돌", + "㎥", + masonry_area * _num(table["wedge_stone_m3_per_m2"]), + DESTINATION["고임돌"], + f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", + ), + ] + + stone_ton = table["stone_ton_per_m2"] + if stone_ton is None: + # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). + notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함") + else: + components.append( + Component( + "야면석", + "ton", + masonry_area * stone_ton, + DESTINATION["야면석"], + f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)", + ) + ) + + # 막자갈 = 입적 − (면적 × 뒷길이 × 2/3 + 고임돌). 실무 식 그대로. + wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) + rubble = volume - (masonry_area * (back_cm / 100.0) * 2.0 / 3.0 + wedge) + if rubble > 0: + components.append( + Component( + "막자갈", "㎥", rubble, DESTINATION["막자갈"], "입적 − (면적×뒷길이×2/3 + 고임돌)" + ) + ) + + if wet: + components.append( + Component( + "채움콘크리트", + "㎥", + masonry_area * _num(table["fill_concrete_m3_per_m2"]), + DESTINATION["채움콘크리트"], + f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", + ) + ) + components.append( + Component( + "모르터", + "㎥", + masonry_area * constants["mortar_m3_per_m2"], + DESTINATION["모르터"], + f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)", + ) + ) + # ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢). + + # 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m. + components.append( + Component( + "물구멍", + "m", + masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], + DESTINATION["물구멍"], + "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소", + ) + ) + + # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). + excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m + backfill = height_m * constants["backfill_thickness_m"] * length_m + components.extend( + [ + Component( + "터파기", "㎥", excavation, DESTINATION["터파기"], "높이 × (평균두께+0.2) × 연장" + ), + Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), + Component( + "잔토처리", + "㎥", + excavation - backfill, + DESTINATION["잔토처리"], + "터파기 − 되메우기", + ), + ] + ) + return components, notes + + +# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). +EXPANDERS = { + "masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True), + "masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False), + "boulder_masonry": lambda h, l, o: stone_masonry(h, l, o, wet=False), +} + + +def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> StructureQuantity: + """구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지).""" + type_id = str(structure.get("type_id") or "") + options = structure.get("options") or {} + start = _num(structure.get("start_m")) + end = _num(structure.get("end_m")) + length = _num(options.get("length_m")) or abs(end - start) + height = _num(options.get("height_m")) + result = StructureQuantity( + structure_id=structure.get("structure_id"), + type_id=type_id, + name=(names or {}).get(type_id, type_id), + length_m=length, + height_m=height, + ) + expander = EXPANDERS.get(type_id) + if expander is None: + result.notes.append(f"'{type_id}' 전개식이 아직 없음 — 물량을 내지 않음") + return result + result.components, notes = expander(height, length, options) + result.notes.extend(notes) + return result + + +def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]: + """⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어). + + 시멘트·모래·자갈은 **B09 일위대가**가 배합표로 분해할 값이다. 여기서 내면 두 배가 된다. + 실무 원단위 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다. + """ + found: list[str] = [] + for item in quantities: + for component in item.components: + if component.name.strip() in MIX_COMPONENTS: + found.append(f"{item.name}({item.type_id}) 의 '{component.name}'") + return found + + +def build_table( + structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None +) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.""" + quantities = [expand(item, names) for item in structures] + violations = verify_no_mix_components(quantities) + + totals: dict[str, dict[str, Any]] = {} + for item in quantities: + for component in item.components: + key = f"{component.name}|{component.unit}" + entry = totals.setdefault( + key, + { + "name": component.name, + "unit": component.unit, + "amount": 0.0, + "destination": component.destination, + }, + ) + entry["amount"] += component.amount + + return { + "structures": [ + { + "structure_id": item.structure_id, + "type_id": item.type_id, + "name": item.name, + "length_m": item.length_m, + "height_m": item.height_m, + "notes": item.notes, + "components": [ + { + "name": component.name, + "unit": component.unit, + "amount": component.amount, + "destination": component.destination, + "basis": component.basis, + } + for component in item.components + ], + } + for item in quantities + ], + "totals": sorted(totals.values(), key=lambda entry: entry["name"]), + # 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠). + "surcharge_applied": False, + "mix_components_found": violations, + "structure_count": len(quantities), + } From 6a487fc6e3fb7f5c63ec83c573fc5b8d642528fb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:43:46 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B08):=20=EC=9E=90=EC=9E=AC=EC=B4=9D?= =?UTF-8?q?=EA=B4=84=20=E2=80=94=20=ED=95=A0=EC=A6=9D=EC=9D=B4=20=EB=B6=99?= =?UTF-8?q?=EB=8A=94=20=EC=9C=A0=EC=9D=BC=ED=95=9C=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물 원단위의 `destination == "material"` 성분만 모아 자재별 합산 후 할증률을 한 번만 적용. 열은 순수량·할증률·합계 + 관급구분·설치주체·비고이며 금액은 없음(B09 경계). - 할증률은 코드가 아니라 데이터 — `resources/data_material_surcharge/` (품셈 1-3-1 재료 할증률 19종 + sha256 매니페스트). 실무 관측값은 `observed_practice` 로 분리(법대로 원칙). - 표에 없는 자재는 0 % 로 넘기지 않고 「할증률 미확보」로 표시. 이름 조회는 정확 일치 — 부분일치면 `막자갈` 이 `자갈` 할증을 뭄. - 이중계상 방어 ㉠ — 앞 단계 `surcharge_applied` 깃발을 실제로 읽어 경고. 자재총괄 응답은 `True`, 원단위표는 `False` 로 어느 쪽 값인지 명시. - 관급/사급 이름은 B09 와 동일(`owner_supplied`/`contractor_supplied`). 관급 줄에만 설치 주체(`install_by`)를 붙이고, 미지정은 기본값으로 때우지 않고 드러냄 — 안전관리비 대상액이 「도급자설치 관급금액」이라서임. - 라우터 `GET /quantity/material-summary` 신설, 화면에 「구조물 원단위」· 「자재총괄」 탭 추가. `design_owner` 가 붙은 타입(측구)은 중복 계상 방지로 제외. 검증 — 전용 테스트 23건 통과, 전체 회귀 473 passed(기존 B05 깨짐 1건 제외). 공용 브라우저 실조작으로 탭 5장·머리글·값 4줄·미확보 안내 확인. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_MaterialSummary.py | 306 ++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Material.py | 105 ++++++ B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 232 +++++++++++++ B08_Quantity/B08_Quantity_UI_Page.ts | 42 ++- common_util/common_util_project_settings.py | 4 + main.py | 2 + .../data_material_surcharge/_manifest.json | 13 + .../material_surcharge_2026-01-01.json | 51 +++ ui_template/ui_template_locale_b2.ts | 6 + 9 files changed, 759 insertions(+), 2 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_MaterialSummary.py create mode 100644 B08_Quantity/B08_Quantity_Router_Material.py create mode 100644 B08_Quantity/B08_Quantity_UI_MaterialGrid.ts create mode 100644 resources/data_material_surcharge/_manifest.json create mode 100644 resources/data_material_surcharge/material_surcharge_2026-01-01.json diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py new file mode 100644 index 00000000..90df4a85 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -0,0 +1,306 @@ +"""자재 총괄표 — 할증이 붙는 **유일한** 자리 (B08 일감 7 · PLAN 8-2·8-3·8-7). + +여기가 하는 일은 하나다 + 구조물 전개(`..._Engine_UnitQuantity`)가 낸 성분 가운데 **`destination == "material"`** + 인 것만 모아, 자재별로 합치고 **할증률을 한 번** 붙인다. 열은 순수량·할증률·합계 셋이고 + **금액은 없다**(금액은 B09 몫, 8-2 경계). + +⚠⚠ ㉠ 이중계상 방어 — 할증은 여기 한 번뿐이다 + 원단위표(`surcharge_applied: False`)도 B09 일위대가 재료비도 **할증 전** 값이다. + 두 곳에서 붙이면 자재가 두 번 부푼다. `verify_single_surcharge()` 가 입력 표의 + 깃발을 실제로 읽어 막는다 — 규칙이 주석에만 있으면 지켜지지 않는다. + +⚠ `earthwork`·`unit_price` 는 여기 오지 않는다 + 터파기·되메우기는 토공집계로, 모르터·돌쌓기는 B09 일위대가로 간다. 섞이면 그게 곧 + 이중계상이다. 걸러 낸 성분은 버리지 않고 `skipped_by_destination` 으로 세어 보인다. + +⚠ 할증률을 코드에 박지 않는다 (요율과 같은 취급) + 값은 `resources/data_material_surcharge/material_surcharge_<판>.json` 에 있다. + 표에 없는 자재는 **0 % 로 조용히 넘기지 않는다** — 「할증률 미확보」로 드러낸다. + 0 % 로 넘기면 빠뜨린 것과 구별이 안 된다. + +⚠ 품셈에 이미 할증이 포함된 항목은 제외한다 (품셈 1-3-1 단서) + 「품셈 항목에 할증이 포함ㆍ표시된 경우 중복 적용 금지」. 성분이 그렇게 표시돼 오면 + (`surcharge_included: True`) 율을 붙이지 않고 비고에 까닭을 남긴다. + +⚠ 관급/사급은 **법이 아니라 발주 결정**이다 + 자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정 + (`quantity.material_supply`)이 정한 것만 따르고, 안 정한 자재는 `unknown` 으로 남겨 + 화면에 드러낸다. 구분 이름은 B09 와 같은 낱말을 쓴다 — 다르면 인계에서 어긋난다. + 관급 줄에는 **설치 주체**(`install_by`)가 하나 더 붙는다 — 안전관리비 대상액이 + 관급 전액이 아니라 「도급자설치 관급금액」이기 때문이다. + +⚠ 자재 이름은 **정확히 일치**로만 찾는다 + 부분일치로 재면 `막자갈`(뒤채움)이 `자갈` 할증을 물게 된다 — 원단위 엔진에서 이미 + 한 번 겪은 자리다. 못 찾으면 지어내지 말고 「할증률 미확보」로 드러낸다. + +⚠ 할증 전/후 값을 **둘 다** 남긴다 (8-2 인계 6필드) + 하나만 넘기면 B09 가 어느 쪽인지 몰라 역산한다. `net_amount`(전) 와 + `total_amount`(후) 를 나란히 둔다. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +# ── 데이터 자리 ────────────────────────────────────────────────────── +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_material_surcharge" +DATASET_PREFIX = "material_surcharge_" + +#: 이 표가 받는 성분 갈래. 나머지는 각자 다른 표로 간다. +ACCEPTED_DESTINATION = "material" + +#: 관급/사급 구분 — **데이터 값은 영문 키, 한글은 화면 표기용**(2026-09-07 B09 와 확정). +#: B09 원가 엔진의 `owner_supplied_material_krw`(⑤ 관급자재대)와 같은 낱말이라 그대로 이어진다. +SUPPLY_OWNER = "owner_supplied" # 관급 — 발주처 지급 +SUPPLY_CONTRACTOR = "contractor_supplied" # 사급 — 도급자 구입 +SUPPLY_UNKNOWN = "unknown" # 아직 안 정함 — 화면에 드러낸다 +SUPPLY_LABELS = {SUPPLY_OWNER: "관급", SUPPLY_CONTRACTOR: "사급", SUPPLY_UNKNOWN: "미분류"} + +#: ⚠ 관급 안의 **설치 주체** — 안전관리비 대상액은 관급 전액이 아니라 「도급자설치 관급금액」이다 +#: (PLAN 8-10 대상액 정의). 관급/사급 두 갈래로만 두면 B09 가 ⑤를 못 세운다. +#: **관급 줄에만 붙이고 사급 줄은 비운다.** 모르면 기본값으로 때우지 않고 `None` 으로 둔다 — +#: 잘못 찍으면 안전관리비가 조용히 틀린다. +INSTALL_BY_CONTRACTOR = "contractor" # 도급자설치 +INSTALL_BY_OWNER = "owner" # 관 직접설치 +INSTALL_BY_LABELS = {INSTALL_BY_CONTRACTOR: "도급자설치", INSTALL_BY_OWNER: "관 직접설치"} +NOTE_INSTALL_BY_MISSING = "설치 주체 미지정" + +NOTE_RATE_MISSING = "할증률 미확보" +NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함" + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +@dataclass +class SurchargeTable: + """할증률표 한 판. 조건이 갈리는 자재는 `alt_rate` 를 같이 들고 있는다.""" + + effective_date: str = "" + source: dict[str, Any] = field(default_factory=dict) + rates: dict[str, dict[str, Any]] = field(default_factory=dict) + + def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]: + """(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**""" + entry = self.rates.get(material.strip()) + if entry is None: + return None, "" + alt_condition = entry.get("alt_condition") + if condition and alt_condition and condition == alt_condition: + return float(entry["alt_rate"]), material + "(" + str(alt_condition) + ")" + base_condition = entry.get("condition") + label = material + "(" + str(base_condition) + ")" if base_condition else material + return float(entry["rate"]), label + + @property + def material_names(self) -> list[str]: + return sorted(self.rates) + + +def load_surcharge_table(path: Path | None = None) -> SurchargeTable: + """할증률표를 읽는다. 파일이 없으면 **빈 표** — 전 자재가 「미확보」로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return SurchargeTable() + payload = json.loads(target.read_text(encoding="utf-8")) + rates = { + str(row["material"]).strip(): row + for row in payload.get("rates_pct", []) + if row.get("material") is not None and row.get("rate") is not None + } + return SurchargeTable( + effective_date=str(payload.get("effective_date") or ""), + source=payload.get("source") or {}, + rates=rates, + ) + + +@dataclass +class MaterialRow: + """총괄표 한 줄. 할증 **전·후를 둘 다** 들고 있는다(8-2 인계).""" + + name: str + unit: str + net_amount: float = 0.0 # 순수량 — 할증 전 + surcharge_pct: float | None = None # None = 미확보 + supply: str = SUPPLY_UNKNOWN + install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다 + surcharge_included: bool = False # 품셈에 이미 포함 + basis: str = "" + sources: list[str] = field(default_factory=list) + + @property + def total_amount(self) -> float: + """합계 = 순수량 × (1 + 할증률). 미확보면 **순수량 그대로** 두고 비고로 알린다.""" + if self.surcharge_included or self.surcharge_pct is None: + return self.net_amount + return self.net_amount * (1.0 + self.surcharge_pct / 100.0) + + @property + def note(self) -> str: + parts: list[str] = [] + if self.surcharge_included: + parts.append(NOTE_INCLUDED) + elif self.surcharge_pct is None: + parts.append(NOTE_RATE_MISSING) + elif self.basis: + parts.append(self.basis) + if self.supply == SUPPLY_OWNER and self.install_by is None: + parts.append(NOTE_INSTALL_BY_MISSING) + return " · ".join(parts) + + +def _supply_of(value: Any) -> tuple[str, str | None]: + """설정 한 칸을 (관급구분, 설치주체) 로 읽는다. + + 설정은 두 모양을 받는다 — 구분만 적은 `"owner_supplied"` 와 설치 주체까지 적은 + `{"supply": ..., "install_by": ...}`. 앞 모양으로 적힌 관급은 **설치 주체 미지정**이 되고 + 그대로 드러난다. 기본값으로 때우지 않는다 — 잘못 찍으면 안전관리비가 조용히 틀린다. + """ + if isinstance(value, dict): + supply = str(value.get("supply") or SUPPLY_UNKNOWN) + install_by = value.get("install_by") + install_by = str(install_by) if install_by else None + else: + supply = str(value) if value else SUPPLY_UNKNOWN + install_by = None + if supply != SUPPLY_OWNER: + install_by = None # 사급 줄은 비워 둔다 + return supply, install_by + + +def verify_single_surcharge(unit_quantity_table: dict[str, Any] | None) -> list[str]: + """⚠ 앞 단계가 이미 할증을 붙였으면 알린다 (㉠ 방어). + + 원단위표는 `surcharge_applied: False` 로 「할증 전」임을 못 박아 보낸다. 그 깃발이 + 참이면 여기서 또 붙일 수 없다 — **조용히 건너뛰지 않고 알린다**. 말없이 넘기면 + 어느 쪽이 적용됐는지 아무도 모른다. + """ + if not unit_quantity_table: + return [] + if unit_quantity_table.get("surcharge_applied"): + return ["앞 단계(구조물 원단위)가 이미 할증을 붙였음 — 자재총괄에서 중복 적용 위험"] + return [] + + +def _collect( + unit_quantity_table: dict[str, Any], +) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]: + """`destination == "material"` 만 모은다. 나머지는 세어서 보인다.""" + rows: dict[tuple[str, str], MaterialRow] = {} + skipped: dict[str, int] = {} + for structure in unit_quantity_table.get("structures", []): + label = str(structure.get("name") or structure.get("type_id") or "") + for component in structure.get("components", []): + destination = str(component.get("destination") or "") or "(없음)" + if destination != ACCEPTED_DESTINATION: + skipped[destination] = skipped.get(destination, 0) + 1 + continue + name = str(component.get("name") or "").strip() + unit = str(component.get("unit") or "").strip() + row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit)) + row.net_amount += float(component.get("amount") or 0.0) + if component.get("surcharge_included"): + row.surcharge_included = True + if label and label not in row.sources: + row.sources.append(label) + return rows, skipped + + +def build_table( + unit_quantity_table: dict[str, Any], + *, + surcharge_table: SurchargeTable | None = None, + supply_map: dict[str, Any] | None = None, + extra_materials: Iterable[dict[str, Any]] = (), +) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. + + `extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다. + 모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`). + """ + table = surcharge_table or load_surcharge_table() + rows, skipped = _collect(unit_quantity_table) + + for item in extra_materials: + if str(item.get("destination") or ACCEPTED_DESTINATION) != ACCEPTED_DESTINATION: + continue + name = str(item.get("name") or "").strip() + unit = str(item.get("unit") or "").strip() + row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit)) + row.net_amount += float(item.get("amount") or 0.0) + if item.get("surcharge_included"): + row.surcharge_included = True + source = str(item.get("source") or "") + if source and source not in row.sources: + row.sources.append(source) + + supply = supply_map or {} + missing_rate: list[str] = [] + missing_supply: list[str] = [] + missing_install_by: list[str] = [] + for (name, _unit), row in rows.items(): + row.supply, row.install_by = _supply_of(supply.get(name)) + if row.supply == SUPPLY_UNKNOWN: + missing_supply.append(name) + # ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다. + if row.supply == SUPPLY_OWNER and row.install_by is None: + missing_install_by.append(name) + if row.surcharge_included: + continue + rate, basis = table.rate_for(name) + row.surcharge_pct = rate + row.basis = basis + if rate is None: + missing_rate.append(name) + + ordered = sorted(rows.values(), key=lambda item: (item.name, item.unit)) + return { + "columns": [ + "자재명", + "단위", + "순수량", + "할증률(%)", + "합계", + "관급구분", + "설치주체", + "비고", + ], + "rows": [ + { + "name": row.name, + "unit": row.unit, + "net_amount": row.net_amount, + "surcharge_pct": row.surcharge_pct, + "total_amount": row.total_amount, + "supply": row.supply, + "supply_label": SUPPLY_LABELS.get(row.supply, row.supply), + "install_by": row.install_by, + "install_by_label": INSTALL_BY_LABELS.get(row.install_by or "", ""), + "note": row.note, + "sources": row.sources, + } + for row in ordered + ], + # 이 표가 할증을 붙인 곳임을 못 박는다 — B09 는 다시 붙이지 않는다(㉠). + "surcharge_applied": True, + "surcharge_dataset": { + "effective_date": table.effective_date, + "source": table.source, + }, + "missing_rate_materials": sorted(set(missing_rate)), + "missing_supply_materials": sorted(set(missing_supply)), + "missing_install_by_materials": sorted(set(missing_install_by)), + "double_count_warnings": verify_single_surcharge(unit_quantity_table), + "skipped_by_destination": skipped, + "row_count": len(ordered), + } diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py new file mode 100644 index 00000000..eeaa6d68 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -0,0 +1,105 @@ +"""B08 구조물 원단위·자재총괄 조회 라우터 (일감 6·7 · PLAN 8-2·8-6·8-7). + +값은 어디서 오나 + 치수 정본은 **`structures.json` 하나**다(B05 가 주인). B08 은 자기 치수표를 들지 않고 + 그 제원을 읽어 전개할 뿐이다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다. + +⚠ `design_owner` 가 붙은 타입은 건너뛴다 + 측구가 그렇다 — 횡단 설계가 이미 터파기 단면적까지 셈하므로 구조물로 또 세면 **같은 것을 + 두 번 계상**한다(레지스트리 주석, 2026-09-07 조사). 건너뛴 것은 숨기지 않고 응답에 적는다. + +⚠ 할증은 자재총괄 한 곳뿐이다 (㉠) + 원단위표는 할증 **전** 값(`surcharge_applied: False`)으로 오고, 자재총괄이 한 번 붙인다. + 응답에 두 깃발이 다 실리므로 화면·B09 가 어느 쪽 값인지 헷갈릴 일이 없다. +""" + +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table +from common_util.common_util_project_settings import quantity_settings +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import run_with_connection + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) + + +def _collect_structures( + project_root: str, +) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]: + """전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다.""" + _revision, items = load_structures(project_root) + types = structure_type_map() + targets: list[dict[str, Any]] = [] + names: dict[str, str] = {} + skipped: list[str] = [] + for item in items: + payload = item.model_dump() + type_id = str(payload.get("type_id") or "") + definition = types.get(type_id) + if definition is None: + skipped.append(f"{type_id}: 레지스트리에 없는 타입") + continue + names[type_id] = definition.name + if definition.design_owner: + skipped.append( + f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지" + ) + continue + if definition.reference_only: + skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만") + continue + targets.append(payload) + return targets, names, sorted(set(skipped)) + + +@router.get("/{project_id}/quantity/material-summary") +async def get_material_summary(project_id: UUID) -> JSONResponse: + """구조물 원단위와 자재총괄을 **한 응답**으로 낸다. + + 자재총괄은 원단위의 `material` 성분만 모은 것이라 따로 부르면 같은 전개를 두 번 돈다. + """ + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + project_root = resolve_stored_project_path(stored_path) + except Exception: + logger.exception("B08 자재총괄 조회 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + try: + structures, names, skipped = _collect_structures(project_root) + except Exception: + logger.exception("B08 구조물 정본 읽기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."}, + ) + + unit_table = build_unit_table(structures, names) + settings = quantity_settings(project_root) + material_table = build_material_table( + unit_table, + supply_map=settings.get("material_supply") or {}, + ) + return JSONResponse( + content={ + "unit_quantity": unit_table, + "material": material_table, + "skipped_structures": skipped, + "structure_count": len(structures), + } + ) diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts new file mode 100644 index 00000000..bb301fd8 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -0,0 +1,232 @@ +/* ============================================================================= + * B08_Quantity_UI_MaterialGrid.ts + * 자재총괄표·구조물 원단위 그리드 (PLAN 8-2·8-6·8-7). + * + * 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다. + * + * ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은 + * 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고, + * 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다. + * + * ⚠ 반올림은 여기서만 한다(PLAN 8-16 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다. + * ========================================================================== */ + +export interface MaterialRow { + name: string; + unit: string; + net_amount: number; + surcharge_pct: number | null; + total_amount: number; + supply: string; + supply_label: string; + install_by: string | null; + install_by_label: string; + note: string; + sources: string[]; +} + +export interface MaterialTable { + columns: string[]; + rows: MaterialRow[]; + surcharge_applied: boolean; + surcharge_dataset: { effective_date: string; source: Record }; + missing_rate_materials: string[]; + missing_supply_materials: string[]; + missing_install_by_materials: string[]; + double_count_warnings: string[]; + skipped_by_destination: Record; + row_count: number; +} + +export interface UnitQuantityStructure { + structure_id: string | null; + type_id: string; + name: string; + length_m: number; + height_m: number; + notes: string[]; + components: { + name: string; + unit: string; + amount: number; + destination: string; + basis: string; + }[]; +} + +export interface MaterialResponse { + unit_quantity: { + structures: UnitQuantityStructure[]; + totals: { name: string; unit: string; amount: number; destination: string }[]; + surcharge_applied: boolean; + mix_components_found: string[]; + structure_count: number; + }; + material: MaterialTable; + skipped_structures: string[]; + structure_count: number; +} + +/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */ +const DESTINATION_LABELS: Record = { + earthwork: "토공 합산", + material: "자재총괄", + unit_price: "일위대가", +}; + +function num(value: number | null | undefined, digits: number): string { + if (value === undefined || value === null || Number.isNaN(value)) return ""; + return value.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function textCell(text: string, className?: string): HTMLTableCellElement { + const td = document.createElement("td"); + td.textContent = text; + if (className) td.className = className; + return td; +} + +function headRow(labels: string[]): HTMLTableSectionElement { + const head = document.createElement("thead"); + const tr = document.createElement("tr"); + for (const label of labels) { + const th = document.createElement("th"); + th.textContent = label; + tr.append(th); + } + head.append(tr); + return head; +} + +/** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */ +function warning(title: string, items: string[]): HTMLElement | null { + if (!items.length) return null; + const element = document.createElement("p"); + element.className = "b08-grid__caption b08-grid__caption--warn"; + element.textContent = `${title}: ${items.join(" · ")}`; + return element; +} + +/** 자재총괄표 — 할증이 붙는 유일한 자리. */ +export function renderMaterialGrid(table: MaterialTable): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + const edition = table.surcharge_dataset?.effective_date || "판 미상"; + caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`; + wrap.append(caption); + + for (const notice of [ + warning("⚠ 중복 할증 위험", table.double_count_warnings), + warning("할증률 미확보", table.missing_rate_materials), + warning("관급구분 미분류", table.missing_supply_materials), + warning("설치 주체 미지정(관급)", table.missing_install_by_materials), + ]) { + if (notice) wrap.append(notice); + } + + if (!table.rows.length) { + const empty = document.createElement("p"); + empty.className = "b08-quantity__message"; + empty.textContent = "구조물에서 나온 자재가 없음 — 구조물을 먼저 배치할 것"; + wrap.append(empty); + return wrap; + } + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + element.append(headRow(table.columns)); + + const body = document.createElement("tbody"); + for (const row of table.rows) { + const tr = document.createElement("tr"); + tr.append(textCell(row.name, "b08-grid__station")); + tr.append(textCell(row.unit, "b08-grid__unit")); + tr.append(textCell(num(row.net_amount, 2))); + // 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다. + tr.append(textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0))); + tr.append(textCell(num(row.total_amount, 2))); + tr.append(textCell(row.supply_label)); + tr.append(textCell(row.install_by_label)); + tr.append(textCell(row.note, "b08-grid__note")); + body.append(tr); + } + + element.append(body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} + +/** 구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. */ +export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + const unit = response.unit_quantity; + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + caption.textContent = `구조물 ${unit.structure_count}개 · 치수는 구조물 정본(B05)에서 · 할증 전 값`; + wrap.append(caption); + + // ㉢ 배합이 섞였으면 화면에도 뜬다 — 코드 검사만으로는 사람이 모른다. + const mixed = warning("⚠ 배합 성분이 섞였음(B09 일위대가와 이중계상)", unit.mix_components_found); + if (mixed) wrap.append(mixed); + const skipped = warning("건너뛴 구조물", response.skipped_structures); + if (skipped) wrap.append(skipped); + + if (!unit.structures.length) { + const empty = document.createElement("p"); + empty.className = "b08-quantity__message"; + empty.textContent = "배치된 구조물이 없음"; + wrap.append(empty); + return wrap; + } + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거"])); + + const body = document.createElement("tbody"); + for (const structure of unit.structures) { + const spec = `H=${num(structure.height_m, 1)} · L=${num(structure.length_m, 1)}m`; + if (!structure.components.length) { + const tr = document.createElement("tr"); + tr.append(textCell(structure.name, "b08-grid__station")); + tr.append(textCell(spec)); + const note = textCell(structure.notes.join(" · "), "b08-grid__note"); + note.colSpan = 5; + tr.append(note); + body.append(tr); + continue; + } + let first = true; + for (const component of structure.components) { + const tr = document.createElement("tr"); + // 같은 구조물이 이어지면 이름을 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다. + tr.append(textCell(first ? structure.name : "", "b08-grid__station")); + tr.append(textCell(first ? spec : "")); + first = false; + tr.append(textCell(component.name)); + tr.append(textCell(component.unit, "b08-grid__unit")); + tr.append(textCell(num(component.amount, 3))); + tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination)); + tr.append(textCell(component.basis, "b08-grid__note")); + body.append(tr); + } + } + + element.append(body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 4d07414f..e9e7b040 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -20,6 +20,11 @@ import { import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid"; import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid"; +import { + renderMaterialGrid, + renderUnitQuantityGrid, + type MaterialResponse, +} from "./B08_Quantity_UI_MaterialGrid"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -47,6 +52,16 @@ async function fetchEarthworkTable(projectId: string): Promise { return (await response.json()) as EarthworkTable; } +/** 구조물 원단위·자재총괄을 받아 온다. 한 번에 받는 까닭은 자재총괄이 원단위의 부분집합이라서다. */ +async function fetchMaterialSummary(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/material-summary`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`material summary failed: ${response.status}`); + return (await response.json()) as MaterialResponse; +} + /** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise { const response = await fetch( @@ -221,7 +236,11 @@ function buildQuantitySidePanel( } /** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */ -function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement { +function buildQuantityBody( + table: EarthworkTable | null, + failed: boolean, + material: MaterialResponse | null, +): HTMLElement { const body = document.createElement("div"); body.className = "b08-quantity__body"; @@ -260,6 +279,18 @@ function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLE ? renderHaulGrid(table.haul, Boolean(table.haul_available)) : message(L("B08_Quantity_Haul_Missing")), }, + { + label: L("B08_Quantity_Tab_UnitQuantity"), + build: () => + material ? renderUnitQuantityGrid(material) : message(L("B08_Quantity_Material_Failed")), + }, + { + label: L("B08_Quantity_Tab_Material"), + build: () => + material + ? renderMaterialGrid(material.material) + : message(L("B08_Quantity_Material_Failed")), + }, ]; const buttons: HTMLButtonElement[] = []; @@ -291,6 +322,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { // 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다. let table: EarthworkTable | null = null; + let material: MaterialResponse | null = null; let failed = false; if (projectId) { try { @@ -298,6 +330,12 @@ export async function renderB08Quantity(root: HTMLElement): Promise { } catch { failed = true; } + // 자재총괄은 따로 받는다 — 구조물이 없어도 토적표는 서야 하므로 실패를 옮기지 않는다. + try { + material = await fetchMaterialSummary(projectId); + } catch { + material = null; + } } // 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장). @@ -334,7 +372,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { steps: workflowSteps(), activeStep: 5, leftPanel: buildQuantitySidePanel(projectId, table, draft, reload), - mainContent: buildQuantityBody(table, failed), + mainContent: buildQuantityBody(table, failed, material), stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 7e229376..c9099467 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -71,6 +71,10 @@ def default_settings() -> dict[str, Any]: "conversion_factors_override": None, "haul_limits_m_override": None, "application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS}, + # 자재총괄의 관급/사급 구분 — `{자재명: "public"|"private"}`. + # ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로 + # 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다. + "material_supply": {}, "dataset_versions": {}, }, "estimation": { diff --git a/main.py b/main.py index 0c89aaa5..135572f7 100644 --- a/main.py +++ b/main.py @@ -60,6 +60,7 @@ from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router +from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( @@ -538,6 +539,7 @@ app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b08_quantity_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) +app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json new file mode 100644 index 00000000..d0b0e74a --- /dev/null +++ b/resources/data_material_surcharge/_manifest.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "dataset_id": "data_material_surcharge_manifest", + "generated_at": "2026-09-07T00:00:00+09:00", + "built_by": "수작업 — 품셈 1-3-1 재료 할증률표 전사 (지식DB 수량산출_일반.md §3)", + "files": [ + { + "file": "material_surcharge_2026-01-01.json", + "sha256": "77ffb9d8ae916252c725d28a4218ff7ecbff6a47bad1b00be8686663e17deb68", + "size_bytes": 2098 + } + ] +} diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json new file mode 100644 index 00000000..aaf05787 --- /dev/null +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -0,0 +1,51 @@ +{ + "schema_version": "1.0", + "dataset_id": "material_surcharge", + "effective_date": "2026-01-01", + "source": { + "doc": "산림사업 표준품셈 (산림청고시 제2025-82호) 제1장 1-3-1 재료의 할증", + "via": "resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md", + "note": "품셈 항목에 할증이 포함·표시된 경우 중복 적용 금지 — 자재총괄 한 곳에서만 붙인다." + }, + "policy": { + "no_invented_values": true, + "unknown_material_is_flagged": true + }, + "rates_pct": [ + { + "material": "시멘트", + "rate": 2, + "condition": "정치식", + "alt_rate": 3, + "alt_condition": "기타" + }, + { "material": "잔골재", "rate": 10, "alt_rate": 12, "alt_condition": "기타" }, + { "material": "채움재", "rate": 10, "alt_rate": 12, "alt_condition": "기타" }, + { "material": "굵은골재", "rate": 3, "alt_rate": 5, "alt_condition": "기타" }, + { "material": "모래", "rate": 6, "condition": "노반재료" }, + { "material": "부순돌", "rate": 4, "condition": "노반재료" }, + { "material": "자갈", "rate": 4, "condition": "노반재료" }, + { "material": "점질토", "rate": 6, "condition": "노반재료" }, + { "material": "이형철근", "rate": 3, "alt_rate": 7, "alt_condition": "복잡 구조물 주철근" }, + { "material": "원형철근", "rate": 5 }, + { "material": "강판", "rate": 10 }, + { "material": "각재", "rate": 5 }, + { "material": "판재", "rate": 10 }, + { + "material": "레미콘", + "rate": 2, + "condition": "무근", + "alt_rate": 1, + "alt_condition": "철근" + }, + { "material": "흄관", "rate": 3 }, + { "material": "떼", "rate": 10 }, + { "material": "초화류", "rate": 10 }, + { "material": "사방용 수목", "rate": 10 }, + { "material": "원석", "rate": 30, "condition": "마름돌용" } + ], + "observed_practice": { + "note": "울진 총괄집계 관측 — 참고이지 기본값이 아니다(PLAN 8-10 ★ 법대로).", + "values": { "모래": 10, "자갈": 3, "혼합석": 2, "시멘트": 2, "떼": 10 } + } +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 8e4c7cea..27a1ff93 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -621,6 +621,12 @@ export const ui_locales_b2 = { ], B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"], B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], + B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"], + B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"], + B08_Quantity_Material_Failed: [ + "자재총괄을 불러오지 못했습니다.", + "Failed to load the material summary.", + ], B08_Quantity_Haul_Missing: [ "운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.", "No haul plan yet. Press [Confirm] on the profile design to build it.", From e52f18769c19c0bfe123a793e8c730480a4fc2ba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:47:47 +0900 Subject: [PATCH 4/6] auto: 2026-09-07 23:47 (EOMSANGDON-HOME) --- .../B08_Quantity_Engine_UnitQuantity.py | 8 +- .../data_material_surcharge/_manifest.json | 6 +- .../material_surcharge_2026-01-01.json | 167 +++++++++++++++--- 3 files changed, 154 insertions(+), 27 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 6a107634..a2a11363 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -51,6 +51,8 @@ STONE_MASONRY = { "thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께 "thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H) "thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H) + # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. + # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 "weep_hole_length_m": 0.5, # 물구멍 1개소당 관 길이 "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) @@ -208,13 +210,17 @@ def stone_masonry( # ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢). # 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m. + # ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고 + # 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다. + # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 + # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. components.append( Component( "물구멍", "m", masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], DESTINATION["물구멍"], - "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소", + "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 (관 규격 미확정)", ) ) diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json index d0b0e74a..b327f4ff 100644 --- a/resources/data_material_surcharge/_manifest.json +++ b/resources/data_material_surcharge/_manifest.json @@ -2,12 +2,12 @@ "schema_version": "1.0", "dataset_id": "data_material_surcharge_manifest", "generated_at": "2026-09-07T00:00:00+09:00", - "built_by": "수작업 — 품셈 1-3-1 재료 할증률표 전사 (지식DB 수량산출_일반.md §3)", + "built_by": "수작업 — 산림품셈 1-3-1 전사 + 건설품셈 1-3-1 대조(2026-09-07)", "files": [ { "file": "material_surcharge_2026-01-01.json", - "sha256": "77ffb9d8ae916252c725d28a4218ff7ecbff6a47bad1b00be8686663e17deb68", - "size_bytes": 2098 + "sha256": "d9a66bbadec54fa384ce9f04d0ff7081c1896bae606a059f06a742416570aac6", + "size_bytes": 4752 } ] } diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json index aaf05787..6f7947bd 100644 --- a/resources/data_material_surcharge/material_surcharge_2026-01-01.json +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -3,9 +3,15 @@ "dataset_id": "material_surcharge", "effective_date": "2026-01-01", "source": { - "doc": "산림사업 표준품셈 (산림청고시 제2025-82호) 제1장 1-3-1 재료의 할증", - "via": "resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md", - "note": "품셈 항목에 할증이 포함·표시된 경우 중복 적용 금지 — 자재총괄 한 곳에서만 붙인다." + "primary": { + "doc": "산림사업 표준품셈 제1장 1-3-1 재료의 할증", + "via": "resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md" + }, + "supplementary": { + "doc": "건설공사 표준품셈 1-3-1 재료의 할증('23년 보완)", + "via": "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제1장_적용기준.md", + "note": "산림품셈에 없는 자재만 보완용으로 본다(PLAN 8-5). 실린 값은 출처를 pumsem 필드로 구분." + } }, "policy": { "no_invented_values": true, @@ -17,35 +23,150 @@ "rate": 2, "condition": "정치식", "alt_rate": 3, - "alt_condition": "기타" + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "잔골재", + "rate": 10, + "alt_rate": 12, + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "채움재", + "rate": 10, + "alt_rate": 12, + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "굵은골재", + "rate": 3, + "alt_rate": 5, + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "모래", + "rate": 6, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "부순돌", + "rate": 4, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "자갈", + "rate": 4, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "점질토", + "rate": 6, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "이형철근", + "rate": 3, + "alt_rate": 7, + "alt_condition": "복잡 구조물 주철근", + "pumsem": "forest" + }, + { + "material": "원형철근", + "rate": 5, + "pumsem": "forest" + }, + { + "material": "강판", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "각재", + "rate": 5, + "pumsem": "forest" + }, + { + "material": "판재", + "rate": 10, + "pumsem": "forest" }, - { "material": "잔골재", "rate": 10, "alt_rate": 12, "alt_condition": "기타" }, - { "material": "채움재", "rate": 10, "alt_rate": 12, "alt_condition": "기타" }, - { "material": "굵은골재", "rate": 3, "alt_rate": 5, "alt_condition": "기타" }, - { "material": "모래", "rate": 6, "condition": "노반재료" }, - { "material": "부순돌", "rate": 4, "condition": "노반재료" }, - { "material": "자갈", "rate": 4, "condition": "노반재료" }, - { "material": "점질토", "rate": 6, "condition": "노반재료" }, - { "material": "이형철근", "rate": 3, "alt_rate": 7, "alt_condition": "복잡 구조물 주철근" }, - { "material": "원형철근", "rate": 5 }, - { "material": "강판", "rate": 10 }, - { "material": "각재", "rate": 5 }, - { "material": "판재", "rate": 10 }, { "material": "레미콘", "rate": 2, "condition": "무근", "alt_rate": 1, - "alt_condition": "철근" + "alt_condition": "철근", + "pumsem": "forest" }, - { "material": "흄관", "rate": 3 }, - { "material": "떼", "rate": 10 }, - { "material": "초화류", "rate": 10 }, - { "material": "사방용 수목", "rate": 10 }, - { "material": "원석", "rate": 30, "condition": "마름돌용" } + { + "material": "흄관", + "rate": 3, + "pumsem": "forest" + }, + { + "material": "떼", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "초화류", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "사방용 수목", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "원석", + "rate": 30, + "condition": "마름돌용", + "pumsem": "forest" + } ], "observed_practice": { "note": "울진 총괄집계 관측 — 참고이지 기본값이 아니다(PLAN 8-10 ★ 법대로).", - "values": { "모래": 10, "자갈": 3, "혼합석": 2, "시멘트": 2, "떼": 10 } + "values": { + "모래": 10, + "자갈": 3, + "혼합석": 2, + "시멘트": 2, + "떼": 10 + } + }, + "candidates_pending_user": { + "note": "건설품셈에서 이름은 찾았으나 **적용 조건이 우리 쓰임과 다른** 것. 지식DB는 근거·후보 가이드이지 값을 확정하는 곳이 아니므로(CLAUDE.md 3장) 엔진은 이 값을 쓰지 않고 「할증률 미확보」로 둔다. 사용자 확정 후 rates_pct 로 옮길 것.", + "items": [ + { + "material": "막자갈", + "rate": 4, + "pumsem": "const", + "listed_condition": "노상 및 노반재료(선택층·보조기층·기층)", + "our_usage": "돌쌓기 뒤채움", + "why_not_applied": "조건이 노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음" + } + ] + }, + "not_found": { + "note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.", + "materials": [ + "야면석", + "고임돌", + "물구멍" + ], + "checked": [ + "산림품셈 1-3-1 전 19종", + "건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)", + "건설품셈 제7장 돌공사 — 재료 할증률표 없음" + ] } } From aecea153e2743b6e5eeaa8ebab5457f79688fab7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:48:28 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(B08):=20=EC=84=9D=EC=9E=AC=20=ED=95=A0?= =?UTF-8?q?=EC=A6=9D=EB=A5=A0=20=EA=B1=B4=EC=84=A4=ED=92=88=EC=85=88=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0=20=E2=80=94=20=ED=9B=84=EB=B3=B4=EC=99=80=20?= =?UTF-8?q?=EB=AF=B8=ED=99=95=EB=B3=B4=EB=A5=BC=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=97=90=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조율 창 지적으로 건설공사 표준품셈 1-3-1 을 대조함. 결과를 데이터 파일에 근거와 함께 남김. - 야면석·고임돌·물구멍 — 산림·건설 두 품셈 모두 이름 없음(`not_found`). 건설품셈이 다루는 석재는 해상 사석과 원석(마름돌용)뿐이고 제7장 돌공사에는 재료 할증률표 자체가 없음. 확인한 범위를 `checked` 로 함께 기록. - 막자갈 — 건설품셈 2호 노반재료에 4 % 로 있으나 우리 쓰임은 뒤채움이라 조건이 다름. `candidates_pending_user` 에 조건과 함께 두고 엔진은 쓰지 않음 (지식DB 는 근거 가이드이지 값을 확정하는 곳이 아님). - 산림품셈 값에는 `pumsem: forest` 표시. 보완 출처를 섞지 않기 위함. - 물구멍은 공제 대상이 아니라 관 자재임을 코드에 명시. 관 규격(법 Ø3~6㎝ / 실무 Ø50)과 간격(법 2~3㎡ / 엔진 2.0)이 미확정임을 잠정으로 표시. Co-Authored-By: Claude Opus 5 (1M context) --- resources/data_material_surcharge/_manifest.json | 4 ++-- .../material_surcharge_2026-01-01.json | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json index b327f4ff..ce84dbd3 100644 --- a/resources/data_material_surcharge/_manifest.json +++ b/resources/data_material_surcharge/_manifest.json @@ -6,8 +6,8 @@ "files": [ { "file": "material_surcharge_2026-01-01.json", - "sha256": "d9a66bbadec54fa384ce9f04d0ff7081c1896bae606a059f06a742416570aac6", - "size_bytes": 4752 + "sha256": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc", + "size_bytes": 4724 } ] } diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json index 6f7947bd..a7eb5b0d 100644 --- a/resources/data_material_surcharge/material_surcharge_2026-01-01.json +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -158,11 +158,7 @@ }, "not_found": { "note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.", - "materials": [ - "야면석", - "고임돌", - "물구멍" - ], + "materials": ["야면석", "고임돌", "물구멍"], "checked": [ "산림품셈 1-3-1 전 19종", "건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)", From ab39c174aad0f8361ce3dbf88755440bef324674 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:04:32 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(B08):=20B09=20=EC=9D=B8=EA=B3=84=20?= =?UTF-8?q?=EB=91=90=20=EB=B2=8C=20+=20=ED=92=88=EC=85=88=20=EB=94=B1?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=ED=91=9C=20=ED=8C=90=EC=A0=95=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 9. 수량 결과(작업 공종 축)와 자재 총괄(자재 축)을 각각 낸다. - 자재 줄에는 공종코드를 붙이지 않음. 붙이면 자재가 내역 줄로 오해되어 이중계상이 됨. `verify_no_code_on_materials()` 로 코드에 못 박음. - `in_bill` 을 실어 무대·합계 줄을 가름. 수량은 그대로 넘김 — 무대+도자+덤프 = 총 운반토량 검산에 쓰이는 값이라 빼면 검산이 죽음. - `ground_class_set` 동봉. 「연암」이 몇 갈래 중 하나인지 알아야 예산내역서가 섬. - 매핑표는 데이터 파일(`resources/data_work_item_mapping/`). 못 이은 줄은 빈 코드로 두지 않고 `unmatched_work_items` 로 냄. 품셈 형태 판정 교차 확인 — 「첫 칸이 갈래 딱지이고 이름이 둘째 칸」인 표에서 직종을 첫 세 행에서만 찾아 12장 구조물 표 8건이 미판정으로 빠져 있었음. 딱지를 알아보는 규칙을 맨 마지막에 두어 앞 판정을 흔들지 않고 해소 (미판정 85 → 77, 생산량형 16건 불변). 비율 지시를 먼저 보면 강관동바리 같은 소요량표가 참조로 넘어가므로 소요량을 먼저 보게 순서를 잡음. 검증 — 인계 22건 + 품셈 5건 통과, 전체 500 passed. 실서버 `GET /quantity/handoff` 200 (내역 10줄·제외 1·자재 4, 갈래세트 5). Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Build_WorkItemMaster.py | 45 +++ B08_Quantity/B08_Quantity_Engine_Handoff.py | 344 ++++++++++++++++++ .../B08_Quantity_Engine_UnitQuantity.py | 7 + B08_Quantity/B08_Quantity_Router_Material.py | 65 +++- .../work_item_mapping_2026-01-01.json | 145 ++++++++ .../data_work_item_master/_manifest.json | 10 +- .../form_undetermined_2026-01-01.json | 198 ---------- .../work_item_master_2026-01-01.json | 32 +- 8 files changed, 626 insertions(+), 220 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Handoff.py create mode 100644 resources/data_work_item_mapping/work_item_mapping_2026-01-01.json diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index d4d3f9c8..7f880e1b 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -83,6 +83,33 @@ OCCUPATION_RE = re.compile( # 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다. MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)") +# ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (2026-09-07 서브 창이 자기 파싱에서 잡은 모양). +# `['자재', 'PVC 지수판(200×5)', 'm', '1.04']` 처럼 첫 칸이 값의 이름이 아니라 갈래다. +# 그런 표는 직종이 넷째·다섯째 행에 있어 **첫 세 행만 보는 판정에 안 걸렸고**, 12장 임도 +# 구조물 표 9건이 통째로 `undetermined` 로 빠져 있었다. 딱지를 알아보고 다시 본다. +CLASSIFICATION_TAGS = frozenset( + { + "자재", + "재료", + "재료비", + "자재비", + "잡재료", + "장비", + "기계", + "인력", + "노무", + "노무비", + "인건비", + "설치비", + "경비", + "공구손료", + } +) +# 딱지 가운데 **품이 붙는 쪽**. 이 딱지가 있으면 그 표는 소요량형이다. +RESOURCE_TAGS = frozenset({"자재", "재료", "잡재료", "장비", "기계", "인력", "노무", "공구손료"}) +# 값이 아니라 **다른 값의 몇 %** 라고만 적은 표. 품이 아니므로 참조로 둔다. +RATIO_DIRECTIVE_MARKS = ("재료비의", "주재료비의", "회기준") + def sha256_of(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -187,6 +214,24 @@ def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]: for mark in REQUIREMENT_MARKS: if mark in both: return "requirement", f"'{mark}'" + + # ⚠ 첫 칸이 분류 딱지인 표 — **맨 마지막에 본다.** 앞의 판정을 흔들지 않으려는 자리다. + tags = [norm(row[0]).replace(" ", "") for row in table.get("rows", []) if row] + if any(tag in CLASSIFICATION_TAGS for tag in tags): + # ⚠ **소요량을 먼저 본다.** 비율 지시를 먼저 보면 `잡재료비(재료비의) 5 %` 한 줄 때문에 + # 강관동바리(내관 0.38본·형틀목공 0.07인) 같은 **멀쩡한 소요량표가 참조로 넘어간다** + # — 만들다 실제로 걸린 자리다. + # 값이 실제로 있는 표만 공종으로 세운다. 이름만 있고 수치가 없으면 판정하지 않는다. + has_number = any( + re.fullmatch(r"[\d,]+(?:\.\d+)?", norm(c)) for r in table.get("rows", []) for c in r + ) + if has_number and any(tag in RESOURCE_TAGS for tag in tags): + return "requirement", f"분류 딱지 {sorted({t for t in tags if t in RESOURCE_TAGS})}" + body = " ".join(norm(c) for r in table.get("rows", []) for c in r).replace(" ", "") + for mark in RATIO_DIRECTIVE_MARKS: + if mark in body: + return "reference", f"분류 딱지 표의 '{mark}' — 값이 아니라 비율 지시" + return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음" diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py new file mode 100644 index 00000000..9c42b7d5 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -0,0 +1,344 @@ +"""B08 → B09 인계 (일감 9 · PLAN 8-2 · 2026-09-07 3자 조율 확정). + +**두 벌로 낸다. 한 벌로 합치지 않는다.** + ① `work_items` = **작업 공종** 축. B09 ④예산내역서가 되는 줄이다. 공종코드가 붙는다. + ② `materials` = **자재** 축. B09 자재대가 되는 줄이다. **공종코드가 붙지 않는다.** + +⚠ 자재 줄에 공종코드를 붙이지 않는 까닭 + 내역 줄의 실체는 `돌쌓기(찰) H=1.5 · 70m` 이지 그 전개인 콘크리트 0.31㎥ 가 아니다 + (8-2 이중계상 함정). 자재에 공종코드를 붙이면 **자재가 내역 줄로 오해될 자리**가 생긴다. + 자재를 카탈로그 키에 잇는 것은 **B09 자원 축의 일**이다(8-7). + +⚠ 할증 전/후는 **자재 쪽에만** 있다 + 작업 공종에는 할증이 없다. 자재는 `net_amount`(전)·`total_amount`(후)를 둘 다 넘긴다 — + 하나만 넘기면 B09 가 어느 쪽인지 몰라 역산한다. + +⚠ `in_bill` 을 반드시 싣는다 (㉡) + 무대(소운반 20m)처럼 **값은 내되 내역에 안 서는** 줄이 있다. B09 가 이 깃발을 안 보면 + ④예산내역서에 무대가 서서 운반비가 두 번 붙는다. 빼고 넘기지 않는 까닭은, + 빠진 줄과 제외된 줄을 나중에 구별할 수 없기 때문이다. + +⚠ `ground_class_set` 을 함께 싣는다 (2026-09-07 서브 이견 채택) + 값이 「연암」이어도 **그 프로젝트가 몇 갈래 세트를 쓰는지**를 알아야 ④예산내역서에서 줄을 + 세울 수 있다(울진 2 · 거창 5 · 오솔길 1). 설정 파일을 안 봐도 **인계본만으로 ④가 서게** 한다. + +⚠ 못 이은 줄은 **빈 코드로 두지 않는다** + `unmatched_work_items` 로 낸다. 빈칸이면 「코드가 없는 줄」과 「매핑을 못 찾은 줄」이 + 구별되지 않고, 조용히 없어진 줄은 아무도 못 찾는다. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" +DATASET_PREFIX = "work_item_mapping_" + +#: 줄이 어디서 왔나 — 되짚을 때 쓴다. +ORIGIN_EARTHWORK = "earthwork" +ORIGIN_STRUCTURE = "structure" +ORIGIN_SLOPE = "slope" +ORIGIN_HAUL = "haul" + +#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. +SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) + +#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. +SUBTOTAL_GROUPS = frozenset({"보정량계"}) + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +@dataclass +class WorkItemMapping: + """수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다.""" + + effective_date: str = "" + earthwork: list[dict[str, Any]] = field(default_factory=list) + haul: list[dict[str, Any]] = field(default_factory=list) + structure: list[dict[str, Any]] = field(default_factory=list) + pending_user: dict[str, Any] = field(default_factory=dict) + + def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: + """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" + exact = [ + row + for row in self.earthwork + if row.get("group") == group and row.get("ground") == ground + ] + if exact: + return exact[0] + # 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다. + loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row] + return loose[0] if loose else None + + def for_haul(self, equipment: str) -> dict[str, Any] | None: + for row in self.haul: + if row.get("equipment") == equipment: + return row + return None + + def for_structure(self, type_id: str) -> dict[str, Any] | None: + for row in self.structure: + if row.get("type_id") == type_id: + return row + return None + + +def load_mapping(path: Path | None = None) -> WorkItemMapping: + """매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return WorkItemMapping() + payload = json.loads(target.read_text(encoding="utf-8")) + return WorkItemMapping( + effective_date=str(payload.get("effective_date") or ""), + earthwork=list(payload.get("earthwork") or []), + haul=list(payload.get("haul") or []), + structure=list(payload.get("structure") or []), + pending_user=payload.get("pending_user") or {}, + ) + + +def _spec_detail(structure: dict[str, Any]) -> str: + """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" + parts: list[str] = [] + height = structure.get("height_m") + length = structure.get("length_m") + if height: + parts.append(f"H={height:g}") + if length: + parts.append(f"L={length:g}m") + return "·".join(parts) + + +def _earthwork_rows( + summary_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """토공집계표 줄을 내역 줄로 옮긴다. + + ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. + 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in summary_table.get("rows") or []: + group = str(row.get("group") or "") + if not group: + continue + ground = row.get("item") or None + origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK + is_subtotal = group in SUBTOTAL_GROUPS + entry = mapping.for_earthwork(group, ground) + code = (entry or {}).get("work_item_code") + if code is None and not is_subtotal: + unmatched.append(f"{group}({ground})" if ground else group) + rows.append( + { + "work_item_code": code, + "name": group, + "spec": str(row.get("spec") or ""), + "unit": str(row.get("unit") or "㎥"), + "quantity": float(row.get("amount") or 0.0), + "ground_class": ground, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "spec_detail": "", + # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. + "in_bill": bool(row.get("in_bill", True)) and not is_subtotal, + "in_bill_reason": "집계 합계 줄 — 검산용" + if is_subtotal + else str(row.get("note") or ""), + "origin": origin, + } + ) + return rows, unmatched + + +def _haul_rows( + haul_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in haul_table.get("rows") or []: + equipment = str(row.get("equipment") or "") + entry = mapping.for_haul(equipment) or {} + code = entry.get("work_item_code") + in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) + if code is None and in_bill: + unmatched.append(f"운반({equipment})") + rows.append( + { + "work_item_code": code, + "name": f"{equipment} 운반", + "spec": str(row.get("ground") or ""), + "unit": "㎥", + "quantity": float(row.get("volume_m3") or 0.0), + "ground_class": row.get("ground") or None, + "haul_distance_m": float(row.get("average_distance_m") or 0.0), + "haul_equipment": equipment, + "station_from": None, + "station_to": None, + "spec_detail": "", + "in_bill": in_bill, + "in_bill_reason": str(entry.get("reason") or ""), + "origin": ORIGIN_HAUL, + } + ) + return rows, unmatched + + +def _structure_rows( + unit_quantity_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """구조물 줄 — **작업 공종 하나**로 선다. + + ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, + 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for structure in unit_quantity_table.get("structures") or []: + type_id = str(structure.get("type_id") or "") + entry = mapping.for_structure(type_id) or {} + code = entry.get("work_item_code") + if code is None: + unmatched.append(f"구조물({type_id})") + length = float(structure.get("length_m") or 0.0) + rows.append( + { + "work_item_code": code, + "name": str(structure.get("name") or type_id), + "spec": _spec_detail(structure), + "unit": "m", + "quantity": length, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": structure.get("start_m"), + "station_to": structure.get("end_m"), + "spec_detail": _spec_detail(structure), + "in_bill": True, + "in_bill_reason": "", + "origin": ORIGIN_STRUCTURE, + } + ) + return rows, unmatched + + +def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: + """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" + rows: list[dict[str, Any]] = [] + for row in material_table.get("rows") or []: + rows.append( + { + "material_name": row.get("name"), + "spec": row.get("spec") or "", + "unit": row.get("unit"), + "net_amount": row.get("net_amount"), + "total_amount": row.get("total_amount"), + "surcharge_pct": row.get("surcharge_pct"), + "surcharge_note": row.get("note") or "", + "supply_type": row.get("supply"), + "install_by": row.get("install_by"), + "source_structure": row.get("sources") or [], + } + ) + return rows + + +def build_handoff( + *, + summary_table: dict[str, Any] | None = None, + haul_table: dict[str, Any] | None = None, + unit_quantity_table: dict[str, Any] | None = None, + material_table: dict[str, Any] | None = None, + mapping: WorkItemMapping | None = None, + ground_class_set: str | None = None, + ground_classes: list[str] | None = None, +) -> dict[str, Any]: + """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.""" + table = mapping or load_mapping() + work_items: list[dict[str, Any]] = [] + unmatched: list[str] = [] + + if summary_table: + rows, misses = _earthwork_rows(summary_table, table) + work_items.extend(rows) + unmatched.extend(misses) + if haul_table: + rows, misses = _haul_rows(haul_table, table) + work_items.extend(rows) + unmatched.extend(misses) + if unit_quantity_table: + rows, misses = _structure_rows(unit_quantity_table, table) + work_items.extend(rows) + unmatched.extend(misses) + + materials = _material_rows(material_table or {}) + return { + "work_items": work_items, + "materials": materials, + # 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다. + "ground_class_set": ground_class_set, + "ground_classes": list(ground_classes or []), + # 자재 쪽에만 할증이 있다 — 작업 공종에는 없다. + "surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")), + "unmatched_work_items": sorted(set(unmatched)), + "mapping_pending_user": table.pending_user, + "mapping_edition": table.effective_date, + "bill_row_count": sum(1 for row in work_items if row["in_bill"]), + "excluded_row_count": sum(1 for row in work_items if not row["in_bill"]), + } + + +def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]: + """⚠ 자재 줄에 공종코드가 섞이면 알린다. + + 자재에 공종코드가 붙으면 **내역 줄로 오해될 자리**가 생기고 그게 곧 이중계상이다. + 축이 둘이라는 것은 주석이 아니라 검사로 지켜야 한다. + """ + found: list[str] = [] + for row in handoff.get("materials") or []: + if row.get("work_item_code"): + found.append(str(row.get("material_name"))) + return found + + +def verify_bill_flags(handoff: dict[str, Any]) -> list[str]: + """⚠ 코드가 없는데 내역에 서는 줄이 있으면 알린다. + + 빈 코드로 내역에 세우면 B09 가 단가를 못 붙인 채 0원 줄을 만든다. + """ + found: list[str] = [] + for row in handoff.get("work_items") or []: + if row.get("in_bill") and not row.get("work_item_code"): + found.append(str(row.get("name"))) + return found + + +def summarize(handoff: dict[str, Any]) -> dict[str, Any]: + """화면 안내용 한 줄 요약 — 넘긴 줄과 못 이은 줄을 함께 보인다.""" + return { + "bill_rows": handoff.get("bill_row_count", 0), + "excluded_rows": handoff.get("excluded_row_count", 0), + "materials": len(handoff.get("materials") or []), + "unmatched": handoff.get("unmatched_work_items") or [], + } + + +def iter_bill_rows(handoff: dict[str, Any]) -> Iterable[dict[str, Any]]: + """내역에 서는 줄만 — B09 ④예산내역서가 쓰는 입구.""" + return (row for row in handoff.get("work_items") or [] if row["in_bill"]) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index a2a11363..1325f3d9 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -109,6 +109,9 @@ class StructureQuantity: name: str length_m: float = 0.0 height_m: float = 0.0 + # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). + start_m: float | None = None + end_m: float | None = None components: list[Component] = field(default_factory=list) notes: list[str] = field(default_factory=list) @@ -267,6 +270,8 @@ def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> St name=(names or {}).get(type_id, type_id), length_m=length, height_m=height, + start_m=start if structure.get("start_m") is not None else None, + end_m=end if structure.get("end_m") is not None else None, ) expander = EXPANDERS.get(type_id) if expander is None: @@ -321,6 +326,8 @@ def build_table( "name": item.name, "length_m": item.length_m, "height_m": item.height_m, + "start_m": item.start_m, + "end_m": item.end_m, "notes": item.notes, "components": [ { diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index eeaa6d68..c61790cb 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -25,9 +25,10 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, summarize from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table -from common_util.common_util_project_settings import quantity_settings +from common_util.common_util_project_settings import quantity_settings, rock_classes from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection @@ -103,3 +104,65 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: "structure_count": len(structures), } ) + + +@router.get("/{project_id}/quantity/handoff") +async def get_handoff(project_id: UUID) -> JSONResponse: + """B09 로 넘길 두 벌 — 작업 공종 축과 자재 축 (일감 9). + + ⚠ **한 벌로 합치지 않는다.** 내역 줄은 작업 공종이고 자재는 자재다. + 자재에 공종코드를 붙이면 자재가 내역 줄로 오해된다(8-2 이중계상 함정). + + ⚠ 토공·운반은 토적표 라우터가 이미 만드는 표를 그대로 받는다 — 여기서 다시 계산하지 + 않는다. 같은 값을 두 벌로 짜지 않는다는 규칙(CLAUDE.md 5장)이 여기에도 걸린다. + """ + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + project_root = resolve_stored_project_path(stored_path) + except Exception: + logger.exception("B08 인계 조회 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + structures, names, skipped = _collect_structures(project_root) + unit_table = build_unit_table(structures, names) + settings = quantity_settings(project_root) + material_table = build_material_table( + unit_table, supply_map=settings.get("material_supply") or {} + ) + + # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. + earthwork = await _earthwork_tables(project_id) + + handoff = build_handoff( + summary_table=earthwork.get("summary"), + haul_table=earthwork.get("haul"), + unit_quantity_table=unit_table, + material_table=material_table, + ground_class_set=settings.get("rock_class_set"), + ground_classes=rock_classes(settings), + ) + handoff["summary"] = summarize(handoff) + handoff["skipped_structures"] = skipped + handoff["earthwork_available"] = bool(earthwork) + return JSONResponse(content=handoff) + + +async def _earthwork_tables(project_id: UUID) -> dict[str, Any]: + """토적표 라우터가 만든 집계·운반 표를 얻는다. 노선이 없으면 빈 값.""" + from B08_Quantity.B08_Quantity_Router_Earthwork import ( + get_earthwork_table_for_current_route, + ) + + try: + response = await get_earthwork_table_for_current_route(project_id) + except Exception: + logger.exception("B08 인계 — 토적표 조회 실패: project_id=%s", project_id) + return {} + if response.status_code != 200: + return {} + import json as _json + + return _json.loads(bytes(response.body).decode("utf-8")) diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json new file mode 100644 index 00000000..646c7681 --- /dev/null +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -0,0 +1,145 @@ +{ + "schema_version": "1.0", + "dataset_id": "work_item_mapping", + "effective_date": "2026-01-01", + "note": "B08 이 낸 수량 줄을 공종 마스터 코드(FP-…)에 잇는 다리. 발주처 골격이 바뀌어도 코드를 안 고치게 데이터로 둔다.", + "master": { + "dataset_id": "work_item_master", + "effective_date": "2026-01-01" + }, + "policy": { + "no_invented_codes": true, + "unmatched_is_listed": true, + "note": "못 이은 줄은 빈 코드로 두지 않고 unmatched_work_items 로 낸다. 빈칸이면 없어진 것과 구별이 안 된다." + }, + "earthwork": [ + { + "group": "흙깎기", + "ground": "토사", + "work_item_code": "FP-09-03-02", + "master_name": "토사깍기 > 기계", + "note": "인력 시공이면 FP-09-03-01" + }, + { + "group": "흙깎기", + "ground": "리핑암", + "work_item_code": "FP-09-04", + "master_name": "암절취" + }, + { + "group": "흙깎기", + "ground": "발파암", + "work_item_code": "FP-09-05", + "master_name": "발파암" + }, + { + "group": "측구터파기", + "ground": "토사", + "work_item_code": "FP-09-12-01", + "master_name": "측구터파기 > 토사" + }, + { + "group": "측구터파기", + "ground": "리핑암", + "work_item_code": "FP-09-12-02", + "master_name": "측구터파기 > 암절취" + }, + { + "group": "측구터파기", + "ground": "발파암", + "work_item_code": "FP-09-12-03", + "master_name": "측구터파기 > 발파암" + }, + { + "group": "성토", + "work_item_code": "FP-09-16", + "master_name": "노체", + "note": "포설(FP-09-16-01)·다짐(FP-09-16-02)로 갈리는 자리 — 내역 양식이 정해지면 내린다" + }, + { + "group": "성토면다짐", + "work_item_code": "FP-09-17-01", + "master_name": "다짐 > 비탈면 다짐" + }, + { + "group": "층따기", + "work_item_code": "FP-09-18", + "master_name": "층따기" + }, + { + "group": "면고르기", + "ground": "토사", + "work_item_code": "FP-09-19-01", + "master_name": "면고르기 > 토사면 고르기" + }, + { + "group": "면고르기", + "ground": "리핑암", + "work_item_code": "FP-09-19-02", + "master_name": "면고르기 > 비탈면 면고르기(암절취)" + }, + { + "group": "면고르기", + "ground": "발파암", + "work_item_code": "FP-09-19-03", + "master_name": "면고르기 > 비탈면 면고르기(발파암)" + }, + { + "group": "초류종자살포", + "work_item_code": "FP-05-24", + "master_name": "씨앗뿜어붙이기" + }, + { + "group": "되메우기", + "work_item_code": "FP-09-14-01", + "master_name": "되메우기 및 다짐 > 되메우기" + } + ], + "haul": [ + { + "equipment": "dozer", + "work_item_code": "FP-10-11", + "master_name": "불도저 운반" + }, + { + "equipment": "dump_truck", + "work_item_code": "FP-10-12", + "master_name": "덤프 운반" + }, + { + "equipment": "free_haul", + "work_item_code": null, + "in_bill": false, + "reason": "무대(소운반 20m 이내)는 품에 포함 — 내역 줄이 아니다(품셈 1-2-7, ㉡)" + } + ], + "structure": [ + { + "type_id": "masonry_wet", + "work_item_code": "FP-13-04-05", + "master_name": "돌쌓기 > 찰쌓기(장비)", + "note": "인력 시공이면 FP-13-04-04" + }, + { + "type_id": "masonry_dry", + "work_item_code": "FP-13-04-02", + "master_name": "돌쌓기 > 메쌓기(장비)", + "note": "인력 시공이면 FP-13-04-01" + } + ], + "pending_user": { + "note": "이름이 비슷한 후보는 있으나 **어느 것인지 정할 근거가 없는** 자리. 임의로 고르지 않고 unmatched 로 낸다(CLAUDE.md 3장).", + "items": [ + { + "group": "지장목제거", + "candidates": ["FP-04-01 수확베기", "FP-04-02 단목베기", "FP-04-03 위험목 베기"], + "why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음" + }, + { + "group": "흙깎기/측구터파기 암", + "candidates": ["FP-09-04 암절취(리핑)", "FP-09-05 발파암"], + "why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함" + } + ] + } +} diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 738215dc..39bbea8d 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-07T19:59:00+09:00", + "generated_at": "2026-09-08T00:00:17+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,13 +12,13 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "ab6afec24867df51374122efb3fc10416a48b611c2b488c9e3aaa48c2035bedc", - "size_bytes": 725962 + "sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0", + "size_bytes": 725931 }, { "file": "form_undetermined_2026-01-01.json", - "sha256": "e43b39bfb844f1d280fb43066ebcf09f9c129eabcbfb2190d249084994d06942", - "size_bytes": 40578 + "sha256": "7334dab9385bc1615a9cdb557ba814482e9a63d83b9e1232946918bfd8b5577f", + "size_bytes": 37139 } ] } \ No newline at end of file diff --git a/resources/data_work_item_master/form_undetermined_2026-01-01.json b/resources/data_work_item_master/form_undetermined_2026-01-01.json index ae6868b9..9724f909 100644 --- a/resources/data_work_item_master/form_undetermined_2026-01-01.json +++ b/resources/data_work_item_master/form_undetermined_2026-01-01.json @@ -1625,204 +1625,6 @@ ], "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" }, - { - "pum_table_id": "F0367", - "section": "12-20. 강관동바리", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "강관 동바리", - "내관(48.6mm×2.4mm)", - "본", - "0.38", - "" - ], - [ - "외관(60.6mm×2.3mm)", - "본", - "0.38", - "", - "", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0369", - "section": "12-22. P.V.C 파이프 설치(50mm)", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "", - "" - ], - [ - "설치비", - "재료비의 5%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0375", - "section": "12-27-1. 지수판 설치", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "PVC 지수판(200×5)", - "m", - "1.04", - "4% 할증" - ], - [ - "용접봉", - "kg", - "0.042", - "", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0377", - "section": "12-27-3. 실런트(20×25mm)", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "실런트", - "㎥", - "0.70", - "0.02×0.025×1400kg/㎥" - ], - [ - "인력(설치비)", - "방수공", - "인", - "0.04", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0378", - "section": "12-28. 신구 BOX접합", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "에폭시 접착제", - "kg", - "1.20", - "" - ], - [ - "시너", - "ℓ", - "0.21", - "", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0382", - "section": "12-31. 물끊기 홈(NOTCH) 설치", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "", - "" - ], - [ - "설치비", - "주재료비의 5%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0383", - "section": "12-32. 전선관 설치(P.V.C ø54m/m)", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "P.V.C ∅54m/m", - "" - ], - [ - "설치비", - "주재료비의 10%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0386", - "section": "12-34-2. 합판거푸집(3회 0~7m)", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "1회 기준 46.1%", - "" - ], - [ - "노무비", - "1회 기준 47.1%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, { "pum_table_id": "F0388", "section": "12-34-4. 채움재(T=20m/m)", diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index 8bd4105f..724c8a4e 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", - "generated_at": "2026-09-07T19:59:00+09:00", + "generated_at": "2026-09-08T00:00:17+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -20,7 +20,7 @@ "tables_total": 475, "tables_attached": 456, "tables_orphan": 19, - "form_undetermined": 85 + "form_undetermined": 77 }, "orphan_tables": [ { @@ -33363,8 +33363,8 @@ "pum_table_id": "F0367", "section": "12-20. 강관동바리", "source_line": 6630, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "requirement", + "form_basis": "분류 딱지 ['인력', '자재']", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -33490,8 +33490,8 @@ "pum_table_id": "F0369", "section": "12-22. P.V.C 파이프 설치(50mm)", "source_line": 6654, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34030,8 +34030,8 @@ "pum_table_id": "F0377", "section": "12-27-3. 실런트(20×25mm)", "source_line": 6758, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "requirement", + "form_basis": "분류 딱지 ['자재']", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34075,8 +34075,8 @@ "pum_table_id": "F0378", "section": "12-28. 신구 BOX접합", "source_line": 6767, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "requirement", + "form_basis": "분류 딱지 ['인력', '자재']", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34357,8 +34357,8 @@ "pum_table_id": "F0382", "section": "12-31. 물끊기 홈(NOTCH) 설치", "source_line": 6814, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34397,8 +34397,8 @@ "pum_table_id": "F0383", "section": "12-32. 전선관 설치(P.V.C ø54m/m)", "source_line": 6823, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34560,8 +34560,8 @@ "pum_table_id": "F0386", "section": "12-34-2. 합판거푸집(3회 0~7m)", "source_line": 6855, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '회기준' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [