From 8eb1189b4fc01c5da9e227d48c68d801fb26d2a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:10:47 +0900 Subject: [PATCH 01/28] =?UTF-8?q?fix(b05,b06,b08):=20=EA=B8=B0=EC=8A=AD?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=EB=B2=BD=20=EB=86=92=EC=9D=B4=EB=A5=BC=20?= =?UTF-8?q?=EA=B4=80=EA=B2=BD=20=EA=B8=B0=EC=A4=80=20=ED=95=9C=20=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=C2=B7=20=ED=8F=BC=EC=9D=80=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=EC=9D=84=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=95=88=20=EB=B3=B4=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한 벽을 두고 값이 셋(등록부·표 H2.5 · 구조물도 H2.5 · 횡단도 관경 기준)이던 결함 — 근거 있는 관경 기준으로 맞춤 - 관 벽 높이 안 적으면 관경 + 여유 0.5 를 0.1m 로 올리고 근입 0.5 더함(Ø800 1.8 · Ø1000 2.0 · Ø1500 2.5) - 파이썬(B06 엔진)·TS(세트 스펙·횡단 상수) 거울 시험 · 등록부 기본 2.5 걷고 empty_means - 최소값이라 미확정·금액 밖은 그대로 - B05 벽 칸(관 유입·유출 · 독립 기슭막이)은 비워 두고 회색 제안값 · [제안값 넣기] 누른 때만 값 — 저장 한 번에 미확정이 확정으로 바뀌던 길을 막음 - 전체 시험: 받은 덤프 적재 커밋 몫 2개만 빨강(보고함) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Structure_Types.json | 10 +-- .../B05_Profile_UI_Drainage_Facility.ts | 7 ++- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 63 +++++++++++++------ B06_Section/B06_Section_Engine_Culvert.py | 34 ++++++++-- B08_Quantity/B08_Quantity_Engine_Pipe.py | 39 +++++++++--- common_util/common_util_culvert_sets.ts | 22 +++++-- .../test_b05_structures_registry_policy.py | 6 +- resources/tester/test_b06_culvert_set.py | 14 +++-- .../test_b06_pipe_wall_height_mirror.py | 43 +++++++++++++ .../tester/test_b08_facility_structures.py | 4 +- 10 files changed, 192 insertions(+), 50 deletions(-) create mode 100644 resources/tester/test_b06_pipe_wall_height_mirror.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 6362f9c3..9d7772ef 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -134,9 +134,10 @@ "label": "유입 기슭막이 높이", "input": "number", "unit": "m", - "default": 2.5, + "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" }, { "key": "outlet_type", @@ -186,9 +187,10 @@ "label": "유출 기슭막이 높이", "input": "number", "unit": "m", - "default": 2.5, + "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" }, { "key": "revet_foundation", diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 409ed2f1..bd9acc19 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -59,9 +59,9 @@ import { WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; +import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets"; -// 유입구 형식 목록은 700줄 한계로 `_Fields` 로 옮김(2026-09-14) — 부르던 곳은 그대로 여기서. -export type { InletStructureKind }; +export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로 /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ @@ -235,9 +235,11 @@ export function createFacilityOptionsForm( const basinRows = [ grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))), ]; + const height = (): string => String(pipeWallMinHeightM(Number(pipeDiameter.value) / 1000)); const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", + height, }); // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 // (2026-08-17 사용자 지시 3). @@ -260,6 +262,7 @@ export function createFacilityOptionsForm( const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)", + height, }); outletGroup.body.append( grid(labeled("구조", outletType), outletRevet.formField), diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 1b3eb9ce..36d3a3a6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -201,16 +201,20 @@ export interface RevetmentKeys { after?: string; } -/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */ +/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준 + * (함수로 받아 볼 때마다 셈). + * ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만 + * 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이 + * 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */ export interface RevetmentDefaults { form: string; length: string; - height: string; + height: string | (() => string); before: string; after: string; } -/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */ +/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */ export const REVET_COMMON_DEFAULTS = { length: "10", height: "2.5", @@ -284,26 +288,30 @@ export function createRevetmentFields( keys: RevetmentKeys, defaults: RevetmentDefaults, ): RevetmentFields { + // ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석). + const suggestHeight = (): string => + typeof defaults.height === "function" ? defaults.height() : defaults.height; const form = document.createElement("select"); - form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value))); - form.value = defaults.form; - const length = keys.length ? numberInput("0.1") : null; - if (length) length.value = defaults.length; - const height = numberInput("0.1"); - height.value = defaults.height; + form.replaceChildren( + new Option(`제안 ${defaults.form}`, ""), + ...REVET_FORMS.map((value) => new Option(value, value)), + ); + const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null; + const height = numberInput("0.1", "0", `제안 ${suggestHeight()}`); + // 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈. + height.addEventListener("focus", () => (height.placeholder = `제안 ${suggestHeight()}`)); // 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동: // 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다. - const before = keys.before && length ? numberInput("0.1") : null; - const after = keys.after && length ? numberInput("0.1") : null; - if (before) before.value = defaults.before; - if (after) after.value = defaults.after; + const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null; + const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null; let lastSplitEdit: "before" | "after" = "before"; const readNumber = (input: HTMLInputElement | null): number => { const value = input ? Number.parseFloat(input.value) : Number.NaN; return Number.isFinite(value) && value >= 0 ? value : 0; }; const syncSplit = (source: "length" | "before" | "after"): void => { - if (!length || !before || !after) return; + // 길이가 비었으면 전/후를 0 으로 채우지 않음 — 빈 칸이 「0 으로 적음」이 되면 안 됨. + if (!length || !before || !after || length.value === "") return; if (source !== "length") lastSplitEdit = source; const total = readNumber(length); const keep = lastSplitEdit === "after" ? after : before; @@ -319,10 +327,25 @@ export function createRevetmentFields( const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null; const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null; + // [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. + const fill = document.createElement("button"); + fill.type = "button"; + fill.className = "b05-structure__suggest"; + fill.textContent = "제안값 넣기"; + fill.addEventListener("click", () => { + if (!form.value) form.value = defaults.form; + if (length && !length.value) length.value = defaults.length; + if (!height.value) height.value = suggestHeight(); + if (before && !before.value) before.value = defaults.before; + if (after && !after.value) after.value = defaults.after; + form.dispatchEvent(new Event("change")); + }); + // 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다. const rows = [ grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)), ...(beforeField && afterField ? [grid(beforeField, afterField)] : []), + grid(fill), ]; return { @@ -330,13 +353,15 @@ export function createRevetmentFields( rows, lengthInput: length, write(options) { + // 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음). const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; - form.value = text(keys.form) || defaults.form; - if (length && keys.length) length.value = text(keys.length) || defaults.length; - height.value = text(keys.height) || defaults.height; - if (before && keys.before) before.value = text(keys.before) || defaults.before; - if (after && keys.after) after.value = text(keys.after) || defaults.after; + form.value = text(keys.form); + if (length && keys.length) length.value = text(keys.length); + height.value = text(keys.height); + height.placeholder = `제안 ${suggestHeight()}`; + if (before && keys.before) before.value = text(keys.before); + if (after && keys.after) after.value = text(keys.after); syncSplit("length"); }, read(target) { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 17f00ca6..f1a8bcd4 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -74,6 +74,26 @@ APRON_THICKNESS_M = 0.45 # 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다. REVET_FACE_SLOPE = 0.3 +# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts` +# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`). +# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정 +# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험 +# `test_b06_pipe_wall_height_mirror`. +REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값) +REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금 +REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이 + + +def pipe_wall_min_height_m(diameter_m: float) -> float: + """관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함. + + ⚠ 「최소」다 — 실제 설계값은 더 클 수 있어 이 값으로 선 수량은 미확정으로 둠(금액 밖). + """ + raw = float(diameter_m) + REVET_FREEBOARD_M + steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) + return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3) + + # (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지 # 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다. @@ -139,7 +159,9 @@ def _number(value: Any, fallback: float | None) -> float | None: return fallback -def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]: +def _side_spec( + options: dict[str, Any], defaults: dict[str, Any], side: str, diameter_m: float = 1.0 +) -> dict[str, Any]: """유입("inlet")·유출("outlet") 한쪽의 부속 제원. 구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 집수정 단면은 후속 작업이라 @@ -164,9 +186,10 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> ) return spec + # 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음). height = _number( options.get(f"{side}_revet_height_m"), - _number(defaults.get(f"{side}_revet_height_m"), None), + _number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)), ) length = _number( options.get(f"{side}_revet_length_m"), @@ -208,10 +231,11 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0) ) kind = values.get("pipe_kind") or defaults.get("pipe_kind") + diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3) return { "type": "pipe", "pipe_kind": str(kind) if kind else None, - "diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3), + "diameter_m": diameter_m, "min_cover_m": MIN_PIPE_COVER_M, # 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로 # 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것. @@ -219,8 +243,8 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip() or None ), - "inlet": _side_spec(values, defaults, "inlet"), - "outlet": _side_spec(values, defaults, "outlet"), + "inlet": _side_spec(values, defaults, "inlet", diameter_m), + "outlet": _side_spec(values, defaults, "outlet", diameter_m), } diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 4c4e1722..ddbde4aa 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -208,12 +208,15 @@ REVET_ROLES = ( ("inlet", "유입부 기슭막이", "유입 칸 벽"), ("outlet", "유출부 기슭막이", "유출 칸 벽"), ) -#: ⚠ 「횡단도와 같은 값」이 아님 — B06 은 관 벽 높이를 **관경 기준 최소 높이**로 따로 그림 -#: (`revetWallSpec`), 독립 기슭막이도 저장 높이가 없으면 근입 깊이로 그림. 형태·길이만 같은 기본값. +#: 관 벽 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(B06 `pipe_wall_min_height_m`, +#: TS 짝 `pipeWallMinPureHeight`)로 섬 — 등록부·구조물도·횡단도가 한 값(2026-09-14 브레인 판정). +#: ⚠ 「최소」라 실제 설계값이 더 클 수 있어 **미확정·금액 밖은 그대로**. NOTE_DEFAULT_WALL = ( - "⚠ 시설 지점에 {filled} 을 안 적어 등록부 기본값으로 섰음 — 미확정이라 금액에 안 들어감" - " · 횡단도 벽 높이는 관경 기준으로 따로 그려져 다를 수 있음" + "⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감" ) +#: 꼬리 — 관 벽은 횡단도와 같은 높이 · 독립 기슭막이는 아직 갈림(관경이 없음). +NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)" +NOTE_WALL_HEIGHT_OWN = " · ⚠ 횡단도는 저장 높이가 없으면 근입 0.5m 만 그림 — 표 높이와 다름" #: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦. UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬" NOTE_SIDE_UNKNOWN = ( @@ -234,9 +237,13 @@ def _num_or_zero(value: Any) -> float: def _revet_values( - options: dict[str, Any], role: str, defaults: dict[str, Any], legacy: bool + options: dict[str, Any], + role: str, + defaults: dict[str, Any], + legacy: bool, + min_height_m: float | None = None, ) -> tuple[dict[str, Any], list[str]]: - """벽 한쪽 제원과 등록부 기본값으로 채운 칸 — B06 세트 스펙(`_side_spec`)과 같은 채움.""" + """벽 한쪽 제원과 기본값으로 채운 칸 — 등록부 기본값 · 관 벽 높이는 관경 기준 최소 높이.""" values: dict[str, Any] = {} filled: list[str] = [] for name, label in REVET_FIELDS: @@ -245,12 +252,23 @@ def _revet_values( value = options.get(name) if _blank(value): value = defaults.get(name if legacy else f"{role}_revet_{name}") + if value is None and name == "height_m" and min_height_m is not None: + value = min_height_m + label = f"{label}(관경 기준)" if label and value is not None: filled.append(f"{label} {value}") values[name] = value return values, filled +def _pipe_diameter_m(options: dict[str, Any], defaults: dict[str, Any]) -> float: + """관경(m) — 저장값, 없으면 등록부 기본(B06 세트 스펙과 같은 채움).""" + raw = options.get("pipe_diameter_mm") + if _blank(raw): + raw = defaults.get("pipe_diameter_mm") + return _num_or_zero(raw) / 1000.0 or 1.0 + + def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: """계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14). @@ -260,6 +278,7 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: ⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감. """ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row @@ -304,11 +323,15 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: ) rows.extend(children) walls = [] + min_height = ( + None if legacy else pipe_wall_min_height_m(_pipe_diameter_m(options, wall_defaults)) + ) for role, pipe_label, own_label in roles: - values, filled = _revet_values(options, role, wall_defaults, legacy) + values, filled = _revet_values(options, role, wall_defaults, legacy, min_height) notes = list(inlet_notes) if role == "inlet" else [] if filled: - notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled))) + tail = NOTE_WALL_HEIGHT_OWN if legacy else NOTE_WALL_HEIGHT_PIPE + notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail) walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled]) side = str(options.get("side") or "") if legacy else "" if side in ("좌", "우"): diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 83a726e9..9144fa4d 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -46,6 +46,18 @@ export const FORD_WALL_THICKNESS_M = 0.2; export const FORD_DEFAULT_WIDTH_M = 10.0; export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0; export const BOX_COVER_M = 0.5; +/** 관 벽 최소 높이 — 횡단도 `B06_Section_UI_Cross_Culvert_Const.ts` 와 같은 값(거울 시험이 셋을 대조). + * 높이를 안 적은 관 벽은 이 값으로 섬(2026-09-14 브레인 판정 · 옛 등록부 기본 2.5 걷음). */ +export const REVET_FREEBOARD_M = 0.5; +export const REVET_HEIGHT_STEP_M = 0.1; +export const REVET_EMBED_DEPTH_M = 0.5; + +/** 관 벽 순수 높이(근입 포함) 최소값 — 파이썬 `pipe_wall_min_height_m`. */ +export function pipeWallMinHeightM(diameterM: number): number { + const raw = diameterM + REVET_FREEBOARD_M; + const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9); + return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3); +} /** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */ const FACILITY_PIPE = "pipe"; @@ -82,6 +94,7 @@ function sideSpec( options: Record, defaults: Record, side: string, + diameterM: number, ): CulvertSetSpec { const structure = text(options[`${side}_type`]) ?? text(defaults[`${side}_type`]) ?? "기슭막이"; const spec: CulvertSetSpec = { role: side, structure }; @@ -100,7 +113,7 @@ function sideSpec( const height = num( options[`${side}_revet_height_m`], - num(defaults[`${side}_revet_height_m`], null), + num(defaults[`${side}_revet_height_m`], pipeWallMinHeightM(diameterM)), ); const length = num( options[`${side}_revet_length_m`], @@ -133,17 +146,18 @@ function culvertSet( ): CulvertSetSpec { const defaults = registry.pipe ?? {}; const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0)); + const diameterM = pipeDiameterM(diameterMm); return { type: "pipe", pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind), - diameter_m: pipeDiameterM(diameterMm), + diameter_m: diameterM, min_cover_m: MIN_PIPE_COVER_M, // 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로 // 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이 // 확인되면 그때 나눌 것. foundation: text(options.revet_foundation) ?? text(defaults.revet_foundation), - inlet: sideSpec(options, defaults, "inlet"), - outlet: sideSpec(options, defaults, "outlet"), + inlet: sideSpec(options, defaults, "inlet", diameterM), + outlet: sideSpec(options, defaults, "outlet", diameterM), }; } diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index 0f5c2879..7b5ff97e 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -451,10 +451,12 @@ def test_pipe_accessory_options_defined(): "outlet_pitching_area_m2", ): assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다" - # 기본 치수 — 길이 10m·높이 2.5m. + # 기본 치수 — 길이 10m. 높이는 **기본값 없음**(2026-09-14 브레인 판정): 비우면 관경 기준 최소 + # 높이로 횡단도·구조물도·표가 같은 값을 씀(옛 2.5 는 횡단도가 안 쓰던 근거 없는 값이라 걷음). for side in ("inlet", "outlet"): assert options[f"{side}_revet_length_m"].default == 10 - assert options[f"{side}_revet_height_m"].default == 2.5 + assert options[f"{side}_revet_height_m"].default is None + assert "관경 기준" in options[f"{side}_revet_height_m"].empty_means # 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail. for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"): assert options[key].phase == "b05", f"pipe.{key}는 b05 phase" diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py index d5bf7c25..4a35b241 100644 --- a/resources/tester/test_b06_culvert_set.py +++ b/resources/tester/test_b06_culvert_set.py @@ -34,7 +34,11 @@ def test_cross_reference_constants_match_plan(): def test_empty_options_fall_back_to_registry_defaults(): - """옵션이 빈 저장분은 레지스트리 기본값(Ø1000·기슭막이 H2.5/L10)으로 채운다.""" + """옵션이 빈 저장분은 레지스트리 기본값(Ø1000·L10)으로 채운다. + + 높이는 2026-09-14 브레인 판정으로 **관경 기준 최소 높이**(Ø1000 → 1.5 + 근입 0.5 = 2.0) — + 횡단도가 그리는 그 높이. 옛 기본 2.5 는 횡단도가 안 쓰던 값이라 걷음. + """ spec = _culvert_set(None) assert spec["type"] == "pipe" assert spec["diameter_m"] == 1.0 @@ -42,11 +46,11 @@ def test_empty_options_fall_back_to_registry_defaults(): for side in ("inlet", "outlet"): part = spec[side] assert part["structure"] == "기슭막이" - assert part["revet_height_m"] == 2.5 + assert part["revet_height_m"] == 2.0 assert part["revet_length_m"] == 10 assert part["face_slope"] == REVET_FACE_SLOPE - # 보호공 = 낙차고(2.5) × 2 = 5.0m, 두께 0.45m(사용자 확정). - assert part["apron_length_m"] == 5.0 + # 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정). + assert part["apron_length_m"] == 4.0 assert part["apron_thickness_m"] == 0.45 # 레지스트리 기본 형태: 유입 찰 / 유출 메. assert spec["inlet"]["revet_form"] == "돌쌓기(찰)" @@ -61,7 +65,7 @@ def test_basin_inlet_skips_revet_and_apron(): assert "apron_length_m" not in spec["inlet"] # 유출측은 그대로 세트를 갖춘다. assert spec["outlet"]["structure"] == "기슭막이" - assert spec["outlet"]["apron_length_m"] == 5.0 + assert spec["outlet"]["apron_length_m"] == 4.0 # 관경 기준 높이 2.0 × 2 def test_string_diameter_and_custom_height(): diff --git a/resources/tester/test_b06_pipe_wall_height_mirror.py b/resources/tester/test_b06_pipe_wall_height_mirror.py new file mode 100644 index 00000000..a41970e0 --- /dev/null +++ b/resources/tester/test_b06_pipe_wall_height_mirror.py @@ -0,0 +1,43 @@ +"""관 벽 최소 높이 거울 — 파이썬(`B06_Section_Engine_Culvert`) ↔ TS(`_Cross_Culvert_Const.ts`). + +⚠ 횡단도(TS)가 그리는 높이와 구조물 표·구조물도 그림(파이썬)이 같은 벽을 다르게 그리면 결함 + (2026-09-14 브레인 판정). 상수 셋이 같고 식이 같으면 같은 값. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section import B06_Section_Engine_Culvert as culvert # noqa: E402 + +TS = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text(encoding="utf-8") +#: 세트 스펙 TS 짝(`common_util_culvert_sets.ts`)도 같은 상수를 들고 있음 — 셋을 함께 대조. +SETS = (ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8") + + +def _ts_const(source: str, name: str) -> float: + return float(re.search(rf"export const {name} = ([\d.]+);", source).group(1)) + + +def test_상수_셋이_TS_와_같다() -> None: + for name in ("REVET_FREEBOARD_M", "REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"): + assert getattr(culvert, name) == _ts_const(TS, name) == _ts_const(SETS, name), name + + +def test_TS_식_모양이_그대로다() -> None: + """식을 바꾸면 여기서 잡힘 — 파이썬 식도 함께 고칠 것.""" + assert "const raw = diameterM + REVET_FREEBOARD_M;" in TS + assert "Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M" in TS + assert "return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;" in TS + + +def test_관경별_최소_높이() -> None: + assert culvert.pipe_wall_min_height_m(1.0) == 2.0 + assert culvert.pipe_wall_min_height_m(0.8) == 1.8 + assert culvert.pipe_wall_min_height_m(1.2) == 2.2 diff --git a/resources/tester/test_b08_facility_structures.py b/resources/tester/test_b08_facility_structures.py index 6ad5f803..dec80ba1 100644 --- a/resources/tester/test_b08_facility_structures.py +++ b/resources/tester/test_b08_facility_structures.py @@ -34,7 +34,9 @@ def test_관_하나에_기슭막이_둘이_기본값_사유와_함께_서고_관 inlet, outlet = table["structures"] assert inlet["options"]["form"] == "돌쌓기(찰)" and outlet["options"]["form"] == "돌쌓기(메)" assert all(s["components"] and s["length_m"] == 10 for s in table["structures"]) - assert "등록부 기본값으로 섰음" in inlet["notes"][0] + assert "기본값으로 섰음" in inlet["notes"][0] + # 높이는 횡단도와 같은 관경 기준 최소 높이(Ø1000 → 2.0) — 그림 둘·표가 한 값(브레인 판정) + assert inlet["height_m"] == outlet["height_m"] == 2.0 and "관경 기준" in inlet["notes"][0] # 관 줄은 관 연장만 — 기슭막이가 거기 섞이지 않음(두 번 안 셈). pipe = build_rows([point], [{"chainage_m": 100.0, "design": {"pipe_length_m": 8}}]) assert [row["quantity"] for row in pipe["rows"]] == [8.0] From 7eecfacca3fc55cb273e18ebaf2f66bf1aa0e645 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:14:50 +0900 Subject: [PATCH 02/28] =?UTF-8?q?fix(b05):=20=EA=B8=B0=EB=B3=B8=EA=B0=92?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=80=20=E3=80=8C=EC=95=88=20=EC=A0=95=ED=95=A8=E3=80=8D?= =?UTF-8?q?=EC=9D=B4=20=EC=8B=A4=EC=A0=9C=20=EC=83=81=ED=83=9C=20=E2=80=94?= =?UTF-8?q?=20=EC=B2=AB=20=EC=84=A0=ED=83=9D=EC=A7=80=EB=A5=BC=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EA=B3=A0=EB=A5=B4=EC=A7=80=20=EC=95=8A=EC=9D=8C(?= =?UTF-8?q?=ED=8F=BC=20=E2=91=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 뿌리: 사용자가 고른 적 없는 첫 선택지가 저장돼 확정으로 굳음(옛 2026-08-17 「빈 보기 두지 않음」 걷음) - 구조물 목록 일반 폼: 기본값 없는 고르기 칸은 늘 빈 보기(필수 「— 선택 —」 · 아니면 「— 안 정함 —」) - 시설 폼 optionalSelect: 맨 앞 빈 보기(날개벽 「안 놓음」 등) — 기본값이 있는 칸은 다음 차례(②③) - ORCA 936be972 B05 관 폼: 날개벽 「안 놓음」 빈 값으로 섬 확인 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B05_Profile_UI_Drainage_Facility.ts | 2 +- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 12 +++++++---- .../B05_Profile_UI_Structures_Panel.ts | 20 ++++++++----------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index bd9acc19..a909b640 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -284,7 +284,7 @@ export function createFacilityOptionsForm( // 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다. // 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만 // 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다. - const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]); + const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]); revetSide.value = "양쪽"; const revetTopRow = grid(labeled("설치 측", revetSide)); const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)"); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 36d3a3a6..2116f882 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -153,14 +153,18 @@ export function putNumber(target: Record, key: string, if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); } -/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고, - * 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */ +/** 선택지 셀렉트 — **맨 앞에 빈 보기(「안 정함」)** 를 둠. 첫 보기를 미리 고르지 않음 + * (2026-09-14 브레인 판정 · 옛 2026-08-17 「빈 항목 두지 않음」 걷음) — 고른 적 없는 첫 값이 저장돼 + * 확정으로 굳지 않게. 기본값이 있는 칸은 부르는 쪽이 값을 넣음(그것은 따로 차례). */ export function optionalSelect( - _blankLabel: string, + blankLabel: string, choices: ReadonlyArray, ): HTMLSelectElement { const element = document.createElement("select"); - element.replaceChildren(...choices.map((choice) => new Option(choice, choice))); + element.replaceChildren( + new Option(blankLabel, ""), + ...choices.map((choice) => new Option(choice, choice)), + ); return element; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index b106e926..023e352b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -320,20 +320,16 @@ export function createStructuresSection( const preset = values[option.key] ?? option.default ?? ""; let input: HTMLInputElement | HTMLSelectElement; if (option.input === "select") { - // 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은 - // 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1). const choices = option.choices.map((choice) => [choice, choice] as [string, string]); - // 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다. - const mustPick = option.required === true && (option.default ?? "") === ""; - // **비워 두는 것이 뜻인 칸**(`empty_means`)도 같다 — 비면 계산 쪽이 기준값으로 도는데 - // 첫 보기가 골라져 저장되면 기준과 다른 값이 조용히 나간다(2026-09-13: - // `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). 기본값이 **있는** 칸은 - // 그대로 첫 보기를 쓴다 — 2026-08-17 「빈 보기 두지 않음」 지시가 그 자리다. - const meansEmpty = (option.empty_means ?? "") !== "" && (option.default ?? "") === ""; - const keepEmpty = mustPick || meansEmpty; - if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]); + // ⭐ 기본값 없는 고르기 칸은 **「안 정함」이 실제 상태** — 첫 보기를 미리 고르지 않음 + // (2026-09-14 브레인 판정, 옛 2026-08-17 「빈 보기 두지 않음」을 걷음). 사용자가 고른 적 + // 없는 첫 값이 저장돼 확정으로 굳는 뿌리였음(2026-09-13 `fill_concrete_mpa` 180 박힘도 + // 같은 병). 저장에는 빈 값이 가고 표가 사유를 보임. + if ((option.default ?? "") === "") { + choices.unshift(["", option.required === true ? "— 선택 —" : "— 안 정함 —"]); + } input = select(choices); - input.value = String(preset || (keepEmpty ? "" : (option.choices[0] ?? ""))); + input.value = String(preset); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); From eb14ec1c69d98f3c48f814b2f4699e2c28e9c196 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:14:56 +0900 Subject: [PATCH 03/28] =?UTF-8?q?refactor(b09):=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=9C=EC=B2=98=EB=A1=9C=20=EA=B0=80=EB=A6=84=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=9D=EC=9D=B4=20=EB=82=B8=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?(formula=5Fvariants)=20=C2=B7=20=EC=A7=9D=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9D=80=20=ED=91=9C=EC=97=90=EC=84=9C=20=EC=98=A8=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제외 목록 대신 갈래마다 어디서 왔나를 한 칸: 덤프 운반·적재 10-12 제목 → 그 식(브레인 판정) - 짝 시험: 표에서 온 갈래는 마스터 갈래 키에 있어야 함 · 식이 낸 갈래는 출처가 적혀 있고 마스터엔 없어야 함 - 마스터에는 「식 공종 갈래」 칸을 안 둠(표에서 뽑은 것만 담는 자리) - 전체 1771 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- ...B09_Estimation_MachineProductivity_Dump.py | 2 ++ B09_Estimation/B09_Estimation_UnitPrice.py | 4 ++++ .../tester/test_work_item_master_variants.py | 24 +++++++++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py index f79ff9b5..3b73a5b6 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py @@ -207,6 +207,7 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec f"×{_LOADING_E0[code]}/22 = {output}㎥/hr · {_LOADING_NOTES[code]}", output=output, ) + build.formula_variants[title_code] = "산림품셈 10-12 「1. 적재」 본문 식" if not distances_m: return hourly = f"X-{DUMP_TRUCK_CODE}" @@ -236,6 +237,7 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec haul.formula_text + " · " + " · ".join(material.notes), output=haul.hourly_output, ) + build.formula_variants[title_code] = "산림품셈 10-12 「2. 운반」 본문 식 · 운반거리" def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]: diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index cb750bf5..5544a6ec 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -141,6 +141,10 @@ class UnitPriceBuild: parent_modes: dict[str, str] = field(default_factory=dict) #: 합산형 부모 → (갈래, 근거) — 내역 줄의 갈래가 표에 없을 때 **원문이 정한** 갈래(9-4-1 [주]① 평균). default_variants: dict[str, tuple[str, str]] = field(default_factory=dict) + #: 갈래가 **어디서 왔나** — 표가 아니라 **식이 낸** 갈래 제목 → 그 식(2026-09-14 브레인 판정). + #: 표에서 온 갈래는 마스터 갈래 키에 있어야 하고(짝 시험), 식이 낸 갈래는 여기 적혀 있어야 함 + #: (덤프 운반·적재 10-12 `#L…m`·`#적재` — 본문 식이라 마스터 표에 갈래가 없음). + formula_variants: dict[str, str] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: diff --git a/resources/tester/test_work_item_master_variants.py b/resources/tester/test_work_item_master_variants.py index 9a46aa3d..0fb13ccc 100644 --- a/resources/tester/test_work_item_master_variants.py +++ b/resources/tester/test_work_item_master_variants.py @@ -18,14 +18,18 @@ def _nodes() -> dict[str, dict]: def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> None: + """갈래를 **어디서 왔나**로 가름(2026-09-14 브레인 판정) — 표에서 온 갈래만 마스터에 있어야 함. + + 식이 낸 갈래(덤프 운반·적재 10-12)는 마스터가 낼 수 없어 검사 대상이 아니되 + `formula_variants` 에 「식이 냈다」가 적혀 있어야 함(아래 짝 시험). + """ nodes = _nodes() missing = [] - for code in cached_build().book.titles: + build = cached_build(dump_haul_m=("164.23",)) + for code in build.book.titles: if not code.startswith("B-") or "#" not in code: continue - if code.startswith("B-FP-10-12-"): - # 덤프 운반·적재(10-12)는 **표가 아니라 본문 식**이라 마스터 표에 갈래가 없음 — - # 갈래(`#적재`·`#L…m`)는 식이 냄(2026-09-14 · `MachineProductivity_Dump`). + if code in build.formula_variants: continue work_item, variant = code[2:].split("#", 1) keys = { @@ -36,6 +40,18 @@ def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> N assert not missing, f"마스터를 다시 뽑을 것(B08_Quantity_Build_WorkItemMaster): {missing[:5]}" +def test_식이_낸_갈래는_출처가_적히고_마스터엔_없다() -> None: + build = cached_build(dump_haul_m=("164.23",)) + formula = build.formula_variants + assert "B-FP-10-12-01#적재" in formula and "B-FP-10-12-02#L164.23m" in formula + assert all(code in build.book.titles and why for code, why in formula.items()) + nodes = _nodes() + for code in formula: + work_item, variant = code[2:].split("#", 1) + keys = {normalize_variant_key(k) for k in nodes[work_item].get("variant_keys") or []} + assert variant not in keys # 마스터에 있으면 「표에서 옴」이라 여기 적을 까닭이 없음 + + def test_표_모양대로_갈래를_가르고_못_가르면_빈칸() -> None: nodes = _nodes() felling = nodes["FP-04-02-02"]["tables"][0]["variant_key"] From 6a427e78f9d19af88643c8433f96b1ebbee9597c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:14:57 +0900 Subject: [PATCH 04/28] auto: 2026-09-14 09:14 (ESD_LAPTOP) --- docs/raw/verification/2026-09-13_축C_명세.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index bcb21055..a9e4d7bd 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -358,7 +358,7 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 | 종목 | 단위 | 지위 | 비고 | 우리 실측과 | | --- | --- | --- | --- | --- | -| 설계서의 **총액** | 원 | **1,000** | 미만버림 | ⚠ **아직 확인 안 됨** — 데스크탑 서브가 재는 중 | +| 설계서의 **총액** | 원 | **1,000** | 미만버림 | ⚠ **우리는 안 버리고 있었음** — 실무 6건은 여섯 다 `000`, 우리는 `373·695·578·654·341·639`. 기본을 켜는 것으로 판정(2026-09-14) | | 설계서의 소계 | 원 | 1 | 미만버림 | ✅ | | 설계서의 **금액란** | 원 | 1 | 미만버림 | ✅ 「내역에 실릴 때 정수 절사」와 같음 | | **일위대가표의 계금** | 원 | 1 | 미만버림 | ✅ 「호표 안 성분 소계 절사」와 같음 | @@ -366,6 +366,15 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 ⇒ **STmate 를 재서 얻은 규칙이 법 조문이었음.** 경쟁사를 베낀 것이 아니라 둘 다 같은 고시를 따른 것임. +⚠ **총액 줄만 우리가 안 따르고 있었음** (2026-09-14 데스크탑 서브 실측). 기본값이 꺼져 있어(`cut_basis=""`) +실무 6건의 총공사비 끝 세 자리가 우리 엔진에서만 남음. **기본을 켜되 화면에 근거를 보일 것** — +2026-09-08 「조용히 깎지 않음」 합의는 근거가 없을 때 정한 것이고, 지금은 고시가 근거임. +곁에 버그 하나 — 절사 자동보정이 둘째 걸음부터 ÷1.1 을 안 해 경계를 지나쳐 **진동**함 +(울진 신설 `654→999→900→910→909…`). 다섯 건은 첫 걸음에 앉아 안 드러났음. + +⚠ 「설계서의 소계 1원 미만버림」과 우리 「집계표 행 금액 `ROUND`(279/279)」는 **다른 자리**로 봄 — +집계표는 설계서 소계가 아님. 같은 자리로 읽을 근거가 나오면 다시 잴 것. + **같은 고시 1-2-2 「1. 수량의 단위표준」 [주]**: ``` From 2744f1bcbb2e0db15bc43988dd2a9f1f1b038953 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:21:27 +0900 Subject: [PATCH 05/28] =?UTF-8?q?fix(b05,b08):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20=ED=8F=BC=EC=9D=B4=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EB=B6=80=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=84=20?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=95=88=20=EC=B1=84=EC=9B=80=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=9C=EC=95=88=EC=9C=BC=EB=A1=9C=EB=A7=8C(?= =?UTF-8?q?=ED=8F=BC=20=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칸에는 저장된 값만 · 기본값은 회색 글씨(숫자)·빈 보기 이름(고르기)으로 제안 · [제안값 넣기] 누른 때만 값 - 새 구조물 추가도 기본값을 안 실음(defaultOptions 로 채우던 자리) · 잠긴 칸(enabled:false)만 프로그램 값 유지 - C군(범위 계산 타입)은 길이처럼 높이도 비면 안 놓음 — 비면 횡단도 벽이 조용히 안 서던 길 - B08: 돌 조달을 안 정하면 「채집으로 셈」 사유를 보임(조용히 채집 되지 않게) - ORCA 936be972: 돌쌓기(메) 고르면 높이·길이·전·설치측 빔 + 제안 · 단추로 채워짐 확인(저장 안 함) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B05_Profile_UI_Structures_Fields.ts | 58 +++++++++++++++++++ .../B05_Profile_UI_Structures_Panel.ts | 40 +++++-------- .../B05_Profile_UI_Structures_Panel_Commit.ts | 7 +++ ...08_Quantity_Engine_UnitQuantity_Masonry.py | 5 ++ ..._Quantity_Engine_UnitQuantity_StoneSpec.py | 2 + 5 files changed, 87 insertions(+), 25 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 4950c574..5e695ecf 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -32,6 +32,64 @@ export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElem return element; } +/** 등록부 옵션 한 칸의 모양 — `StructureOptionField` 에서 이 칸이 쓰는 몫만. */ +interface OptionShape { + input: "select" | "number" | "text"; + choices: string[]; + default: string | number | null; + required?: boolean; +} + +/** + * 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임 + * (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래 + * 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨. + * 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음. + */ +export function optionControl( + option: OptionShape, + stored: string | number | undefined, +): HTMLInputElement | HTMLSelectElement { + const value = stored === undefined || stored === null ? "" : String(stored); + const suggested = option.default === null || option.default === "" ? "" : String(option.default); + if (option.input === "select") { + const blank = suggested + ? `— 안 정함 (제안 ${suggested}) —` + : option.required + ? "— 선택 —" + : "— 안 정함 —"; + const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]); + element.value = value; + return element; + } + const element = + option.input === "number" ? numberInput("0.1", "0") : document.createElement("input"); + if (option.input !== "number") element.type = "text"; + element.value = value; + element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : ""; + return element; +} + +/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */ +export function suggestButton( + entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>, +): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-structure__suggest"; + button.textContent = "제안값 넣기"; + button.addEventListener("click", () => { + const filled = entries.filter((entry) => entry.suggested && !entry.input.value); + filled.forEach((entry) => { + entry.input.value = entry.suggested; + entry.input.dispatchEvent(new Event("input", { bubbles: true })); + }); + // 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림. + filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true })); + }); + return button; +} + /** 측점번호 + 잔여거리 두 칸 묶음. */ export interface StationFields { wrap: HTMLElement; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 023e352b..21b86c87 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -24,7 +24,7 @@ import { } from "./B05_Profile_Api_Structures"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; -import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields"; +import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, @@ -316,32 +316,18 @@ export function createStructuresSection( return; } optionRow.hidden = false; + const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = + []; visible.forEach((option) => { - const preset = values[option.key] ?? option.default ?? ""; - let input: HTMLInputElement | HTMLSelectElement; - if (option.input === "select") { - const choices = option.choices.map((choice) => [choice, choice] as [string, string]); - // ⭐ 기본값 없는 고르기 칸은 **「안 정함」이 실제 상태** — 첫 보기를 미리 고르지 않음 - // (2026-09-14 브레인 판정, 옛 2026-08-17 「빈 보기 두지 않음」을 걷음). 사용자가 고른 적 - // 없는 첫 값이 저장돼 확정으로 굳는 뿌리였음(2026-09-13 `fill_concrete_mpa` 180 박힘도 - // 같은 병). 저장에는 빈 값이 가고 표가 사유를 보임. - if ((option.default ?? "") === "") { - choices.unshift(["", option.required === true ? "— 선택 —" : "— 안 정함 —"]); - } - input = select(choices); - input.value = String(preset); - } else if (option.input === "number") { - input = numberInput("0.1", "0"); - input.value = String(preset ?? ""); - if (option.required) (input as HTMLInputElement).placeholder = "필수 입력"; - } else { - input = document.createElement("input"); - (input as HTMLInputElement).type = "text"; - input.value = String(preset ?? ""); - } + // ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은 + // 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석). + const input = optionControl(option, values[option.key]); + suggestions.push({ input, suggested: String(option.default ?? "") }); const label = option.unit ? `${option.label} (${option.unit})` : option.label; - // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다. + // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다 + // (고를 수 없는 칸이라 제안이 아니라 프로그램 값). const locked = option.enabled === false; + if (locked && !input.value) input.value = String(option.default ?? ""); if (locked) { input.disabled = true; input.classList.add("is-locked"); @@ -386,6 +372,9 @@ export function createStructuresSection( input.addEventListener("change", () => refresh(true)); } }); + if (suggestions.some((entry) => entry.suggested)) { + optionRow.append(suggestButton(suggestions)); + } syncOptionLock(); syncRangeDisplay(); } @@ -673,7 +662,8 @@ export function createStructuresSection( endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step); memoField.value = ""; syncPlacementFields(); - renderOptionFields(type.managed_by ? undefined : defaultOptions(type)); + // 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②). + renderOptionFields(); // BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을 // 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지). syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {}); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index f9fd343e..7ef4bc87 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -231,6 +231,13 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); return; } + // 높이도 비면 안 놓음 — 칸이 비면 횡단도 벽이 조용히 안 서고 수량도 안 섬. 적거나 + // [제안값 넣기]를 누를 것(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 안 바꿈」). + const height = ctx.optionInputs.find((entry) => entry.key === "height_m"); + if (height?.isEmpty()) { + height.input.focus(); + return; + } const before = ctx.readBeforeM(); if (anchor - before < -0.005) { const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py index 8a981f6d..725b961a 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py @@ -23,6 +23,8 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( back_length_default_note, face_slope_ratio, fill_concrete_mpa, + STONE_SUPPLY_DEFAULT_NOTE, + STONE_SUPPLY_KEYS, is_collected_stone, load_stone_kind_table, stone_coefficients, @@ -487,6 +489,9 @@ def stone_masonry( # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** # ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고 # 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다. + if all(options.get(key) in (None, "") for key in STONE_SUPPLY_KEYS): + # 폼이 기본값을 값으로 안 보내게 된 뒤(2026-09-14) 빈 조달이 조용히 「채집」이 되지 않게. + notes.append(STONE_SUPPLY_DEFAULT_NOTE) if is_collected_stone(options): wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 collected = max(stone_pile + wedge + rubble, 0.0) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py index 05ecf4db..8e19a929 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py @@ -279,6 +279,8 @@ STONE_MASONRY = { #: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} +#: 조달을 안 정한 구조물의 사유 — 셈은 확정 ② 「기본은 캔다」대로 채집이되 **안 정한 사실**을 보임. +STONE_SUPPLY_DEFAULT_NOTE = "돌 조달(채집·구입)을 안 정해 「채집」으로 셈 — 사용자 확정 ② 기본 · 구조물 상세에서 고르면 바뀜" #: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 #: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). From fc2ecc0f39d87dde048ca7ed4c98f70179423060 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:28:06 +0900 Subject: [PATCH 06/28] =?UTF-8?q?fix(b05):=20=EA=B3=84=EA=B3=A1=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC=20=EC=8B=9C=EC=84=A4=20=ED=8F=BC=EC=9D=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=84=20=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=20=EB=B3=B4=EB=83=84=20=E2=80=94=20?= =?UTF-8?q?=EA=B4=80=EC=A2=85=C2=B7=EA=B4=80=EA=B2=BD=C2=B7=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=C2=B7=EC=A7=91=EC=88=98=EC=A0=95=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=C2=B7=EB=82=A0=EA=B0=9C=EB=B2=BD=C2=B7BOX=C2=B7=EC=9B=94?= =?UTF-8?q?=EB=A5=98=C2=B7=EC=84=A4=EC=B9=98=20=EC=B8=A1(=ED=8F=BC=20?= =?UTF-8?q?=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관종 파형강관·관경 1000 · 유입/유출 구조 기슭막이 · 집수정 길이 2 · 날개벽 있음·1·2·45 · BOX 2.0×2.0 · 월류 폭 5/10 · 월류 높이 필요 수심 · 독립 기슭막이 양쪽 — 전부 「안 정함 (제안 …)」·회색 글씨로만 - [제안값 넣기] 누른 때만 빈 칸에 제안값 · 적은 월류 높이가 필요 수심보다 낮으면 여전히 되돌림 - BOX암거 새로 놓을 때 기본값 싣던 자리도 걷음 · 비운 칸은 횡단도가 등록부 기본값으로 그림(저장만 안 함) - 700줄 한계로 물넘이·세월교 칸 묶음을 `_Drainage_Facility_Ford.ts` 로 뗌 - ORCA 936be972: 관종 「안 정함」 · 저장된 관경 1000 은 그대로 · 날개벽 고친 요청에 관경·날개벽만 실림 · 캐시 되돌림 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B05_Profile_UI_Drainage_Facility.ts | 274 ++++++------------ ...B05_Profile_UI_Drainage_Facility_Fields.ts | 37 ++- .../B05_Profile_UI_Drainage_Facility_Ford.ts | 151 ++++++++++ .../B05_Profile_UI_Structures_Panel.ts | 7 +- 4 files changed, 265 insertions(+), 204 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a909b640..a4f121fc 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -26,10 +26,6 @@ * 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다. * ========================================================================== */ -import { - FORD_BRIDGE_DEFAULT_WIDTH_M, - FORD_PAVEMENT_DEFAULT_WIDTH_M, -} from "@config/config_frontend"; import type { DetailPipeInput, PipeFacility, @@ -42,7 +38,6 @@ import { createRevetmentFields, createRevetSideGroup, createWingFields, - fordSection, grid, group, INLET_KINDS, @@ -60,6 +55,7 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets"; +import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford"; export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로 @@ -191,17 +187,13 @@ export function createFacilityOptionsForm( root.hidden = true; // ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ────────────── - // 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나. - const pipeMaterial = document.createElement("select"); - pipeMaterial.replaceChildren( - ...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)), - ); - pipeMaterial.value = "파형강관"; + // 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③). + const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]); const pipeDiameter = document.createElement("select"); pipeDiameter.replaceChildren( + new Option("— 안 정함 (제안 Ø1000) —", ""), ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), ); - pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17) // 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다 // (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다. const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]); @@ -210,11 +202,12 @@ export function createFacilityOptionsForm( // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── const inletGroup = group("유입구"); - // 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로 - // 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시). + // 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③). const inletType = document.createElement("select"); - inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label))); - inletType.value = INLET_KINDS[0].label; + inletType.replaceChildren( + new Option("— 안 정함 (제안 기슭막이) —", ""), + ...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)), + ); const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", @@ -230,12 +223,12 @@ export function createFacilityOptionsForm( ]); const basinMaterialField = labeled("형태", basinMaterial); // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). - const basinLength = numberInput("0.1"); - basinLength.value = "2"; + const basinLength = numberInput("0.1", "0", "제안 2"); const basinRows = [ grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))), ]; - const height = (): string => String(pipeWallMinHeightM(Number(pipeDiameter.value) / 1000)); + const height = (): string => + String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000)); const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", @@ -257,8 +250,7 @@ export function createFacilityOptionsForm( // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── const outletGroup = group("유출구"); - const outletType = document.createElement("select"); - outletType.replaceChildren(new Option("기슭막이", "기슭막이")); + const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]); const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)", @@ -284,8 +276,7 @@ export function createFacilityOptionsForm( // 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다. // 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만 // 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다. - const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]); - revetSide.value = "양쪽"; + const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]); const revetTopRow = grid(labeled("설치 측", revetSide)); const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)"); const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)"); @@ -323,12 +314,12 @@ export function createFacilityOptionsForm( // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). - const boxSize = optionalSelect("미지정", [ + const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [ ...BOX_SIZE_PRESETS.map(([label]) => label), BOX_SIZE_CUSTOM, ]); - const boxWidth = numberInput("0.1"); - const boxHeight = numberInput("0.1"); + const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`); + const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`); const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight)); const boxWrap = document.createElement("div"); boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow); @@ -343,106 +334,43 @@ export function createFacilityOptionsForm( boxWidth.value = String(preset[1]); boxHeight.value = String(preset[2]); } - boxCustomRow.hidden = !!preset; + // 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름). + boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM; } - // ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자 - // 지시) 수량만 따로 받는다. - const fordCount = numberInput("1", "1", "련"); - // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3). - // 련은 정수라 소수 자릿수를 두지 않는다. - const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0))); + // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만). + const ford = createFordFields(() => emit()); - // ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시). - // 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음). - // 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는 - // 있으나 계산값(필요 최소 수심) 미만은 되돌린다. - const fordWidth = numberInput("0.1"); - const fordHeight = numberInput("0.01"); - const fordWidthRow = grid( - labeled("월류 폭 (m)", stepper(fordWidth, 0.1)), - labeled("월류 높이 (m)", stepper(fordHeight, 0.1)), - ); - // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). - // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. - const fordSlope = numberInput("0.1"); - fordSlope.placeholder = "노면 기울기"; - // 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(2026-09-14 A3). - // 비우면 표가 안 서고 그 까닭이 뜸 — 지어내지 않는다. - const fordThickness = numberInput("1"); - const fordLength = numberInput("0.1"); - const fordSlopeRow = grid( - labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)), - labeled("포장 두께 (㎝)", stepper(fordThickness, 1, 0)), - labeled("포장 길이 노폭 방향 (m)", stepper(fordLength, 0.1)), - ); - const fordSummary = document.createElement("p"); - fordSummary.className = "b05-drainage__facility-note"; - /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ - let designFlowM3s: number | null = null; - /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ - let fordMinDepthM: number | null = null; - /** 직전에 자동으로 채워 넣은 필요 수심(m) — 칸이 아직 그 값이면 "자동"으로 보고 - * 월류 폭이 바뀔 때마다 계산값을 계속 따라가게 한다. 사용자가 다른 값을 넣으면 - * 그때부터 그 값이 이긴다(2026-08-30 사용자: 폭을 바꿔도 높이가 안 따라온다). */ - let fordAutoDepthM: number | null = null; - - function syncFordSummary(): void { - const section = - designFlowM3s !== null - ? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value)) - : null; - fordMinDepthM = section ? section.depthM : null; - if (section) { - // 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전 - // 자동값 그대로면 계산값으로 다시 채운다. - const min = Number(section.depthM.toFixed(2)); - const current = Number.parseFloat(fordHeight.value); - const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005; - if (!Number.isFinite(current) || current < min || untouched) - fordHeight.value = min.toFixed(2); - fordAutoDepthM = min; - } - if (designFlowM3s === null) { - fordSummary.textContent = - "담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다."; - return; - } - const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`; - if (!section) { - fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`; - return; - } - // 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다. - fordSummary.textContent = - `${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` + - `필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` + - `월류 높이는 필요 수심 이상만 입력됩니다.`; - } - - // 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다. - fordHeight.addEventListener("change", () => { - if (fordMinDepthM !== null) { - const min = Number(fordMinDepthM.toFixed(2)); - const value = Number.parseFloat(fordHeight.value); - if (!Number.isFinite(value) || value < min) { - fordHeight.value = min.toFixed(2); - fordHeight.classList.add("is-invalid"); - window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900); - } - } + // [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류). + // 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③). + const suggest = document.createElement("button"); + suggest.type = "button"; + suggest.className = "b05-structure__suggest"; + suggest.textContent = "제안값 넣기"; + suggest.addEventListener("click", () => { + if (!pipeMaterial.value) pipeMaterial.value = "파형강관"; + if (!pipeDiameter.value) pipeDiameter.value = "1000"; + if (!inletType.value) inletType.value = INLET_KINDS[0].label; + if (!outletType.value) outletType.value = "기슭막이"; + if (!basinLength.value) basinLength.value = "2"; + if (!revetSide.value) revetSide.value = "양쪽"; + if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0]; + wingInFields.fillSuggested(); + wingOutFields.fillSuggested(); + if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current); emit(); }); // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( + grid(suggest), pipeRow, wingRow, - fordWidthRow, - fordSlopeRow, - fordRow, - fordSummary, + ford.widthRow, + ford.slopeRow, + ford.countRow, + ford.summary, inletGroup.root, outletGroup.root, extraGroup.root, @@ -473,24 +401,23 @@ export function createFacilityOptionsForm( wingInFields.root.hidden = !hasWing; wingOutFields.root.hidden = !hasWing; const isFord = current === "ford_pavement" || current === "ford_bridge"; - fordRow.hidden = current !== "ford_bridge"; - fordWidthRow.hidden = !isFord; + ford.countRow.hidden = current !== "ford_bridge"; + ford.widthRow.hidden = !isFord; // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. - fordSlopeRow.hidden = current !== "ford_pavement"; - fordSummary.hidden = !isFord; + ford.slopeRow.hidden = current !== "ford_pavement"; + ford.summary.hidden = !isFord; const isRevet = current === "revetment"; revetTopRow.hidden = !isRevet; - // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다. - revetInlet.root.hidden = - !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet); - revetOutlet.root.hidden = - !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet); + // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임). + const side = revetSide.value || "양쪽"; + revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet); + revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet); revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`; revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`; // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; - if (isFord) syncFordSummary(); + if (isFord) ford.sync(); if (isBox) syncBoxSize(); if (hasWing) { wingInFields.syncVisibility(); @@ -509,12 +436,6 @@ export function createFacilityOptionsForm( } } - /** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */ - function putFordHeight(options: Record): void { - const value = Number.parseFloat(fordHeight.value); - if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2)); - } - function emit(): void { syncVisibility(); callbacks.onChange?.(); @@ -536,8 +457,7 @@ export function createFacilityOptionsForm( boxHeight, ...wingInFields.inputs, ...wingOutFields.inputs, - fordCount, - fordWidth, + ...ford.inputs, ].forEach((input) => input.addEventListener("change", emit)); /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ @@ -574,35 +494,36 @@ export function createFacilityOptionsForm( }, setFacility(facility, options = {}, designFlow = null) { current = facility; - designFlowM3s = designFlow ?? null; + ford.setDesignFlow(designFlow ?? null); if (facility === null) { syncVisibility(); return; } const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; - const isFord = facility === "ford_bridge"; - pipeMaterial.value = text("pipe_kind") || "파형강관"; - pipeDiameter.value = text("pipe_diameter_mm") || "1000"; + // ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③). + pipeMaterial.value = text("pipe_kind"); + pipeDiameter.value = text("pipe_diameter_mm"); // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. - const savedType = text("inlet_type") || "기슭막이"; + const savedType = text("inlet_type"); const savedStructure = text("inlet_structure"); - inletType.value = ( - INLET_KINDS.find( - (kind) => - kind.type === savedType && (!savedStructure || kind.structure === savedStructure), - ) ?? inletKindOf(savedType) - ).label; - outletType.value = text("outlet_type") || "기슭막이"; + inletType.value = savedType + ? ( + INLET_KINDS.find( + (kind) => + kind.type === savedType && (!savedStructure || kind.structure === savedStructure), + ) ?? inletKindOf(savedType) + ).label + : ""; + outletType.value = text("outlet_type"); wingWall.value = text("wing_wall_type"); basinForm.value = text("inlet_basin_form"); basinMaterial.value = text("inlet_basin_material"); - basinLength.value = text("inlet_basin_length_m") || "2"; + basinLength.value = text("inlet_basin_length_m"); inletRevet.write(options); outletRevet.write(options); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. - // 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다. - const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0]; + // 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만). const savedWidth = Number.parseFloat(text("body_width_m")); const savedHeight = Number.parseFloat(text("body_height_m")); const hasSaved = @@ -616,45 +537,38 @@ export function createFacilityOptionsForm( Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05, ) : undefined; - boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : firstLabel; - boxWidth.value = String(hasSaved ? savedWidth : firstWidth); - boxHeight.value = String(hasSaved ? savedHeight : firstHeight); + boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : ""; + boxWidth.value = hasSaved ? String(savedWidth) : ""; + boxHeight.value = hasSaved ? String(savedHeight) : ""; wingInFields.write(options); wingOutFields.write(options); - fordCount.value = isFord ? text("pipe_count") : ""; - fordWidth.value = text("ford_width_m"); - fordHeight.value = text("ford_height_m"); - // 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다. - fordAutoDepthM = null; - fordSlope.value = text("ford_slope_pct"); - fordThickness.value = text("thickness_cm"); - fordLength.value = text("length_m"); - revetSide.value = text("side") || "양쪽"; + if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options); + revetSide.value = text("side"); const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); revetOutlet.fields.write(spread); - // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처). - if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { - fordWidth.value = String( - facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M, - ); - } syncVisibility(); }, setDesignFlow(designFlow) { - designFlowM3s = designFlow ?? null; + ford.setDesignFlow(designFlow ?? null); syncVisibility(); }, readOptions() { const options: Record = {}; + // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). + const putPipe = (): void => { + if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value); + if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value; + }; if (current === "pipe") { - options.pipe_diameter_mm = Number(pipeDiameter.value); - options.pipe_kind = pipeMaterial.value; + putPipe(); const inletKind = inletKindOf(inletType.value); if (wingWall.value) options.wing_wall_type = wingWall.value; - options.inlet_type = inletKind.type; // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). - options.inlet_structure = inletKind.structure; + if (inletType.value) { + options.inlet_type = inletKind.type; + options.inlet_structure = inletKind.structure; + } if (inletKind.type === "집수정") { if (basinForm.value) options.inlet_basin_form = basinForm.value; if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; @@ -662,7 +576,7 @@ export function createFacilityOptionsForm( } else { inletRevet.read(options); } - options.outlet_type = outletType.value; + if (outletType.value) options.outlet_type = outletType.value; outletRevet.read(options); } else if (current === "box_culvert") { // 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다. @@ -671,22 +585,14 @@ export function createFacilityOptionsForm( wingInFields.read(options); wingOutFields.read(options); } else if (current === "ford_pavement") { - putNumber(options, "ford_width_m", fordWidth.value); - putFordHeight(options); - putNumber(options, "ford_slope_pct", fordSlope.value); - putNumber(options, "thickness_cm", fordThickness.value); - putNumber(options, "length_m", fordLength.value); + ford.read(current, options); } else if (current === "ford_bridge") { - options.pipe_kind = pipeMaterial.value; - options.pipe_diameter_mm = Number(pipeDiameter.value); - const count = Number.parseInt(fordCount.value, 10); - if (Number.isFinite(count) && count > 0) options.pipe_count = count; - putNumber(options, "ford_width_m", fordWidth.value); - putFordHeight(options); + putPipe(); + ford.read(current, options); wingInFields.read(options); wingOutFields.read(options); } else if (current === "revetment") { - options.side = revetSide.value; + if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 // 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제). // 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 2116f882..db569500 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -450,19 +450,18 @@ interface WingFields { syncVisibility: () => void; write: (options: Record) => void; read: (target: Record) => void; + /** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */ + fillSuggested: () => void; } /** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각. - * 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */ + * 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). + * ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */ export function createWingFields(title: string, keys: WingKeys): WingFields { - const install = optionalSelect("미지정", ["있음", "없음"]); - install.value = WING_DEFAULTS.install; - const height = numberInput("0.1"); - height.value = WING_DEFAULTS.height; - const length = numberInput("0.1"); - length.value = WING_DEFAULTS.length; - const angle = numberInput("1"); - angle.value = WING_DEFAULTS.angle; + const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]); + const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`); + const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`); + const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`); // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자). const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1)); @@ -481,7 +480,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields { dimsRow.hidden = off; } - // 만들자마자 기본값("있음") 상태를 맞춘다. syncVisibility(); return { @@ -492,18 +490,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields { write(options) { const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; - install.value = text(keys.install) || WING_DEFAULTS.install; - height.value = text(keys.height) || WING_DEFAULTS.height; - length.value = text(keys.length) || WING_DEFAULTS.length; - angle.value = text(keys.angle) || WING_DEFAULTS.angle; + install.value = text(keys.install); + height.value = text(keys.height); + length.value = text(keys.length); + angle.value = text(keys.angle); syncVisibility(); }, read(target) { - target[keys.install] = install.value; - if (install.value !== "있음") return; + if (install.value) target[keys.install] = install.value; + if (install.value === "없음") return; putNumber(target, keys.height, height.value); putNumber(target, keys.length, length.value); putNumber(target, keys.angle, angle.value); }, + fillSuggested() { + if (!install.value) install.value = WING_DEFAULTS.install; + if (!height.value) height.value = WING_DEFAULTS.height; + if (!length.value) length.value = WING_DEFAULTS.length; + if (!angle.value) angle.value = WING_DEFAULTS.angle; + syncVisibility(); + }, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts new file mode 100644 index 00000000..641479b5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Facility_Ford.ts + * 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄. + * + * 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14). + * ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」): + * 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은 + * **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값. + * 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음). + * ========================================================================== */ + +import { + FORD_BRIDGE_DEFAULT_WIDTH_M, + FORD_PAVEMENT_DEFAULT_WIDTH_M, +} from "@config/config_frontend"; +import { + fordSection, + grid, + labeled, + numberInput, + putNumber, + stepper, +} from "./B05_Profile_UI_Drainage_Facility_Fields"; + +type FordKind = "ford_pavement" | "ford_bridge"; + +export interface FordFields { + widthRow: HTMLElement; + slopeRow: HTMLElement; + countRow: HTMLElement; + summary: HTMLElement; + /** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */ + inputs: HTMLInputElement[]; + setDesignFlow: (designFlow: number | null) => void; + sync: () => void; + write: (kind: FordKind, options: Record) => void; + read: (kind: FordKind, target: Record) => void; + fillSuggested: (kind: FordKind) => void; +} + +export function createFordFields(emit: () => void): FordFields { + // 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다. + const count = numberInput("1", "1", "련"); + const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0))); + const width = numberInput("0.1"); + const height = numberInput("0.01"); + const widthRow = grid( + labeled("월류 폭 (m)", stepper(width, 0.1)), + labeled("월류 높이 (m)", stepper(height, 0.1)), + ); + // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). + // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. + const slope = numberInput("0.1"); + slope.placeholder = "노면 기울기"; + // 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3). + const thickness = numberInput("1"); + const length = numberInput("0.1"); + const slopeRow = grid( + labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)), + labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)), + labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)), + ); + const summary = document.createElement("p"); + summary.className = "b05-drainage__facility-note"; + let designFlowM3s: number | null = null; + /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ + let minDepthM: number | null = null; + + const suggestedWidth = (kind: FordKind): number => + kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M; + + function sync(): void { + const widthM = Number.parseFloat(width.value || width.dataset.suggested || ""); + const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null; + minDepthM = section ? Number(section.depthM.toFixed(2)) : null; + height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : ""; + if (designFlowM3s === null) { + summary.textContent = + "담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다."; + return; + } + const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`; + if (!section) { + summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`; + return; + } + // 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다. + summary.textContent = + `${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` + + `필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` + + `월류 높이는 필요 수심 이상만 입력됩니다.`; + } + + // 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸). + height.addEventListener("change", () => { + const value = Number.parseFloat(height.value); + if ( + minDepthM !== null && + height.value !== "" && + (!Number.isFinite(value) || value < minDepthM) + ) { + height.value = minDepthM.toFixed(2); + height.classList.add("is-invalid"); + window.setTimeout(() => height.classList.remove("is-invalid"), 900); + } + emit(); + }); + + return { + widthRow, + slopeRow, + countRow, + summary, + inputs: [count, width], + setDesignFlow(designFlow) { + designFlowM3s = designFlow ?? null; + }, + sync, + write(kind, options) { + const text = (key: string): string => + options[key] !== undefined ? String(options[key]) : ""; + count.value = kind === "ford_bridge" ? text("pipe_count") : ""; + width.value = text("ford_width_m"); + width.dataset.suggested = String(suggestedWidth(kind)); + width.placeholder = `제안 ${suggestedWidth(kind)}`; + height.value = text("ford_height_m"); + slope.value = text("ford_slope_pct"); + thickness.value = text("thickness_cm"); + length.value = text("length_m"); + }, + read(kind, target) { + putNumber(target, "ford_width_m", width.value); + // 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). + const value = Number.parseFloat(height.value); + if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2)); + if (kind === "ford_pavement") { + putNumber(target, "ford_slope_pct", slope.value); + putNumber(target, "thickness_cm", thickness.value); + putNumber(target, "length_m", length.value); + return; + } + const pipes = Number.parseInt(count.value, 10); + if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes; + }, + fillSuggested(kind) { + if (!width.value) width.value = String(suggestedWidth(kind)); + sync(); + if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 21b86c87..7d10dd28 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -14,7 +14,6 @@ * ========================================================================== */ import { - defaultOptions, isB05Option, structureAnchorM, type StructureInstance, @@ -664,9 +663,9 @@ export function createStructuresSection( syncPlacementFields(); // 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②). renderOptionFields(); - // BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을 - // 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지). - syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {}); + // BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음). + // 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함. + syncFacilityForm({}); // 임시 배치도 즉시 진행(A군). if (type.managed_by) commitTempPipe(); syncButtons(); From 0a7f4a5008d899847decc57d03e3168f03502cbb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:30:59 +0900 Subject: [PATCH 07/28] =?UTF-8?q?test(b05):=20=ED=8F=BC=20=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EC=8B=9C=ED=97=98=EC=9D=84=20=EC=83=88=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=97=90=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=EC=B9=B8=EC=9D=80=20=EB=8A=98=20=EB=B9=88?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EA=B8=B0=EB=B3=B8=EA=B0=92?= =?UTF-8?q?=EC=9D=80=20=EC=A0=9C=EC=95=88=EC=9C=BC=EB=A1=9C=EB=A7=8C(?= =?UTF-8?q?=ED=8F=BC=20=E2=91=A0=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 「기본값 있는 칸은 첫 보기」(2026-08-17)를 확인하던 줄을 브레인 판정(2026-09-14)대로 뒤집음 - 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨간 자리를 봄 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../tester/test_b05_empty_means_select.py | 25 +++++++++++-------- resources/tester/test_b06_detail_options.py | 9 ++++--- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/resources/tester/test_b05_empty_means_select.py b/resources/tester/test_b05_empty_means_select.py index 54a0a7e6..99e781a7 100644 --- a/resources/tester/test_b05_empty_means_select.py +++ b/resources/tester/test_b05_empty_means_select.py @@ -24,6 +24,9 @@ if str(ROOT) not in sys.path: from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8") +#: 2026-09-14 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨감(브레인 판정 ①② — 고르기 칸은 +#: 늘 빈 보기 · 기본값은 제안으로만). +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") API = (ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts").read_text(encoding="utf-8") COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text( encoding="utf-8" @@ -39,22 +42,24 @@ def _registry_options(type_id: str) -> dict[str, dict]: def test_폼이_empty_means_를_보고_빈_보기를_둔다() -> None: - """소스 검사 — 빈 보기와 초기값이 **같은 판정**(`keepEmpty`)을 쓴다.""" - assert "option.empty_means" in PANEL - assert "const keepEmpty = mustPick || meansEmpty;" in PANEL - assert 'if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);' in PANEL - # 빈 보기만 넣고 초기값을 첫 보기로 두면 화면은 「안 정함」인데 값은 첫 보기가 된다. - assert 'String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")))' in PANEL + """소스 검사 — 고르기 칸은 **늘** 빈 보기를 두고, 초기값은 저장된 값뿐(첫 보기를 안 박음).""" + assert "optionControl(option, values[option.key])" in PANEL + assert 'select([["", blank], ...option.choices' in FIELDS + # 초기값은 저장된 값만 — 첫 보기·기본값으로 채우지 않음. + assert "element.value = value;" in FIELDS + assert "option.choices[0]" not in PANEL # 클라이언트 옵션 타입에 칸이 있어야 서버 응답의 `empty_means` 가 살아 온다. assert "empty_means?: string | null;" in API # 빈 칸은 아예 안 실린다 — 이 규칙이 있어야 「안 정함」이 정본에 안 박힌다. assert "if (input.isEmpty()) return;" in COMMIT -def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None: - """`empty_means` 가 적혀 있어도 **기본값이 있으면** 그 값이 뜬다(2026-08-17 지시 유지).""" +def test_기본값_있는_칸도_빈_보기에_제안으로만_보인다() -> None: + """⭐ 2026-09-14 브레인 판정으로 뒤집힘 — 기본값이 **있어도** 칸에 채우지 않고 빈 보기 이름에 + 「제안 …」으로만 보임(옛 2026-08-17 「첫 보기가 곧 기본값」 걷음). 저장 한 번에 기본값이 + 확정으로 굳던 뿌리였음.""" options = _registry_options("masonry_wet") - # 버림 콘크리트 — empty_means 가 있으나 기본이 「넣음」이다. + # 버림 콘크리트 — empty_means 가 있고 기본이 「넣음」. assert options["blinding_concrete"]["empty_means"] assert options["blinding_concrete"]["default"] == "넣음" # 채움 강도·야면석 계수 — 기본이 없어 빈 보기가 서야 하는 칸. @@ -62,7 +67,7 @@ def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None: assert options[key]["empty_means"], key assert options[key]["default"] is None, key assert options[key]["required"] is False, key - assert '(option.default ?? "") === ""' in PANEL # 기본값 있으면 빈 보기를 안 둠 + assert "`— 안 정함 (제안 ${suggested}) —`" in FIELDS def _wall(**options) -> dict: diff --git a/resources/tester/test_b06_detail_options.py b/resources/tester/test_b06_detail_options.py index 29fb04a1..9de4c45f 100644 --- a/resources/tester/test_b06_detail_options.py +++ b/resources/tester/test_b06_detail_options.py @@ -39,10 +39,13 @@ def test_B05_는_종전대로_유무_종류_위치만(): def test_기본값_없는_필수_항목은_빈_칸으로_연다(): """첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다.""" panel = _PANEL.read_text(encoding="utf-8") - # 2026-09-13 — 빈 보기를 두는 까닭이 둘이 되어(필수 + `empty_means`) 한 줄로 합쳐졌다. + fields = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text( + encoding="utf-8" + ) + # 2026-09-14 — 칸 만들기가 `optionControl` 로 옮겨감 · 고르기 칸은 **늘** 빈 보기(브레인 판정 ①). # 필수 칸의 글자는 그대로 「— 선택 —」이다. - assert 'choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"])' in panel - assert 'option.required === true && (option.default ?? "") === ""' in panel + assert "optionControl(option, values[option.key])" in panel + assert '? "— 선택 —"' in fields and 'select([["", blank]' in fields def test_큰돌쌓기에_메_찰_구분이_있다(): From c4eccee51f15bb403e65714758cdd4463f3566c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:30:59 +0900 Subject: [PATCH 08/28] auto: 2026-09-14 09:30 (ESD_LAPTOP) --- B06_Section/B06_Section_UI_Page.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 621d7c6f..b4f5f165 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -166,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); + // 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음 + // (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」). + if (adjust?.h == null) return null; return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m) .pureHeight; }, @@ -180,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); + if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④) return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form; }, // 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. From d5c5fe7b40e14c816f06bc942d1f96f128d4e6af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:34:31 +0900 Subject: [PATCH 09/28] =?UTF-8?q?fix(b06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=EC=9D=B4=20=EC=8A=A4=ED=8E=99=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=EC=9D=84=20=EA=B4=80=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EC=97=90=20=EC=B1=84=EC=9A=B0=EC=A7=80=20=EC=95=8A=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=A1=B0=EC=9E=91=C2=B7=EC=A0=80=EC=9E=A5=EB=B6=84?= =?UTF-8?q?=EB=A7=8C(=ED=8F=BC=20=E2=91=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이지를 열기만 해도 관종·관경·구조·벽 길이·전/후·집수정 길이 기본값이 옵션에 박혀 임시 저장·[저장]에 확정으로 굳던 자리 - 벽 형태·높이는 조정창에서 조작했을 때만 싣음(앞 조각 c4eccee5 auto 커밋에 먼저 쓸려 올라감) - 세월교·BOX: 저장돼 있던 칸이거나 조정창 조작으로 등록부 기본값과 달라진 칸만 스펙 값을 싣음 - ORCA 936be972 B06: 관 폼 관종 「안 정함」 · 저장된 관경 1000 만 · 벽 칸 빔+제안 · 임시 저장에 기본값 안 생김 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B06_Section_UI_Page_Structures_Panel.ts | 183 +++++++++++------- 1 file changed, 117 insertions(+), 66 deletions(-) diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 4f53a3de..af64050f 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -31,6 +31,7 @@ import { writeStructurePick, } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { showToast } from "@ui/ui_template_elements"; +import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend"; import { adjustDockRoot, refreshAdjustSlots, @@ -125,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M; } +type Merged = Record; + +/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */ +function registryDefaults(types: StructureType[], typeId: string): Record { + const options = types.find((type) => type.type_id === typeId)?.options ?? []; + return Object.fromEntries(options.map((option) => [option.key, option.default])); +} + /** - * 세월교 폼 옵션을 **지금 그려진 스펙**(`section.ford`)으로 덮는다 — 조정창 조작이 - * 이 스펙을 제자리에서 고치므로, 폼이 저장된 옵션을 붙들면 두 창이 갈린다 - * (2026-08-30 사용자: 수량(련) 칸이 비어 있고 조정창은 1련이었다). + * 스펙 값 한 칸을 옵션에 싣는 규칙 — **저장돼 있던 칸이거나, 조작해 기본값과 달라진 칸만.** + * 기본값에서 온 값을 실으면 페이지를 열기만 해도 임시 저장·[저장]에 기본값이 확정으로 굳음 + * (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 바꾸지 않는다」). 폼은 빈 칸에 제안을 보임. + */ +function keepSpec( + merged: Merged, + stored: Merged | undefined, + key: string, + spec: string | number | null | undefined, + fromDefault: unknown, +): void { + if (spec === null || spec === undefined) return; + if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec; +} + +type WingSpec = { + installed: boolean; + height_m: number | null; + length_m: number | null; + angle_deg: number | null; +}; + +function keepWings( + merged: Merged, + stored: Merged | undefined, + defaults: Record, + wings: ReadonlyArray, +): void { + for (const [wing, prefix] of wings) { + // 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45. + const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음"; + keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); + keepSpec( + merged, + stored, + `${prefix}_height_m`, + wing.height_m, + defaults[`${prefix}_height_m`] ?? 0, + ); + keepSpec( + merged, + stored, + `${prefix}_length_m`, + wing.length_m, + defaults[`${prefix}_length_m`] ?? 0, + ); + keepSpec( + merged, + stored, + `${prefix}_angle_deg`, + wing.angle_deg, + defaults[`${prefix}_angle_deg`] ?? 45, + ); + } +} + +/** + * 세월교 폼 옵션 — **지금 그려진 스펙**(`section.ford`) 중 조작·저장분만 싣는다. 조정창 조작이 + * 스펙을 제자리에서 고치므로 그 값은 폼에 보여야 함(2026-08-30 사용자: 수량(련) 칸이 비고 조정창은 + * 1련) — 다만 1련이 **기본값에서 온 값이면** 제안으로만(판정 ④). */ function withFordSpec( - options: Record | undefined, + options: Merged | undefined, ford: NonNullable, -): Record { - const merged: Record = { ...(options ?? {}) }; - if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind; - merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000); - merged.pipe_count = ford.pipe_count; - merged.ford_width_m = ford.span_m; - for (const [wing, prefix] of [ + defaults: Record, +): Merged { + const merged: Merged = { ...(options ?? {}) }; + keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind); + const diameter = Math.round(ford.diameter_m * 1000); + keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000); + const count = Number(defaults.pipe_count ?? 0); + keepSpec( + merged, + options, + "pipe_count", + ford.pipe_count, + count > 0 ? Math.max(Math.trunc(count), 1) : 1, + ); + keepSpec( + merged, + options, + "ford_width_m", + ford.span_m, + defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M, + ); + keepWings(merged, options, defaults, [ [ford.wing_in, "wing_in"], [ford.wing_out, "wing_out"], - ] as const) { - merged[prefix] = wing.installed ? "있음" : "없음"; - if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m; - if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m; - if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg; - } + ]); return merged; } -/** - * BOX암거 폼 옵션을 **지금 그려진 스펙**(`section.box`)으로 덮는다 — 세월교와 같은 - * 규칙이다(2026-08-30 사용자). 본체 규격은 내공 폭·높이가 정본이고, 구체 길이·표고는 - * 조정 채널이라 여기 오지 않는다. - */ +/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */ function withBoxSpec( - options: Record | undefined, + options: Merged | undefined, box: NonNullable, -): Record { - const merged: Record = { ...(options ?? {}) }; - merged.body_width_m = box.inner_width_m; - merged.body_height_m = box.inner_height_m; - for (const [wing, prefix] of [ + defaults: Record, +): Merged { + const merged: Merged = { ...(options ?? {}) }; + keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2); + keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2); + keepWings(merged, options, defaults, [ [box.wing_in, "wing_in"], [box.wing_out, "wing_out"], - ] as const) { - merged[prefix] = wing.installed ? "있음" : "없음"; - if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m; - if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m; - if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg; - } + ]); return merged; } /** - * 관 지점 옵션에 **횡단 스펙 값**을 채워 넣는다 — 조정창이 보여주던 값과 폼이 - * 어긋나지 않게 한다(2026-08-29 사용자). 옵션에 이미 값이 있으면 그대로 두고, - * 비어 있을 때만 스펙(서버 계산·저장분)에서 가져온다. + * 관 지점 옵션에 **조정창에서 조작한 값**만 싣는다 — 조정창과 폼이 어긋나지 않게(2026-08-29 사용자). + * ⚠ 스펙의 기본값(관종·관경·구조·벽 길이·전/후·집수정 길이)은 **안 실음** — 페이지를 열기만 해도 + * 임시 저장에 기본값이 확정으로 굳던 자리(2026-09-14 브레인 판정 ④). 폼 빈 칸은 제안을 보임. */ function withSpecDefaults( - options: Record | undefined, + options: Merged | undefined, detail: SectionDetailResponse | null, chainageM: number, + types: StructureType[], wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null, wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null, -): Record | undefined { +): Merged | undefined { const owner = detail?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, ); - // 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든 - // 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는 - // 구조물 배치 상세 UI, 로직·값은 조정창 것). - if (owner?.ford) return withFordSpec(options, owner.ford); - if (owner?.box) return withBoxSpec(options, owner.box); + // 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30). + if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge")); + if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert")); const culvert = owner?.culvert; if (!culvert) return options; - const merged: Record = { ...(options ?? {}) }; - const put = (key: string, value: string | number | null | undefined): void => { - if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value; - }; - put("pipe_kind", culvert.pipe_kind); - put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000)); - for (const [side, prefix] of [ - [culvert.inlet, "inlet"], - [culvert.outlet, "outlet"], - ] as const) { - put(`${prefix}_type`, side.structure); - // 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다 - // 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자: - // 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). + const merged: Merged = { ...(options ?? {}) }; + for (const prefix of ["inlet", "outlet"] as const) { + // 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자: + // 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌. const drawnForm = wallForm?.(chainageM, prefix); if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm; - else put(`${prefix}_revet_form`, side.revet_form); const drawnHeight = wallHeight?.(chainageM, prefix); if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1)); - else put(`${prefix}_revet_height_m`, side.revet_height_m); - put(`${prefix}_revet_length_m`, side.revet_length_m); - put(`${prefix}_revet_before_m`, side.revet_before_m); - put(`${prefix}_revet_after_m`, side.revet_after_m); } - put("inlet_basin_length_m", culvert.inlet.basin_length_m); - put("inlet_basin_before_m", culvert.inlet.basin_before_m); - put("inlet_basin_after_m", culvert.inlet.basin_after_m); return merged; } @@ -374,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc pipe.options, detail, pipe.chainage_m, + types, deps.wallHeight, deps.wallForm, ), @@ -418,6 +468,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc hit.options, deps.detail?.() ?? null, hit.chainage_m, + markTypes, deps.wallHeight, deps.wallForm, ); From 996300752da4c088dfe0a702cebe7d45ccd52db4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:39:42 +0900 Subject: [PATCH 10/28] =?UTF-8?q?feat(b08):=20=EC=82=AC=ED=86=A0=20?= =?UTF-8?q?=EC=9A=B4=EB=B0=98=EC=97=90=EB=8F=84=20=E3=80=8C=EB=8D=A4?= =?UTF-8?q?=ED=94=84=20=EC=A0=81=EC=9E=AC=E3=80=8D=20=EC=A7=9D=20=EC=A4=84?= =?UTF-8?q?=20=C2=B7=20=EC=82=AC=ED=86=A0=20=EC=A4=84=EC=9D=B4=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=EB=A5=BC=20=EC=8B=A4=EC=96=B4=20=EB=B3=B4=EB=83=84=20?= =?UTF-8?q?=E2=80=94=20=EC=A2=85=EC=A0=84=20=EA=B0=88=EB=9E=98=20=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=8D=A4=ED=94=84=20?= =?UTF-8?q?=EC=9E=8E=EC=9D=84=20=EB=AA=BB=20=EA=B3=A0=EB=A5=B4=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_Handoff_Spoil.py | 36 ++++++++++++++++--- resources/tester/test_b08_spoil_haul.py | 31 ++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py index 38036c8e..98d3c228 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py @@ -23,6 +23,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( ORIGIN_HAUL, WorkItemMapping, ) +from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT SPOIL_NAME = "사토 운반" #: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을 @@ -127,8 +128,33 @@ def spoil_haul_rows( extra=GROUND_UNKNOWN, ) ) - return rows - return [_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)] + return _with_loading(rows) + return _with_loading( + [_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)] + ) + + +def _with_loading(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """사토도 싣는 일이 따로 — 흙깎기 운반과 같은 「덤프 적재」 짝 줄(10-12 「1. 적재」). + + 내역에 서는 운반 줄에만 붙음 · 거리 무관 · 수량은 운반량 그대로(2026-09-14 브레인 판정). + 운반토량 검산은 `dump_loading` 을 빼므로 두 번 안 셈. + """ + out: list[dict[str, Any]] = [] + for row in rows: + out.append(row) + if row["in_bill"]: + out.append( + { + **row, + "name": "덤프 적재", + "spec": row["ground_class"] or "", + "haul_distance_m": None, + "haul_equipment": LOADING_EQUIPMENT, + "spec_detail": f"{row['spec_detail']} · 사토량만큼 싣기(10-12 「1. 적재」)", + } + ) + return out def _spoil_row( @@ -165,8 +191,10 @@ def _spoil_row( "structure_kind": None, "blocked_kind": BLOCKED_INPUT_MISSING if blocked else None, "blocked_reason": reason, - "variant_axis": None, - "variant_value": None, + # 갈래는 `variant_value` 한 통로로 — 덤프 운반·적재 단가가 토사·암으로 갈림 + # (종전 None 이라 B09 가 10-12 잎을 못 골라 사토 운반이 「갈래 미지정」으로 막힘). + "variant_axis": "ground_class" if ground else None, + "variant_value": ground, "secondary_axes": None, "spec_class": None, "spec_class_basis": "", diff --git a/resources/tester/test_b08_spoil_haul.py b/resources/tester/test_b08_spoil_haul.py index 86bfc359..50c83b92 100644 --- a/resources/tester/test_b08_spoil_haul.py +++ b/resources/tester/test_b08_spoil_haul.py @@ -167,6 +167,37 @@ def test_사토도_자연상태로_선다() -> None: assert "자연상태" in rows[0]["spec_detail"] +def test_사토에도_적재_짝_줄이_서고_단가가_선다() -> None: + """사토도 싣는 일이 따로(10-12 「1. 적재」) — 내역에 서는 운반 줄에만 붙고, 검산엔 안 셈.""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import dump_haul_distances + from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + payload = build_handoff( + haul_table={ + "rows": [], + "spoil": { + "volume_m3": 100.0, + "distance_m": 800.0, + "by_ground_m3": {"ea_m3": 70.0}, + "ground_unknown_m3": 30.0, + }, + } + ) + rows = payload["work_items"] + loads = [row for row in rows if row["name"] == "덤프 적재"] + assert [(row["variant_value"], row["quantity"]) for row in loads] == [("토사", 70.0)] + assert loads[0]["haul_distance_m"] is None and loads[0]["haul_equipment"] == "dump_loading" + # 갈래 못 붙인 몫은 막힌 줄이라 짝 줄이 없음 + assert len([row for row in rows if row["name"] == "사토 운반"]) == 2 + + bill = build_bill(payload, build=cached_build(dump_haul_m=dump_haul_distances(payload))) + got = {row.name: row for row in bill.rows if row.name in ("사토 운반", "덤프 적재")} + assert got["사토 운반"].price_code.startswith("B-FP-10-12-01#L800") + assert got["덤프 적재"].price_code == "B-FP-10-12-01#적재" + assert got["사토 운반"].amount_krw > 0 and got["덤프 적재"].amount_krw > 0 + + def test_되돌린_값이_없으면_다짐_그대로_두고_사유() -> None: rows = 줄들(volume_m3=61.88, distance_m=800.0, by_ground_m3={"rr_m3": 61.88}) assert rows[0]["quantity"] == 61.88 From 273132f8f647bb9481076f6fc9bd873c58361249 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 10:03:21 +0900 Subject: [PATCH 11/28] auto: 2026-09-14 10:03 (ESD_LAPTOP) --- .../B09_Estimation_BillOfQuantities_Rows.py | 16 ++ .../B09_Estimation_MaterialPrices.py | 217 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 4 + .../B09_Estimation_Router_MaterialPrices.py | 102 ++++++++ .../B09_Estimation_UI_Tab_MaterialPrices.ts | 179 +++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 29 ++- main.py | 4 + resources/tester/test_b09_material_prices.py | 92 ++++++++ ui_template/ui_template_locale_b4.ts | 1 + 9 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MaterialPrices.py create mode 100644 B09_Estimation/B09_Estimation_Router_MaterialPrices.py create mode 100644 B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts create mode 100644 resources/tester/test_b09_material_prices.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index e18a4905..74ce9347 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -29,6 +29,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( dump_title_code, loading_title_code, ) +from B09_Estimation.B09_Estimation_MaterialPrices import manual_count from B09_Estimation.B09_Estimation_PriceBook import Money3 from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( @@ -148,6 +149,7 @@ def _composite_row( row.labor_krw = line.labor row.expense_krw = line.expense row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계") + _mark_manual_materials(row, [code for code, _ in row.parts], unit_prices, result) return row @@ -551,9 +553,23 @@ def _leaf_row( row.material_krw = line.material row.labor_krw = line.labor row.expense_krw = line.expense + _mark_manual_materials(row, [price_code], unit_prices, result) return row +def _mark_manual_materials( + row: BillRow, codes: list[str], unit_prices: UnitPriceBuild, result: BillResult +) -> None: + """자재 수동 단가가 닿은 줄 — 금액은 세우되 「미확정 N건」(구조물도 수동 단가와 같은 통로).""" + manual = getattr(unit_prices, "manual_materials", {}) + count = sum(manual_count(unit_prices.book, code, manual) for code in codes) + if not count: + return + row.unconfirmed += count + row.add_note("unit_price_krw", f"⚠ 자재 수동 단가 {count}건 미확정") + result.unconfirmed.append({"name": row.name, "code": codes[0], "count": count}) + + #: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다. #: #: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이 diff --git a/B09_Estimation/B09_Estimation_MaterialPrices.py b/B09_Estimation/B09_Estimation_MaterialPrices.py new file mode 100644 index 00000000..8cf7fcca --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialPrices.py @@ -0,0 +1,217 @@ +"""B09 원가계산 — **자재 수동 단가** 한 벌 (PLAN 1장 Ⓐ · 2026-09-14 브레인 판정). + + 저장 자리 산출 조건 `estimation.material_prices` + {키: {price_krw, source, entered_at, name, spec, unit}} + 키 자원 코드가 있으면 **코드**(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 + (명세 2장 「잇기는 코드로」 · 11장 「규격은 조인 키」) + ⚠ 이름·규격·단위는 **보이기용** — 맞추는 데 안 씀. 이름이 고쳐져도 단가가 안 끊김 + 얹는 자리 조립 때 자재 제목(M)을 세움 — 6번 슬롯 = 수동 단가 · 쪽수 칸 = 출처 + → 자원 축 자재 줄이 그 제목에 붙어 일위대가 재료비가 섬 + 넣은 날 값·출처가 같으면 다시 저장해도 안 바뀜(구조물도 수동 단가 `save_manual` 과 같은 꼴) + 미확정 수동 단가가 닿은 내역 줄마다 「미확정 N건」(구조물도 수동 단가·폐기물과 같은 통로) + +⚠ 값이 없거나 0 이하인 칸은 받지 않음 — 0 원으로 채우면 「단가 없음」이 금액 0 으로 숨음. +⚠ 코드 없는 「이름 규격」 줄은 자재총괄 몫(다음 단계) — 조립에는 코드 줄만 얹음. + 이름 줄에 나중에 코드가 붙으면 코드 키로 옮겨 붙일 자리 — 아직 안 만듦(브레인 판정). +""" + +from __future__ import annotations + +import json +import os +import re +from decimal import Decimal, InvalidOperation +from functools import lru_cache +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import ( + PRICE_SLOT_COUNT, + PriceBook, + PriceKind, + PriceTitle, +) + +MATERIAL_PRICES_KEY = "material_prices" +#: 조립에 얹는 키 — 자원 목록 자재 코드(명세 §2 ③ 모양). +_RE_MATERIAL_CODE = re.compile(r"^AR-M-[0-9a-f]{8}$") +#: 출처를 안 적었을 때 쪽수 칸 글. +MANUAL_SOURCE = "수동 입력" +_TEXT_FIELDS = ("source", "name", "spec", "unit") + + +class MaterialPriceError(ValueError): + """받을 수 없는 단가 — 저장하지 않고 까닭을 돌려줌.""" + + +def _price(value: Any) -> Decimal | None: + try: + price = Decimal(str(value).replace(",", "").strip()) + except (InvalidOperation, ValueError): + return None + return price if price.is_finite() and price > 0 else None + + +def is_code_key(key: str) -> bool: + """조립에 얹는 코드 키인가 — 아니면 「이름 규격」 키.""" + return bool(_RE_MATERIAL_CODE.match(key)) + + +def normalize(raw: Any) -> dict[str, dict[str, str]]: + """저장본을 받을 수 있는 줄만 — 값이 없거나 0 이하면 버림.""" + prices: dict[str, dict[str, str]] = {} + if not isinstance(raw, dict): + return prices + for key, entry in raw.items(): + price = _price(entry.get("price_krw")) if isinstance(entry, dict) else None + if not str(key).strip() or price is None: + continue + prices[str(key)] = { + "price_krw": str(price), + "entered_at": str(entry.get("entered_at") or ""), + **{field: str(entry.get(field) or "") for field in _TEXT_FIELDS}, + } + return prices + + +def build_key(raw: Any) -> tuple[tuple[str, str, str], ...]: + """조립 캐시 키 — 코드 키만 (키, 단가, 출처). 같은 값이면 같은 벌.""" + return tuple( + sorted( + (key, entry["price_krw"], entry["source"]) + for key, entry in normalize(raw).items() + if is_code_key(key) + ) + ) + + +def merge(stored: Any, changes: list[dict[str, Any]], today: str) -> dict[str, dict[str, str]]: + """바꿀 것 여럿을 한 번에 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감).""" + prices = normalize(stored) + for change in changes: + key = str(change.get("key") or "").strip() + if not key: + raise MaterialPriceError("자재 키가 비었습니다") + raw_price = change.get("price_krw") + if raw_price is None or str(raw_price).strip() == "": + prices.pop(key, None) + continue + price = _price(raw_price) + if price is None: + raise MaterialPriceError(f"단가는 0 보다 큰 수여야 합니다: {raw_price}") + old = prices.get(key) or {} + source = str(change.get("source") or "").strip() + same = old.get("price_krw") == str(price) and old.get("source") == source + prices[key] = { + "price_krw": str(price), + "entered_at": old["entered_at"] if same and old.get("entered_at") else today, + "source": source, + **{ + field: str(change.get(field) or old.get(field) or "") + for field in ("name", "spec", "unit") + }, + } + return prices + + +@lru_cache(maxsize=1) +def material_catalog_rows() -> dict[str, dict[str, str]]: + """자원 목록(`AR-M-`) 코드 → 이름·규격·단위 — 제목을 세우고 화면에 보일 글.""" + from B09_Estimation.B09_Estimation_ResourceAxis_Join import EXT_CATALOG_FILE + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + path = os.path.join(root, "resources", "data_resource_catalog", EXT_CATALOG_FILE) + if not os.path.isfile(path): + return {} + with open(path, encoding="utf-8") as handle: + entries = json.load(handle).get("entries") or [] + return { + str(row["code"]): { + "name": str(row.get("name") or ""), + "spec": str(row.get("spec") or ""), + "unit": str(row.get("unit") or ""), + } + for row in entries + if is_code_key(str(row.get("code") or "")) + } + + +def add_material_titles( + book: PriceBook, prices: tuple[tuple[str, str, str], ...] +) -> dict[str, str]: + """코드 키 수동 단가로 자재 제목(M)을 세움 — 돌려주는 값 = 코드 → 출처(미확정 셈에 씀). + + 자원 목록에 없는 코드는 안 세움 — 화면 목록이 「단가표에서 사라진 코드」로 드러냄(`listing`). + """ + catalog = material_catalog_rows() + manual: dict[str, str] = {} + for code, price, source in prices: + row = catalog.get(code) + if row is None or code in book.titles: + continue + slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT + pages: list[str | None] = [None] * PRICE_SLOT_COUNT + slots[-1] = Decimal(price) + pages[-1] = source or MANUAL_SOURCE + book.add_title( + PriceTitle( + code=code, + kind=PriceKind.MATERIAL, + name=row["name"], + spec=row["spec"], + unit=row["unit"], + slots=slots, + slot_pages=pages, + ) + ) + manual[code] = source or MANUAL_SOURCE + return manual + + +def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> int: + """그 단가가 밟는 **수동 단가 자재 수**(같은 자재는 한 번) — 내역 줄 「미확정 N건」.""" + if not code or not manual: + return 0 + found: set[str] = set() + seen: set[str] = set() + stack = [code] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + if current in manual: + found.add(current) + stack.extend(detail.ref_code for detail in book.details.get(current, [])) + return len(found) + + +def listing(build: Any, stored: Any) -> list[dict[str, Any]]: + """화면 줄 — 자원 축이 쓰는 자재(코드) + 저장만 있고 자원 축에서 사라진 줄. + + ⚠ 사라진 줄도 조용히 안 버림 — 「단가표에서 사라진 코드」로 보이고 사용자가 지움. + """ + prices = normalize(stored) + catalog = material_catalog_rows() + uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {} + rows: list[dict[str, Any]] = [] + for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)): + info = catalog.get(code, {}) + entry = prices.get(code) or {} + rows.append( + { + "key": code, + "name": info.get("name") or entry.get("name", ""), + "spec": info.get("spec") or entry.get("spec", ""), + "unit": info.get("unit") or entry.get("unit", ""), + "work_items": sorted(uses[code]), + "price_krw": entry.get("price_krw"), + "source": entry.get("source", ""), + "entered_at": entry.get("entered_at", ""), + "missing": False, + } + ) + for key, entry in sorted(prices.items()): + if key in uses or not is_code_key(key): + continue + rows.append({"key": key, **entry, "work_items": [], "missing": True}) + return rows diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 869e314e..041a9477 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -275,6 +275,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()): # 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」). # 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]). from B09_Estimation.B09_Estimation_Edits import edited_build, edits_key + from B09_Estimation.B09_Estimation_MaterialPrices import MATERIAL_PRICES_KEY + from B09_Estimation.B09_Estimation_MaterialPrices import build_key as material_prices_key args = ( ranges, @@ -288,6 +290,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()): # 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만. str(settings.get("operator_wage_digits") or ""), tuple(sorted(set(dump_haul_m), key=Decimal)), + # 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 조립에 얹음. 없으면 종전 벌 그대로. + material_prices_key(settings.get(MATERIAL_PRICES_KEY)), ) # 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로. return edited_build(args, edits_key(settings.get("edits"))) diff --git a/B09_Estimation/B09_Estimation_Router_MaterialPrices.py b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py new file mode 100644 index 00000000..234c9491 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py @@ -0,0 +1,102 @@ +"""B09 원가계산 라우터 — **자재 수동 단가** 읽기·저장 (PLAN 1장 Ⓐ · `…_MaterialPrices`). + +⚠ 저장은 단가·출처만 — 금액은 다음 조회 때 서버가 그 값으로 **다시 조립**함 + (브라우저 값을 받아 적지 않음). +""" + +from __future__ import annotations + +import logging +from datetime import date +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B09_Estimation.B09_Estimation_MaterialPrices import ( + MATERIAL_PRICES_KEY, + MaterialPriceError, + listing, + merge, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation Material Prices"]) + + +class MaterialPriceChange(BaseModel): + """단가 칸 하나 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감).""" + + key: str + price_krw: Any = None + source: str = "" + name: str = "" + spec: str = "" + unit: str = "" + + +class MaterialPriceRequest(BaseModel): + changes: list[MaterialPriceChange] = Field(default_factory=list) + + +async def _rows(project_id: UUID) -> dict[str, Any]: + from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of + from common_util.common_util_project_settings import estimation_settings + + root = await _project_root_of(project_id) + settings = estimation_settings(root) if root else {} + build = await _build_for(project_id) + rows = listing(build, settings.get(MATERIAL_PRICES_KEY)) + return { + "status": "success", + "rows": rows, + "unconfirmed_count": sum(1 for row in rows if row.get("price_krw")), + } + + +@router.get("/{project_id}/estimation/material-prices") +async def get_material_prices(project_id: UUID) -> JSONResponse: + """단가 칸을 낼 자재 줄 — 자원 축 자재(코드) + 자원 축에서 사라진 저장 줄.""" + try: + return JSONResponse(content=await _rows(project_id)) + except Exception: + logger.exception("B09 자재 수동 단가 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "자재 단가 목록을 못 만들었습니다."}, + ) + + +@router.put("/{project_id}/estimation/material-prices") +async def put_material_prices(project_id: UUID, payload: MaterialPriceRequest) -> JSONResponse: + """단가 저장 — 0 이하·수가 아닌 값은 받지 않음 · 값·출처가 같으면 넣은 날 그대로.""" + from B09_Estimation.B09_Estimation_Router import _project_root_of + from common_util.common_util_project_settings import estimation_settings, save_section + + root = await _project_root_of(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + try: + prices = merge( + estimation_settings(root).get(MATERIAL_PRICES_KEY), + [change.model_dump() for change in payload.changes], + date.today().isoformat(), + ) + except MaterialPriceError as error: + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + try: + save_section( + root, "estimation", {MATERIAL_PRICES_KEY: prices}, replace_keys=(MATERIAL_PRICES_KEY,) + ) + return JSONResponse(content=await _rows(project_id)) + except Exception: + logger.exception("B09 자재 수동 단가 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "자재 단가를 저장하지 못했습니다."}, + ) diff --git a/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts new file mode 100644 index 00000000..4c6e66e5 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts @@ -0,0 +1,179 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_MaterialPrices.ts + * 자재 단가 탭 — 자재 수동 단가 칸 (PLAN 1장 Ⓐ · 랩탑 메인 · 2026-09-14 브레인 판정) + * + * - 줄 = 일위대가 자원 축이 쓰는 자재(코드 `AR-M-…`) · 코드 · 명칭 · 규격 · 단위 · 쓰인 공종 · + * 단가 · 출처 · 넣은 날. 넣은 줄 = 빨간 테두리(수동 단가 = 미확정 · 구조물도·폐기물과 같은 꼴). + * - [저장] = 바뀐 줄만 서버로. 단가 칸을 비우면 그 줄을 지움(단가 없음으로 돌아감) · 0 이하는 서버가 거절. + * - ⚠ 금액은 서버가 다시 조립 — 여기서 곱하지 않음. 저장 뒤 내역서·일위대가 탭은 새로 받음. + * - 자원 축에서 사라진 코드의 저장 줄도 조용히 안 버림 — 경고와 함께 보이고 지울 수 있음. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { L, el, hint, injectSheetStyles, plainTable, won } from "./B09_Estimation_UI_Sheet"; + +interface MaterialPriceRow { + key: string; + name: string; + spec: string; + unit: string; + work_items: string[]; + price_krw: string | null; + source: string; + entered_at: string; + missing: boolean; +} + +interface MaterialPricesDto { + status: string; + message?: string; + rows: MaterialPriceRow[]; + unconfirmed_count: number; +} + +interface Change { + key: string; + price_krw: string; + source: string; + name: string; + spec: string; + unit: string; +} + +async function request(projectId: string, init: RequestInit = {}): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/material-prices`, + { credentials: "include", ...init }, + ); + const body = (await response.json()) as MaterialPricesDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +function input(value: string, width: string, placeholder = ""): HTMLInputElement { + const node = el("input", "b09s-edit-input"); + node.value = value; + node.placeholder = placeholder; + node.style.width = width; + return node; +} + +function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): void { + const drafts: Array<{ + row: MaterialPriceRow; + price: HTMLInputElement; + source: HTMLInputElement; + }> = []; + const save = el("button", "b09s-undo", "저장"); + save.type = "button"; + const bar = el("div", "b09s-bar"); + bar.append(el("span", "b09s-title", L("B09_Estimation_Tab_MaterialPrices")), save); + if (data.unconfirmed_count > 0) { + bar.append(el("span", "b09s-badge", `수동 단가 ${data.unconfirmed_count}건 — 미확정`)); + } + + const { wrap, tbody } = plainTable([ + "코드", + "명칭", + "규격", + "단위", + "쓰인 공종", + "단가(원)", + "출처", + "넣은 날", + ]); + for (const row of data.rows) { + const tr = el("tr", row.price_krw ? "is-manual" : ""); + const price = input(row.price_krw ?? "", "96px", "비움 = 단가 없음"); + price.inputMode = "decimal"; + price.title = row.price_krw ? won(row.price_krw) : ""; + const source = input(row.source, "160px", "견적 업체·물가지 쪽"); + const priceCell = el("td", "b09s-num"); + priceCell.append(price); + const sourceCell = el("td"); + sourceCell.append(source); + tr.append( + el("td", "", row.key), + el("td", "", row.name), + el("td", "", row.spec), + el("td", "", row.unit), + el( + "td", + "b09s-note", + row.missing + ? "⚠ 단가표에서 사라진 코드 — 단가를 비우고 저장해 지움" + : row.work_items.join(", "), + ), + priceCell, + sourceCell, + el("td", "", row.entered_at), + ); + tbody.append(tr); + drafts.push({ row, price, source }); + } + + save.addEventListener("click", () => { + const changes: Change[] = drafts + .filter( + ({ row, price, source }) => + price.value.trim() !== (row.price_krw ?? "") || source.value.trim() !== row.source, + ) + .map(({ row, price, source }) => ({ + key: row.key, + price_krw: price.value.trim(), + source: source.value.trim(), + name: row.name, + spec: row.spec, + unit: row.unit, + })); + if (changes.length === 0) return; + save.disabled = true; + request(projectId, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ changes }), + }) + .then((next) => { + showToast(L("B09_Sheet_Saved"), "success"); + ctx.body.replaceChildren(); + draw(ctx, projectId, next); + }) + .catch((error: Error) => { + save.disabled = false; + showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"); + }); + }); + + ctx.body.append(bar, wrap); + if (data.rows.length === 0) ctx.body.append(hint("일위대가가 쓰는 자재 코드가 없음")); + ctx.body.append( + hint( + "수동 단가는 6번 슬롯(적용 단가)으로 서고, 닿은 내역 줄마다 「미확정」으로 셈 — 자재단가대비표에도 보임", + ), + hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"), + ); +} + +export const materialPricesTab: B09Tab = { + key: "material_prices", + label: () => L("B09_Estimation_Tab_MaterialPrices"), + render(ctx) { + injectSheetStyles(); + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + const projectId = ctx.projectId; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + request(projectId) + .then((data) => { + ctx.body.replaceChildren(); + draw(ctx, projectId, data); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 5544a6ec..01ec0a06 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -145,6 +145,10 @@ class UnitPriceBuild: #: 표에서 온 갈래는 마스터 갈래 키에 있어야 하고(짝 시험), 식이 낸 갈래는 여기 적혀 있어야 함 #: (덤프 운반·적재 10-12 `#L…m`·`#적재` — 본문 식이라 마스터 표에 갈래가 없음). formula_variants: dict[str, str] = field(default_factory=dict) + #: 수동 단가로 선 자재 제목 → 출처 — 내역 줄 「미확정 N건」(PLAN 1장 Ⓐ). + manual_materials: dict[str, str] = field(default_factory=dict) + #: 자원 축 자재 코드 → 쓰는 공종들 — 「자재 단가」 탭이 단가 칸을 낼 줄. + material_uses: dict[str, list[str]] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: @@ -622,6 +626,7 @@ def build_unit_prices( labor_surcharge_choices: dict[str, str] | None = None, operator_wage_digits: int = 0, dump_haul_m: tuple[Decimal, ...] = (), + material_prices: tuple[tuple[str, str, str], ...] = (), ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -788,6 +793,15 @@ def build_unit_prices( build.incomplete_machines = _add_machine_layers( build.book, machine_codes, fuel_region, operator_wage_digits ) + # 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 제목으로 섬. 안 넣으면 자재 줄은 종전처럼 안 붙음. + from B09_Estimation.B09_Estimation_MaterialPrices import add_material_titles + + build.manual_materials = add_material_titles(build.book, material_prices) + for row in axis.rows: + if row.resource_kind == "material": + uses = build.material_uses.setdefault(row.resource_code, []) + if row.work_item_code not in uses: + uses.append(row.work_item_code) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. @@ -834,10 +848,7 @@ def build_unit_prices( # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). - attachable = [ - (row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}") - for row in rows - ] + attachable = [(row, _ref_of(row)) for row in rows] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] # ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히 # 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(2026-09-13 축 C). @@ -1093,6 +1104,11 @@ def _has_full_formula( ) +def _ref_of(row) -> str: + """자원 줄이 붙을 단가표 제목 — 노임·자재는 코드 그대로, 기계는 시간당 층(`X-`).""" + return f"X-{row.resource_code}" if row.resource_kind == "machine" else row.resource_code + + def _share_of(row) -> Decimal: """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" ratio = getattr(row, "group_ratio_pct", None) @@ -1114,8 +1130,7 @@ def _covered_ratio_pct( ratio = getattr(row, "group_ratio_pct", None) if ratio is None or ratio in seen: continue - ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" - if ref in attached_refs: + if _ref_of(row) in attached_refs: seen.add(ratio) covered += ratio return covered @@ -1177,6 +1192,7 @@ def cached_build( labor_surcharge: tuple[tuple[str, str], ...] = (), operator_wage_digits: str = "", dump_haul_m: tuple[str, ...] = (), + material_prices: tuple[tuple[str, str, str], ...] = (), ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -1205,6 +1221,7 @@ def cached_build( labor_surcharge_choices=dict(labor_surcharge), operator_wage_digits=parse_operator_wage_digits(operator_wage_digits), dump_haul_m=tuple(Decimal(value) for value in dump_haul_m), + material_prices=material_prices, ) diff --git a/main.py b/main.py index a682f00f..ad281e75 100644 --- a/main.py +++ b/main.py @@ -71,6 +71,9 @@ from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router +from B09_Estimation.B09_Estimation_Router_MaterialPrices import ( + router as b09_material_prices_router, +) from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -648,6 +651,7 @@ app.include_router(b09_execution_router, dependencies=protected_with_company) app.include_router(b09_progress_router, dependencies=protected_with_company) app.include_router(b09_edits_router, dependencies=protected_with_company) app.include_router(b09_factors_router, dependencies=protected_with_company) +app.include_router(b09_material_prices_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py new file mode 100644 index 00000000..2524a8d2 --- /dev/null +++ b/resources/tester/test_b09_material_prices.py @@ -0,0 +1,92 @@ +"""자재 수동 단가(PLAN 1장 Ⓐ · 2026-09-14 브레인 판정). + +지키는 것 + ① 키 = 코드(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 — 조립엔 코드 키만 얹음 + ② 0 이하·수 아님은 안 받음 · 비우면 지움 · 값·출처가 같으면 넣은 날 그대로 + ③ 넣으면 자재 제목(6번 슬롯)이 서고 자원 축 자재 줄이 붙어 재료비가 섬 — 안 넣으면 종전 벌 그대로 + ④ 수동 단가가 닿은 내역 줄 = 금액은 서되 「미확정 N건」 +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_MaterialPrices import ( # noqa: E402 + MaterialPriceError, + build_key, + listing, + merge, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + +STAKE = "AR-M-dce99e46" # 말뚝 직경4~6㎝ — FP-05-15 에 2개/단위 + + +def test_키와_넣은_날() -> None: + first = merge({}, [{"key": STAKE, "price_krw": "1,500", "source": "견적 A"}], "2026-09-14") + assert first[STAKE]["price_krw"] == "1500" and first[STAKE]["entered_at"] == "2026-09-14" + same = merge(first, [{"key": STAKE, "price_krw": "1500", "source": "견적 A"}], "2026-09-20") + assert same[STAKE]["entered_at"] == "2026-09-14" # 다시 저장해도 안 바뀜 + moved = merge(first, [{"key": STAKE, "price_krw": "1600", "source": "견적 A"}], "2026-09-20") + assert moved[STAKE]["entered_at"] == "2026-09-20" + assert merge(first, [{"key": STAKE, "price_krw": ""}], "2026-09-20") == {} + for bad in ("0", "-3", "abc"): + with pytest.raises(MaterialPriceError): + merge({}, [{"key": STAKE, "price_krw": bad}], "2026-09-14") + named = merge(first, [{"key": "각재 50×50", "price_krw": "900000"}], "2026-09-14") + assert build_key(named) == ((STAKE, "1500", "견적 A"),) # 이름 키는 조립에 안 얹음 + + +def test_넣으면_재료비가_서고_안_넣으면_종전_그대로() -> None: + base = cached_build() + assert base.book.resolve("B-FP-05-15").material == 0 + assert STAKE not in base.book.titles and not base.manual_materials + built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) + title = built.book.titles[STAKE] + assert (title.slots[5], title.slot_pages[5], title.unit) == (Decimal("1500"), "견적 A", "개") + assert built.book.resolve("B-FP-05-15").material == Decimal("3000") # 2개 × 1,500 + assert built.book.resolve("B-FP-05-15").labor == base.book.resolve("B-FP-05-15").labor + assert "FP-05-15" in built.material_uses[STAKE] + + +def test_내역_줄은_미확정으로_선다() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) + payload = { + "work_items": [ + { + "work_item_code": "FP-05-15", + "name": "말뚝박기", + "unit": built.book.titles["B-FP-05-15"].unit, + "quantity": 10, + "in_bill": True, + } + ], + "materials": [], + } + row = next(r for r in build_bill(payload, build=built).rows if r.name == "말뚝박기") + assert row.material_krw == Decimal("30000") and row.unconfirmed == 1 + result = build_bill(payload, build=built) + assert result.unconfirmed == [{"name": "말뚝박기", "code": "B-FP-05-15", "count": 1}] + plain = build_bill(payload, build=cached_build()) + assert not plain.unconfirmed + + +def test_목록은_자원_축_자재와_사라진_저장_줄() -> None: + built = cached_build() + stored = { + STAKE: {"price_krw": "1500"}, + "AR-M-00000000": {"price_krw": "7", "name": "옛 자재"}, + } + rows = {row["key"]: row for row in listing(built, stored)} + assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500" + assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림 + assert rows["AR-M-b0853497"]["price_krw"] is None diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index fa7bafaa..2a8405ae 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -12,6 +12,7 @@ export const ui_locales_b4 = { B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"], B09_Estimation_Tab_Progress: ["기성", "Progress Payment"], B09_Estimation_Tab_Completion: ["준공", "Completion"], + B09_Estimation_Tab_MaterialPrices: ["자재 단가", "Material Prices"], B08_Quantity_Side_BenchCut_Fill: [ "제안값 넣기 — KDS 표준치수 토사 0.5 m", "Insert suggested value — KDS standard soil 0.5 m", From 57a949c6243a5cb9daba9f726bcb78223f18b8f3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 10:12:54 +0900 Subject: [PATCH 12/28] =?UTF-8?q?feat(b09):=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=ED=83=AD=20=EB=93=B1=EB=A1=9D=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=90=EC=9E=AC=EB=8B=A8=EA=B0=80=EB=8C=80=EB=B9=84?= =?UTF-8?q?=ED=91=9C=20=EC=98=86(=EC=9E=90=EC=9E=AC=20=EC=88=98=EB=8F=99?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=20=C2=B7=20=EB=9E=A9=ED=83=91=5F=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20273132f8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B09_Estimation/B09_Estimation_UI_Shell.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 8134ec0d..5b40dc7d 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -20,6 +20,7 @@ import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice"; import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis"; import { machineTab } from "./B09_Estimation_UI_Tab_Machine"; import { priceCompareTab } from "./B09_Estimation_UI_Tab_PriceCompare"; +import { materialPricesTab } from "./B09_Estimation_UI_Tab_MaterialPrices"; import { summaryTab } from "./B09_Estimation_UI_Tab_Summary"; import { listsTab } from "./B09_Estimation_UI_Tab_Lists"; import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc"; @@ -40,6 +41,7 @@ const TABS: B09Tab[] = [ priceBasisTab, machineTab, priceCompareTab, + materialPricesTab, // 랩탑_메인 — 자재단가대비표 옆(자재 수동 단가) summaryTab, listsTab, supplyTab, From e6e6f9b7d5b503ecbb197414e9d8c8145295fd20 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 10:41:19 +0900 Subject: [PATCH 13/28] =?UTF-8?q?feat(b08,b09):=20=EC=82=AC=EA=B8=89=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=EC=B4=9D=EA=B4=84=20=EC=A4=84=EC=9D=B4=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20=EB=8B=A8=EA=B0=80=EB=A1=9C=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=B3=B8=EC=B2=B4=20=E3=80=8C=EC=9E=90=EC=9E=AC(?= =?UTF-8?q?=EC=82=AC=EA=B8=89)=E3=80=8D=20=EC=A4=84=EC=9D=B4=20=EB=90=A8?= =?UTF-8?q?=20=C2=B7=20=EA=B8=B0=EB=B3=B8=20=EC=82=AC=EA=B8=89=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EC=A4=91=EA=B3=84=EC=83=81=20=EA=B0=80=EB=93=9C(?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9A=B0=EC=84=A0)=20=C2=B7=20=EA=B7=9C?= =?UTF-8?q?=EC=A4=80=ED=8B=80=20=EC=9E=AC=EB=A3=8C=20=EC=9D=B4=EB=A6=84/?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=20=EA=B0=80=EB=A6=84(=ED=95=A0=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=81=EC=9E=AC=205=C2=B7=ED=8C=90=EC=9E=AC=2010)=20=C2=B7?= =?UTF-8?q?=20=ED=95=A0=EC=A6=9D=20=EB=B3=B4=EB=A5=98=C2=B7=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=97=86=EC=9D=8C=20=EC=82=AC=EC=9C=A0=20=C2=B7=20?= =?UTF-8?q?=ED=95=A0=EC=A6=9D=20=E3=80=8C=EC=9D=B4=EB=82=B4=E3=80=8D=20?= =?UTF-8?q?=EC=B9=B8=20=EC=84=9C=EB=B2=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_MaterialSummary.py | 59 ++++++++- .../B08_Quantity_Engine_Preparation.py | 7 +- B08_Quantity/B08_Quantity_Router_Earthwork.py | 3 + B08_Quantity/B08_Quantity_Router_Material.py | 3 + .../B09_Estimation_BillOfQuantities.py | 12 +- ...9_Estimation_BillOfQuantities_Materials.py | 120 ++++++++++++++++++ .../B09_Estimation_BillOfQuantities_Rows.py | 17 ++- .../B09_Estimation_MaterialPrices.py | 45 +++++-- .../B09_Estimation_MaterialSheet.py | 40 +++++- B09_Estimation/B09_Estimation_Router.py | 11 +- .../B09_Estimation_Router_MaterialPrices.py | 11 +- .../B09_Estimation_UI_Tab_MaterialPrices.ts | 48 +++++-- .../data_material_surcharge/_manifest.json | 4 +- .../material_surcharge_2026-01-01.json | 8 +- resources/tester/test_b08_frame_material.py | 17 ++- resources/tester/test_b08_handoff.py | 7 +- resources/tester/test_b08_material_summary.py | 43 +++++-- resources/tester/test_b09_material_prices.py | 74 ++++++++++- 18 files changed, 468 insertions(+), 61 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index b1471b29..5e025581 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -92,6 +92,10 @@ SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을 NOTE_RATE_MISSING = "할증률 미확보" NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함" +#: ⚠ **안 정한 자재는 사급으로 선다**(2026-09-14 브레인 판정 ⓐ — 「기본 사급 · 관급은 프로젝트 +#: 설정이 정함」 · 품셈 제1장 「관계규정이나 계약조건에 따른다」). 종전 `unknown` 은 어느 합계에도 +#: 안 들어 자재비가 통째로 빠져 있었음. 명시적으로 `unknown` 을 고른 줄은 그대로 미정. +NOTE_SUPPLY_DEFAULT = "관급구분 기본 사급 — 관급이면 산출 조건에서 고름" def _latest_dataset_path(directory: Path | None = None) -> Path | None: @@ -109,6 +113,9 @@ class SurchargeTable: effective_date: str = "" source: dict[str, Any] = field(default_factory=dict) rates: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 율이 없는 **까닭** — 자재 → 사유. 보류(조건이 다름)와 이름 없음을 줄에 드러냄. + #: ⚠ 「할증률 미확보」만 적으면 「아직 안 찾음」과 「찾았으나 일부러 안 걺」이 안 갈림. + missing_reasons: dict[str, str] = field(default_factory=dict) def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]: """(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**""" @@ -138,10 +145,25 @@ def load_surcharge_table(path: Path | None = None) -> SurchargeTable: for row in payload.get("rates_pct", []) if row.get("material") is not None and row.get("rate") is not None } + reasons = { + str(item.get("material")): ( + f"조건이 다름 — 사용자 확정 후 옮길 것 · 원문 「{item.get('listed_condition')}」" + f" {item.get('rate')}% · {item.get('why_not_applied')}" + ) + for item in (payload.get("candidates_pending_user") or {}).get("items") or [] + if item.get("material") + } + reasons.update( + { + str(name): "할증률표에 이름 없음(산림·건설 품셈 1-3-1 확인) — 임의로 안 정함" + for name in (payload.get("not_found") or {}).get("materials") or [] + } + ) return SurchargeTable( effective_date=str(payload.get("effective_date") or ""), source=payload.get("source") or {}, rates=rates, + missing_reasons=reasons, ) @@ -154,7 +176,10 @@ class MaterialRow: spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ) net_amount: float = 0.0 # 순수량 — 할증 전 surcharge_pct: float | None = None # None = 미확보 + surcharge_cap: float | None = None # 할증률표 값(1-3-1 「이내」의 상한) — 고른 값의 천장 supply: str = SUPPLY_UNKNOWN + #: 산출 조건에 안 적어 **기본 사급**으로 선 줄 — 그 사실을 비고에 적음. + supply_default: bool = False install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다 surcharge_included: bool = False # 품셈에 이미 포함 basis: str = "" @@ -187,6 +212,8 @@ class MaterialRow: parts.append(self.basis) if self.supply == SUPPLY_OWNER and self.install_by is None: parts.append(NOTE_INSTALL_BY_MISSING) + if self.supply_default: + parts.append(NOTE_SUPPLY_DEFAULT) return " · ".join(parts) @@ -286,6 +313,19 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any: return None +def _chosen_rate(cap: float | None, raw: Any) -> tuple[float | None, str]: + """(쓸 할증률, 사유) — 고른 값은 0 ~ 표값(상한)만. 표에 없으면 고른 값도 안 씀(임의 금지).""" + if raw is None or str(raw).strip() == "" or cap is None: + return cap, "" + try: + value = float(raw) + except (TypeError, ValueError): + return cap, f"고른 할증률 「{raw}」 을 수로 못 읽어 표값 {cap:g}% 씀" + if not 0 <= value <= cap: + return cap, f"고른 할증률 {value:g}% 는 표값 {cap:g}% 이내가 아니라 안 씀(1-3-1 「이내」)" + return value, f"산출 조건에서 고른 값 {value:g}% (표 상한 {cap:g}% 이내)" + + #: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈). UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)" @@ -320,11 +360,16 @@ def build_table( supply_map: dict[str, Any] | None = None, extra_materials: Iterable[dict[str, Any]] = (), concrete_placing_method: str | None = None, + surcharge_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. `extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다. 모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`). + + `surcharge_overrides` — `{이름 규격: %}` 산출 조건에서 고른 할증률. 품셈 1-3-1 + 「표의 값 **이내**」라 **0 ~ 표값만** 받음 · 표에 없는 자재·할증 포함 줄엔 안 걺 + (넘으면 안 쓰고 사유). """ table = surcharge_table or load_surcharge_table() rows, skipped = _collect(unit_quantity_table) @@ -341,9 +386,14 @@ def build_table( missing_rate: list[str] = [] missing_supply: list[str] = [] missing_install_by: list[str] = [] + chosen_rates = surcharge_overrides or {} for row in rows.values(): name = row.name - row.supply, row.install_by = _supply_of(_supply_setting(supply, row)) + setting = _supply_setting(supply, row) + row.supply, row.install_by = ( + _supply_of(setting) if setting is not None else (SUPPLY_CONTRACTOR, None) + ) + row.supply_default = setting is None if row.supply == SUPPLY_UNKNOWN: missing_supply.append(row.supply_key) # ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다. @@ -353,8 +403,11 @@ def build_table( continue lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method) rate, basis = table.rate_for(lookup) + row.surcharge_cap = rate + rate, chosen_note = _chosen_rate(rate, chosen_rates.get(row.supply_key)) row.surcharge_pct = rate - row.basis = " · ".join(part for part in (basis, alias_note) if part) + reason = table.missing_reasons.get(lookup, "") if rate is None else "" + row.basis = " · ".join(part for part in (basis, alias_note, chosen_note, reason) if part) if rate is None: missing_rate.append(name) @@ -379,6 +432,8 @@ def build_table( "unit": row.unit, "net_amount": row.net_amount, "surcharge_pct": row.surcharge_pct, + # 고를 수 있는 천장(표값) — `None` 이면 칸이 안 섬(표에 없거나 할증 포함 줄). + "surcharge_cap": None if row.surcharge_included else row.surcharge_cap, "total_amount": row.total_amount, "supply": row.supply, "supply_label": SUPPLY_LABELS.get(row.supply, row.supply), diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index c2ab2276..198369a8 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -523,6 +523,9 @@ FRAME_MATERIAL_SOURCE = ( " 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음" ) FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80} +#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음 +#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로. +FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")} def frame_material_rows( @@ -549,9 +552,11 @@ def frame_material_rows( except (TypeError, ValueError): per_ea = float(default) picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)" + material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, "")) rows.append( { - "name": name, + "name": material, + "spec": spec, "unit": unit, "amount": float(count) * per_ea, "destination": "material", diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 5cbd698b..64202a35 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -432,6 +432,8 @@ class QuantitySettingsBody(BaseModel): # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). material_supply: dict[str, Any] | None = None + # 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름). + material_surcharge: dict[str, Any] | None = None # 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다. concrete_placing_method: str | None = None # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). @@ -574,6 +576,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - ( "rock_methods", "material_supply", + "material_surcharge", "concrete_placing_method", "topsoil_target", "tree_waste", diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 2b767788..93999bf5 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -186,6 +186,7 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: material_table = build_material_table( unit_table, supply_map=settings.get("material_supply") or {}, + surcharge_overrides=settings.get("material_surcharge") or {}, # 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥). concrete_placing_method=settings.get("concrete_placing_method"), ) @@ -439,6 +440,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: material_table = build_material_table( unit_table, supply_map=settings.get("material_supply") or {}, + surcharge_overrides=settings.get("material_surcharge") or {}, concrete_placing_method=settings.get("concrete_placing_method"), ) @@ -455,6 +457,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: material_table = build_material_table( unit_table, supply_map=settings.get("material_supply") or {}, + surcharge_overrides=settings.get("material_surcharge") or {}, concrete_placing_method=settings.get("concrete_placing_method"), extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}), ) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 86b1c528..85079cbb 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -428,12 +428,17 @@ def build_bill( master: dict[str, Any] | None = None, structure_prices: dict[str, dict[str, Any]] | None = None, rates: dict[str, Any] | None = None, + material_prices: dict[str, Any] | None = None, ) -> BillResult: """인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다. `structure_prices` — 구조물도 호표 금액 `{B-AX-ST-…#키: …}`(B08 `structure_bill_prices`, **이 `build` 단가표로** 셈). 안 주면 호표 줄은 금액 없이 사유와 함께 섬. + `material_prices` — 「자재 단가」 탭 저장본. 사급 자재총괄 줄이 이 값으로 본체 「자재」 줄이 됨. """ + from B09_Estimation.B09_Estimation_MaterialPrices import normalize as normalize_prices + + manual_prices = normalize_prices(material_prices) work_items, materials = parse_handoff(payload) unit_prices = build or cached_build() master = master or load_work_item_master() @@ -576,7 +581,7 @@ def build_bill( # ── 3) 자재 벌 ──────────────────────────────────────────────────────────── for material in materials: - result.material_rows.append(_material_row(material, result)) + result.material_rows.append(_material_row(material, result, manual_prices)) # ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ────────────────────── check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded]) @@ -649,7 +654,12 @@ def build_bill( result.material_sheet = build_material_sheet( materials, surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"), + manual=manual_prices, ) + # 사급·수동 단가 자재 → 본체 「자재(사급)」 줄(이중계상 가드 · PLAN 1장 Ⓐ-2). + from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import raise_material_rows + + raise_material_rows(result, unit_prices, next_number, bill_line) # ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13). from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py new file mode 100644 index 00000000..62d6b98e --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py @@ -0,0 +1,120 @@ +"""B09 원가계산 — 사급 자재총괄 줄을 **내역 본체 「자재」 줄**로 (PLAN 1장 Ⓐ-2 · 브레인 판정). + +셈부터 함(배정 프로젝트 자재총괄 9줄): ㉠ 일위대가를 거쳐 이미 내역에 선 자재 0 · ㉡ 일위대가에 안 +드는 자재 9 · ㉢ ㉠ 의 할증 0 — 품셈 표에 재료 줄이 없는 공종(규준틀·찰쌓기·타설)의 자재라 따로 +계상하는 것이 실무 모양. 본체 줄로 서므로 도급 재료비·계약·기성·집계가 고칠 곳 없이 그 줄을 셈. + + 올리는 줄 사급 · 수동 단가가 선 줄만(미확정 1건) — 관급은 수량표에만(도급 금액 밖) + ⚠ 이중계상 가드 — 같은 구조물 내역 줄의 일위대가 자재(붙은 것·못 붙은 것)와 겹치면 안 올리고 + 사유로 멈춤. **코드가 있으면 코드로, 없을 때만 이름으로**(명세 2장). 막는 쪽이라 틀려도 과소 + (줄이 안 서고 사유가 뜸) — 양식이 늘어 ㉠ 이 생기는 날 금액이 조용히 부풀지 않게. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Callable + +from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind +from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild + +MATERIAL_GROUP_NAME = "자재(사급)" +DOUBLE_COUNT_SUSPECT = "double_count_suspect" +_ZERO = Decimal(0) + + +def _flat(text: Any) -> str: + return "".join(str(text or "").split()) + + +def unit_price_materials(row: BillRow, unit_prices: UnitPriceBuild) -> list[tuple[str, str]]: + """그 내역 줄의 일위대가가 밟는 자재 — 붙은 것 (코드, 이름) · 못 붙은 줄 (「」, 글).""" + book = unit_prices.book + stack = [code for code in (row.price_code, *(code for code, _ in row.parts)) if code] + seen: set[str] = set() + found: list[tuple[str, str]] = [] + while stack: + code = stack.pop() + if code in seen: + continue + seen.add(code) + title = book.titles.get(code) + if title is not None and title.kind is PriceKind.MATERIAL: + if not code.startswith(FUEL_CODE_PREFIX): # 기계 연료는 자재총괄 자재가 아님 + found.append((code, title.name)) + if code.startswith("B-"): + labels = unit_prices.unattached.get(code[2:].split("#", 1)[0]) or [] + found.extend(("", label) for label in labels) + stack.extend(detail.ref_code for detail in book.details.get(code, [])) + return found + + +def overlap(name: str, code: str, inside: list[tuple[str, str]]) -> str: + """겹친 일위대가 자재 글 — 코드가 있으면 코드로, 없으면 이름(공백 무시 앞머리)으로.""" + if code: + return next((label for found, label in inside if found == code), "") + wanted = _flat(name) + return next((label for _, label in inside if wanted and _flat(label).startswith(wanted)), "") + + +def raise_material_rows( + result: BillResult, + unit_prices: UnitPriceBuild, + next_number: Callable[[str], str], + bill_line: Callable[[Money3, Decimal], Money3], +) -> None: + """자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만.""" + sheet = result.material_sheet + picked = [] + for item in getattr(sheet, "contractor_rows", []): + if item.amount_krw is None or not item.manual: + continue + inside = [ + pair + for row in result.rows + if not row.is_group and row.name in item.source_structure + for pair in unit_price_materials(row, unit_prices) + ] + hit = overlap(item.name, getattr(item, "code", ""), inside) + if hit: + reason = f"두 번 셀 수 있어 내역 줄로 안 올림 — 같은 구조물 일위대가에 「{hit}」" + item.note = f"{item.note} · {reason}" + result.missing.append( + { + "name": item.key, + "unit": item.unit, + "quantity": str(item.total_amount), + "reason": reason, + "blocked_kind": DOUBLE_COUNT_SUSPECT, + } + ) + continue + picked.append(item) + if not picked: + return + group_no = next_number("") + result.rows.append( + BillRow(item_no=group_no, level=1, code=None, name=MATERIAL_GROUP_NAME, is_group=True) + ) + for index, item in enumerate(picked, start=1): + price = item.unit_price_krw + line = bill_line(Money3(material=price), item.total_amount) + row = BillRow( + item_no=f"{group_no}-{index}", + level=2, + code=None, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.total_amount, + ) + row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO + row.unit_price_krw = price + row.amount_krw = line.total + row.material_krw = line.material + row.unconfirmed = 1 + row.add_note("quantity", f"자재총괄 할증 뒤 수량 — {', '.join(item.source_structure)}") + row.add_note("unit_price_krw", item.note) + result.unconfirmed.append({"name": row.name, "code": item.key, "count": 1}) + result.rows.append(row) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 74ce9347..0a3a4928 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -596,8 +596,14 @@ def pending_formula_note(code: str | None) -> str: return "" -def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: - """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" +def _material_row( + material: HandoffMaterial, result: BillResult, manual: dict | None = None +) -> BillRow: + """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**. + + `manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림 + (금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`). + """ row = BillRow( item_no="", level=1, @@ -632,9 +638,14 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: "관급자재대(총원가 밖 별도 표기)로 갑니다.", ) reason = "관급 자재 단가 없음" + elif f"{material.material_name} {material.spec}".strip() in (manual or {}): + row.add_note( + "unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬" + ) + return row else: row.add_note( - "unit_price_krw", "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + "unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기." ) reason = "사급 자재 단가 없음(미결 No.18)" diff --git a/B09_Estimation/B09_Estimation_MaterialPrices.py b/B09_Estimation/B09_Estimation_MaterialPrices.py index 8cf7fcca..5cfdf16c 100644 --- a/B09_Estimation/B09_Estimation_MaterialPrices.py +++ b/B09_Estimation/B09_Estimation_MaterialPrices.py @@ -185,33 +185,50 @@ def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> i return len(found) -def listing(build: Any, stored: Any) -> list[dict[str, Any]]: - """화면 줄 — 자원 축이 쓰는 자재(코드) + 저장만 있고 자원 축에서 사라진 줄. +def listing( + build: Any, stored: Any, materials: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: + """화면 줄 — 자원 축이 쓰는 자재(코드) + 자재총괄 자재(「이름 규격」) + 저장만 남은 줄. - ⚠ 사라진 줄도 조용히 안 버림 — 「단가표에서 사라진 코드」로 보이고 사용자가 지움. + `materials` — B08 인계 `materials`(자재총괄). 사급 줄만 단가 칸이 섬(관급은 도급 금액 밖). + ⚠ 사라진 줄도 조용히 안 버림 — 「사라진 자재」로 보이고 사용자가 지움. """ prices = normalize(stored) catalog = material_catalog_rows() uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {} rows: list[dict[str, Any]] = [] - for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)): - info = catalog.get(code, {}) - entry = prices.get(code) or {} + + def add(key: str, shown: dict[str, Any], **extra: Any) -> None: + entry = prices.get(key) or {} rows.append( { - "key": code, - "name": info.get("name") or entry.get("name", ""), - "spec": info.get("spec") or entry.get("spec", ""), - "unit": info.get("unit") or entry.get("unit", ""), - "work_items": sorted(uses[code]), + "key": key, + **{ + field: shown.get(field) or entry.get(field, "") + for field in ("name", "spec", "unit") + }, "price_krw": entry.get("price_krw"), "source": entry.get("source", ""), "entered_at": entry.get("entered_at", ""), "missing": False, + **extra, } ) + + for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)): + add(code, catalog.get(code, {}), work_items=sorted(uses[code]), origin="unit_price") + for material in materials or []: + key = f"{material.get('material_name', '')} {material.get('spec') or ''}".strip() + add( + key, + {**material, "name": material.get("material_name")}, + work_items=list(material.get("source_structure") or []), + origin="material_sheet", + supply_type=material.get("supply_type") or "", + quantity=str(material.get("total_amount")), + ) + listed = {row["key"] for row in rows} for key, entry in sorted(prices.items()): - if key in uses or not is_code_key(key): - continue - rows.append({"key": key, **entry, "work_items": [], "missing": True}) + if key not in listed: + rows.append({"key": key, **entry, "work_items": [], "missing": True}) return rows diff --git a/B09_Estimation/B09_Estimation_MaterialSheet.py b/B09_Estimation/B09_Estimation_MaterialSheet.py index 135b36ff..53d5f2d6 100644 --- a/B09_Estimation/B09_Estimation_MaterialSheet.py +++ b/B09_Estimation/B09_Estimation_MaterialSheet.py @@ -58,6 +58,9 @@ class MaterialSheetRow: surcharge_pct: Decimal | None = None source_structure: tuple[str, ...] = () note: str = "" + #: 「자재 단가」 탭 키(이름 규격) · 수동 단가로 섰나(= 미확정). + key: str = "" + manual: bool = False def as_dict(self) -> dict[str, Any]: def money(value: Decimal | None) -> str | None: @@ -75,6 +78,8 @@ class MaterialSheetRow: "surcharge_pct": money(self.surcharge_pct), "source_structure": list(self.source_structure), "note": self.note, + "key": self.key, + "manual": self.manual, } @@ -119,8 +124,14 @@ def build_material_sheet( *, surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE, catalog: MaterialCatalog | None = None, + manual: dict[str, dict[str, str]] | None = None, ) -> MaterialSheet: - """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.""" + """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**. + + `manual` — 「자재 단가」 탭 수동 단가(`MaterialPrices.normalize`, 키 「이름 규격」). + ⚠ **사급 단가는 수동 단가만**(2026-09-14 PLAN 1장 Ⓐ-2) — 나라장터는 관급 단가라 사급에 쓰면 + 도급 재료비가 관급 값으로 조용히 섬. 관급 줄은 종전대로 나라장터(총원가 밖). + """ book = catalog or load_material_catalog() sheet = MaterialSheet() @@ -141,6 +152,7 @@ def build_material_sheet( surcharge_pct=getattr(material, "surcharge_pct", None), source_structure=tuple(getattr(material, "source_structure", ()) or ()), ) + row.key = f"{row.name} {row.spec}".strip() if row.supply_type == SUPPLY_UNKNOWN: # ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다. @@ -151,19 +163,33 @@ def build_material_sheet( ) continue - found = book.resolve(row.name, row.spec) - if found is None: + entry = (manual or {}).get(row.key) if row.supply_type == SUPPLY_CONTRACTOR else None + found = book.resolve(row.name, row.spec) if row.supply_type == SUPPLY_OWNER else None + price = Decimal(entry["price_krw"]) if entry else found.price_krw if found else None + if price is None: row.note = ( - "자재 단가가 없습니다 — 유료 물가지 미결(No.18). " - "6번 슬롯(적용 단가) 수동 입력 대기." + "사급 자재 단가 없음 — 「자재 단가」 탭에서 수동 입력(유료 물가지 미결 No.18)" + if row.supply_type == SUPPLY_CONTRACTOR + else "관급 자재 단가 없음 — 나라장터 목록에 이 품목이 없음" ) sheet.missing.append( {"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"} ) else: - row.unit_price_krw = found.price_krw + row.unit_price_krw = price # 자재대 줄도 **내역서 본체와 같은 절사** 자리다. - row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW) + row.amount_krw = round_at(price * row.total_amount, OutputPlace.BOQ_ROW) + if entry: + row.manual = True + row.note = " · ".join( + part + for part in ( + "⚠ 수동 단가(미확정)", + entry.get("source"), + entry.get("entered_at"), + ) + if part + ) if row.supply_type == SUPPLY_OWNER: sheet.owner_rows.append(row) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 041a9477..e97a7adb 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -503,10 +503,15 @@ async def get_bill(project_id: UUID) -> JSONResponse: from common_util.common_util_project_settings import estimation_settings root = await _project_root_of(project_id) - rates = normalize((estimation_settings(root) if root else {}).get("edits")).get( - "bill_rates" + stored = estimation_settings(root) if root else {} + rates = normalize(stored.get("edits")).get("bill_rates") + result = build_bill( + payload, + build=build, + structure_prices=structure_prices, + rates=rates, + material_prices=stored.get("material_prices"), ) - result = build_bill(payload, build=build, structure_prices=structure_prices, rates=rates) except DoubleCountError as error: # 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다. logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error) diff --git a/B09_Estimation/B09_Estimation_Router_MaterialPrices.py b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py index 234c9491..3becbfb0 100644 --- a/B09_Estimation/B09_Estimation_Router_MaterialPrices.py +++ b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py @@ -42,16 +42,25 @@ class MaterialPriceRequest(BaseModel): async def _rows(project_id: UUID) -> dict[str, Any]: + import json + + from B08_Quantity.B08_Quantity_Router_Material import get_handoff from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of from common_util.common_util_project_settings import estimation_settings root = await _project_root_of(project_id) settings = estimation_settings(root) if root else {} build = await _build_for(project_id) - rows = listing(build, settings.get(MATERIAL_PRICES_KEY)) + # 자재총괄 줄(「이름 규격」 키) — 인계를 못 받으면 코드 줄만 서고 그 사실을 `handoff_note` 로. + handoff = json.loads(bytes((await get_handoff(project_id)).body).decode("utf-8")) + materials = handoff.get("materials") + rows = listing(build, settings.get(MATERIAL_PRICES_KEY), materials) return { "status": "success", "rows": rows, + "handoff_note": "" + if materials is not None + else f"B08 인계를 못 받아 자재총괄 줄이 빠짐 — {handoff.get('message') or ''}", "unconfirmed_count": sum(1 for row in rows if row.get("price_krw")), } diff --git a/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts index 4c6e66e5..42b2da13 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts @@ -24,6 +24,10 @@ interface MaterialPriceRow { source: string; entered_at: string; missing: boolean; + /** `unit_price` = 일위대가 자원(코드 키) · `material_sheet` = 자재총괄(이름 규격 키). */ + origin?: string; + supply_type?: string; + quantity?: string; } interface MaterialPricesDto { @@ -31,6 +35,23 @@ interface MaterialPricesDto { message?: string; rows: MaterialPriceRow[]; unconfirmed_count: number; + handoff_note?: string; +} + +const SUPPLY_LABELS: Record = { + contractor_supplied: "사급", + owner_supplied: "관급 — 도급 금액 밖(수량만)", + unknown: "미정 — 관급구분을 먼저 고름(B08 자재총괄)", +}; + +function whereText(row: MaterialPriceRow): string { + if (row.missing) return "⚠ 목록에서 사라진 자재 — 단가를 비우고 저장해 지움"; + if (row.origin === "material_sheet") { + return `자재총괄 ${row.quantity ?? ""}${row.unit} · ${row.work_items.join(", ")} · ${ + SUPPLY_LABELS[row.supply_type ?? ""] ?? row.supply_type ?? "" + }`; + } + return `일위대가 · ${row.work_items.join(", ")}`; } interface Change { @@ -75,11 +96,11 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v } const { wrap, tbody } = plainTable([ - "코드", + "코드·키", "명칭", "규격", "단위", - "쓰인 공종", + "쓰이는 곳", "단가(원)", "출처", "넣은 날", @@ -90,6 +111,13 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v price.inputMode = "decimal"; price.title = row.price_krw ? won(row.price_krw) : ""; const source = input(row.source, "160px", "견적 업체·물가지 쪽"); + // 자재총괄 줄은 사급만 금액이 섬 — 관급·미정 줄은 칸을 잠금(값이 남아 있으면 지울 수는 있게). + const locked = + row.origin === "material_sheet" && + row.supply_type !== "contractor_supplied" && + !row.price_krw; + price.disabled = locked; + source.disabled = locked; const priceCell = el("td", "b09s-num"); priceCell.append(price); const sourceCell = el("td"); @@ -99,13 +127,7 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v el("td", "", row.name), el("td", "", row.spec), el("td", "", row.unit), - el( - "td", - "b09s-note", - row.missing - ? "⚠ 단가표에서 사라진 코드 — 단가를 비우고 저장해 지움" - : row.work_items.join(", "), - ), + el("td", "b09s-note", whereText(row)), priceCell, sourceCell, el("td", "", row.entered_at), @@ -147,10 +169,14 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v }); ctx.body.append(bar, wrap); - if (data.rows.length === 0) ctx.body.append(hint("일위대가가 쓰는 자재 코드가 없음")); + if (data.handoff_note) ctx.body.append(hint(data.handoff_note, true)); + if (data.rows.length === 0) ctx.body.append(hint("단가 칸을 낼 자재가 없음")); ctx.body.append( hint( - "수동 단가는 6번 슬롯(적용 단가)으로 서고, 닿은 내역 줄마다 「미확정」으로 셈 — 자재단가대비표에도 보임", + "일위대가 자재 — 6번 슬롯(적용 단가)으로 서고 닿은 내역 줄마다 「미확정」으로 셈 · 자재단가대비표에도 보임", + ), + hint( + "자재총괄 사급 자재 — 내역서 끝 「자재(사급)」 줄로 섬(할증 뒤 수량 × 단가) · 관급은 도급 금액 밖", ), hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"), ); diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json index ce84dbd3..31b2bde7 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": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc", - "size_bytes": 4724 + "sha256": "30a1e11acb6f6bd52c72e0ff471c96f772917858306ea61f66ecbcb5df433c9f", + "size_bytes": 5087 } ] } 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 3451db13..2e732b1e 100644 --- a/resources/data_material_surcharge/material_surcharge_2026-01-01.json +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -149,18 +149,20 @@ { "material": "막자갈", "rate": 4, - "pumsem": "const", + "pumsem": "forest", + "listed_in": "산림청고시 제2025-82호 1-3-1 3호 「부순돌ㆍ자갈ㆍ막자갈 4」(건설품셈도 같은 조건)", "listed_condition": "노상 및 노반재료(선택층·보조기층·기층)", "our_usage": "돌쌓기 뒤채움", - "why_not_applied": "조건이 노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음" + "why_not_applied": "조문이 쓰임을 노반재료로 못박아 뒤채움에 그 상한을 끌어올 근거가 없음(2026-09-14 브레인 판정 보류 유지 · 사방기술교본도 할증률 없음)" } ] }, "not_found": { "note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.", - "materials": ["야면석", "고임돌", "물구멍관"], + "materials": ["야면석", "고임돌", "물구멍관", "못"], "checked": [ "산림품셈 1-3-1 전 19종", + "못 — 산림청고시 제2025-82호 1-3-1 6호 기타재료에 없음(2026-09-14 브레인 확인)", "건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)", "건설품셈 제7장 돌공사 — 재료 할증률표 없음" ] diff --git a/resources/tester/test_b08_frame_material.py b/resources/tester/test_b08_frame_material.py index 3f2c85aa..1a1e5736 100644 --- a/resources/tester/test_b08_frame_material.py +++ b/resources/tester/test_b08_frame_material.py @@ -33,7 +33,22 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 def 이름별(rows: list[dict]) -> dict: - return {(row["source"], row["name"]): row for row in rows} + return {(row["source"], f"{row['name']} {row.get('spec', '')}".strip()): row for row in rows} + + +def test_이름과_규격을_가른다() -> None: + """이름 칸에 규격을 섞으면 할증률표(각재 5 · 판재 10)와 안 맞음 — 관급구분 키는 그대로.""" + from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table + + rows = frame_material_rows(규준틀) + assert {(row["name"], row["spec"]) for row in rows} == { + ("각재", "50×50"), + ("판재", "T12"), + ("못", ""), + } + table = build_table({"structures": []}, extra_materials=rows) + rates = {row["supply_key"]: row["surcharge_pct"] for row in table["rows"]} + assert rates == {"각재 50×50": 5, "판재 T12": 10, "못": None} def test_개소가_서면_재료도_선다() -> None: diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py index 016d73dc..201dc864 100644 --- a/resources/tester/test_b08_handoff.py +++ b/resources/tester/test_b08_handoff.py @@ -552,11 +552,12 @@ def test_자재가_없으면_not_applied() -> None: assert handoff["surcharge_status"] == "not_applied" -def test_관급구분_미분류가_계약값임() -> None: - """`unknown` 은 세 번째 값이다 — B09 는 이 줄을 관급에도 도급에도 안 넣는다.""" +def test_관급구분_안_정하면_기본_사급() -> None: + """2026-09-14 브레인 판정 ⓐ — 안 정한 자재는 사급으로 인계(관급은 산출 조건이 정함).""" unit = build_unit_table([구조물()]) handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit)) - assert all(row["supply_type"] == "unknown" for row in handoff["materials"]) + assert handoff["materials"] + assert all(row["supply_type"] == "contractor_supplied" for row in handoff["materials"]) # ── 운반 줄 (2026-09-07 조율 창 요청) ─────────────────────────────── diff --git a/resources/tester/test_b08_material_summary.py b/resources/tester/test_b08_material_summary.py index 98d71ebc..53ccd9f8 100644 --- a/resources/tester/test_b08_material_summary.py +++ b/resources/tester/test_b08_material_summary.py @@ -103,7 +103,7 @@ def test_품셈에_포함된_항목은_또_붙이지_않음() -> None: table = build_table(원단위표(성분("모래", "㎥", 10.0, surcharge_included=True))) row = 줄(table, "모래") assert row["total_amount"] == pytest.approx(10.0) - assert row["note"] == NOTE_INCLUDED + assert row["note"].split(" · ")[0] == NOTE_INCLUDED # 뒤는 「기본 사급」 # ── destination 가르기 ────────────────────────────────────────────── @@ -214,7 +214,7 @@ def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None: table = build_table(원단위표(성분("낯선자재", "㎥", 10.0))) row = 줄(table, "낯선자재") assert row["surcharge_pct"] is None - assert row["note"] == NOTE_RATE_MISSING + assert row["note"].split(" · ")[0] == NOTE_RATE_MISSING # 뒤는 「기본 사급」 assert "낯선자재" in table["missing_rate_materials"] # 값은 잃지 않는다 — 순수량 그대로 둔다. assert row["total_amount"] == pytest.approx(10.0) @@ -252,13 +252,40 @@ def test_합계는_반올림하지_않음() -> None: # ── 관급/사급 ─────────────────────────────────────────────────────── -def test_안_정한_자재는_미정으로_드러남() -> None: - """⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼 - 읽힌다(2026-09-09 화면 확인). 값(`unknown`)과 판정은 그대로다.""" +def test_안_정한_자재는_기본_사급으로_서고_그_사실이_적힘() -> None: + """2026-09-14 브레인 판정 ⓐ — 기본 사급 · 관급은 산출 조건이 정함(종전 `unknown` 은 어느 + 합계에도 안 들어 자재비가 통째로 빠짐). 명시적으로 미정을 고른 줄은 그대로 미정.""" table = build_table(원단위표(성분("야면석", "㎥", 5.0))) - assert 줄(table, "야면석")["supply"] == SUPPLY_UNKNOWN - assert 줄(table, "야면석")["supply_label"] == "미정(발주기관 결정)" - assert table["missing_supply_materials"] == ["야면석"] + assert 줄(table, "야면석")["supply"] == "contractor_supplied" + assert "기본 사급" in 줄(table, "야면석")["note"] + assert table["missing_supply_materials"] == [] + picked = build_table(원단위표(성분("야면석", "㎥", 5.0)), supply_map={"야면석": "unknown"}) + assert 줄(picked, "야면석")["supply"] == SUPPLY_UNKNOWN + assert 줄(picked, "야면석")["supply_label"] == "미정(발주기관 결정)" + assert picked["missing_supply_materials"] == ["야면석"] + + +def test_율이_없는_까닭이_줄에_뜸() -> None: + """보류(조건이 다름)와 이름 없음을 가름 — 「미확보」만으로는 할 일이 안 보임.""" + table = build_table(원단위표(성분("막자갈", "㎥", 10.0), 성분("못", "㎏", 1.0))) + assert 줄(table, "막자갈")["surcharge_pct"] is None + assert "조건이 다름 — 사용자 확정 후 옮길 것" in 줄(table, "막자갈")["note"] + assert "할증률표에 이름 없음" in 줄(table, "못")["note"] + + +def test_할증률은_표값_이내에서만_고름() -> None: + """1-3-1 「표의 값 이내」 — 0~표값만 받고 넘거나 표에 없는 자재는 안 씀.""" + fake = SurchargeTable(rates={"판재": {"material": "판재", "rate": 10}}) + unit = 원단위표(성분("판재", "㎥", 1.0), 성분("낯선자재", "㎥", 1.0)) + lower = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 6, "낯선자재": 3}) + assert 줄(lower, "판재")["surcharge_pct"] == 6 and 줄(lower, "판재")["surcharge_cap"] == 10 + assert "표 상한 10% 이내" in 줄(lower, "판재")["note"] + assert 줄(lower, "낯선자재")["surcharge_pct"] is None # 표에 없으면 고른 값도 안 씀 + over = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 12}) + assert ( + 줄(over, "판재")["surcharge_pct"] == 10 + and "이내가 아니라 안 씀" in 줄(over, "판재")["note"] + ) def test_설정이_정한_구분을_따름() -> None: diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py index 2524a8d2..2ece9f5f 100644 --- a/resources/tester/test_b09_material_prices.py +++ b/resources/tester/test_b09_material_prices.py @@ -80,13 +80,85 @@ def test_내역_줄은_미확정으로_선다() -> None: assert not plain.unconfirmed +def _자재(name: str, spec: str, total: str, supply: str, source: list[str]) -> dict: + return { + "material_name": name, + "spec": spec, + "unit": "㎥", + "net_amount": total, + "total_amount": total, + "supply_type": supply, + "source_structure": source, + } + + +def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량만() -> None: + """Ⓐ-2 — 셈(㉠ 0 · ㉡ 9 · ㉢ 0) 뒤 브레인 판정. 도급 재료비에 들고 미확정으로 셈.""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + payload = { + "work_items": [], + "materials": [ + _자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"]), + _자재("판재", "T12", "0.1769", "contractor_supplied", ["비탈 규준틀"]), + _자재("채움콘크리트", "180", "3.4", "owner_supplied", ["돌쌓기(찰)"]), + ], + } + prices = {"각재 50×50": {"price_krw": "650000"}, "채움콘크리트 180": {"price_krw": "90000"}} + result = build_bill(payload, build=cached_build(), material_prices=prices) + group = next(r for r in result.rows if r.name == "자재(사급)") + rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")] + assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림 + assert rows[0].material_krw == Decimal("174460") # 0.2684 × 650,000 + assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("174460") + assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing) + sheet = result.material_sheet + assert ( + sheet.contractor_rows[0].manual and not sheet.owner_rows[0].manual + ) # 관급엔 수동 단가 안 씀 + plain = build_bill(payload, build=cached_build()) + assert not any(r.name == "자재(사급)" for r in plain.rows) and plain.direct_material_krw == 0 + + +def test_같은_구조물_일위대가_자재와_겹치면_안_올리고_사유() -> None: + """이중계상 가드 — 코드가 있으면 코드로, 없을 때만 이름으로(막는 쪽이라 틀려도 과소).""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import overlap + + built = cached_build(material_prices=((STAKE, "1500", ""),)) + unit = built.book.titles["B-FP-05-15"].unit + payload = { + "work_items": [ + {"work_item_code": "FP-05-15", "name": "말뚝박기", "unit": unit, "quantity": 10} + ], + "materials": [_자재("말뚝", "", "20", "contractor_supplied", ["말뚝박기"])], + } + result = build_bill(payload, build=built, material_prices={"말뚝": {"price_krw": "1500"}}) + assert not any(r.name == "자재(사급)" for r in result.rows) + hit = next(m for m in result.missing if m.get("blocked_kind") == "double_count_suspect") + assert "말뚝" in hit["reason"] + inside = [(STAKE, "말뚝"), ("", "시멘트 510 kg — 자재 단가 층 없음")] + assert overlap("시멘트", "", inside).startswith("시멘트") + assert overlap("말뚝", "AR-M-00000000", inside) == "" # 코드가 있으면 코드로만 + + def test_목록은_자원_축_자재와_사라진_저장_줄() -> None: built = cached_build() stored = { STAKE: {"price_krw": "1500"}, "AR-M-00000000": {"price_krw": "7", "name": "옛 자재"}, } - rows = {row["key"]: row for row in listing(built, stored)} + materials = [_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"])] + rows = { + row["key"]: row for row in listing(built, {**stored, "못": {"price_krw": "3"}}, materials) + } assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500" assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림 assert rows["AR-M-b0853497"]["price_krw"] is None + sheet_row = rows["각재 50×50"] + assert (sheet_row["name"], sheet_row["spec"], sheet_row["origin"]) == ( + "각재", + "50×50", + "material_sheet", + ) + assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True From 87083c3341953e0b0670a626272f9bb44e1624f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 10:52:52 +0900 Subject: [PATCH 14/28] =?UTF-8?q?feat(b08):=20=EC=9E=90=EC=9E=AC=EC=B4=9D?= =?UTF-8?q?=EA=B4=84=20=ED=95=A0=EC=A6=9D=EB=A5=A0=20=E3=80=8C=EC=9D=B4?= =?UTF-8?q?=EB=82=B4=E3=80=8D=20=EC=B9=B8=20=E2=80=94=20=ED=91=9C=EA=B0=92?= =?UTF-8?q?=20=EC=83=81=ED=95=9C=20=EC=95=88=EC=97=90=EC=84=9C=EB=A7=8C=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=C2=B7=20=EB=B9=84=EC=9A=B0=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=EA=B0=92(=EC=B4=88=EC=95=88=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=9D=80=20Page=20=ED=95=9C=20=EC=A4=84=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 49 +++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index da19e5e9..6c173438 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -28,6 +28,8 @@ export interface MaterialRow { unit: string; net_amount: number; surcharge_pct: number | null; + /** 할증률표 값 = 고를 수 있는 천장(품셈 1-3-1 「표의 값 이내」). `null` 이면 칸이 안 섬. */ + surcharge_cap?: number | null; total_amount: number; supply: string; supply_label: string; @@ -241,9 +243,49 @@ export interface SupplyChoice { export interface MaterialGridOptions { /** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */ choices: Record; + /** 할증률 고른 값 `{이름 규격: % | null}` — 산출 조건 `material_surcharge`. 안 주면 칸 없이 글자만. + * `null` = 표값으로 되돌림. 0 ~ 표값만 받음(서버도 한 번 더 거름). */ + surcharge?: Record; onChange: () => void; } +/** 할증률 칸 — 표값(상한) 안에서만. 범위를 넘으면 칸이 빨갛게 되고 값을 안 받음. */ +function surchargeCell( + row: MaterialRow, + key: string, + picks: Record, + onChange: () => void, +): HTMLTableCellElement { + const cap = row.surcharge_cap as number; + const td = document.createElement("td"); + const input = document.createElement("input"); + input.type = "number"; + input.min = "0"; + input.max = String(cap); + input.step = "0.1"; + input.className = "b08-grid__select"; + input.style.width = "4.5em"; + const picked = picks[key]; + input.value = String(picked ?? row.surcharge_pct ?? cap); + input.title = `표값 ${cap}% 이내(품셈 1-3-1) · 비우면 표값`; + input.addEventListener("change", () => { + const text = input.value.trim(); + const value = Number(text); + if (text !== "" && !(Number.isFinite(value) && value >= 0 && value <= cap)) { + input.setCustomValidity(`0 ~ ${cap}% 만 받음`); + input.reportValidity(); + return; + } + input.setCustomValidity(""); + picks[key] = text === "" ? null : value; + if (text === "") input.value = String(cap); + td.classList.add("is-changed"); + onChange(); + }); + td.append(input); + return td; +} + /** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */ function choiceCell( value: string, @@ -337,7 +379,12 @@ export function renderMaterialGrid( 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))); + // 표값이 있는 줄은 「이내」로 고를 칸(초안 `surcharge` 가 이어졌을 때만). + tr.append( + options?.surcharge && typeof row.surcharge_cap === "number" + ? surchargeCell(row, key, options.surcharge, options.onChange) + : textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0)), + ); tr.append(textCell(num(row.total_amount, 2))); if (options) { // 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정). From afea225ae587202bc3c4045dfaf0473b230c79e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 10:54:37 +0900 Subject: [PATCH 15/28] =?UTF-8?q?feat(b08):=20=EC=9E=90=EC=9E=AC=EC=B4=9D?= =?UTF-8?q?=EA=B4=84=20=ED=95=A0=EC=A6=9D=EB=A5=A0=20=E3=80=8C=EC=9D=B4?= =?UTF-8?q?=EB=82=B4=E3=80=8D=20=EC=B9=B8=20=EC=B4=88=EC=95=88=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=E2=80=94=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= =?UTF-8?q?=C2=B7=EC=A0=80=EC=9E=A5=20=EB=B3=B8=EB=AC=B8=C2=B7=ED=91=9C=20?= =?UTF-8?q?=EC=98=B5=EC=85=98(=EB=9E=A9=ED=83=91=5F=EB=A9=94=EC=9D=B8=2087?= =?UTF-8?q?083c33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B08_Quantity/B08_Quantity_UI_Page.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index abc717ee..64fabd9d 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -95,6 +95,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr // 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다. rock_methods: draft.rock_methods, material_supply: draft.material_supply, + material_surcharge: draft.material_surcharge, concrete_placing_method: draft.concrete_placing_method, // ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙). topsoil_thickness_m: draft.topsoil_thickness_m, @@ -314,6 +315,8 @@ interface DraftSettings { tree_waste_root_method: string; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; + // 자재별 할증률(%) — 표값 이내에서 고른 값만 · `null` 은 표값. + material_surcharge: Record; // 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다. conversion_factors: Record; // 도쟈 한계거리(m) — `null` 은 기본값(서버 정본 60 m)이 선다. @@ -925,6 +928,7 @@ function buildQuantityBody( material.material, { choices: draft.material_supply, + surcharge: draft.material_surcharge, onChange: () => { draft.dirty = true; }, @@ -1011,6 +1015,9 @@ export async function renderB08Quantity(root: HTMLElement): Promise { ...((stored.ancillary_counts ?? {}) as Record), }, material_supply: { ...((stored.material_supply ?? {}) as Record) }, + material_surcharge: { + ...((stored.material_surcharge ?? {}) as Record), + }, // ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다. conversion_factors: Object.fromEntries( Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [ From c12a2bf8262ea1eb8cec59e89811e624699de6c0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:07:57 +0900 Subject: [PATCH 16/28] =?UTF-8?q?fix(b08):=20=EA=B0=88=20=EA=B3=B3=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EA=B8=88=EC=A7=80=20=E2=80=94=20?= =?UTF-8?q?=ED=91=9C=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=84=B1=EB=B6=84=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=80=20=EB=A7=89=EA=B3=A0=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0(=EC=A2=85=EC=A0=84=20quantity=20=EB=A1=9C=20=EB=96=A8?= =?UTF-8?q?=EC=96=B4=EC=A0=B8=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EB=B9=A0?= =?UTF-8?q?=EC=A7=90)=20=C2=B7=20=EB=93=9C=EB=9F=AC=EB=82=9C=20=EB=84=B7(?= =?UTF-8?q?=EC=9D=B4=ED=98=95=EC=B2=A0=EA=B7=BC=20D13=C2=B7=EB=A9=B4?= =?UTF-8?q?=EB=AA=A9=C2=B7=EB=96=BC=20=EC=9E=90=EC=9E=AC=20=C2=B7=20?= =?UTF-8?q?=EC=9C=A0=EB=A1=9C=ED=8F=BC=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80)=20=ED=91=9C=EC=97=90=20=EC=98=AC=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_UnitQuantity_Base.py | 27 ++++++++++ ..._Quantity_Engine_UnitQuantity_Revetment.py | 27 ++++++---- .../tester/test_b08_destination_no_default.py | 54 +++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_b08_destination_no_default.py diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py index 5bd4177b..ae11a11b 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -51,8 +51,35 @@ DESTINATION = { # 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다. # 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다. RUBBLE_BASE_NAME: "unit_price", + # ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄). + # 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게). + # ⚠ 「이형철근 D13」 은 이름에 규격이 섞인 글자 그대로 — 인계 대응표 `from_components` 가 + # 그 글자로 조각을 찾아 지금 가르면 옹벽 철근 조각이 끊김(이름 가르기는 대응표와 함께 따로). + "이형철근 D13": "material", + "유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품 + "면목": "material", + "떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1) } +#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄. +#: 종전엔 `DESTINATION.get(name, "quantity")` 로 떨어져 **아무 축에도 안 가고** 자재총괄 +#: 건너뛴 수로만 셈(2026-09-14 L형수로 이형철근·떼흙막이 떼가 통째로 빠져 있던 자리). +DESTINATION_MISSING = ( + "「{name}」 갈 곳이 표(`DESTINATION`)에 없어 물량을 어느 축에도 안 보냄" + " — 표에 올릴 것(명세 13장 갈 곳 기본값 금지)" +) + + +def routed(name: str, notes: list[str]) -> str | None: + """성분 이름의 갈 곳 — 표에 없으면 `None` 이고 사유 한 줄을 `notes` 에 더함(이름마다 한 번).""" + destination = DESTINATION.get(name) + if destination is None: + text = DESTINATION_MISSING.format(name=name) + if text not in notes: + notes.append(text) + return destination + + # ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. # ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 # 오탐된다 — 개발 중 실제로 걸렸던 자리다. diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index f138f672..3b84fb2b 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -25,6 +25,7 @@ from typing import Any from B08_Quantity.B08_Quantity_Engine_StructureSheet import REVETMENT_STONE_FORMS from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import routed #: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다. #: ⚠ 구조물도 기울기·그림이 같은 표를 씀(`StructureSheet.REVETMENT_STONE_FORMS` 한 벌). @@ -369,8 +370,9 @@ def erosion_check_dam( ) components = [ - Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec)) + Component(name, unit, amount, destination, basis, spec="".join(spec)) for name, unit, amount, basis, *spec in rows + if (destination := routed(name, notes)) ] mpa, mpa_basis = fill_concrete_mpa(options) fill_per = coeff["fill_concrete_m3_per_m2"] @@ -489,7 +491,6 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]: ⚠ 치수가 **전부 평균 붙박이**다 — 정본 표에 「(평균)」이라 적혀 있고 개소별 치수조서가 따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다. """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION form = str(options.get("form") or "").strip() if not form: @@ -501,13 +502,16 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]: "— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다" ] + notes: list[str] = [] components = [ - Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis) + Component(name, unit, amount, destination, basis) for name, unit, amount, basis in SOIL_GUARD_SOD["rows"] + if (destination := routed(name, notes)) ] return components, [ + *notes, f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다" - "(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다" + "(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다", ] @@ -516,7 +520,6 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component ⚠ 규격을 고를 칸이 없어 지금은 **「콘크리트 개거 150×200」 붙박이**다. 그 사실을 줄로 낸다. """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION if length_m <= 0: from B08_Quantity.B08_Quantity_Wording import option_missing @@ -529,17 +532,18 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}" ] + notes: list[str] = [] components = [ Component( name, unit, per_m * length_m, - DESTINATION.get(name, "quantity"), + destination, f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})", ) for name, unit, per_m, basis in table["rows"] + if (destination := routed(name, notes)) ] - notes: list[str] = [] if not options.get("ditch_spec"): notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다") if spec == "콘크리트 개거 150×200": @@ -562,7 +566,6 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], 등록부 밑수도 `area_m2` 라 축이 그대로 맞는다. ⚠ 기울기 몫이 **없다** — 평면에 붙이는 것이라 √(1+n²) 를 곱하지 않는다. """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION form = str(options.get("form") or "").strip() table = BED_SILL_FORMS.get(form) @@ -607,11 +610,15 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], # 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다. rows.append(("터파기", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m")) + notes: list[str] = [] components = [ - Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec)) + Component(name, unit, amount, destination, basis, spec="".join(spec)) for name, unit, amount, basis, *spec in rows + if (destination := routed(name, notes)) ] - notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"] + notes.append( + f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다" + ) if form == "돌붙임(찰)": notes.append( "버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 " diff --git a/resources/tester/test_b08_destination_no_default.py b/resources/tester/test_b08_destination_no_default.py new file mode 100644 index 00000000..8e540db1 --- /dev/null +++ b/resources/tester/test_b08_destination_no_default.py @@ -0,0 +1,54 @@ +"""갈 곳(`destination`) 기본값 금지 — 명세 13장 「⛔ 기본값 금지(빠진 것이 조용해짐)」 (2026-09-14). + +종전 `DESTINATION.get(name, "quantity")` 는 표에 없는 이름을 **아무 축에도 안 가는 칸**으로 떨어뜨려 +L형수로 이형철근·면목 · 개거 유로폼·면목 · 떼흙막이 떼가 자재총괄에서 통째로 빠져 있었음. + +지키는 것 + ① 표에 없는 이름은 성분이 안 서고 **사유가 뜸**(조용히 새지 않음) + ② 정본 표(개거·떼흙막이·바닥막이)의 모든 줄 이름이 갈 곳 표에 있음 — 줄이 늘면 여기서 걸림 + ③ 드러난 넷이 제자리로 감(관측 원단위와 같은 갈 곳) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, routed # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402 + BED_SILL_FORMS, + OPEN_DITCH_FORMS, + SOIL_GUARD_SOD, + open_ditch, + soil_guard, +) + + +def test_표에_없는_이름은_막히고_사유가_뜸() -> None: + notes: list[str] = [] + assert routed("낯선성분", notes) is None + assert routed("낯선성분", notes) is None + assert len(notes) == 1 and "갈 곳이 표" in notes[0] and "기본값 금지" in notes[0] + assert routed("터파기", notes) == "earthwork" and len(notes) == 1 + + +def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None: + names = {row[0] for table in OPEN_DITCH_FORMS.values() for row in table["rows"]} + names |= {row[0] for row in SOIL_GUARD_SOD["rows"]} + names |= {table["stone_name"] for table in BED_SILL_FORMS.values()} + assert sorted(name for name in names if name not in DESTINATION) == [] + + +def test_드러난_넷이_제자리로_감() -> None: + ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"}) + got = {component.name: component.destination for component in ditch} + assert got["이형철근 D13"] == "material" and got["면목"] == "material" + assert not [note for note in notes if "갈 곳이 표" in note] + plain, _ = open_ditch(10.0, {}) + assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price" + sod, sod_notes = soil_guard({"form": "떼"}) + assert {c.name: c.destination for c in sod}["떼"] == "material" + assert not [note for note in sod_notes if "갈 곳이 표" in note] From 646081757ccad3d609d95d3fa2d2f6c5bacd4a8b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:09:51 +0900 Subject: [PATCH 17/28] =?UTF-8?q?fix(b08):=20=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=ED=83=80=EC=9E=85=EC=97=90=20material=5Fs?= =?UTF-8?q?urcharge=20=EC=B9=B8=20=E2=80=94=20afea225a=20=EB=92=A4=20typec?= =?UTF-8?q?heck=20=EB=B9=A8=EA=B0=95=20=EB=8B=AB=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index cbb65fae..7d5e3a51 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -78,6 +78,8 @@ export interface QuantitySettings { rock_methods?: Record; /** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */ material_supply?: Record; + /** 자재별 할증률(%) — 표값 이내에서 고른 값만. `null`·없음이면 표값. */ + material_surcharge?: Record; /** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */ concrete_placing_method?: string | null; /** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */ From a0e144aed583895dca7b8a74f4d9939ca712be81 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:22:26 +0900 Subject: [PATCH 18/28] =?UTF-8?q?fix(b08):=20=ED=95=A0=EC=A6=9D=EB=A5=A0?= =?UTF-8?q?=20=E3=80=8C=EC=9D=B4=EB=82=B4=E3=80=8D=20=EC=B9=B8=20=E2=80=94?= =?UTF-8?q?=20=EC=95=88=20=EA=B3=A0=EB=A5=B8=20=EC=B9=B8=EC=9D=80=20?= =?UTF-8?q?=EB=B9=84=EC=9A=B0=EA=B3=A0=20=ED=9A=8C=EC=83=89=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=EA=B0=92(=EC=A7=80=EA=B8=88=20=EB=B6=99=EB=8A=94=20?= =?UTF-8?q?=ED=91=9C=EA=B0=92)=EB=A7=8C=20=C2=B7=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=EA=B0=92=EC=B2=98=EB=9F=BC=20=EC=95=88=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 6c173438..e13fd55b 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -266,7 +266,9 @@ function surchargeCell( input.className = "b08-grid__select"; input.style.width = "4.5em"; const picked = picks[key]; - input.value = String(picked ?? row.surcharge_pct ?? cap); + // 안 고른 칸은 **비워 두고 회색 제안값**(지금 붙는 표값)만 — 기본값을 값처럼 채우지 않음(폼 ② 규칙). + input.value = picked === null || picked === undefined ? "" : String(picked); + input.placeholder = String(row.surcharge_pct ?? cap); input.title = `표값 ${cap}% 이내(품셈 1-3-1) · 비우면 표값`; input.addEventListener("change", () => { const text = input.value.trim(); @@ -278,7 +280,6 @@ function surchargeCell( } input.setCustomValidity(""); picks[key] = text === "" ? null : value; - if (text === "") input.value = String(cap); td.classList.add("is-changed"); onChange(); }); From bd69aa47883a8a2da744571fb397719b553fa251 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:35:02 +0900 Subject: [PATCH 19/28] =?UTF-8?q?fix(b08):=20=EC=9E=90=EC=9E=AC=EC=B4=9D?= =?UTF-8?q?=EA=B4=84=20=ED=99=94=EB=A9=B4=C2=B7=EC=9D=B8=EA=B3=84=EB=A5=BC?= =?UTF-8?q?=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C=20=E2=80=94=20material=5Ftabl?= =?UTF-8?q?e=5Ffor=20=EA=B0=80=20=EA=B7=9C=EC=A4=80=ED=8B=80=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=EA=B9=8C=EC=A7=80=20=EC=96=B9=EA=B3=A0=20=EB=91=90=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=EA=B0=80=20=EA=B0=99=EC=9D=B4=20?= =?UTF-8?q?=EB=B6=80=EB=A6=84(=ED=99=94=EB=A9=B4=206=EC=A4=84=C2=B7?= =?UTF-8?q?=EC=9D=B8=EA=B3=84=209=EC=A4=84=20=EA=B0=88=EB=A6=BC=20?= =?UTF-8?q?=EB=8B=AB=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B08_Quantity/B08_Quantity_Router_Material.py | 61 ++++++++++---------- resources/tester/test_b08_frame_material.py | 22 +++++++ 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 93999bf5..09edd1f7 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -146,6 +146,33 @@ async def _section_modes(project_id: UUID) -> dict[float, str]: return section_modes_from_designs(designs) +def material_table_for( + unit_table: dict[str, Any], + settings: dict[str, Any], + preparation_table: dict[str, Any] | None, +) -> dict[str, Any]: + """자재총괄 **한 벌** — 화면(`material-summary`)·인계(`handoff`)가 같이 부름(브레인 판정). + + ⚠ 규준틀 재료는 **준비공 개소가 선 뒤에야** 서므로 준비공 표를 받아 여기서 얹음. 종전엔 인계만 + 얹어 화면 6줄 · 인계 9줄로 갈림 → 화면에서 규준틀 재료의 관급구분·할증을 못 만짐. + ⚠ 화면 쪽에 따로 복사하지 말 것 — 두 벌이 되면 또 갈림. + ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있음 — 그 사실이 줄 사유에 적힘. + """ + frame_rows = [ + row + for row in ((preparation_table or {}).get("rows") or []) + if str(row.get("item") or "").endswith("규준틀") + ] + return build_material_table( + unit_table, + supply_map=settings.get("material_supply") or {}, + surcharge_overrides=settings.get("material_surcharge") or {}, + # 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥). + concrete_placing_method=settings.get("concrete_placing_method"), + extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}), + ) + + @router.get("/{project_id}/quantity/material-summary") async def get_material_summary(project_id: UUID) -> JSONResponse: """구조물 원단위와 자재총괄을 **한 응답**으로 낸다. @@ -183,13 +210,9 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: # 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭. structure_templates=project_templates(project_root), ) - material_table = build_material_table( - unit_table, - supply_map=settings.get("material_supply") or {}, - surcharge_overrides=settings.get("material_surcharge") or {}, - # 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥). - concrete_placing_method=settings.get("concrete_placing_method"), - ) + # 인계와 같은 한 벌 — 규준틀 재료가 준비공 표를 따라 서므로 토공 표를 받음. + earthwork = await _earthwork_tables(project_id) + material_table = material_table_for(unit_table, settings, earthwork.get("preparation")) # 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다. handoff = build_handoff(unit_quantity_table=unit_table) composite = [ @@ -437,30 +460,10 @@ async def get_handoff(project_id: UUID) -> JSONResponse: structure_formulas=settings.get("structure_formula_overrides"), structure_templates=project_templates(project_root), ) - material_table = build_material_table( - unit_table, - supply_map=settings.get("material_supply") or {}, - surcharge_overrides=settings.get("material_surcharge") or {}, - concrete_placing_method=settings.get("concrete_placing_method"), - ) - # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. 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 {}, - surcharge_overrides=settings.get("material_surcharge") or {}, - concrete_placing_method=settings.get("concrete_placing_method"), - extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}), - ) + # 자재총괄 — 화면과 같은 한 벌(규준틀 재료 포함). + material_table = material_table_for(unit_table, settings, earthwork.get("preparation")) handoff = build_handoff( summary_table=earthwork.get("summary"), diff --git a/resources/tester/test_b08_frame_material.py b/resources/tester/test_b08_frame_material.py index 1a1e5736..b28fe237 100644 --- a/resources/tester/test_b08_frame_material.py +++ b/resources/tester/test_b08_frame_material.py @@ -85,6 +85,28 @@ def test_자재_축으로_간다() -> None: assert all(row["destination"] == "material" for row in frame_material_rows(규준틀)) +def test_화면과_인계가_같은_한_벌을_부른다() -> None: + """자재총괄 화면(6줄)·인계(9줄)가 갈리던 자리 — 규준틀 재료를 한 곳에서 얹음(2026-09-14). + + ⚠ 두 라우터가 이 함수를 부르는지까지 본다 — 한쪽이 옛 길로 돌아가면 다시 갈림. + """ + import inspect + + from B08_Quantity import B08_Quantity_Router_Material as router + + table = router.material_table_for( + {"structures": []}, + {"material_supply": {"각재 50×50": "owner_supplied"}}, + {"rows": 규준틀}, + ) + keys = {row["supply_key"]: row for row in table["rows"]} + assert set(keys) == {"각재 50×50", "판재 T12", "못"} + assert keys["각재 50×50"]["supply"] == "owner_supplied" # 화면에서 고른 값이 먹음 + for route in (router.get_material_summary, router.get_handoff): + source = inspect.getsource(route) + assert "material_table_for(" in source and "build_material_table(" not in source + + def test_제안값이_실무_관측값과_같다() -> None: """울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당).""" assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044 From 290eccbb9213f09bc37f1525eda8e494d17b14dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:44:01 +0900 Subject: [PATCH 20/28] =?UTF-8?q?fix(b05,b08):=20C=EA=B5=B0=20=EB=B2=BD=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=97=86=EC=96=B4=EB=8F=84=20=EB=86=93?= =?UTF-8?q?=EC=9E=84=20=E2=80=94=20=EB=86=92=EC=9D=B4=EA=B0=80=20=EB=B0=91?= =?UTF-8?q?=EC=88=98=EC=9D=B8=20=EB=B2=BD=EC=9D=80=20=EC=A4=84=EB=A7=8C=20?= =?UTF-8?q?=EC=84=9C=EA=B3=A0=20=EB=AF=B8=ED=99=95=EC=A0=95(=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=E2=91=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Structure_Types.json | 12 ++-- .../B05_Profile_UI_Structures_Fields.ts | 4 ++ .../B05_Profile_UI_Structures_Panel_Commit.ts | 9 +-- .../B08_Quantity_Engine_UnitQuantity.py | 3 +- .../B08_Quantity_Engine_UnitQuantity_Base.py | 17 +++++ .../test_b08_wall_height_unconfirmed.py | 64 +++++++++++++++++++ 6 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_b08_wall_height_unconfirmed.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 9d7772ef..38146213 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -728,7 +728,8 @@ "unit": "m", "default": 2.5, "required": false, - "phase": "b05" + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" }, { "key": "length_m", @@ -814,7 +815,8 @@ "unit": "m", "default": 2.5, "required": false, - "phase": "b05" + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" }, { "key": "length_m", @@ -987,7 +989,8 @@ "unit": "m", "default": 2.5, "required": false, - "phase": "b05" + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" }, { "key": "length_m", @@ -1373,7 +1376,8 @@ "unit": "m", "default": 2.5, "required": false, - "phase": "b05" + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" }, { "key": "length_m", diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 5e695ecf..cdfcd657 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -38,6 +38,8 @@ interface OptionShape { choices: string[]; default: string | number | null; required?: boolean; + /** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */ + empty_means?: string | null; } /** @@ -60,6 +62,7 @@ export function optionControl( : "— 안 정함 —"; const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]); element.value = value; + if (option.empty_means) element.title = option.empty_means; return element; } const element = @@ -67,6 +70,7 @@ export function optionControl( if (option.input !== "number") element.type = "text"; element.value = value; element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : ""; + if (option.empty_means) element.title = option.empty_means; return element; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 7ef4bc87..516ac307 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -231,13 +231,8 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); return; } - // 높이도 비면 안 놓음 — 칸이 비면 횡단도 벽이 조용히 안 서고 수량도 안 섬. 적거나 - // [제안값 넣기]를 누를 것(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 안 바꿈」). - const height = ctx.optionInputs.find((entry) => entry.key === "height_m"); - if (height?.isEmpty()) { - height.input.focus(); - return; - } + // 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인 + // 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`). const before = ctx.readBeforeM(); if (anchor - before < -0.005) { const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index a7feeb03..69c2fc15 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -52,6 +52,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401 StructureQuantity, _num, is_unconfirmed, + missing_height_reason, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄 boulder_masonry, @@ -529,7 +530,7 @@ def build_table( if rubble is not None: quantity.components.append(rubble) _append_section_trench(quantity, item, observed, rubble_base_thickness_m) - quantity.unconfirmed = str(item.get("unconfirmed") or "") + quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item) quantities.append(quantity) if use_templates: # 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림. diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py index ae11a11b..8b8aabc5 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -145,5 +145,22 @@ def is_unconfirmed(structure: dict[str, Any]) -> bool: return bool(structure.get("unconfirmed")) +#: 높이가 물량 밑수인 벽 — 높이를 안 적고 놓아도 줄은 서되 금액 밖(2026-09-14 브레인 판정 ① +#: 「설계자가 자리부터 잡고 치수를 뒤에 넣는 길을 막지 말 것」). 흙막이(떼 개소당)는 높이를 안 씀. +HEIGHT_DRIVEN_TYPES = frozenset({"retaining_wall", "masonry_wet", "masonry_dry", "boulder_masonry"}) +UNCONFIRMED_NO_HEIGHT = "높이를 안 적고 놓았음 — 높이를 적으면 금액이 섬" + + +def missing_height_reason(structure: dict[str, Any]) -> str: + """높이 없이 놓인 벽이면 미확정 까닭 한 줄, 아니면 빈 글.""" + if structure.get("type_id") not in HEIGHT_DRIVEN_TYPES: + return "" + try: + height = float((structure.get("options") or {}).get("height_m") or 0.0) + except (TypeError, ValueError): + height = 0.0 + return "" if height > 0 else UNCONFIRMED_NO_HEIGHT + + def _num(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback diff --git a/resources/tester/test_b08_wall_height_unconfirmed.py b/resources/tester/test_b08_wall_height_unconfirmed.py new file mode 100644 index 00000000..0b375b30 --- /dev/null +++ b/resources/tester/test_b08_wall_height_unconfirmed.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""높이 없이 놓인 벽 — **놓기는 되고 줄은 서되 미확정(금액 밖)** (2026-09-14 브레인 판정 ①). + +앞서 B05 폼이 C군 높이가 비면 놓기를 막았음. 우리 규칙 「줄은 서고 금액은 안 섬」과 어긋나 풀고, +높이가 물량 밑수인 벽(옹벽·돌쌓기 찰/메·큰돌쌓기)은 B08 표에서 미확정으로 뜨게 함. +흙막이(떼 개소당)는 높이를 안 써 그대로. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: E402 + UNCONFIRMED_NO_HEIGHT, + missing_height_reason, +) + +COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text( + encoding="utf-8" +) + + +def _wall(type_id: str = "masonry_wet", **options) -> dict: + return { + "structure_id": "w", + "type_id": type_id, + "start_m": 0.0, + "end_m": 10.0, + "options": { + "length_m": 10.0, + "back_len_cm": 45, + "stone_kind": "깬돌", + "foundation": "기초유", + **options, + }, + } + + +def test_높이_없는_돌쌓기는_미확정으로_선다() -> None: + table = build_table([_wall()], {"masonry_wet": "돌쌓기(찰)"}, {}) + row = table["structures"][0] + assert row["unconfirmed"] == UNCONFIRMED_NO_HEIGHT + + +def test_높이를_적으면_미확정이_아니다() -> None: + table = build_table([_wall(height_m=2.0)], {"masonry_wet": "돌쌓기(찰)"}, {}) + assert table["structures"][0]["unconfirmed"] == "" + + +def test_높이를_안_쓰는_종류는_그대로() -> None: + assert missing_height_reason(_wall("soil_guard")) == "" + for type_id in ("retaining_wall", "masonry_dry", "boulder_masonry"): + assert missing_height_reason(_wall(type_id)) == UNCONFIRMED_NO_HEIGHT, type_id + assert missing_height_reason(_wall(type_id, height_m="1.5")) == "", type_id + + +def test_폼이_높이_빈칸으로_놓기를_안_막는다() -> None: + assert "height?.isEmpty()" not in COMMIT From ecf3b65b42f8556419ae3403a44955450c9b9d5e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:50:31 +0900 Subject: [PATCH 21/28] =?UTF-8?q?feat(b06):=20BOX=C2=B7=EC=84=B8=EC=9B=94?= =?UTF-8?q?=EA=B5=90=C2=B7=EB=AC=BC=EB=84=98=EC=9D=B4=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EA=B7=B8=EB=A6=B0=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=84=20=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=E3=80=8C=EA=B8=B0=EB=B3=B8=EA=B0=92(?= =?UTF-8?q?=EB=AF=B8=ED=99=95=EC=A0=95)=E3=80=8D=20=C2=B7=20=EC=9B=94?= =?UTF-8?q?=EB=A5=98=20=EB=86=92=EC=9D=B4=20=EC=97=86=EC=96=B4=20=ED=8C=8C?= =?UTF-8?q?=EC=9E=84=20=EC=95=88=20=EA=B7=B8=EB=A6=AC=EB=8A=94=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD(=ED=8C=90=EC=A0=95=20=E2=91=A1=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B06_Section/B06_Section_Api_Types.ts | 4 + B06_Section/B06_Section_Engine_Culvert.py | 74 ++++++++++++++++-- .../B06_Section_UI_Cross_Card_Chrome.ts | 34 +++++++++ .../B06_Section_UI_Cross_Ford_Pavement.ts | 2 + .../B06_Section_UI_Page_Ford_Controls.ts | 36 +++++++++ common_util/common_util_culvert_sets.ts | 75 +++++++++++++++++-- .../tester/test_b06_facility_default_notes.py | 61 +++++++++++++++ 7 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_b06_facility_default_notes.py diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index c5d1a4a0..0abb514b 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -265,6 +265,8 @@ export interface FordSet { min_cover_m: number; wing_in: FordWingSpec; wing_out: FordWingSpec; + /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ + defaulted?: string[]; } /** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */ @@ -283,6 +285,8 @@ export interface BoxSet { span_m: number; wing_in: FordWingSpec; wing_out: FordWingSpec; + /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ + defaulted?: string[]; } export interface CrossSection extends SectionStation { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index f1a8bcd4..db99b34d 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -332,6 +332,36 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d } +def _defaulted(values: dict[str, Any], fields: list[tuple[str, str, Any]]) -> list[str]: + """정본에 안 적혀 **기본값으로 그린 칸** 「이름 값」 — 횡단도가 「기본값(미확정)」으로 적음. + + 2026-09-14 브레인 판정 ② — 저장만 안 하는 것으로는 모자람 · 그림이 설계값처럼 보이면 같은 병. + TS 짝 `defaulted`(`common_util_culvert_sets.ts`). + """ + found = [] + for key, label, used in fields: + if used is None or values.get(key) not in (None, ""): + continue + found.append(f"{label} {used:g}" if isinstance(used, float) else f"{label} {used}") + return found + + +def _wing_defaulted(values: dict[str, Any], wing: dict[str, Any], side: str) -> list[str]: + """날개벽 한쪽의 기본값 칸 — 안 세운 날개벽은 치수를 안 봄.""" + name = "유입" if side == "in" else "유출" + prefix = f"wing_{side}" + fields: list[tuple[str, str, Any]] = [ + (prefix, f"{name} 날개벽", "있음" if wing["installed"] else "없음") + ] + if wing["installed"]: + fields += [ + (f"{prefix}_height_m", f"{name} 날개벽 높이", wing["height_m"]), + (f"{prefix}_length_m", f"{name} 날개벽 길이", wing["length_m"]), + (f"{prefix}_angle_deg", f"{name} 날개벽 각도", wing["angle_deg"]), + ] + return _defaulted(values, fields) + + def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: """세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장). @@ -347,12 +377,26 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None)) count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None)) depth = _number(values.get("ford_height_m"), None) + span = width if width and width > 0 else FORD_DEFAULT_WIDTH_M + pipe_count = max(int(count), 1) if count else 1 + wing_in = _wing_spec(values, defaults, "in") + wing_out = _wing_spec(values, defaults, "out") + defaulted = _defaulted( + values, + [ + ("pipe_kind", "관종", str(kind) if kind else None), + ("pipe_diameter_mm", "관경", diameter_mm), + ("ford_width_m", "월류 폭", span), + ("pipe_count", "배관 수량", pipe_count), + ("ford_height_m", "월류 높이", 0.0), + ], + ) return { "type": "ford", "pipe_kind": str(kind) if kind else None, "diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3), - "pipe_count": max(int(count), 1) if count else 1, - "span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M, + "pipe_count": pipe_count, + "span_m": span, # 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른 # 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다 # (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다. @@ -360,8 +404,11 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: "slab_thickness_m": FORD_SLAB_THICKNESS_M, "wall_thickness_m": FORD_WALL_THICKNESS_M, "min_cover_m": MIN_PIPE_COVER_M, - "wing_in": _wing_spec(values, defaults, "in"), - "wing_out": _wing_spec(values, defaults, "out"), + "wing_in": wing_in, + "wing_out": wing_out, + "defaulted": defaulted + + _wing_defaulted(values, wing_in, "in") + + _wing_defaulted(values, wing_out, "out"), } @@ -377,12 +424,14 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None)) depth = _number(values.get("ford_height_m"), None) slope = _number(values.get("ford_slope_pct"), None) + span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M return { "type": "ford_pavement", - "span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M, + "span_m": span, # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). "depth_m": depth if depth and depth > 0 else None, "slope_pct": slope, + "defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]), } @@ -399,6 +448,8 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]: inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0)) wall = FORD_WALL_THICKNESS_M slab = FORD_SLAB_THICKNESS_M + wing_in = _wing_spec(values, defaults, "in") + wing_out = _wing_spec(values, defaults, "out") return { "type": "box", "inner_width_m": inner_width or 2.0, @@ -409,8 +460,17 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]: "cover_m": BOX_COVER_M, # 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다. "span_m": (inner_width or 2.0) + 2 * wall, - "wing_in": _wing_spec(values, defaults, "in"), - "wing_out": _wing_spec(values, defaults, "out"), + "wing_in": wing_in, + "wing_out": wing_out, + "defaulted": _defaulted( + values, + [ + ("body_width_m", "본체 폭", inner_width or 2.0), + ("body_height_m", "본체 높이", inner_height or 2.0), + ], + ) + + _wing_defaulted(values, wing_in, "in") + + _wing_defaulted(values, wing_out, "out"), } diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index b6e1b8d5..5695a5c9 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -49,6 +49,33 @@ function fillSlopeInfo(section: CrossSection): HTMLElement | null { return info; } +/** + * 시설을 **기본값으로 그렸거나 못 그린 까닭** `[보이는 글, 툴팁]` (2026-09-14 브레인 판정 ②③). + * 저장만 안 하는 것으로는 모자람 — 그림이 설계값처럼 보이면 기본값이 몰래 확정으로 굳던 병의 잔재. + */ +function facilityNotes(section: CrossSection): Array<[string, string]> { + const notes: Array<[string, string]> = []; + const sets: Array<[string, string[] | undefined]> = [ + ["BOX암거", section.box?.defaulted], + ["세월교", section.ford?.defaulted], + ["물넘이포장", section.ford_pavement?.defaulted], + ]; + for (const [name, items] of sets) { + if (!items?.length) continue; + notes.push([ + `⚠ ${name} 기본값(미확정)`, + `B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`, + ]); + } + if (section.ford_pavement && !section.ford_pavement.depth_m) { + notes.push([ + "⚠ 월류 높이 없음 — 파임 안 그림", + "물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림", + ]); + } + return notes; +} + /** * 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지). * @@ -86,6 +113,13 @@ export function appendCardHeader( openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); meta.append(openSlope); } + for (const [text, tip] of facilityNotes(section)) { + const note = document.createElement("span"); + note.className = "b06-cross-card__warning"; + note.textContent = text; + note.title = tip; + meta.append(note); + } const structureName = section.structure; if (structureName) { const structure = document.createElement("span"); diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 734c63d0..74ce333b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -27,6 +27,8 @@ export interface FordPavementSpec { depth_m: number | null; /** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */ slope_pct: number | null; + /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ + defaulted?: string[]; } interface Edge { diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 9b2e21cd..e399baa2 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record = { + pipe_kind: "관종", + pipe_diameter_mm: "관경", + ford_width_m: "월류 폭", + pipe_count: "배관 수량", + body_width_m: "본체 폭", + body_height_m: "본체 높이", +}; +for (const [side, name] of [ + ["in", "유입"], + ["out", "유출"], +]) { + DEFAULTED_LABELS[`wing_${side}`] = `${name} 날개벽`; + DEFAULTED_LABELS[`wing_${side}_height_m`] = `${name} 날개벽 높이`; + DEFAULTED_LABELS[`wing_${side}_length_m`] = `${name} 날개벽 길이`; + DEFAULTED_LABELS[`wing_${side}_angle_deg`] = `${name} 날개벽 각도`; +} + +/** 조작으로 값이 온 칸은 「기본값(미확정)」 목록에서 뺌 — 캐시를 바로 고치는 길이라 카드 머리가 + * 옛 기본값을 계속 말하지 않게(2026-09-14 브레인 판정 ②). 항목 모양은 「이름 값」(값에 빈칸 없음). */ +function dropDefaulted(spec: { defaulted?: string[] } | undefined, options: object): void { + if (!spec?.defaulted) return; + const labels = Object.keys(options).flatMap((key) => DEFAULTED_LABELS[key] ?? []); + spec.defaulted = spec.defaulted.filter( + (item) => + !labels.some( + (label) => item.startsWith(`${label} `) && !item.slice(label.length + 1).includes(" "), + ), + ); +} + /** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */ function wingPatchFrom(patch: Record, prefix: string): WingPatch { const num = (key: string): number | undefined => { @@ -157,6 +189,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { // 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리. if (patch.ford_width_m) spec.span_m = patch.ford_width_m; } + dropDefaulted(spec, patch); deps.queuePipeOptions(chainageM, patch); deps.refreshCard(chainageM); }, @@ -171,6 +204,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg; wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg); } + dropDefaulted(spec, wingOptions(role, patch)); deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); }, @@ -349,6 +383,7 @@ export function createBoxControls(deps: FordControlDeps): { } if (patch.body_height_m) spec.inner_height_m = patch.body_height_m; } + dropDefaulted(spec, patch); deps.queuePipeOptions(chainageM, patch as Record); deps.refreshCard(chainageM); }, @@ -364,6 +399,7 @@ export function createBoxControls(deps: FordControlDeps): { ? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0) : 0; } + dropDefaulted(spec, wingOptions(role, patch)); deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); }, diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 9144fa4d..a86dbb7f 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -247,6 +247,37 @@ function wingSpec( }; } +/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 파이썬 `_defaulted`(2026-09-14 브레인 판정 ②). */ +function defaulted( + values: Record, + fields: ReadonlyArray<[string, string, unknown]>, +): string[] { + return fields + .filter(([key, , used]) => used !== null && used !== undefined && text(values[key]) === null) + .map(([, label, used]) => `${label} ${String(used)}`); +} + +/** 날개벽 한쪽의 기본값 칸 — 파이썬 `_wing_defaulted`. */ +function wingDefaulted( + values: Record, + wing: CulvertSetSpec, + side: string, +): string[] { + const name = side === "in" ? "유입" : "유출"; + const prefix = `wing_${side}`; + const fields: Array<[string, string, unknown]> = [ + [prefix, `${name} 날개벽`, wing.installed ? "있음" : "없음"], + ]; + if (wing.installed) { + fields.push( + [`${prefix}_height_m`, `${name} 날개벽 높이`, wing.height_m], + [`${prefix}_length_m`, `${name} 날개벽 길이`, wing.length_m], + [`${prefix}_angle_deg`, `${name} 날개벽 각도`, wing.angle_deg], + ); + } + return defaulted(values, fields); +} + /** 세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장). */ function fordSet( values: Record, @@ -257,19 +288,35 @@ function fordSet( const width = num(values.ford_width_m, num(defaults.ford_width_m, null)); const count = num(values.pipe_count, num(defaults.pipe_count, null)); const depth = num(values.ford_height_m, null); + const kind = text(values.pipe_kind) ?? text(defaults.pipe_kind); + const span = width && width > 0 ? width : FORD_DEFAULT_WIDTH_M; + const pipeCount = count ? Math.max(Math.trunc(count), 1) : 1; + const wingIn = wingSpec(values, defaults, "in"); + const wingOut = wingSpec(values, defaults, "out"); return { type: "ford", - pipe_kind: text(values.pipe_kind) ?? text(defaults.pipe_kind), + pipe_kind: kind, diameter_m: pipeDiameterM(diameterMm), - pipe_count: count ? Math.max(Math.trunc(count), 1) : 1, - span_m: width && width > 0 ? width : FORD_DEFAULT_WIDTH_M, + pipe_count: pipeCount, + span_m: span, // 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다. 없으면 0 = 내리지 않는다. overflow_depth_m: depth && depth > 0 ? depth : 0.0, slab_thickness_m: FORD_SLAB_THICKNESS_M, wall_thickness_m: FORD_WALL_THICKNESS_M, min_cover_m: MIN_PIPE_COVER_M, - wing_in: wingSpec(values, defaults, "in"), - wing_out: wingSpec(values, defaults, "out"), + wing_in: wingIn, + wing_out: wingOut, + defaulted: [ + ...defaulted(values, [ + ["pipe_kind", "관종", kind], + ["pipe_diameter_mm", "관경", diameterMm], + ["ford_width_m", "월류 폭", span], + ["pipe_count", "배관 수량", pipeCount], + ["ford_height_m", "월류 높이", 0], + ]), + ...wingDefaulted(values, wingIn, "in"), + ...wingDefaulted(values, wingOut, "out"), + ], }; } @@ -281,12 +328,14 @@ function fordPavementSet( const defaults = registry.ford_pavement ?? {}; const width = num(values.ford_width_m, num(defaults.ford_width_m, null)); const depth = num(values.ford_height_m, null); + const span = width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M; return { type: "ford_pavement", - span_m: width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, + span_m: span, // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). depth_m: depth && depth > 0 ? depth : null, slope_pct: num(values.ford_slope_pct, null), + defaulted: defaulted(values, [["ford_width_m", "월류 폭", span]]), }; } @@ -300,6 +349,8 @@ function boxSet( const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0)); const wall = FORD_WALL_THICKNESS_M; const slab = FORD_SLAB_THICKNESS_M; + const wingIn = wingSpec(values, defaults, "in"); + const wingOut = wingSpec(values, defaults, "out"); return { type: "box", inner_width_m: innerWidth || 2.0, @@ -309,8 +360,16 @@ function boxSet( top_thickness_m: slab, cover_m: BOX_COVER_M, span_m: boxSpanM(innerWidth || 2.0), - wing_in: wingSpec(values, defaults, "in"), - wing_out: wingSpec(values, defaults, "out"), + wing_in: wingIn, + wing_out: wingOut, + defaulted: [ + ...defaulted(values, [ + ["body_width_m", "본체 폭", innerWidth || 2.0], + ["body_height_m", "본체 높이", innerHeight || 2.0], + ]), + ...wingDefaulted(values, wingIn, "in"), + ...wingDefaulted(values, wingOut, "out"), + ], }; } diff --git a/resources/tester/test_b06_facility_default_notes.py b/resources/tester/test_b06_facility_default_notes.py new file mode 100644 index 00000000..3b704566 --- /dev/null +++ b/resources/tester/test_b06_facility_default_notes.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""BOX암거·세월교·물넘이포장 — **기본값으로 그린 칸을 그림에 적음** (2026-09-14 브레인 판정 ②③). + +폼이 기본값을 안 보내게 고친 뒤, 비운 칸은 횡단도가 등록부 기본값으로 그리고 있었음. 저장만 안 하면 +된다고 볼 일이 아님 — 그림이 설계값처럼 보이는 것이 같은 병. 세트 제원이 `defaulted` 「이름 값」을 +싣고 카드 머리가 「⚠ … 기본값(미확정)」으로 보임. 물넘이 월류 높이가 비면 파임을 안 그리는 까닭도 보임. +TS 짝은 거울 시험(`test_b06_culvert_sets_mirror`)이 딕셔너리째 대조함. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402 + _box_set, + _ford_pavement_set, + _ford_set, +) + +CHROME = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8") +CONTROLS = (ROOT / "B06_Section" / "B06_Section_UI_Page_Ford_Controls.ts").read_text( + encoding="utf-8" +) + + +def test_BOX_빈_칸은_기본값_목록에_선다() -> None: + items = _box_set({})["defaulted"] + assert "본체 폭 2" in items and "본체 높이 2" in items + assert "유입 날개벽 있음" in items and "유출 날개벽 각도 45" in items + + +def test_적은_칸은_목록에서_빠진다() -> None: + items = _box_set({"body_width_m": 3.0, "wing_in": "없음"})["defaulted"] + assert not any(item.startswith("본체 폭 ") for item in items) + # 안 세운 날개벽은 치수를 안 봄. + assert not any(item.startswith("유입 날개벽") for item in items) + assert "본체 높이 2" in items + + +def test_세월교_월류_폭_관경도_적힌다() -> None: + items = _ford_set({"ford_width_m": 12.0})["defaulted"] + assert "관경 1000" in items and "월류 높이 0" in items + assert not any(item.startswith("월류 폭 ") for item in items) + + +def test_물넘이_월류_높이가_비면_파임_없음() -> None: + spec = _ford_pavement_set({}) + assert spec["depth_m"] is None + assert spec["defaulted"] == ["월류 폭 5"] + + +def test_카드_머리가_목록과_까닭을_보인다() -> None: + assert "기본값(미확정)" in CHROME + assert "월류 높이 없음 — 파임 안 그림" in CHROME + # 조작으로 값이 오면 목록에서 뺌 — 캐시를 바로 고치는 길. + assert CONTROLS.count("dropDefaulted(spec,") == 4 From d6820db7b02f9dc66993d22456b6a0f13e8cbece Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:55:23 +0900 Subject: [PATCH 22/28] =?UTF-8?q?fix(b09):=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=EC=9D=84=201-2-2=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=98=EC=98=AC=EB=A6=BC=ED=95=B4=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=ED=95=9C=20=EB=92=A4=20=EA=B8=88=EC=95=A1(=EB=B2=84?= =?UTF-8?q?=EB=A6=BC)=20=E2=80=94=20=ED=91=9C=EC=97=90=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A2=85=EB=AA=A9=20=EA=B8=B0=EB=B3=B8=202?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=C2=B7=20=EC=B2=A0=EA=B7=BC=20ton=20?= =?UTF-8?q?=EC=86=8C=EC=88=98=203=EC=9E=90=EB=A6=AC(kg=20=EC=A0=95?= =?UTF-8?q?=EC=88=98=20=ED=99=98=EC=82=B0)=20=C2=B7=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=ED=9D=A1=EC=88=98=20?= =?UTF-8?q?=C2=B7=20STmate=20=EC=9B=90=EB=B3=B8=20=EC=88=98=EB=9F=89=20321?= =?UTF-8?q?=EC=A4=84=20=EB=8C=80=EC=A1=B0=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- ...9_Estimation_BillOfQuantities_Materials.py | 4 ++- .../B09_Estimation_BillOfQuantities_Rows.py | 18 +++++++++-- .../B09_Estimation_QuantityDigits.py | 31 ++++++++++++------- resources/tester/test_b09_material_prices.py | 29 +++++++++++++++-- .../tester/test_b09_quantity_digits_golden.py | 29 +++++++++++++++++ 5 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_b09_quantity_digits_golden.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py index 62d6b98e..ce8063a6 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py @@ -65,6 +65,8 @@ def raise_material_rows( bill_line: Callable[[Money3, Decimal], Money3], ) -> None: """자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만.""" + from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import settle_quantity + sheet = result.material_sheet picked = [] for item in getattr(sheet, "contractor_rows", []): @@ -99,7 +101,6 @@ def raise_material_rows( ) for index, item in enumerate(picked, start=1): price = item.unit_price_krw - line = bill_line(Money3(material=price), item.total_amount) row = BillRow( item_no=f"{group_no}-{index}", level=2, @@ -109,6 +110,7 @@ def raise_material_rows( unit=item.unit, quantity=item.total_amount, ) + line = bill_line(Money3(material=price), settle_quantity(row)) # 수량 확정 뒤 금액 row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO row.unit_price_krw = price row.amount_krw = line.total diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 0a3a4928..6a0f97ff 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -31,6 +31,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( ) from B09_Estimation.B09_Estimation_MaterialPrices import manual_count from B09_Estimation.B09_Estimation_PriceBook import Money3 +from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( UnitPriceBuild, @@ -52,6 +53,17 @@ def bill_line(unit: Money3, quantity) -> Money3: ) +def settle_quantity(row: BillRow) -> Decimal: + """수량을 품셈 1-2-2 자리로 **반올림해 확정**하고 그 값을 돌려줌 — 금액은 확정한 수량으로 셈. + + 산림청고시 2025-82호 1-2-2 [주]① 「설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, + **반올림하여 적용**한다」 ⇒ 수량 먼저 확정 → 금액(`bill_line` 버림). 둘을 섞지 않음(2026-09-14 + 브레인 판정). 종전엔 인계 전정밀(0.28181999…96)로 곱해 원 미만 절사에 1원씩 샜음. + """ + row.quantity, _ = round_quantity(row.quantity, row.name, row.unit, row.spec) + return row.quantity + + def _sum_groups(rows: list[BillRow]) -> None: """머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음. @@ -141,7 +153,7 @@ def _composite_row( return row money = money.floored(Decimal(1)) - line = bill_line(money, item.quantity) + line = bill_line(money, settle_quantity(row)) _set_unit(row, money) row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW) row.amount_krw = line.total @@ -199,7 +211,7 @@ def _structure_price_row( return row # 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장). - line = bill_line(entry["money"], item.quantity) + line = bill_line(entry["money"], settle_quantity(row)) _set_unit(row, entry["money"]) row.price_code = ref row.parts = list(entry.get("parts") or []) @@ -544,7 +556,7 @@ def _leaf_row( row.price_code = price_code unit_money = unit_prices.book.resolve(price_code) - line = bill_line(unit_money, item.quantity) + line = bill_line(unit_money, settle_quantity(row)) _set_unit(row, unit_money) row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) # 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상. diff --git a/B09_Estimation/B09_Estimation_QuantityDigits.py b/B09_Estimation/B09_Estimation_QuantityDigits.py index edd3363c..f5c4d3e2 100644 --- a/B09_Estimation/B09_Estimation_QuantityDigits.py +++ b/B09_Estimation/B09_Estimation_QuantityDigits.py @@ -16,9 +16,10 @@ **항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종 마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다. -⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만 -여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 — -「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다. +⚠ **표에 없는 종목은 우리 기본 소수 2자리**(2026-09-14 브레인 판정 — 종전 「손대지 않음」 폐기). +수량을 이 자리로 **먼저 확정한 뒤** 금액을 셈 — [주]① 이 시킨 차례이고, 명세의 「Q 를 소수 2자리로 +먼저 확정한 뒤 나눔」과 같은 원칙(내역 줄 `BillOfQuantities_Rows.settle_quantity`). +못 맞춘 이름은 종전대로 로그로 드러냄. """ from __future__ import annotations @@ -50,6 +51,9 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( (("철강재", "강재"), ("kg", "㎏"), 3), (("용접봉",), ("kg", "㎏"), 1), (("철근",), ("kg", "㎏"), 0), + # 같은 줄의 단위 환산 — 원문 「철근 kg 정수」 = ton 소수 3자리. STmate 실무 원본 영월·울진 + # 설계내역서 철근·철근운반 Ton 0.264·0.008 이 그 자리(2026-09-14 골든셋 대조 · 원문 값 아님). + (("철근",), ("ton", "톤"), 3), (("볼트", "너트", "꺽쇠"), ("개",), 0), (("철선", "철사"), ("kg", "㎏"), 2), (("못",), ("kg", "㎏"), 2), @@ -68,6 +72,8 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( #: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라 #: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다. _UNIT_ONLY = {"㎥": 2, "m3": 2} +#: 표에도 단위에도 안 걸리는 줄의 자리 — 우리 기본(브레인 판정 2026-09-14). +DEFAULT_DIGITS = 2 logger = logging.getLogger(__name__) #: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게. @@ -78,15 +84,16 @@ def _tight(text: str) -> str: return "".join(str(text or "").split()) -def digits_for(name: str, unit: str, spec: str = "") -> int | None: - """그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다. +def digits_for(name: str, unit: str, spec: str = "") -> int: + """그 줄의 수량 소수자리. 표에 없으면 단위(㎥ 2) → 그것도 없으면 우리 기본 2. ⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음 (금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가 서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄). """ haystack = _tight(name) + _tight(spec) - unit_tight = _tight(unit) + # 단위 대소문자는 뜻이 같음(실무 원본 「M3」·「TON」·「Ton」) — 표기만 흡수. + unit_tight = _tight(unit).lower() for words, units, digits in _DIGITS: if unit_tight not in units: continue @@ -100,9 +107,11 @@ def digits_for(name: str, unit: str, spec: str = "") -> int | None: "B09 수량 자리 — 종목 이름을 못 맞춤: 「%s」 %s → %s", name, unit, - "단위만으로 %s자리" % fallback if fallback is not None else "표시 자리 없음", + "단위만으로 %s자리" % fallback + if fallback is not None + else "기본 %s자리" % DEFAULT_DIGITS, ) - return _UNIT_ONLY.get(unit_tight) + return _UNIT_ONLY.get(unit_tight, DEFAULT_DIGITS) def round_quantity( @@ -114,11 +123,9 @@ def round_quantity( ) -> tuple[Decimal, int | None]: """수량을 그 종목의 자리로 **반올림**한다. - 돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`. - `override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다. + 돌려주는 것 — (자른 값, 쓴 자리). + `override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다(마스터 칸 대기). """ digits = override if override is not None else digits_for(name, unit, spec) - if digits is None: - return value, None quantum = Decimal(1).scaleb(-digits) return value.quantize(quantum, rounding=ROUND_HALF_UP), digits diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py index 2ece9f5f..0bb875d0 100644 --- a/resources/tester/test_b09_material_prices.py +++ b/resources/tester/test_b09_material_prices.py @@ -109,8 +109,9 @@ def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량 group = next(r for r in result.rows if r.name == "자재(사급)") rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")] assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림 - assert rows[0].material_krw == Decimal("174460") # 0.2684 × 650,000 - assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("174460") + # 수량을 1-2-2 자리(㎥ 2자리)로 반올림해 확정한 뒤 곱함 — 0.2684 → 0.27 × 650,000 + assert rows[0].quantity == Decimal("0.27") and rows[0].material_krw == Decimal("175500") + assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("175500") assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing) sheet = result.material_sheet assert ( @@ -162,3 +163,27 @@ def test_목록은_자원_축_자재와_사라진_저장_줄() -> None: "material_sheet", ) assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True + + +def test_내역_수량은_자리로_반올림해_확정한_뒤_금액은_버림() -> None: + """1-2-2 [주]① 수량 반올림 → 금액 버림 — 둘을 섞지 않음(2026-09-14 브레인 판정). + + 인계 전정밀 0.28181999…96 로 곱하면 183,182 원(1원 샘) — 확정 수량 0.28 × 650,000 = 182,000. + """ + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_QuantityDigits import digits_for + + payload = { + "work_items": [], + "materials": [ + _자재("각재", "50×50", "0.28181999999999996", "contractor_supplied", ["비탈 규준틀"]) + ], + } + result = build_bill( + payload, build=cached_build(), material_prices={"각재 50×50": {"price_krw": "650000"}} + ) + row = next(r for r in result.rows if r.name == "각재") + assert (row.quantity, row.amount_krw) == (Decimal("0.28"), Decimal("182000")) + assert row.as_dict()["quantity"] == "0.28" # 계약·기성이 읽는 수량도 확정값 + assert digits_for("낯선종목", "개") == 2 # 표에 없는 종목은 우리 기본 2자리 + assert digits_for("돌쌓기(찰)", "㎡") == 1 and digits_for("철근", "kg") == 0 diff --git a/resources/tester/test_b09_quantity_digits_golden.py b/resources/tester/test_b09_quantity_digits_golden.py new file mode 100644 index 00000000..e241fe2e --- /dev/null +++ b/resources/tester/test_b09_quantity_digits_golden.py @@ -0,0 +1,29 @@ +"""수량 자리 맞춤 골든 대조 — STmate 실무 원본 내역 수량이 우리 자리 규칙으로 **안 바뀌어야** 함. + +2026-09-14 브레인 판정 — 내역 수량은 1-2-2 자리로 반올림해 확정한 뒤 금액(버림). 골든 금액 시험 +(`test_b09_golden_stmate`)은 원본 수량을 `bill_line` 에 바로 넣어 이 길을 안 거치므로, 원본 수량 +자체로 자리 규칙을 따로 잰다(321줄 · 철근 ton 소수 3자리가 이 대조에서 드러남). +""" + +from __future__ import annotations + +import pytest +from test_b09_golden_stmate import _bill_rows, _num + +from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity + + +def test_실무_원본_내역_수량이_자리_규칙으로_안_바뀐다() -> None: + rows = _bill_rows() + if not rows: + pytest.skip("실무 원본 XLSX 가 없음") + changed = [] + for name, row in rows: + quantity, unit = _num(row[3]), str(row[4] or "").strip() + if quantity is None or unit == "%": + continue + settled, digits = round_quantity(quantity, str(row[1] or ""), unit, str(row[2] or "")) + if settled != quantity: + changed.append((name, row[1], unit, str(quantity), digits)) + assert len(rows) >= 300, len(rows) + assert not changed, (len(changed), changed[:5]) From 298133dce40355183e31e134733c3937a53ce85a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:57:31 +0900 Subject: [PATCH 23/28] =?UTF-8?q?knowledge(=EC=B6=95C=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?):=20=EA=B2=80=EC=82=B0=20=EA=B3=84=EC=95=BD=EC=97=90=20?= =?UTF-8?q?=E3=80=8C=EA=B8=B0=EC=A4=80=EC=9D=B4=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=84=20=EC=9E=B4=20=EC=88=98=20=EC=9E=88=EB=8A=94=EC=A7=80?= =?UTF-8?q?=20=EB=A8=BC=EC=A0=80=20=EB=B3=BC=20=EA=B2=83=E3=80=8D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 골든 금액 시험이 원본 수량을 bill_line 에 바로 넣어 새 길을 안 거침 — 초록이 증거가 아니던 자리 - 원본 321줄 자리 규칙 대조를 따로 만들어 철근 Ton 소수 3자리 4줄을 뽑아냄 - 재지 못한 것을 잰 척하지 않을 것 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index a9e4d7bd..994020cd 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -424,6 +424,21 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 **STmate 골든셋 재현이 초록이면 끝.** +### ⚠⚠ 2026-09-14 보강 — 초록이 늘 증거인 것은 아님 + +**판정 기준을 받으면, 그 기준이 이 변경을 실제로 잴 수 있는지부터 볼 것.** +못 재면 그 자리에서 말하고 **잴 수 있는 것을 새로 만들 것.** 브레인이 준 기준도 마찬가지임. + +드러난 자리 — 인계 수량 자리 맞춤(1-2-2 [주]① 반올림) 때 브레인이 「골든셋 전수 초록」을 +판정 기준으로 줬으나, **골든 금액 시험은 STmate 원본 수량을 `bill_line` 에 바로 넣어 +새 길을 안 거침.** 초록이어도 증거가 아니었음. 랩탑 메인이 이를 알아채고 +**원본 내역 수량 321줄을 자리 규칙에 통과시키는 대조**를 따로 만들어 4줄 빨강을 뽑아냄 +(영월·울진 철근·철근운반 `Ton` 0.264·0.008 을 기본 2자리가 자름 → 철근 kg 정수 = ton 소수 3자리 +환산 + 단위 대소문자 흡수로 321/321). 그 대조를 `test_b09_quantity_digits_golden.py` 로 남김. + +**재지 못한 것을 잰 척하지 않을 것** — 같은 보고에 「원본 수량 대부분이 정수라 자리 규칙 +전체를 입증하진 못함」이라고 한계를 그대로 적었음. 그것이 옳은 모양임. + - 기준점 — 실무 6건 · 내역 1,515행을 **원 단위로 재현한 실증본** (`…/STmate 분석/20_분석/16_골든셋_회귀_결과.md`) - 시험 자리 — `resources/tester/` (git 추적 대상) From beb30999e3b24c0084a8cd42d37b1f2702b1b860 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:57:37 +0900 Subject: [PATCH 24/28] auto: 2026-09-14 11:57 (EOMSANGDON-HOME) --- main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/main.py b/main.py index a682f00f..ad281e75 100644 --- a/main.py +++ b/main.py @@ -71,6 +71,9 @@ from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router +from B09_Estimation.B09_Estimation_Router_MaterialPrices import ( + router as b09_material_prices_router, +) from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -648,6 +651,7 @@ app.include_router(b09_execution_router, dependencies=protected_with_company) app.include_router(b09_progress_router, dependencies=protected_with_company) app.include_router(b09_edits_router, dependencies=protected_with_company) app.include_router(b09_factors_router, dependencies=protected_with_company) +app.include_router(b09_material_prices_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) From acb377f5dbbf28312262a9a74318a811c3e1b7f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 12:09:18 +0900 Subject: [PATCH 25/28] =?UTF-8?q?fix(b08):=20=EC=B2=A0=EA=B7=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=C2=B7=EA=B7=9C=EA=B2=A9=20=EA=B0=80=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0(=EC=9D=B4=ED=98=95=EC=B2=A0=EA=B7=BC=20+=20D13=C2=B7D?= =?UTF-8?q?16)=20=E2=80=94=20=EB=8C=80=EC=9D=91=ED=91=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B0=81=EC=9D=80=20=EC=9D=B4=EB=A6=84+=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=ED=82=A4=C2=B7=EC=9D=B4=EB=A6=84=20=EB=91=90=20=EC=83=89?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=A7=91=EA=B3=A0=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EA=B2=80=EC=82=AC=EB=8F=84=20=EB=91=90=20=ED=82=A4?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EA=B4=80=EC=B8=A1=20=EC=9B=90=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20spec=20=EC=B9=B8=EC=9D=84=20=EB=84=98=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=ED=9B=84=20=EB=AC=BC=EB=9F=89=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=20=EC=8B=9C=ED=97=98(=EA=B0=80=EB=93=9C=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=8F=8C=EB=A6=AC=EB=A9=B4=20=EB=B9=A8=EA=B0=95=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_Handoff_Boundaries.py | 3 + .../B08_Quantity_Engine_Handoff_Mapping.py | 22 ++-- .../B08_Quantity_Engine_ObservedUnit.py | 2 + .../B08_Quantity_Engine_UnitQuantity_Base.py | 5 +- ..._Quantity_Engine_UnitQuantity_Revetment.py | 5 +- .../structure_unit_observed_2026-01-01.json | 9 +- .../tester/test_b08_destination_no_default.py | 3 +- resources/tester/test_b08_rebar_name_spec.py | 116 ++++++++++++++++++ 8 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 resources/tester/test_b08_rebar_name_spec.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py index c4e0ad5b..f1ee3631 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py @@ -72,6 +72,9 @@ def verify_double_count_boundaries( name = str(component.get("name") or "").strip() destination = str(component.get("destination") or "") destinations.setdefault(name, set()).add(destination) + # 집는 쪽은 이름+규격 키(「이형철근 D13」)로도 집음 — 두 키에 다 올려야 검사가 안 놂. + key = f"{name} {str(component.get('spec') or '').strip()}".strip() + destinations.setdefault(key, set()).add(destination) if name in EARTHWORK_ONLY and destination != "earthwork": found.append( f"① {label} 「{name}」 갈 곳이 {destination or '(없음)'} — " diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 40b2be44..4d8fa446 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -327,10 +327,18 @@ def composite_quantities( note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음" return [], [{"code": None, "reason": note}] - amounts: dict[str, tuple[float, str]] = {} - for component in structure.get("components") or []: + # ⚠ 조각 이름은 **이름+규격 키**(「이형철근 D13」)로도, 이름만(「콘크리트」)으로도 옴 — 두 + # 색인을 함께 둠. 이름 한 칸만 키로 쓰면 규격만 다른 성분(D13·D16)이 **서로 덮어씀**. + by_key: dict[str, float] = {} + by_name: dict[str, float] = {} + key_of: dict[int, str] = {} + for index, component in enumerate(structure.get("components") or []): name = str(component.get("name") or "").strip() - amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or "")) + key = f"{name} {str(component.get('spec') or '').strip()}".strip() + amount = float(component.get("amount") or 0.0) + by_key[key] = by_key.get(key, 0.0) + amount + by_name[name] = by_name.get(name, 0.0) + amount + key_of[index] = key kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001) parts: list[dict[str, Any]] = [] @@ -340,14 +348,14 @@ def composite_quantities( parts.append({"code": str(spec)}) continue sources = list(spec.get("from_components") or []) - found = [name for name in sources if name in amounts] - total = sum(amounts[name][0] for name in found) + found = [name for name in sources if name in by_key or name in by_name] + total = sum(by_key[name] if name in by_key else by_name[name] for name in found) if spec.get("unit_from") == "kg" and spec.get("unit") == "ton": total *= kg_to_ton kinds = { component.get("basis_kind") - for component in structure.get("components") or [] - if str(component.get("name") or "").strip() in found + for index, component in enumerate(structure.get("components") or []) + if key_of[index] in found or str(component.get("name") or "").strip() in found } suffix = spec.get("kind_suffix") entry: dict[str, Any] = { diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index b8ba88ed..a29c376f 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -304,6 +304,8 @@ def expand_observed( + (f" ({note})" if note else ""), "basis_kind": BASIS_OBSERVED, "source": source_key, + # 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기). + "spec": str(item.get("spec") or ""), } ) notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"] diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py index 8b8aabc5..0fbf4bac 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -53,9 +53,8 @@ DESTINATION = { RUBBLE_BASE_NAME: "unit_price", # ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄). # 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게). - # ⚠ 「이형철근 D13」 은 이름에 규격이 섞인 글자 그대로 — 인계 대응표 `from_components` 가 - # 그 글자로 조각을 찾아 지금 가르면 옹벽 철근 조각이 끊김(이름 가르기는 대응표와 함께 따로). - "이형철근 D13": "material", + # 철근은 이름 「이형철근」 + 규격 「D13·D16」(같은 날 가름 — 대응표 조각은 이름+규격 키로 집음). + "이형철근": "material", "유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품 "면목": "material", "떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 3b84fb2b..b376f997 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -439,7 +439,7 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { 0.1508, "{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1", ), - ("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"), + ("이형철근", "kg", 0.398, "0.2 × 2 × 0.995", "D13"), # 이름 · 규격 따로 ("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"), ("면목", "m", 1.0, "1"), ), @@ -540,8 +540,9 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component per_m * length_m, destination, f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})", + spec="".join(spec), ) - for name, unit, per_m, basis in table["rows"] + for name, unit, per_m, basis, *spec in table["rows"] if (destination := routed(name, notes)) ] if not options.get("ditch_spec"): diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json index c0cf2cfe..85f1bfda 100644 --- a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -86,13 +86,15 @@ "basis_note": "Ø50" }, { - "name": "이형철근 D13", + "name": "이형철근", + "spec": "D13", "unit": "kg", "amount": 13.45, "destination": "material" }, { - "name": "이형철근 D16", + "name": "이형철근", + "spec": "D16", "unit": "kg", "amount": 30.42, "destination": "material" @@ -218,7 +220,8 @@ "basis_note": "원문 값(라이브러리 표기 21.28)" }, { - "name": "이형철근 D13", + "name": "이형철근", + "spec": "D13", "unit": "kg", "amount": 4.776, "destination": "material", diff --git a/resources/tester/test_b08_destination_no_default.py b/resources/tester/test_b08_destination_no_default.py index 8e540db1..e604b41a 100644 --- a/resources/tester/test_b08_destination_no_default.py +++ b/resources/tester/test_b08_destination_no_default.py @@ -45,7 +45,8 @@ def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None: def test_드러난_넷이_제자리로_감() -> None: ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"}) got = {component.name: component.destination for component in ditch} - assert got["이형철근 D13"] == "material" and got["면목"] == "material" + assert got["이형철근"] == "material" and got["면목"] == "material" + assert next(c.spec for c in ditch if c.name == "이형철근") == "D13" # 이름·규격 따로 assert not [note for note in notes if "갈 곳이 표" in note] plain, _ = open_ditch(10.0, {}) assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price" diff --git a/resources/tester/test_b08_rebar_name_spec.py b/resources/tester/test_b08_rebar_name_spec.py new file mode 100644 index 00000000..0a7f0cd4 --- /dev/null +++ b/resources/tester/test_b08_rebar_name_spec.py @@ -0,0 +1,116 @@ +"""철근 이름·규격 가르기(2026-09-14 · 목록 3+4) — **가르기 전후 물량이 그대로**인지 잼. + +「이형철근 D13」 한 칸을 이름 「이형철근」 + 규격 「D13」 으로 가르면 셋이 같은 글자에 묶여 있어 +하나만 고치면 조용히 어긋남(브레인 경고): ① 묶음 조각 `from_components` · `amounts[name]` +(D13·D16 이 서로 덮어씀) ② 이중계상 경계 검사 `_pickers`·`destinations`(못 집으면 검사가 놂) +③ 자재총괄 줄·할증. 이 시험은 **가르기 전 코드에서 먼저 초록**으로 만들고 가른 뒤에도 초록이어야 함. + + 옹벽 반중력식 10m D13 13.45 · D16 30.42 ㎏/m → 조각 0.4387 ton · 자재 134.5 · 304.2 ㎏ + 집수정 □형 Ø800 1개소 D13 4.776 ㎏ + L형수로 H=0.2 10m D13 0.398 ㎏/m → 3.98 ㎏ +""" + +from __future__ import annotations + +import copy +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( # noqa: E402 + verify_double_count_boundaries, +) +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 + + +def _구조물들() -> dict: + return build_unit( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + }, + { + "structure_id": "b1", + "type_id": "pipe_inlet_basin", + "start_m": 200.0, + "end_m": 200.0, + "options": { + "inlet_basin_form": "□형(기본형)", + "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": "800", + }, + }, + { + "structure_id": "d1", + "type_id": "open_ditch", + "start_m": 300.0, + "end_m": 310.0, + "options": {"ditch_spec": "L형수로 H=0.2", "length_m": 10.0}, + }, + ], + {"retaining_wall": "옹벽", "pipe_inlet_basin": "집수정", "open_ditch": "개거"}, + ) + + +def test_옹벽_철근_조각은_D13_D16_을_다_모아_ton() -> None: + row = next( + r + for r in build_handoff(unit_quantity_table=_구조물들())["work_items"] + if r.get("composite_parts") + ) + rebar = next(p for p in row["composite_parts"] if "12-03" in str(p.get("code"))) + assert rebar["quantity"] == pytest.approx(0.4387) and not rebar.get("not_ready") + from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind + + wall = next(s for s in _구조물들()["structures"] if s["type_id"] == "retaining_wall") + assert structure_kind(wall) == "철근구조물" + + +def test_자재총괄_철근_줄_물량과_할증이_그대로() -> None: + table = build_table(_구조물들()) + rebar = { + row["supply_key"]: (row["net_amount"], row["surcharge_pct"], row["total_amount"]) + for row in table["rows"] + if row["supply_key"].startswith("이형철근") + } + assert set(rebar) == {"이형철근 D13", "이형철근 D16"} # 관급구분·단가 키는 전후 같은 글자 + assert rebar["이형철근 D13"][0] == pytest.approx(134.5 + 4.776 + 3.98) + assert rebar["이형철근 D16"][0] == pytest.approx(304.2) + assert rebar["이형철근 D13"][1] == 3 and rebar["이형철근 D16"][1] == 3 # 1-3-1 이형철근 + assert "이형철근" not in " ".join(table["missing_rate_materials"]) + + +def test_경계_검사가_철근을_여전히_집는다() -> None: + """철근이 토공 축으로 잘못 가면 묶음 조각이 또 센다고 걸려야 함 — 못 집으면 검사가 놂.""" + unit = copy.deepcopy(_구조물들()) + wall = next(s for s in unit["structures"] if s["type_id"] == "retaining_wall") + for component in wall["components"]: + if str(component["name"]).startswith("이형철근"): + component["destination"] = "earthwork" + found = verify_double_count_boundaries(unit, None, load_mapping()) + assert any("이형철근" in text and "묶음 조각" in text for text in found), found + assert not verify_double_count_boundaries(_구조물들(), None, load_mapping()) + + +def test_가른_뒤_이름과_규격이_따로_서고_할증은_별칭_없이_이어짐() -> None: + table = build_table(_구조물들()) + rows = [(r["name"], r["spec"]) for r in table["rows"] if r["name"].startswith("이형철근")] + assert sorted(rows) == [("이형철근", "D13"), ("이형철근", "D16")] + unit = _구조물들() + specs = { + (c["name"], c.get("spec")) + for s in unit["structures"] + for c in s["components"] + if str(c["name"]).startswith("이형철근") + } + assert specs == {("이형철근", "D13"), ("이형철근", "D16")} From 95ce24d31c208026a2fdd74dc34ab39892b80651 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 12:12:30 +0900 Subject: [PATCH 26/28] auto: 2026-09-14 12:12 (ESD_LAPTOP) --- docs/raw/verification/2026-09-13_축C_명세.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index a9e4d7bd..994020cd 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -424,6 +424,21 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 **STmate 골든셋 재현이 초록이면 끝.** +### ⚠⚠ 2026-09-14 보강 — 초록이 늘 증거인 것은 아님 + +**판정 기준을 받으면, 그 기준이 이 변경을 실제로 잴 수 있는지부터 볼 것.** +못 재면 그 자리에서 말하고 **잴 수 있는 것을 새로 만들 것.** 브레인이 준 기준도 마찬가지임. + +드러난 자리 — 인계 수량 자리 맞춤(1-2-2 [주]① 반올림) 때 브레인이 「골든셋 전수 초록」을 +판정 기준으로 줬으나, **골든 금액 시험은 STmate 원본 수량을 `bill_line` 에 바로 넣어 +새 길을 안 거침.** 초록이어도 증거가 아니었음. 랩탑 메인이 이를 알아채고 +**원본 내역 수량 321줄을 자리 규칙에 통과시키는 대조**를 따로 만들어 4줄 빨강을 뽑아냄 +(영월·울진 철근·철근운반 `Ton` 0.264·0.008 을 기본 2자리가 자름 → 철근 kg 정수 = ton 소수 3자리 +환산 + 단위 대소문자 흡수로 321/321). 그 대조를 `test_b09_quantity_digits_golden.py` 로 남김. + +**재지 못한 것을 잰 척하지 않을 것** — 같은 보고에 「원본 수량 대부분이 정수라 자리 규칙 +전체를 입증하진 못함」이라고 한계를 그대로 적었음. 그것이 옳은 모양임. + - 기준점 — 실무 6건 · 내역 1,515행을 **원 단위로 재현한 실증본** (`…/STmate 분석/20_분석/16_골든셋_회귀_결과.md`) - 시험 자리 — `resources/tester/` (git 추적 대상) From 560df4ac83cffec970c122b4bb51fe073e8424c0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 12:22:35 +0900 Subject: [PATCH 27/28] =?UTF-8?q?fix(b08):=20=EA=B4=80=EC=B8=A1=20?= =?UTF-8?q?=EC=9B=90=EB=8B=A8=EC=9C=84=20=EC=A4=84=EB=8F=84=20=EA=B0=88=20?= =?UTF-8?q?=EA=B3=B3=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EA=B8=88=EC=A7=80=20?= =?UTF-8?q?=E2=80=94=20destination=20=EC=97=86=EB=8A=94=20=EC=A4=84?= =?UTF-8?q?=EC=9D=80=20=EB=A7=89=EA=B3=A0=20=EC=82=AC=EC=9C=A0(=EC=A2=85?= =?UTF-8?q?=EC=A0=84=20or=20material=20=EB=A1=9C=20=EC=A1=B0=EC=9A=A9?= =?UTF-8?q?=ED=9E=88=20=EC=9E=90=EC=9E=AC=ED=96=89)=20=C2=B7=20=ED=91=9C?= =?UTF-8?q?=20=EC=A0=84=20=EC=A4=84=20=EA=B0=88=20=EA=B3=B3=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_ObservedUnit.py | 12 +++++-- .../tester/test_b08_destination_no_default.py | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index a29c376f..aab337de 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -283,8 +283,16 @@ def expand_observed( source_key = str(found.get("source") or "") options = structure.get("options") or {} components: list[dict[str, Any]] = [] + blocked: list[str] = [] for item in found.get("components") or []: note = str(item.get("basis_note") or "") + if not item.get("destination"): + # ⛔ 갈 곳 기본값 금지(명세 13장) — 종전 `or "material"` 은 줄이 늘 때 조용히 자재로 샘. + blocked.append( + f"「{item['name']}」 관측 원단위 줄에 갈 곳이 없어 물량을 어느 축에도 안 보냄" + " — 표(`structure_unit_observed`)에 적을 것(명세 13장 갈 곳 기본값 금지)" + ) + continue weep = ( weep_component(found, options, scale, scale_note) if item["name"] == "물구멍관" @@ -298,7 +306,7 @@ def expand_observed( "name": item["name"], "unit": item["unit"], "amount": float(item["amount"]) * scale, - "destination": item.get("destination") or "material", + "destination": item["destination"], # 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다. "basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}" + (f" ({note})" if note else ""), @@ -308,7 +316,7 @@ def expand_observed( "spec": str(item.get("spec") or ""), } ) - notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"] + notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}", *blocked] # 줄에 붙은 사유(원문끼리 다름 등) — 구조물도 그림도 같은 글자를 씀. notes.extend(str(note) for note in found.get("notes") or []) earthwork_note = earthwork_missing_note(found) diff --git a/resources/tester/test_b08_destination_no_default.py b/resources/tester/test_b08_destination_no_default.py index e604b41a..67c4cd1c 100644 --- a/resources/tester/test_b08_destination_no_default.py +++ b/resources/tester/test_b08_destination_no_default.py @@ -53,3 +53,36 @@ def test_드러난_넷이_제자리로_감() -> None: sod, sod_notes = soil_guard({"form": "떼"}) assert {c.name: c.destination for c in sod}["떼"] == "material" assert not [note for note in sod_notes if "갈 곳이 표" in note] + + +def test_관측_원단위_줄도_갈_곳이_없으면_막히고_사유() -> None: + """관측 원단위 로더의 `destination or "material"` 도 같은 병 — 줄이 늘 때 바로 드러나게.""" + from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ObservedUnitTable, expand_observed + + table = ObservedUnitTable( + entries=[ + { + "type_id": "시험구조물", + "unit": "개소", + "components": [ + {"name": "콘크리트", "unit": "㎥", "amount": 1.0, "destination": "unit_price"}, + {"name": "새자재", "unit": "㎥", "amount": 2.0}, + ], + } + ] + ) + components, notes = expand_observed("시험구조물", {}, {"options": {}}, table) + assert [c["name"] for c in components] == ["콘크리트"] + assert any("새자재" in note and "기본값 금지" in note for note in notes) + + +def test_관측_원단위_표의_전_줄에_갈_곳이_있음() -> None: + from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table + + missing = [ + (entry.get("type_id"), item.get("name")) + for entry in load_observed_table().entries + for item in entry.get("components") or [] + if not item.get("destination") + ] + assert missing == [] From 7e036ddd516a6b7c2e09ef1878aae3a09a4a5b5b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 12:28:36 +0900 Subject: [PATCH 28/28] =?UTF-8?q?feat(b08):=20=EC=95=88=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EB=8F=8C=EC=8C=93=EA=B8=B0=20=EB=92=B7=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=ED=92=88=EC=85=88=2013-4-4=20[=EC=A3=BC]?= =?UTF-8?q?=E2=91=A9=20=EB=92=B7=EA=B8=B8=EC=9D=B4=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=95=98=ED=95=9C=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=95=88=20=EC=B2=AB=20=EA=B7=9C=EA=B2=A9(?= =?UTF-8?q?=EB=A9=94=20~3m=2036=E2=86=9245)=20=C2=B7=20=EC=A0=84=EA=B0=9C?= =?UTF-8?q?=C2=B7=EC=96=91=EC=8B=9D=C2=B7=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=ED=95=9C=20=EB=B2=8C=20=C2=B7=20=EC=82=AC=EC=9C=A0?= =?UTF-8?q?=EC=97=90=20=EC=9B=90=EB=AC=B8=20=EC=A4=84=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?(B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Structure_Types.json | 2 +- .../B08_Quantity_Engine_StructureFigure.py | 11 ++- .../B08_Quantity_Engine_StructureSheet.py | 27 ++++--- .../B08_Quantity_Engine_StructureTemplate.py | 10 +++ ...08_Quantity_Engine_UnitQuantity_Masonry.py | 4 +- ..._Quantity_Engine_UnitQuantity_StoneSpec.py | 70 +++++++++++++++++-- .../masonry_back_length_2026-01-01.json | 40 +++++++++++ resources/library_structure/masonry_wet.json | 8 ++- .../tester/test_b08_back_length_standard.py | 57 +++++++++++++++ .../tester/test_b08_build_table_templates.py | 18 +++++ resources/tester/test_b08_structure_figure.py | 4 +- .../test_b08_structure_template_parity.py | 11 ++- resources/tester/test_b08_unit_quantity.py | 5 +- 13 files changed, 241 insertions(+), 26 deletions(-) create mode 100644 resources/data_masonry/masonry_back_length_2026-01-01.json create mode 100644 resources/tester/test_b08_back_length_standard.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 38146213..e1318d77 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -1945,7 +1945,7 @@ "default": null, "required": false, "phase": "detail", - "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" }, { "key": "stone_kind", diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py index 91ed57c9..7a14596a 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py @@ -27,7 +27,7 @@ from __future__ import annotations from typing import Any from B08_Quantity.B08_Quantity_Engine_Pipe import NOTE_DEFAULT_WALL -from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of +from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_is_wet, slope_of from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( _back_length, back_length_default_note, @@ -94,9 +94,14 @@ def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]: 양식 장은 양식이 푼 값(`formula_sheet.vars.L3`), 전개 장은 `_back_length` — 표와 한 벌. """ options = sheet.get("options") or {} + wet, height = sheet_is_wet(sheet), float(sheet.get("height_m") or 0.0) solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3") - back = int(float(solved)) if isinstance(solved, (int, float)) else _back_length(options) - return back, back_length_default_note(options, back) + back = ( + int(float(solved)) + if isinstance(solved, (int, float)) + else _back_length(options, wet=wet, height_m=height) + ) + return back, back_length_default_note(options, back, wet=wet, height_m=height) def figure_reason(sheet: dict[str, Any]) -> str | None: diff --git a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py index fd19cb2c..33ede3c6 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py @@ -103,23 +103,28 @@ SLOPE_TYPE_IDS: frozenset[str] = frozenset( REVETMENT_STONE_FORMS: dict[str, bool] = {"돌쌓기(찰)": True, "돌쌓기(메)": False} -def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: - """장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다. +def sheet_is_wet(sheet: dict[str, Any]) -> bool: + """장의 메/찰 — 표준경사·뒷길이 표준이 같은 갈래로 읽음. - 메/찰은 종류에서 온다. 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면 - 찰쌓기로 본다 — 품셈 13-6 은 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다. + 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면 찰쌓기로 본다 — 품셈 13-6 은 + 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다. """ options = sheet.get("options") or {} type_id = str(sheet.get("type_id") or "") if type_id == "masonry_dry": - wet = False - elif type_id == "boulder_masonry": - wet = options.get("bond") != "메쌓기" - elif type_id == "revetment": + return False + if type_id == "boulder_masonry": + return options.get("bond") != "메쌓기" + if type_id == "revetment": # 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메. - wet = REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True) - else: - wet = True + return REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True) + return True + + +def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: + """장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.""" + options = sheet.get("options") or {} + wet = sheet_is_wet(sheet) return face_slope_ratio( options, wet=wet, diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index be19dfad..f378c12b 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -16,7 +16,11 @@ import json from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import standard_back_length + ROOT = Path(__file__).resolve().parents[1] +#: 양식 칸 `default_from` — 빈 뒷길이를 뒷길이 표준표 하한으로(전개와 한 벌). +STANDARD_BACK_LENGTH = "standard_back_length" TEMPLATE_DIR = ROOT / "resources" / "library_structure" #: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`). @@ -75,6 +79,12 @@ def template_vars( raw = options.get(spec["option"]) else: raw = (settings or {}).get(spec.get("setting")) + if raw in (None, "") and spec.get("default_from") == STANDARD_BACK_LENGTH: + # 안 고른 뒷길이 — 전개와 같은 표준표 하한(`standard_back_length`, 브레인 판정 ㉮). + wet = template.get("type_id") != "masonry_dry" + height = float(structure.get("height_m") or 0.0) + values[name] = float(standard_back_length(wet=wet, height_m=height)[0]) + continue values[name] = default if raw in (None, "") else _typed(raw, default) return values diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py index 725b961a..66089043 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py @@ -260,14 +260,14 @@ def stone_masonry( if height_m <= 0 or length_m <= 0: return [], ["높이·연장이 없어 전개하지 않음"] - back_cm = _back_length(options) + back_cm = _back_length(options, wet=wet, height_m=height_m) if back_cm not in STONE_BACK_LENGTH_TABLE: # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. return [], [ f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" ] table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) - default_note = back_length_default_note(options, back_cm) + default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m) if default_note: notes.append(default_note) # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py index 8e19a929..58e203d3 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py @@ -444,8 +444,57 @@ def wants_blinding(options: dict[str, Any]) -> bool: BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") -def _back_length(options: dict[str, Any]) -> int: - """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. +#: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한 +#: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45. +BACK_LENGTH_PREFIX = "masonry_back_length_" + + +@lru_cache(maxsize=1) +def load_back_length_table() -> dict[str, Any]: + """뒷길이 표준표. 없으면 빈 표 — 그때는 종전 기본 45㎝ 로 돎.""" + files = sorted(SLOPE_DIR.glob(BACK_LENGTH_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else [] + return json.loads(files[-1].read_text(encoding="utf-8")) if files else {} + + +def standard_back_length(*, wet: bool, height_m: float) -> tuple[int, str]: + """(제안 뒷길이 ㎝, 근거) — 높이·메/찰 칸 범위 안 품셈 규격 중 가장 작은 것. + + ⚠ 하한이 규격(25·30·35·45·55·60·75)에 없으면 **그 위 첫 규격** — 메쌓기 ~3m 하한 36 은 45. + 내리면 품셈 범위 밖이 됨. 경계값은 표준경사표와 같이 **앞 칸**(높이 3.0m → `~3`). + """ + table = load_back_length_table() + steps = table.get("steps_m") or [] + bond = "찰쌓기" if wet else "메쌓기" + rows = (table.get("table_cm") or {}).get(bond) or [] + if not rows: + return DEFAULT_BACK_LENGTH_CM, f"뒷길이 표준표를 못 읽어 종전 {DEFAULT_BACK_LENGTH_CM}㎝" + index = next((i for i, limit in enumerate(steps) if height_m <= float(limit)), len(steps)) + low, high = rows[min(index, len(rows) - 1)] + label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과") + span = f"{low}~{high}㎝" if high is not None else f"{low}㎝ 이상" + picked = next( + ( + size + for size in sorted(STONE_BACK_LENGTH_TABLE) + if size >= low and (high is None or size <= high) + ), + None, + ) + head = f"품셈 13-4-4 [주]⑩ 뒷길이 표준(원문 L7172~7181) · {bond} {label} {span}" + if picked is None: + return ( + DEFAULT_BACK_LENGTH_CM, + f"{head} 안에 품셈 규격이 없어 종전 {DEFAULT_BACK_LENGTH_CM}㎝", + ) + tail = "" if picked == low else f"(하한 {low}㎝ 는 규격 일곱(원문 L7152)에 없어 그 위 첫 규격)" + return picked, f"{head} → 하한 {picked}㎝{tail}" + + +def _back_length( + options: dict[str, Any], *, wet: bool | None = None, height_m: float | None = None +) -> int: + """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 뒷길이 표준표 하한(메/찰·높이를 받으면), + 못 받는 자리(골막이 등)는 종전 기본 45. ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — @@ -463,13 +512,26 @@ def _back_length(options: dict[str, Any]) -> int: # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. return value - return DEFAULT_BACK_LENGTH_CM + if wet is None or height_m is None: + return DEFAULT_BACK_LENGTH_CM + return standard_back_length(wet=wet, height_m=height_m)[0] -def back_length_default_note(options: dict[str, Any], back_cm: int) -> str | None: +def back_length_default_note( + options: dict[str, Any], + back_cm: int, + *, + wet: bool | None = None, + height_m: float | None = None, +) -> str | None: """뒷길이를 안 골라 기본으로 섰을 때의 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀.""" if any(options.get(key) not in (None, "") for key in BACK_LENGTH_KEYS): return None + if wet is not None and height_m is not None: + return ( + f"⚠ 뒷길이를 안 골라 {standard_back_length(wet=wet, height_m=height_m)[1]} 로 섰습니다 " + "— 구조물 상세에서 고르면 바뀜 · 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다" + ) return ( f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 " "— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다" diff --git a/resources/data_masonry/masonry_back_length_2026-01-01.json b/resources/data_masonry/masonry_back_length_2026-01-01.json new file mode 100644 index 00000000..c8de0f01 --- /dev/null +++ b/resources/data_masonry/masonry_back_length_2026-01-01.json @@ -0,0 +1,40 @@ +{ + "schema_version": "1.0", + "dataset_id": "masonry_back_length_standard", + "effective_date": "2026-01-01", + "note": "돌쌓기 뒷길이 표준 — **높이 × 메/찰** 로 범위가 갈린다. 뒷길이를 안 고른 돌쌓기는 붙박이 45㎝ 로 서고 있었는데, 그 자리를 이 표의 **하한**으로 바꿈(2026-09-14 브레인 판정). 사용자가 칸에서 고르면 그 값이 이김.", + "source": { + "doc": "산림사업 표준품셈(산림청고시 제2025-82호) 13-4-4 [주]⑩ <뒷길이 표준>", + "line": "원문 L7172~7181", + "quote": "높은 메쌓기는 밑으로 내려갈수록 뒷길이를 길게 하는 것이 원칙이다 · 높이는 첨단으로부터 기준 한다 · 특수한 조건에서는 본 기준과 달리 적용할 수 있다" + }, + "policy": { + "suggestion": "⭐ 제안값 = **범위 안에 드는 품셈 규격 중 가장 작은 것**(브레인 판정 ㉮). 하한이 규격(25·30·35·45·55·60·75)에 없으면 그 위 첫 규격 — 메쌓기 ~3m·~5m 하한 36㎝ 는 45㎝. 35 로 내리면 품셈 범위 아래로 나가 안 됨.", + "boundary": "⚠ 원문 표기 `∼1.5 · ∼3 · ∼5 · ∼7 · 7이상` — 표준경사 표(`masonry_slope`)와 같이 「∼」를 **이하**로 읽어 경계값은 앞 칸(높이 3.0m → `~3` 칸).", + "no_write_back": "⚠ 제안값을 저장에 박지 않음 — 「안 고름」과 「45 로 고름」이 구별돼야 함(표준경사와 같은 자리)." + }, + "steps_m": [1.5, 3.0, 5.0, 7.0], + "table_cm": { + "메쌓기": [ + [25, 35], + [36, 45], + [36, 60], + [45, 75], + [75, null] + ], + "찰쌓기": [ + [25, 35], + [30, 35], + [35, 45], + [35, 55], + [45, 60] + ] + }, + "not_here": { + "note": "이 표가 쓰지 않는 칸.", + "items": [ + "돌붙이기 열(메 25~60 · 찰 20~40)은 높이로 안 갈림 — 바닥막이 돌붙임은 뒷길이가 붙박이(`_UnitQuantity_Revetment.BED_SILL_FORMS`)라 여기서 안 씀", + "골막이는 뒷길이 기본이 종전대로 45㎝ — 이 표의 대상(돌쌓기 13-4)이 아님" + ] + } +} diff --git a/resources/library_structure/masonry_wet.json b/resources/library_structure/masonry_wet.json index b9c1cc9a..bf4248e7 100644 --- a/resources/library_structure/masonry_wet.json +++ b/resources/library_structure/masonry_wet.json @@ -19,7 +19,13 @@ "source": "judged_face_slope", "note": "사용자 값이 있으면 그 값, 없으면 품셈 13-4-4 [주]⑪ 표준경사를 전개가 판정해 넘김" }, - "L3": { "label": "뒷길이(㎝)", "option": "back_len_cm", "default": 45 }, + "L3": { + "label": "뒷길이(㎝)", + "option": "back_len_cm", + "default": 45, + "default_from": "standard_back_length", + "note": "안 고르면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·메/찰 · 범위 안 가장 작은 규격) — 전개와 한 벌(2026-09-14 브레인 판정 ㉮). 45 는 표를 못 읽을 때만" + }, "돌종류": { "label": "돌 종류", "option": "stone_kind", "default": "" }, "계수기준": { "label": "야면석 계수", "option": "stone_coeff_basis", "default": "" }, "TOP_IN": { diff --git a/resources/tester/test_b08_back_length_standard.py b/resources/tester/test_b08_back_length_standard.py new file mode 100644 index 00000000..7a26c8fe --- /dev/null +++ b/resources/tester/test_b08_back_length_standard.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""뒷길이 표준(품셈 13-4-4 [주]⑩) — 안 고른 돌쌓기의 뒷길이는 **표의 하한** (2026-09-14 브레인 판정 ㉮). + +앞서 붙박이 45㎝ 로 섰음. 표는 높이 × 메/찰 로 범위를 줌 — 하한이 규격(25·30·35·45·55·60·75)에 +없으면 **범위 안 그 위 첫 규격**(메쌓기 ~3m·~5m 하한 36 → 45). 경계값은 앞 칸(표준경사와 같음). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402 + _back_length, + back_length_default_note, + standard_back_length, +) + +# (높이, 메쌓기 제안, 찰쌓기 제안) — 원문 L7174~7177 +CELLS = [ + (1.0, 25, 25), + (1.5, 25, 25), # 경계값은 앞 칸 + (2.5, 45, 30), # 메 36~45 → 45 · 찰 30~35 → 30 + (3.0, 45, 30), + (4.0, 45, 35), # 메 36~60 → 45 · 찰 35~45 → 35 + (6.0, 45, 35), # 메 45~75 · 찰 35~55 + (7.5, 75, 45), # 메 75이상 · 찰 45~60 +] + + +@pytest.mark.parametrize("height, dry, wet", CELLS) +def test_표_하한(height: float, dry: int, wet: int) -> None: + assert standard_back_length(wet=False, height_m=height)[0] == dry + assert standard_back_length(wet=True, height_m=height)[0] == wet + + +def test_하한이_규격에_없으면_그_위_첫_규격_사유() -> None: + cm, basis = standard_back_length(wet=False, height_m=2.5) + assert cm == 45 + assert "36~45㎝" in basis and "하한 36㎝ 는 규격 일곱(원문 L7152)에 없어" in basis + assert "원문 L7172~7181" in basis # 사용자가 되짚을 줄 번호(브레인 2026-09-14) + + +def test_고른_값이_이긴다() -> None: + assert _back_length({"back_len_cm": "55"}, wet=True, height_m=2.5) == 55 + assert back_length_default_note({"back_len_cm": 55}, 55, wet=True, height_m=2.5) is None + + +def test_메찰_높이를_모르는_자리는_종전_45() -> None: + assert _back_length({}) == 45 + assert "기본 45㎝" in (back_length_default_note({}, 45) or "") diff --git a/resources/tester/test_b08_build_table_templates.py b/resources/tester/test_b08_build_table_templates.py index adbbf5ae..de4976bb 100644 --- a/resources/tester/test_b08_build_table_templates.py +++ b/resources/tester/test_b08_build_table_templates.py @@ -38,6 +38,9 @@ def _structures() -> list[dict]: "length_m": 10, "foundation": "기초유", "stone_kind": "야면석·호박돌", + # 2026-09-14 — 안 고르면 뒷길이 표준 하한(찰 ≤3m 30㎝)이라 야면석 관측 무게(35·45·55)가 + # 없어 돌 줄이 안 섬. 돌 줄을 보는 시험이라 45 로 고름 · 빈 칸 길은 아래 시험. + "back_len_cm": 45, }, }, { @@ -76,6 +79,21 @@ def test_양식_갈음은_값을_안_움직인다() -> None: assert stone["source"] == "uljin_library" +def test_뒷길이_안_고르면_양식도_전개와_같은_표준_하한() -> None: + """품셈 13-4-4 [주]⑩ 하한(찰 ≤3m 30㎝)을 전개와 양식이 한 벌로 씀(브레인 판정 ㉮).""" + structures = _structures()[:1] + structures[0]["options"].pop("back_len_cm") + structures[0]["options"].pop("stone_kind") + names = {"masonry_wet": "돌쌓기(찰)"} + before = build_table(structures, names, None, None, 0.2, use_templates=False)["structures"][0] + after = build_table(structures, names, None, None, 0.2)["structures"][0] + assert after["library_item"] == "돌쌓기(찰)" + assert [c["name"] for c in after["components"]] == [c["name"] for c in before["components"]] + for got, want in zip(after["components"], before["components"]): + assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"] + assert any("하한 30㎝" in note for note in before["notes"]) + + def test_자재총괄_갈래와_배합_성분() -> None: names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"} before = build_material_table( diff --git a/resources/tester/test_b08_structure_figure.py b/resources/tester/test_b08_structure_figure.py index ef184246..ddf46501 100644 --- a/resources/tester/test_b08_structure_figure.py +++ b/resources/tester/test_b08_structure_figure.py @@ -89,7 +89,9 @@ def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() - options = {"height_m": 2.0} _rows, table_notes = stone_masonry(2.0, 1.0, options, wet=False) table_default = [note for note in table_notes if "뒷길이를 안 골라" in note] - assert table_default == [back_length_default_note(options, 45)] + # 2026-09-14 — 기본은 뒷길이 표준 하한(메 ≤3m 36~45㎝ → 45) · 표·그림이 같은 말. + assert table_default == [back_length_default_note(options, 45, wet=False, height_m=2.0)] + assert "하한 36㎝ 는 규격 일곱" in table_default[0] texts = _texts(build_figure(_sheet())) assert table_default[0] in texts diff --git a/resources/tester/test_b08_structure_template_parity.py b/resources/tester/test_b08_structure_template_parity.py index 0fc802b8..98a61a31 100644 --- a/resources/tester/test_b08_structure_template_parity.py +++ b/resources/tester/test_b08_structure_template_parity.py @@ -61,7 +61,16 @@ CASES = [ dict(height=2.0, length=7.5, back_len_cm=45, face_slope_ratio=0.35, foundation="기초유"), dict(height=2.0, length=10.0, back_len_cm=55, thickness_top_m=0.9, foundation="기초버림"), dict(height=2.0, length=10.0, back_len_cm=45, thickness_bottom_m="1.4", stone_kind="깬잡석"), - dict(height=2.0, length=10.0, stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행"), + # 뒷길이 45 — 안 고르면 표준 하한 30㎝ 라 야면석 관측 무게(35·45·55)가 없어 양식만 오류 줄(명세 13장 Ⓐ) + dict( + height=2.0, + length=10.0, + back_len_cm=45, + stone_kind="야면석·호박돌", + stone_coeff_basis="실무 관행", + ), + # 뒷길이 안 고름 — 전개·양식이 같은 표준 하한(찰 ≤3m 30㎝, 브레인 판정 ㉮) + dict(height=2.0, length=10.0, stone_kind="깬돌", foundation="기초유"), dict(height=1.5, length=10.0, back_len_cm=35, weep_hole_area_m2=3, foundation="기초유"), dict(height=2.5, length=12.0, back_len_cm=60, stone_kind="견치돌", rubble=0.3), # 줄이 안 서는 갈래 — `when` diff --git a/resources/tester/test_b08_unit_quantity.py b/resources/tester/test_b08_unit_quantity.py index 8e7249ef..3ad095f4 100644 --- a/resources/tester/test_b08_unit_quantity.py +++ b/resources/tester/test_b08_unit_quantity.py @@ -125,7 +125,8 @@ def test_실무_시트_m당_값_재현() -> None: 품셈 1-2-2 의 소수 자리는 표기 규칙이고 계산은 전정밀로 둔다(PLAN 8-16). 맞추려고 식을 비틀지 않는다 — 허용오차를 그 차이만큼 둔다. """ - result = expand(돌쌓기찰(height=1.5, length=1.0)) + # 실무 시트가 뒷길이 45 — 2026-09-14 부터 안 고르면 표준 하한(찰 ≤1.5m 25㎝)이라 고름. + result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45)) assert result.height_m == 1.5 assert result.length_m == 1.0 assert 성분(result, "터파기").amount == pytest.approx(1.55, abs=0.02) @@ -135,7 +136,7 @@ def test_실무_시트_m당_값_재현() -> None: def test_평균두께는_전정밀로() -> None: """실무가 0.83 으로 반올림해 재계산한 자리 — 우리는 0.825 를 그대로 쓴다.""" - result = expand(돌쌓기찰(height=1.5, length=1.0)) + result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45)) # 터파기 = 높이 × (평균두께 + 0.2) × 연장 = 1.5 × 1.025 = 1.5375 assert 성분(result, "터파기").amount == pytest.approx(1.5375)