From e100d311471f7a75442d3314fdabb05254ba4635 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:30:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EA=B7=9C=EC=A4=80=ED=8B=80=20?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=20=E2=80=94=20=EC=A0=9C=EC=95=88=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=84=B8=EC=9A=B0=EA=B3=A0=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20(=ED=92=88?= =?UTF-8?q?=EC=85=88=20[=EC=A3=BC]=E2=91=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「재료량은 설계수량에 따른다」는 값을 안 준다는 뜻이지 안 센다는 뜻이 아님. 근주이식·임목파쇄(셀지 말지가 설계 판단)와 성격이 다름 — 확정 ⑨·⑩ 과 같은 틀로 감 - 제안값(울진 소광 §8 관측): 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏/개소 - ⚠ 「실무 관측값이지 법정 기준 아님」과 손율 원문값(비탈 50% · 수평 80%)을 줄 사유와 화면 칸 밑에 함께 적음 — 값만 박으면 「대신 페이지에 남길 것」 지시를 어기는 것 - 개소가 안 서면 재료도 안 섬(밑수가 그 줄) · 자재 축으로 감(할증은 자재총괄에서 한 번) - 설정 `frame_material` + 화면 칸 셋 - tmp/tests/test_b08_frame_material.py 신설(7건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 70 ++++++++++++++++++- B08_Quantity/B08_Quantity_Router_Earthwork.py | 2 + B08_Quantity/B08_Quantity_Router_Material.py | 15 ++++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 2 + B08_Quantity/B08_Quantity_UI_Page.ts | 37 ++++++++++ common_util/common_util_project_settings.py | 4 ++ ui_template/ui_template_locale_b2.ts | 8 +++ 7 files changed, 136 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index cfe56495..52e665c7 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -324,6 +324,67 @@ def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict } +#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09). +#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지 +#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다 +#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」). +#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8 +#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」. +#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다. +#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**. +FRAME_MATERIAL_SUGGESTED = { + "각재 50×50": (0.0044, "㎥"), + "판재 T12": (0.0029, "㎥"), + "못": (0.03, "㎏"), +} +FRAME_MATERIAL_SOURCE = ( + "⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**." + " 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음" +) +FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80} + + +def frame_material_rows( + frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.** + + ⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다). + ⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다. + """ + given = overrides or {} + rows: list[dict[str, Any]] = [] + for frame in frame_rows: + count = frame.get("amount") + if not count: + continue + loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None) + for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items(): + raw = given.get(name) + try: + per_ea = ( + float(raw) if raw is not None and str(raw).strip() != "" else float(default) + ) + except (TypeError, ValueError): + per_ea = float(default) + picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)" + rows.append( + { + "name": name, + "unit": unit, + "amount": float(count) * per_ea, + "destination": "material", + "source": str(frame.get("item") or "규준틀"), + "basis": ( + f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소" + f" ({picked}) · {FRAME_MATERIAL_SOURCE}" + + (f" · 손율 {loss}%(품셈 [주]③)" if loss else "") + ), + } + ) + return rows + + def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" count, notes = batter_frame_count(slope_rows) @@ -333,7 +394,11 @@ def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: "unit": "개소", "amount": float(count) if count else None, "status": STATUS_READY if count else STATUS_PENDING, - "reason": ("; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」라 미확보"), + "reason": ( + "; ".join(notes) + + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —" + + " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**" + ), "work_item_code": "FP-11-02", } @@ -349,7 +414,8 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: "status": STATUS_READY if count is not None else STATUS_PENDING, "reason": "; ".join(notes) + ( - " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」라 미확보" + " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —" + " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**" if count is not None else "" ), diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 2ebb4d30..7cf37ca5 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -324,6 +324,8 @@ class QuantitySettingsBody(BaseModel): topsoil_haul_distance_m: float | None = None # 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다. stand_volume_class: str | None = None + # 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. + frame_material: dict[str, Any] | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index d9e2cd49..f5c55b21 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -43,6 +43,7 @@ from common_util.common_util_project_settings import ( rock_method, ) from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs +from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_structure_lengths import structure_lengths from config.config_db import run_with_connection @@ -251,6 +252,20 @@ async def get_handoff(project_id: UUID) -> JSONResponse: # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. earthwork = await _earthwork_tables(project_id) + # 규준틀 재료 — **개소가 선 뒤에야 설 수 있어** 준비공 표를 받은 다음 자재 축에 얹는다. + # ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있다 — 그 사실이 줄 사유에 적힌다. + frame_rows = [ + row + for row in ((earthwork.get("preparation") or {}).get("rows") or []) + if str(row.get("item") or "").endswith("규준틀") + ] + material_table = build_material_table( + unit_table, + supply_map=settings.get("material_supply") or {}, + concrete_placing_method=settings.get("concrete_placing_method"), + extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}), + ) + handoff = build_handoff( summary_table=earthwork.get("summary"), haul_table=earthwork.get("haul"), diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 681fb0b4..7eb74241 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -81,6 +81,8 @@ export interface QuantitySettings { ancillary_counts?: Record; /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */ stand_volume_class?: string | null; + /** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */ + frame_material?: Record; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 72fb1ada..d710a543 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -93,6 +93,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr structure_trench_water: draft.structure_trench_water, topsoil_haul_distance_m: draft.topsoil_haul_distance_m, stand_volume_class: draft.stand_volume_class, + frame_material: draft.frame_material, // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. ancillary_counts: draft.ancillary_counts, }), @@ -114,6 +115,19 @@ function field(label: string, value: string): HTMLElement { return row; } +/** 규준틀 재료 칸 — 이름은 **서버 `FRAME_MATERIAL_SUGGESTED` 와 같은 낱말**이라야 한다. */ +const FRAME_MATERIAL_FIELDS: ReadonlyArray = [ + ["각재 50×50", "B08_Quantity_Frame_Square", "0.0001"], + ["판재 T12", "B08_Quantity_Frame_Board", "0.0001"], + ["못", "B08_Quantity_Frame_Nail", "0.01"], +]; +/** 채워 보이는 **제안값**(실무 관측). ⚠ 법정 기준이 아니라 「고치라고 보이는 값」이다. */ +const FRAME_MATERIAL_SUGGESTED: Record = { + "각재 50×50": 0.0044, + "판재 T12": 0.0029, + 못: 0.03, +}; + /** 부대시설 항목 키 — 서버 `ANCILLARY_ITEMS` 와 **같은 차례·같은 낱말**이라야 한다. */ const ANCILLARY_KEYS = [ "national_point_sign", @@ -268,6 +282,8 @@ interface DraftSettings { ancillary_counts: Record; // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①). stand_volume_class: string; + // 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점. + frame_material: Record; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; dirty: boolean; @@ -552,6 +568,26 @@ function buildQuantitySidePanel( } panel.append(hintRow(L("B08_Quantity_Side_Ancillary_Hint"))); + // ── 규준틀 재료 — ⚠ **세는 것은 확정이고 수량만 모르던 자리**(품셈 [주]④ 「설계수량에 따른다」). + // 그래서 **제안값을 채워 보이고 고칠 수 있게** 둔다(확정 ⑨·⑩ 과 같은 틀). + // ⚠ 값만 박고 근거를 안 보이면 사용자 지시(「대신 페이지에 남길 것」)를 어기는 것이라 + // 칸 밑에 **관측값임**과 **손율 원문값**을 함께 적는다. + panel.append(field(L("B08_Quantity_Side_Frame"), "")); + for (const [key, label, step] of FRAME_MATERIAL_FIELDS) { + panel.append( + optionalNumberField( + L(label as keyof typeof ui_locales), + draft.frame_material[key] ?? FRAME_MATERIAL_SUGGESTED[key], + step, + (value) => { + draft.frame_material[key] = value; + draft.dirty = true; + }, + ), + ); + } + panel.append(hintRow(L("B08_Quantity_Side_Frame_Hint"))); + // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── panel.append(field(L("B08_Quantity_Side_Placing"), "")); panel.append( @@ -809,6 +845,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { structure_trench_water: (stored.structure_trench_water as string) ?? "", topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, stand_volume_class: (stored.stand_volume_class as string) ?? "", + frame_material: { ...((stored.frame_material ?? {}) as Record) }, ancillary_counts: { ...((stored.ancillary_counts ?? {}) as Record), }, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 3503cb28..805a72fe 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -135,6 +135,10 @@ def default_settings() -> dict[str, Any]: # [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다. # ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다. "stand_volume_class": None, + # 규준틀 개소당 재료 — `{자재명: 개소당 수량}`. **비우면 제안값(실무 관측)이 선다.** + # 품셈 11-2·11-3 [주]④ 가 「재료량은 설계수량에 따른다」라 값을 안 주는 자리이고, + # 세는 것 자체는 확정이라 **제안값 + 고칠 수 있게**로 둔다(확정 ⑨·⑩ 과 같은 틀). + "frame_material": {}, "dataset_versions": {}, }, "estimation": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ea2bb4c8..68a5d0a5 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -683,6 +683,14 @@ export const ui_locales_b2 = { "제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다", "Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey", ], + B08_Quantity_Side_Frame: ["규준틀 개소당 재료", "Batter Board Materials (per unit)"], + B08_Quantity_Side_Frame_Hint: [ + "⚠ 채워진 값은 실무 관측값(제안값)이고 법정 기준이 아닙니다 — 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둡니다. 손율은 원문값(비탈 50% · 수평 80%)", + "Pre-filled values are field observations, not a legal standard — the standard only says “per design quantity”", + ], + B08_Quantity_Frame_Square: ["각재 50×50 (㎥/개소)", "Square timber (㎥/ea)"], + B08_Quantity_Frame_Board: ["판재 T12 (㎥/개소)", "Board T12 (㎥/ea)"], + B08_Quantity_Frame_Nail: ["못 (㎏/개소)", "Nails (㎏/ea)"], B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], B08_Quantity_Side_Ancillary_Hint: [ "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)",