From 6a9506f1fb2371e713aeb3a7123764820fa6ddf9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 18:43:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b08):=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=85=8B(?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20=ED=86=A0=EA=B3=B5=20?= =?UTF-8?q?=C2=B7=20=EC=88=98=EB=A6=AC)=20+=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EC=98=B5=EC=85=98=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B8=B8=20GET/PUT=20quantity/options(122-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 › 배관 1본 길이 · 토공 › 연암 · 경암 % 입력(공용 compact 입력 · 접이 카드) - 조작 = 캐시 b08-options · [저장] = 프로젝트 층 M02 구조물집계표 변수 · project_settings rock_ratios_pct - 브라우저 짝 식 파일 B08_Quantity_Options_Calc.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/b_page_state.ts | 2 + B08_Quantity/B08_Quantity_Options_Calc.ts | 108 +++++++++++ B08_Quantity/B08_Quantity_Router_Options.py | 91 +++++++++ B08_Quantity/B08_Quantity_UI_Hydro.ts | 2 +- B08_Quantity/B08_Quantity_UI_Options.ts | 201 ++++++++++++++++++++ B08_Quantity/B08_Quantity_UI_Page.css | 13 ++ B08_Quantity/B08_Quantity_UI_Page.ts | 6 + common_util/common_util_project_settings.py | 15 ++ main.py | 2 + 9 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 B08_Quantity/B08_Quantity_Options_Calc.ts create mode 100644 B08_Quantity/B08_Quantity_Router_Options.py create mode 100644 B08_Quantity/B08_Quantity_UI_Options.ts diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 75850c9cb..23ec0d270 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -166,6 +166,8 @@ export const STATE_REGISTRY = { crossdesign: { bucket: "draft", scope: "route" }, /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */ "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, + /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ + "b08-options": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ "alignment-draft": { diff --git a/B08_Quantity/B08_Quantity_Options_Calc.ts b/B08_Quantity/B08_Quantity_Options_Calc.ts new file mode 100644 index 000000000..b4f33256c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Options_Calc.ts @@ -0,0 +1,108 @@ +/* ============================================================================= + * B08_Quantity_Options_Calc.ts + * 좌측 패널 옵션 — 조작 중 브라우저가 바로 다시 세는 식(화면 없음 · PLAN 122). + * + * 짝(파이썬 · 같은 식) — 거울 시험 `test_122_b08_options_mirror`: + * couplings · recountCouplings ↔ `B08_Quantity_Router_Summary.py` couplings(관마다 `pipes`) + * rockSplit · splitRockColumns · splitRockSummary + * ↔ `B08_Quantity_Engine_Earthwork_Summary.py` rock_split · + * split_rock_columns · summary_rows(연암 · 경암 네 줄) + * [저장] 뒤에는 서버가 정본으로 다시 셈 — 여기 값을 받아 적지 않음. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +/** 커플링 = 올림(L ÷ 1본) − 1(0 아래 없음) · 길이나 1본이 빈칸이면 null. */ +export function couplings(lengthM: number | null | undefined, oneM: number | null): number | null { + if (!lengthM || !oneM) return null; + return Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0); +} + +export interface PipeLength { + chainage_m: number; + /** 커플링 열 id(`pipe:cp|관경`) */ + column: string; + length_m: number | null; +} + +interface CouplingSummary { + rows: Array<{ chainage_m: number; values: Record }>; + total: Record; + pipes: PipeLength[]; +} + +/** 커플링 열만 새 1본 길이로 — 같은 측점(1 cm)의 같은 열은 더함 · 계 = 줄 합. */ +export function recountCouplings(summary: S, oneM: number | null): S { + const at = (chainage: number): number => Math.round(chainage * 100); + const columns = new Set(summary.pipes.map((pipe) => pipe.column)); + const sums = new Map(); + for (const pipe of summary.pipes) { + const count = couplings(pipe.length_m, oneM); + if (count === null) continue; + const key = `${at(pipe.chainage_m)}|${pipe.column}`; + sums.set(key, (sums.get(key) ?? 0) + count); + } + const keep = (values: Record): Record => + Object.fromEntries(Object.entries(values).filter(([id]) => !columns.has(id))); + const rows = summary.rows.map((row) => { + const values = keep(row.values); + for (const column of columns) { + const count = sums.get(`${at(row.chainage_m)}|${column}`); + if (count !== undefined) values[column] = count; + } + return { ...row, values }; + }); + const total = keep(summary.total); + for (const row of rows) + for (const column of columns) + if (row.values[column] !== undefined) + total[column] = (total[column] ?? 0) + row.values[column]; + return { ...summary, rows, total }; +} + +/** 0~100 으로 자름 · 숫자가 아니면 null. */ +export function clampPct(value: number): number | null { + return Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : null; +} + +/** 암 → [연암, 경암] — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암. */ +export function rockSplit(value: number, softPct: number): [number, number] { + const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100; + return [soft, value - soft]; +} + +const ROCK_SPLIT = [ + ["cut_rock", "cut_soft_rock", "cut_hard_rock"], + ["ditch_rock", "ditch_soft_rock", "ditch_hard_rock"], +] as const; +const ROCK_SUFFIXES = ["area_m2", "volume_m3", "adjusted_m3"] as const; + +/** 줄 · 합계의 암 칸마다 연암 · 경암 칸을 덮어씀(숫자인 칸만). */ +export function splitRockColumns(record: Record, softPct: number): void { + for (const [base, softKey, hardKey] of ROCK_SPLIT) + for (const suffix of ROCK_SUFFIXES) { + const value = record[`${base}_${suffix}`]; + if (typeof value !== "number") continue; + const [soft, hard] = rockSplit(value, softPct); + record[`${softKey}_${suffix}`] = soft; + record[`${hardKey}_${suffix}`] = hard; + } +} + +/** 토공집계표 연암 · 경암 줄 — 흙깍기 = 절토 암 · 측구 터파기 = 측구 암 합계 × 비율 · 반올림 정수. */ +const ROCK_ROWS: Record = { + 흙깍기: "cut_rock_volume_m3", + "측구 터파기": "ditch_rock_volume_m3", +}; + +export function splitRockSummary( + rows: readonly R[], + totals: Record, + softPct: number, +): R[] { + return rows.map((row) => { + const base = ROCK_ROWS[row.group]; + if (!base || (row.kind !== "연암" && row.kind !== "경암")) return row; + const [soft, hard] = rockSplit(totals[base] ?? 0, softPct); + return { ...row, value: Math.round(row.kind === "연암" ? soft : hard) }; + }); +} diff --git a/B08_Quantity/B08_Quantity_Router_Options.py b/B08_Quantity/B08_Quantity_Router_Options.py new file mode 100644 index 000000000..27fbaae3b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Options.py @@ -0,0 +1,91 @@ +"""B08 좌측 패널 옵션 길(PLAN 122) — 배관 1본 길이 · 연암 / 경암 비율. + + GET /api/projects/{id}/quantity/options 지금 값 + PUT /api/projects/{id}/quantity/options [저장] — 받은 칸만 정본 자리에 씀 + +1본 길이 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` 한 벌(98 `one_pipe_length` 이 읽는 자리 · +M02 표 식 `[$파형강관_1본_m]` 도 같은 값) · 프로젝트 사본이 없으면 시스템 층 문서를 바탕으로 깔고 씀 · +M02 [초기화]면 같이 되돌아감. +연암 % = `project_settings.json › quantity › rock_ratios_pct`(연암 · 경암 = 100 − 연암) · 기본 90 은 +파일에 안 쓰고 읽을 때 채움(`soft_rock_pct`). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Summary import ONE_LENGTH_VAR, TABLE_NAME, one_pipe_length +from common_util.common_util_project_settings import quantity_settings, save_section, soft_rock_pct +from M02_MasterTemplete import M02_Template_Layers as layers + +router = APIRouter(prefix="/api/projects", tags=["B08 Options"]) + + +class OptionsSave(BaseModel): + """빈칸(None) = 그 칸은 그대로.""" + + one_pipe_length_m: float | None = Field(default=None, gt=0, le=100) + soft_rock_pct: float | None = Field(default=None, ge=0, le=100) + + +def read_options(root: str | Path) -> dict[str, Any]: + soft = soft_rock_pct(quantity_settings(root)) + return { + "one_pipe_length_m": one_pipe_length(root), + "rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft}, + } + + +def _write_one_pipe(root: str | Path, value: float) -> None: + """프로젝트 층 구조물집계표 변수 한 칸 — 판이 그새 바뀌었으면 `StaleTemplate`.""" + folder = layers.project_dir(root) + found = layers.read_template(folder, "table", TABLE_NAME) + version = found["판"] if found else None + found = found or layers.read_template(layers.system_dir(), "table", TABLE_NAME) + if found is None: + raise HTTPException(status_code=404, detail=f"{TABLE_NAME} 양식이 없습니다 — M02 표부터.") + document = found["문서"] + document["변수"] = {**(document.get("변수") or {}), ONE_LENGTH_VAR: value} + layers.write_template( + folder, "table", TABLE_NAME, document, version=version, check_version=True + ) + + +def save_options(root: str | Path, body: OptionsSave) -> dict[str, Any]: + if body.one_pipe_length_m is not None: + _write_one_pipe(root, body.one_pipe_length_m) + if body.soft_rock_pct is not None: + soft = float(body.soft_rock_pct) + ratios = { + **(quantity_settings(root).get("rock_ratios_pct") or {}), + "연암": soft, + "경암": 100.0 - soft, + } + save_section( + root, "quantity", {"rock_ratios_pct": ratios}, replace_keys=("rock_ratios_pct",) + ) + return read_options(root) + + +@router.get("/{project_id}/quantity/options") +async def options_read(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(read_options, root) + + +@router.put("/{project_id}/quantity/options") +async def options_save(project_id: UUID, body: OptionsSave) -> dict[str, Any]: + root = await _project_root(project_id) + try: + return await asyncio.to_thread(save_options, root, body) + except layers.StaleTemplate as error: + raise HTTPException( + status_code=409, detail="구조물집계표가 그새 바뀌었습니다 — 다시 열고 저장하세요." + ) from error diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index 07450ae0c..5056c993a 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -44,7 +44,7 @@ interface HydroStation { type Drafts = Record>; type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; -async function request(path: string, init: RequestInit = {}): Promise { +export async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts new file mode 100644 index 000000000..d994e765d --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * B08_Quantity_UI_Options.ts + * 좌측 패널(PLAN 122) — 메인 탭과 같은 컨테이너 셋(구조물 · 토공 · 수리집수면적표 · 접이) + [저장] 줄. + * + * 구조물 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` + * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) + * 수리집수면적표 — 빈 자리(다음 옵션) + * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · + * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. + * ========================================================================== */ + +import { + createButton, + createInputField, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { clampPct } from "./B08_Quantity_Options_Calc"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 조작 중 값 — 없는 칸은 저장본 그대로 · 1본 null = 빈칸. */ +export interface OptionsDraft { + one_pipe_length_m?: number | null; + soft_rock_pct?: number; +} + +interface SavedOptions { + one_pipe_length_m: number | null; + rock_ratios_pct: { 연암: number; 경암: number }; +} + +const changes = new EventTarget(); + +const readDraft = (projectId: string): OptionsDraft => + readState("b08-options", projectId) ?? {}; + +/** 지금 1본 길이 — 캐시 → 저장본. */ +export function liveOnePipe(projectId: string, saved: number | null): number | null { + const draft = readDraft(projectId); + return draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved; +} + +/** 지금 연암 % — 캐시 → 저장본. */ +export function liveSoftRock(projectId: string, saved: number): number { + return readDraft(projectId).soft_rock_pct ?? saved; +} + +/** host 가 화면에 붙어 있는 동안 옵션이 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌. */ +export function onOptionsChange(host: HTMLElement, fn: () => void): void { + const listener = (): void => { + if (host.isConnected) fn(); + else changes.removeEventListener("change", listener); + }; + changes.addEventListener("change", listener); +} + +const pctText = (value: number): string => String(Math.round(value * 1e6) / 1e6); + +function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[]): HTMLElement { + const section = document.createElement("section"); + section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; + section.dataset.b08Options = id; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = L(title); + section.append(heading, ...body); + return section; +} + +function hint(text: string): HTMLElement { + const p = document.createElement("p"); + p.className = "b08-options__hint"; + p.textContent = text; + return p; +} + +/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. */ +export function createOptionsPanel(projectId: string | null, onSaved: () => void): HTMLElement { + const one = createInputField({ + label: "배관 1본 길이 (m)", + type: "number", + compact: true, + min: 0, + }); + const soft = createInputField({ + label: "연암 (%)", + type: "number", + compact: true, + min: 0, + max: 100, + }); + const hard = createInputField({ + label: "경암 (%)", + type: "number", + compact: true, + min: 0, + max: 100, + }); + one.input.dataset.b08Option = "one_pipe_length_m"; + soft.input.dataset.b08Option = "soft_rock_pct"; + hard.input.dataset.b08Option = "hard_rock_pct"; + const rockRow = document.createElement("div"); + rockRow.className = "b08-options__row"; + rockRow.append(soft.root, hard.root); + + let saved: { one: number | null; soft: number } = { one: 8, soft: 90 }; + const show = (): void => { + const draft = projectId ? readDraft(projectId) : {}; + const oneM = draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved.one; + const softPct = draft.soft_rock_pct ?? saved.soft; + one.input.value = oneM == null ? "" : String(oneM); + soft.input.value = pctText(softPct); + hard.input.value = pctText(100 - softPct); + }; + const put = (patch: OptionsDraft): void => { + if (!projectId) return; + writeState("b08-options", { ...readDraft(projectId), ...patch }, projectId); + changes.dispatchEvent(new Event("change")); + }; + + one.input.addEventListener("input", () => { + const value = Number(one.input.value); + put({ one_pipe_length_m: one.input.value.trim() && value > 0 ? value : null }); + }); + // 한쪽을 고치면 다른 쪽 = 100 − 값 · 칸을 떠나면 자른 값으로 다시 씀 + const pair = (from: HTMLInputElement, other: HTMLInputElement, isSoft: boolean): void => { + from.addEventListener("input", () => { + const value = from.value.trim() ? clampPct(Number(from.value)) : null; + if (value === null) return; + other.value = pctText(100 - value); + put({ soft_rock_pct: isSoft ? value : 100 - value }); + }); + from.addEventListener("change", show); + }; + pair(soft.input, hard.input, true); + pair(hard.input, soft.input, false); + + const saveAll = async (): Promise => { + if (!projectId) return; + const draft = readDraft(projectId); + if (draft.one_pipe_length_m === null) { + showToast("배관 1본 길이를 넣으세요 — 0 보다 큰 값.", "error"); + return; + } + showLoadingOverlay(); + try { + const body = await request(`/projects/${projectId}/quantity/options`, { + method: "PUT", + body: JSON.stringify(draft), + }); + saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 }; + clearState("b08-options", projectId); + show(); + showToast("수량 산출 옵션을 저장했습니다.", "success"); + onSaved(); + } catch (error) { + showToast(`저장하지 못했습니다 — ${String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } + }; + + const save = createButton({ + label: L("B08_Quantity_Btn_Save"), + variant: "filled", + onClick: () => void saveAll(), + }); + save.dataset.b08OptionsSave = ""; + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(save); + + const panel = document.createElement("div"); + panel.className = "ui-sidebar-panel b08-options"; + panel.append( + card("structure", "B08_Quantity_Tab_Structure", one.root), + card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), + card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), + actions, + ); + attachCollapsible(panel); + show(); + + if (projectId) + request(`/projects/${projectId}/quantity/options`).then( + (body) => { + saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 }; + show(); + }, + (error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"), + ); + return panel; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index a74e7d678..332249424 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -18,3 +18,16 @@ padding-bottom: var(--spacing-8); border-bottom: 1px solid var(--color-border); } + +/* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */ +.b08-options__row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); +} + +.b08-options__hint { + margin: var(--spacing-4) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index c2621bab7..6982d8c84 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -6,6 +6,7 @@ * 구조물 아래 보조 탭 = 구조물 집계표(61-1) · 총괄자재수량집계표 · 콘크리트소요자재집계표 · * 자재수량집계표(셋은 아직 빈 탭) — 구조물도는 집계표 행을 눌러 여는 모달(95-3). * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. + * 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122). * ========================================================================== */ import "@ui/ui_template_general_layout.css"; @@ -19,6 +20,7 @@ import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflo import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; import { renderHydro } from "./B08_Quantity_UI_Hydro"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; +import { createOptionsPanel } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -99,7 +101,9 @@ export function renderB08Quantity(root: HTMLElement): void { panel.className = "b08-quantity__panel"; panel.setAttribute("role", "tabpanel"); + let current: TabId = "structure"; const select = (tab: TabId): void => { + current = tab; tabBar.replaceChildren( ...TABS.map(({ id, label }) => { const button = createButton({ @@ -126,6 +130,8 @@ export function renderB08Quantity(root: HTMLElement): void { steps: workflowSteps(), activeStep: 5, mainContent: body, + // [저장] 뒤 열린 탭을 서버 정본으로 다시 그림 + leftPanel: createOptionsPanel(projectId, () => select(current)), routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 68c8fcc21..66157aecb 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -253,6 +253,21 @@ def quantity_settings(project_root: str | Path) -> dict[str, Any]: return load_settings(project_root).get("quantity") or {} +#: 연암 % 기본 — 암 전체 기준 · 경암 = 100 − 연암 · 파일에 안 쓰고 읽을 때 채움(PLAN 122) +SOFT_ROCK_PCT_DEFAULT = 90.0 + + +def soft_rock_pct(settings: dict[str, Any]) -> float: + """연암 %(0~100) — `rock_ratios_pct` 연암 · 없으면 100 − 경암 · 둘 다 없으면 기본 90.""" + ratios = settings.get("rock_ratios_pct") or {} + for name, flip in (("연암", False), ("경암", True)): + value = ratios.get(name) + if isinstance(value, (int, float)) and not isinstance(value, bool): + pct = min(max(float(value), 0.0), 100.0) + return 100.0 - pct if flip else pct + return SOFT_ROCK_PCT_DEFAULT + + def estimation_settings(project_root: str | Path) -> dict[str, Any]: """B09 구획만 꺼낸다 — 범위 계수·장비 규격처럼 **사용자가 고른 단가 조건**이 여기 산다.""" return load_settings(project_root).get("estimation") or {} diff --git a/main.py b/main.py index 1205b1eb6..31e5d193d 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_r from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router +from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -649,6 +650,7 @@ app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) app.include_router(b08_hydro_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_summary_router, dependencies=protected_with_company) +app.include_router(b08_options_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] From a42fcd920ed55432384888fb2454d271618c291a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 18:43:59 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=BB=A4=ED=94=8C=EB=A7=81=20=3D?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=201=EB=B3=B8=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=EB=A1=9C=20=EB=B0=94=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=85=88=20=C2=B7=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EA=B4=80=EB=A7=88=EB=8B=A4=20=EA=B8=B8=EC=9D=B4=20pipes(122-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- B08_Quantity/B08_Quantity_Router_Summary.py | 6 ++++- B08_Quantity/B08_Quantity_UI_Summary.ts | 24 ++++++++++++++++---- resources/tester/test_98_b08_pipe_summary.py | 17 ++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index c70159575..cdec4ccfe 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -13,6 +13,8 @@ 머리 글 = M02 정의 이름(`구조물정의.json` 종류.pipe.이름 — 사용자가 M02 에서 고침) · 열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리). 설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용. +`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈 +(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122). """ from __future__ import annotations @@ -181,6 +183,7 @@ def pipe_summary( entry["base"] = item sheet = _Sheet() + pipes: list[dict[str, Any]] = [] no_length = no_basin = 0 soil_name = names.get(f"outlet.structure={SOIL}", SOIL) for owner, entry in by_owner.items(): @@ -213,6 +216,7 @@ def pipe_summary( couplings(length, one_m), pipe_combos, ) + pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}) shape = fill._nearest(basins, at) no_basin += shape is None if shape: @@ -288,7 +292,7 @@ def pipe_summary( notes.append(f"집수정 모양 기록 없음 {no_basin}곳 — B06 횡단 [저장] 뒤 채워짐") if one_m is None: notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸") - return {**out, "notes": notes, "one_length_m": one_m} + return {**out, "notes": notes, "one_length_m": one_m, "pipes": pipes} def _summary(root: Path, designs: list[dict[str, Any]]) -> dict[str, Any]: diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 3686f449c..cddde59b7 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -8,6 +8,7 @@ * 배관(횡단배수관) 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 다른 구조물 열은 조합 그대로 * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) + * 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -32,6 +33,8 @@ import { type PlacedItem, type StationCombo, } from "./B08_Quantity_Summary_Data"; +import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; +import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -65,6 +68,8 @@ interface PipeSummary { rows: Array<{ chainage_m: number; values: Record }>; total: Record; notes: string[]; + one_length_m: number | null; + pipes: PipeLength[]; } async function fetchPipeSummary(projectId: string): Promise { @@ -204,20 +209,29 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin ...placed.filter((point) => (point.facility ?? "pipe") !== "pipe"), ]; const columns = summaryColumns(others, describe); - const summary = typeof pipe === "string" ? null : pipe; + const saved = typeof pipe === "string" ? null : pipe; + const live = (): PipeSummary | null => + saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)); + const summary = live(); const parts: HTMLElement[] = []; if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); for (const text of summary?.notes ?? []) parts.push(note(text)); if (!columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); - parts.push( + const draw = (): HTMLElement => stations.length - ? buildTable(stations, columns, summary, comboPath, interval, (station, label) => + ? buildTable(stations, columns, live(), comboPath, interval, (station, label) => openSheets(projectId, label, combosAt(things, station), m02, describe), ) - : note(L("B08_Quantity_Summary_NoStation")), - ); + : note(L("B08_Quantity_Summary_NoStation")); + let table = draw(); + parts.push(table); host.replaceChildren(...parts); + onOptionsChange(host, () => { + const next = draw(); + table.replaceWith(next); + table = next; + }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); host.replaceChildren(note(`${L("B08_Quantity_Summary_Failed")} — ${reason}`)); diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index be9f0c99a..477f3fc20 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -210,6 +210,23 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch): assert summary.one_pipe_length(root) is None +def test_관마다_길이_칸_화면이_1본_바꿔_다시_셈(root): + # 122 — 응답 `pipes` = 관마다 길이 · 커플링 열(화면 recountCouplings 가 같은 식으로 셈) + six, eight = _run(root, 6.0), _run(root, 8.0) + assert six["pipes"] == eight["pipes"] + assert [(round(p["chainage_m"]), p["column"], p["length_m"]) for p in six["pipes"]] == [ + (1433, "pipe:cp|1200", 22), + (1567, "pipe:cp|800", 18), + (1680, "pipe:cp|800", 16), + (1790, "pipe:cp|800", None), + ] + for out, one in ((six, 6.0), (eight, 8.0)): + total = sum( + summary.couplings(p["length_m"], one) or 0 for p in out["pipes"] if "800" in p["column"] + ) + assert out["total"]["pipe:cp|800"] == total + + def test_라우터_등록(): text = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8") assert "app.include_router(b08_summary_router, dependencies=protected_with_company)" in text From 9a253ece97c8a7fc4bf8f5b07314cac6912b84d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 18:43:59 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b08):=20=EC=97=B0=EC=95=94=20=C2=B7=20?= =?UTF-8?q?=EA=B2=BD=EC=95=94=20=3D=20=EC=95=94=20=C3=97=20=EB=B9=84?= =?UTF-8?q?=EC=9C=A8(=EA=B8=B0=EB=B3=B8=2090=20:=2010)=20=C2=B7=20?= =?UTF-8?q?=ED=86=A0=EA=B3=B5=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=84=A4=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=ED=86=A0=EC=A0=81=ED=91=9C=20=EC=A0=88?= =?UTF-8?q?=ED=86=A0=20=EC=97=B0=EC=95=94=20=C2=B7=20=EA=B2=BD=EC=95=94=20?= =?UTF-8?q?=EB=91=90=20=EC=97=B4=20=C2=B7=20=EA=B1=B0=EC=9A=B8=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98(122-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../B08_Quantity_Engine_Earthwork_Summary.py | 51 ++++- B08_Quantity/B08_Quantity_Router_Earthwork.py | 23 ++- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 24 ++- .../B08_Quantity_UI_Earthwork_Sheets.ts | 2 +- resources/tester/test_122_b08_options.py | 164 ++++++++++++++++ .../tester/test_122_b08_options_mirror.py | 177 ++++++++++++++++++ .../tester/test_69_b08_earthwork_tabs.py | 10 +- .../tester/test_rock_mixed_conversion.py | 2 +- 8 files changed, 434 insertions(+), 19 deletions(-) create mode 100644 resources/tester/test_122_b08_options.py create mode 100644 resources/tester/test_122_b08_options_mirror.py diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py index 2acad151a..080ea669c 100644 --- a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py @@ -11,8 +11,11 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토 장비 띠(`blocks[].bands[]`)와 떨어진 구간 운반(`transfers[]`)의 `equipment` 로 가름 (`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭). -암 갈래 — 리핑암 = 「연암」 줄 · 발파암 = 「경암」 줄. 풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 -값이 없어 비움(None). +암 갈래 — 「연암」 · 「경암」 줄 = 암 전체 × 비율(좌측 패널 · 기본 90 : 10 · PLAN 122). +풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 값이 없어 비움(None). + +짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` · +`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`). """ from __future__ import annotations @@ -20,8 +23,16 @@ from __future__ import annotations from decimal import ROUND_HALF_UP, Decimal from typing import Any +from common_util.common_util_project_settings import SOFT_ROCK_PCT_DEFAULT + #: 운반 시트 열 — 장비 키 HAUL_EQUIPMENT = ("dump_truck", "dozer", "free_haul") +#: 암 칸 → 연암 칸 · 경암 칸(토적표 · 합계) · 칸 꼬리 셋 +ROCK_SPLIT = ( + ("cut_rock", "cut_soft_rock", "cut_hard_rock"), + ("ditch_rock", "ditch_soft_rock", "ditch_hard_rock"), +) +ROCK_SUFFIXES = ("area_m2", "volume_m3", "adjusted_m3") def excel_round(value: float, digits: int = 0) -> float: @@ -30,6 +41,24 @@ def excel_round(value: float, digits: int = 0) -> float: return float(Decimal(str(value)).quantize(step, rounding=ROUND_HALF_UP)) +def rock_split(value: float, soft_pct: float) -> tuple[float, float]: + """암 → (연암, 경암) — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암.""" + soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0 + return soft, value - soft + + +def split_rock_columns(record: dict[str, Any], soft_pct: float) -> dict[str, Any]: + """줄 · 합계의 암 칸마다 연암 · 경암 칸을 더함(숫자인 칸만).""" + for base, soft_key, hard_key in ROCK_SPLIT: + for suffix in ROCK_SUFFIXES: + value = record.get(f"{base}_{suffix}") + if isinstance(value, (int, float)) and not isinstance(value, bool): + soft, hard = rock_split(float(value), soft_pct) + record[f"{soft_key}_{suffix}"] = soft + record[f"{hard_key}_{suffix}"] = hard + return record + + def haul_lists(haul_plan: dict[str, Any] | None) -> dict[str, dict[str, Any]]: """장비별 `{rows: [{q, l, ql}], q, ql, average_m}` — 시트 한 줄 = 띠 · 운반 하나.""" items: dict[str, list[dict[str, float]]] = {key: [] for key in HAUL_EQUIPMENT} @@ -58,9 +87,15 @@ def _row(group: str, kind: str, spec: str, unit: str, value: float | None, note: return {"group": group, "kind": kind, "spec": spec, "unit": unit, "value": value, "note": note} -def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: - """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석).""" +def summary_rows( + totals: dict[str, float], + haul: dict[str, dict[str, Any]], + soft_pct: float = SOFT_ROCK_PCT_DEFAULT, +) -> list[dict[str, Any]]: + """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔.""" t = totals + cut_soft, cut_hard = rock_split(t["cut_rock_volume_m3"], soft_pct) + ditch_soft, ditch_hard = rock_split(t["ditch_rock_volume_m3"], soft_pct) r0 = excel_round free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"] diverted = t["diverted_m3"] @@ -69,14 +104,14 @@ def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> l return [ _row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137 _row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None), - _row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(t["cut_ripping_rock_volume_m3"])), + _row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(cut_soft)), _row("흙깍기", "보통암", "굴삭기 + 브레카", m3, None), - _row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(t["cut_blasting_rock_volume_m3"])), + _row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(cut_hard)), _row("측구 터파기", "토사", "기계", m3, r0(t["ditch_soil_volume_m3"])), # 토적표!K137 _row("측구 터파기", "풍화암", "굴삭기 + 브레카", m3, None), - _row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(t["ditch_ripping_rock_volume_m3"])), + _row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(ditch_soft)), _row("측구 터파기", "보통암", "굴삭기 + 브레카", m3, None), - _row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(t["ditch_blasting_rock_volume_m3"])), + _row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(ditch_hard)), _row("보정량계", "", "", m3, r0(t["adjusted_total_m3"])), # 토적표!P137 _row("성토", "", "", m3, r0(t["fill_volume_m3"])), # 토적표!R137 _row("유용토 소계", "", "", m3, diverted + free_q), # G18+G19 diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index a7abaa3e1..9d249fe22 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -7,6 +7,7 @@ 단면적 = B06 횡단 설계 저장값(`cross_sections.data.design`) · 노선 = 확정 노선(없으면 최신 노선) · 토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)`. +암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표. """ from __future__ import annotations @@ -24,9 +25,17 @@ from B06_Section.B06_Section_Repository import ( get_workflow_route_context, ) from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table -from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import haul_lists, summary_rows +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( + haul_lists, + split_rock_columns, + summary_rows, +) from B08_Quantity.B08_Quantity_Router_Hydro import _project_root -from common_util.common_util_project_settings import mixed_conversion_factors, quantity_settings +from common_util.common_util_project_settings import ( + mixed_conversion_factors, + quantity_settings, + soft_rock_pct, +) from config.config_db import get_db_pool router = APIRouter(prefix="/api/projects", tags=["B08 Earthwork"]) @@ -44,14 +53,18 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]: route_id = int(context["route_id"]) rows = await get_cross_section_designs(connection, route_id) section = await get_longitudinal_section(connection, project_id, route_id) - factors = await asyncio.to_thread(lambda: mixed_conversion_factors(quantity_settings(root))) + settings = await asyncio.to_thread(quantity_settings, root) + soft = soft_rock_pct(settings) stations = [StationArea.from_design(row["chainage_m"], row["design"]) for row in rows] - table = build_table(stations, factors) + table = build_table(stations, mixed_conversion_factors(settings)) + for record in [*table["rows"], table["totals"]]: + split_rock_columns(record, soft) mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {} haul = haul_lists(mass_haul.get("haul_plan")) return { **table, - "summary": summary_rows(table["totals"], haul), + "summary": summary_rows(table["totals"], haul, soft), + "rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft}, "haul": haul, "haul_saved": bool(mass_haul.get("haul_plan")), "route_id": route_id, diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 9952747d0..53049fa37 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -7,6 +7,7 @@ * 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각 * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—). * 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`. + * 절토 연암 · 경암 = 암 × 좌측 패널 비율 — 조작 중 같은 식으로 바로 다시 셈(`…_Options_Calc` · PLAN 122). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -19,6 +20,8 @@ import { renderFreeHaul, renderHaulDistance, } from "./B08_Quantity_UI_Earthwork_Sheets"; +import { splitRockColumns, splitRockSummary } from "./B08_Quantity_Options_Calc"; +import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options"; type Face = { length_m: number | null; area_m2: number | null }; @@ -80,12 +83,15 @@ export interface EarthworkTable { summary: SummaryRow[]; haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; haul_saved: boolean; + /** 저장된 연암 · 경암 %(암 전체 기준) */ + rock_ratios_pct: { 연암: number; 경암: number }; } /** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ const TRIPLES: Array<[string[], string, string?]> = [ [["절토", "토사"], "cut_soil"], - [["절토", "연암"], "cut_rock"], + [["절토", "연암"], "cut_soft_rock", "암 × 연암 %"], + [["절토", "경암"], "cut_hard_rock", "암 × 경암 %"], [["측구파기", "토사"], "ditch_soil"], [["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"], ]; @@ -254,11 +260,23 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro return; } + // 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율(캐시 → 저장본) + const summary = table.summary; + const applyRock = (): void => { + const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90); + for (const record of [...table.rows, table.totals]) + splitRockColumns(record as unknown as Record, soft); + table.summary = splitRockSummary(summary, table.totals, soft); + }; + applyRock(); + const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs b08-earthwork__tabs"; tabs.setAttribute("role", "tablist"); const view = document.createElement("div"); + let current: SheetId = "summary"; const select = (picked: SheetId): void => { + current = picked; tabs.replaceChildren( ...SHEETS.map(({ id, label }) => { const button = createButton({ @@ -280,4 +298,8 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro }; host.replaceChildren(tabs, view); select("summary"); + onOptionsChange(host, () => { + applyRock(); + select(current); + }); } diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index bb9e1bc02..5f09a2827 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -28,7 +28,7 @@ const SUMMARY_COLUMNS: DataColumn[] = [ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { const info = note( "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + - "리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)", + "연암 · 경암 = 암 × 좌측 패널 비율 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)", ); const grid = createDataTable({ columns: SUMMARY_COLUMNS, diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py new file mode 100644 index 000000000..8e07ad11c --- /dev/null +++ b/resources/tester/test_122_b08_options.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""122 B08 좌측 패널 옵션 — 배관 1본 길이 · 연암 / 경암 비율 · 저장 길 · 화면 배선. + +1본 길이 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` · 비율 정본 = +`project_settings.json › quantity › rock_ratios_pct`(기본 90 : 10 은 파일에 안 씀). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from B08_Quantity import B08_Quantity_Router_Options as options +from B08_Quantity import B08_Quantity_Router_Summary as summary +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( + excel_round, + haul_lists, + split_rock_columns, + summary_rows, +) +from common_util.common_util_project_settings import settings_path, soft_rock_pct +from M02_MasterTemplete import M02_Template_Layers as layers + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def root(tmp_path, monkeypatch) -> Path: + system = tmp_path / "master" + (system / "table").mkdir(parents=True) + (system / "table/구조물집계표.json").write_text( + json.dumps({"열": [], "변수": {"파형강관_1본_m": 8, "측점간격_m": 20}}), encoding="utf-8" + ) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + project = tmp_path / "project" + project.mkdir() + return project + + +def test_기본값은_8_m_와_90_10_파일에_안_씀(root): + assert options.read_options(root) == { + "one_pipe_length_m": 8, + "rock_ratios_pct": {"연암": 90.0, "경암": 10.0}, + } + assert not settings_path(root).exists() + assert not layers.template_path(layers.project_dir(root), "table", "구조물집계표").exists() + + +def test_1본_6_m_저장_뒤_커플링을_다시_셈(root): + assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1 + out = options.save_options(root, options.OptionsSave(one_pipe_length_m=6)) + assert out["one_pipe_length_m"] == 6 == summary.one_pipe_length(root) + assert summary.couplings(22, 6) == 3 and summary.couplings(16, 6) == 2 + # 프로젝트 층 사본 = 시스템 문서 그대로 + 변수 한 칸 · 시스템 층은 안 바뀜 + mine = layers.read_template(layers.project_dir(root), "table", "구조물집계표")["문서"] + assert mine["변수"] == {"파형강관_1본_m": 6, "측점간격_m": 20} and mine["열"] == [] + system = layers.read_template(layers.system_dir(), "table", "구조물집계표")["문서"] + assert system["변수"]["파형강관_1본_m"] == 8 + # 다시 저장 = 같은 사본 판 위에 씀 + options.save_options(root, options.OptionsSave(one_pipe_length_m=8)) + assert summary.one_pipe_length(root) == 8 + + +def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root): + settings_path(root).write_text( + json.dumps( + {"quantity": {"rock_ratios_pct": {"풍화암": 5}, "rock_methods": {"연암": "ripping"}}} + ), + encoding="utf-8", + ) + out = options.save_options(root, options.OptionsSave(soft_rock_pct=70)) + assert out["rock_ratios_pct"] == {"연암": 70.0, "경암": 30.0} + stored = json.loads(settings_path(root).read_text(encoding="utf-8"))["quantity"] + assert stored["rock_ratios_pct"] == {"풍화암": 5, "연암": 70.0, "경암": 30.0} + assert stored["rock_methods"] == {"연암": "ripping"} + assert options.read_options(root)["rock_ratios_pct"]["연암"] == 70.0 + # 1본 길이는 안 보냈으니 그대로 + assert options.read_options(root)["one_pipe_length_m"] == 8 + + +def test_비율_읽기_자름_경암만_있을_때(): + assert soft_rock_pct({}) == 90 + assert soft_rock_pct({"rock_ratios_pct": {"연암": 130}}) == 100 + assert soft_rock_pct({"rock_ratios_pct": {"경암": 25}}) == 75 + assert soft_rock_pct({"rock_ratios_pct": {"연암": True}}) == 90 + + +def test_범위_밖_값은_거절(): + with pytest.raises(ValueError): + options.OptionsSave(one_pipe_length_m=0) + with pytest.raises(ValueError): + options.OptionsSave(soft_rock_pct=101) + + +def test_토공집계표_연암_경암은_암_곱하기_비율_합_100(): + totals = { + "cut_soil_volume_m3": 10.0, + "cut_rock_volume_m3": 47.6, + "ditch_soil_volume_m3": 1.0, + "ditch_rock_volume_m3": 2.3, + "adjusted_total_m3": 60.0, + "fill_volume_m3": 5.0, + "diverted_m3": 5.0, + "balance_m3": 50.0, + "benching_area_m2": 0.0, + "grading_fill_area_m2": 0.0, + "grading_cut_area_m2": 0.0, + "tree_removal_fill_area_m2": 0.0, + "tree_removal_cut_area_m2": 0.0, + } + rows = summary_rows(totals, haul_lists(None), 70) + value = {(r["group"], r["kind"]): r["value"] for r in rows} + assert value[("흙깍기", "연암")] == excel_round(47.6 * 0.7) == 33 + assert value[("흙깍기", "경암")] == excel_round(47.6 - 47.6 * 0.7) == 14 + assert value[("측구 터파기", "연암")] == 2 and value[("측구 터파기", "경암")] == 1 + # 토적표 칸 · 합계 — 연암 + 경암 = 암 + record = split_rock_columns({"cut_rock_area_m2": 1.2, "cut_rock_volume_m3": 47.6}, 70) + assert record["cut_soft_rock_area_m2"] == pytest.approx(0.84) + assert record["cut_soft_rock_volume_m3"] + record["cut_hard_rock_volume_m3"] == pytest.approx( + 47.6 + ) + + +def test_라우터_등록_응답_칸(): + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b08_options_router, dependencies=protected_with_company)" in main + earthwork = (ROOT / "B08_Quantity/B08_Quantity_Router_Earthwork.py").read_text(encoding="utf-8") + assert "split_rock_columns(record, soft)" in earthwork and '"rock_ratios_pct"' in earthwork + pipe = (ROOT / "B08_Quantity/B08_Quantity_Router_Summary.py").read_text(encoding="utf-8") + assert '"pipes": pipes' in pipe + + +def test_좌측_패널_컨테이너_셋과_저장_줄(): + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") + panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8") + assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page + for tab in ("structure", "earthwork", "hydrology"): + assert f'card("{tab}", "B08_Quantity_Tab_' in panel, tab + assert "ui-collapsible ui-sidebar-section ui-sidebar-card" in panel + assert ( + 'actions.className = "ui-sidebar-actions"' in panel and "attachCollapsible(panel)" in panel + ) + assert len(re.findall(r'type: "number",\s+compact: true', panel)) == 3 + # 조작 = 캐시 · [저장] = PUT 하나 · 자동 저장 없음 + assert 'writeState("b08-options"' in panel and 'clearState("b08-options"' in panel + assert panel.count('method: "PUT"') == 1 + registry = (ROOT / "A00_Common/b_page_state.ts").read_text(encoding="utf-8") + assert '"b08-options": { bucket: "draft", scope: "project" }' in registry + + +def test_표가_옵션_바뀜에_바로_다시_셈(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m))" in ui + assert "onOptionsChange(host," in ui + earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + assert "splitRockSummary(summary, table.totals, soft)" in earth + assert "onOptionsChange(host," in earth + assert ( + '[["절토", "연암"], "cut_soft_rock"' in earth + and '[["절토", "경암"], "cut_hard_rock"' in earth + ) diff --git a/resources/tester/test_122_b08_options_mirror.py b/resources/tester/test_122_b08_options_mirror.py new file mode 100644 index 000000000..a24139945 --- /dev/null +++ b/resources/tester/test_122_b08_options_mirror.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +"""122 거울 — 좌측 패널 옵션 식 · 파이썬 ↔ TS(`B08_Quantity_Options_Calc.ts`). + +조작 중 브라우저가 세는 값 = [저장] 뒤 서버가 세는 값이어야 함: + 커플링(관마다 올림(L ÷ 1본) − 1 · 같은 측점 더함) · 암 → 연암 · 경암 칸 · 토공집계표 네 줄. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( + haul_lists, + rock_split, + split_rock_columns, + summary_rows, +) +from B08_Quantity.B08_Quantity_Router_Summary import couplings + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +LENGTHS = [None, 0, 5, 8, 8.0000000001, 16, 17, 22, 23.5, 48] +ONES = [None, 6, 8, 2.5] +PCTS = [0, 10, 33.3, 70, 90, 100, 130, -5] +ROCKS = [0.0, 1.0, 2.3, 47.6, 123.456] +PIPES = [ + {"chainage_m": 1433.0, "column": "pipe:cp|1200", "length_m": 22}, + {"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 18}, + {"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 9}, # 같은 측점 둘째 관 + {"chainage_m": 1680.0, "column": "pipe:cp|800", "length_m": 16}, + {"chainage_m": 1790.0, "column": "pipe:cp|800", "length_m": None}, +] +SUMMARY = { + "rows": [ + {"chainage_m": 1433.0, "values": {"pipe:len|1200": 22, "pipe:cp|1200": 99}}, + {"chainage_m": 1567.0, "values": {"pipe:len|800": 27, "pipe:cp|800": 99}}, + {"chainage_m": 1680.0, "values": {"pipe:len|800": 16, "pipe:cp|800": 99}}, + {"chainage_m": 1790.0, "values": {}}, + ], + "total": {"pipe:len|1200": 22, "pipe:len|800": 43, "pipe:cp|1200": 99, "pipe:cp|800": 99}, + "pipes": PIPES, +} +TOTALS = { + "cut_soil_volume_m3": 10.0, + "cut_rock_volume_m3": 47.6, + "ditch_soil_volume_m3": 1.0, + "ditch_rock_volume_m3": 2.3, + "adjusted_total_m3": 60.0, + "fill_volume_m3": 5.0, + "diverted_m3": 5.0, + "balance_m3": 50.0, + "benching_area_m2": 0.0, + "grading_fill_area_m2": 0.0, + "grading_cut_area_m2": 0.0, + "tree_removal_fill_area_m2": 0.0, + "tree_removal_cut_area_m2": 0.0, +} +RECORD = { + "cut_rock_area_m2": 1.2, + "cut_rock_volume_m3": 47.6, + "cut_rock_adjusted_m3": 54.74, + "ditch_rock_area_m2": 0.18, + "ditch_rock_volume_m3": 2.3, + "ditch_rock_adjusted_m3": None, +} + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as calc from "./B08_Quantity_Options_Calc.js"; +const input = JSON.parse(process.argv[3]); +const out = { + couplings: input.lengths.map((l) => input.ones.map((o) => calc.couplings(l, o))), + splits: input.rocks.map((r) => input.pcts.map((p) => calc.rockSplit(r, p))), + records: input.pcts.map((p) => { const r = { ...input.record }; calc.splitRockColumns(r, p); return r; }), + summary: input.pcts.map((p) => calc.splitRockSummary(input.rows, input.totals, p)), + recount: input.ones.map((o) => calc.recountCouplings(input.summary, o)), +}; +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +def _ts(tmp_path: Path, payload: dict) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result), json.dumps(payload)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def _py_recount(one: float | None) -> dict: + """서버 `_Sheet` 와 같은 모음 — 관마다 couplings · 같은 측점(1 cm) 더함 · 계 = 줄 합.""" + rows = [] + for row in SUMMARY["rows"]: + values = {k: v for k, v in row["values"].items() if not k.startswith("pipe:cp|")} + for pipe in PIPES: + count = couplings(pipe["length_m"], one) + if count is None or round(pipe["chainage_m"] * 100) != round(row["chainage_m"] * 100): + continue + values[pipe["column"]] = values.get(pipe["column"], 0) + count + rows.append({"chainage_m": row["chainage_m"], "values": values}) + total = {k: v for k, v in SUMMARY["total"].items() if not k.startswith("pipe:cp|")} + for row in rows: + for key, value in row["values"].items(): + if key.startswith("pipe:cp|"): + total[key] = total.get(key, 0) + value + return {**SUMMARY, "rows": rows, "total": total} + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None: + rows = summary_rows(TOTALS, haul_lists(None), 90) + ts = _ts( + tmp_path, + { + "lengths": LENGTHS, + "ones": ONES, + "rocks": ROCKS, + "pcts": PCTS, + "record": RECORD, + "rows": rows, + "totals": TOTALS, + "summary": SUMMARY, + }, + ) + assert ts["couplings"] == [[couplings(l, o) for o in ONES] for l in LENGTHS] + assert ts["splits"] == [[list(rock_split(r, p)) for p in PCTS] for r in ROCKS] + for pct, got in zip(PCTS, ts["records"]): + assert got == split_rock_columns(dict(RECORD), pct), pct + for pct, got in zip(PCTS, ts["summary"]): + assert got == summary_rows(TOTALS, haul_lists(None), pct), pct + for one, got in zip(ONES, ts["recount"]): + assert got == _py_recount(one), one + + +def test_1본_6_m_면_커플링이_바뀜(tmp_path: Path) -> None: + six, eight = _py_recount(6), _py_recount(8) + assert six["total"]["pipe:cp|800"] == 2 + 1 + 2 and eight["total"]["pipe:cp|800"] == 2 + 1 + 1 + assert six["total"]["pipe:cp|1200"] == 3 and eight["total"]["pipe:cp|1200"] == 2 + + +def test_식_모양이_두_쪽_같다() -> None: + ts = (ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts").read_text(encoding="utf-8") + py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_Earthwork_Summary.py").read_text("utf-8") + assert "Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0)" in ts + assert "const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100;" in ts + assert "soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0" in py + assert not re.search(r"^import ", ts, re.M) # 시험이 이 파일만 옮겨 돌림 diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index 9f7feb2d5..f29e0890a 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -182,9 +182,12 @@ def test_토공집계표_식() -> None: rows = summary_rows(t, haul_lists(HAUL_PLAN)) value = {(r["group"], r["kind"], r["spec"]): r["value"] for r in rows} assert value[("흙깍기", "토사", "기계(굴삭기)")] == excel_round(t["cut_soil_volume_m3"]) - assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == 48 - assert value[("흙깍기", "경암", "발파 + 굴삭기")] == 0 - assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == 2 + # 연암 · 경암 = 암 × 기본 90 : 10(122) + rock, ditch = t["cut_rock_volume_m3"], t["ditch_rock_volume_m3"] + assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == excel_round(rock * 0.9) == 43 + assert value[("흙깍기", "경암", "발파 + 굴삭기")] == excel_round(rock - rock * 0.9) == 5 + assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == excel_round(ditch * 0.9) + assert value[("측구 터파기", "경암", "발파 + 굴삭기")] == excel_round(ditch - ditch * 0.9) assert value[("보정량계", "", "")] == 91 assert value[("성토", "", "")] == 189 assert value[("종방향유용토", "", "백호우")] == 82 @@ -205,6 +208,7 @@ def test_토적표_머리_글자는_사용자_견본대로(): ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") for head in ( '["절토", "연암"]', + '["절토", "경암"]', '["측구파기", "토사"]', '["측구파기", "암"]', "리핑암 · 발파암 합", diff --git a/resources/tester/test_rock_mixed_conversion.py b/resources/tester/test_rock_mixed_conversion.py index 639823a94..ca72dbbf4 100644 --- a/resources/tester/test_rock_mixed_conversion.py +++ b/resources/tester/test_rock_mixed_conversion.py @@ -63,4 +63,4 @@ def test_토적표_유토곡선_운반표가_같은_함수를_씀() -> None: router = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8") assert prebuild.count("mixed_conversion_factors(") == 2 assert "earthwork_conversion_factors(" not in prebuild - assert router.count("mixed_conversion_factors(settings)") == 2 + assert router.count("mixed_conversion_factors(settings)") == 1