From 9125e03ac9c9b22130e79ecd380c5ddf539d5c5b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 19:40:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=ED=95=A0=EC=A6=9D=20=EB=AA=A8?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=EC=9E=90=EA=B0=88=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=3D=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=2012=20=C2=B7=205=20=C2=B7=203(=ED=92=88?= =?UTF-8?q?=EC=85=88=201-3-1=20=ED=98=84=EC=9E=A5=EB=B9=84=EB=B9=94)=20?= =?UTF-8?q?=C2=B7=20=EC=B4=9D=EA=B4=84=20=EC=84=B8=20=EC=A4=84=EC=9D=80=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EA=B0=92(=ED=91=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=95=88=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EB=B9=84=EA=B3=A0=20?= =?UTF-8?q?=E3=80=8C=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=E3=80=8D)=20?= =?UTF-8?q?=C2=B7=20=EC=A1=B0=EC=9E=91=20=EC=A4=91=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=3D=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EA=B8=B8=20=C2=B7=20=EA=B1=B0=EC=9A=B8=20=EC=8B=9C=ED=97=98(12?= =?UTF-8?q?4-1)?= 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_016PSXsvyDxjNmufteN5bqmt --- B08_Quantity/B08_Quantity_Material_Calc.py | 35 +++-- B08_Quantity/B08_Quantity_Material_Calc.ts | 37 ++++- B08_Quantity/B08_Quantity_Router_Material.py | 26 +++- B08_Quantity/B08_Quantity_Router_Options.py | 18 ++- B08_Quantity/B08_Quantity_UI_Material.ts | 26 +++- B08_Quantity/B08_Quantity_UI_Options.ts | 62 ++++++++- B08_Quantity/B08_Quantity_UI_Page.css | 4 + .../tester/test_121_b08_material_sheets.py | 8 +- resources/tester/test_122_b08_options.py | 3 +- .../tester/test_124_b08_surcharge_panel.py | 131 ++++++++++++++++++ 10 files changed, 311 insertions(+), 39 deletions(-) create mode 100644 resources/tester/test_124_b08_surcharge_panel.py diff --git a/B08_Quantity/B08_Quantity_Material_Calc.py b/B08_Quantity/B08_Quantity_Material_Calc.py index d59e36aad..ab9ee186d 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.py +++ b/B08_Quantity/B08_Quantity_Material_Calc.py @@ -14,6 +14,7 @@ {"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}}, "surcharge": {총괄 줄: %}} 키가 있으면 그 값(null = 빈칸) · 없으면 아래 기본값. +좌측 패널 할증(`panel` = quantity › material_surcharge_pct) = 총괄 모래 · 자갈 · 시멘트 줄. """ from __future__ import annotations @@ -78,14 +79,13 @@ MIX_FIELDS = ("cement", "sand", "gravel") # ÷ 는 「/1000」(kg → ton) · 「/40」(kg → 40 kg 대) # 할증 = 건설품셈 공통 1-3-1(지식DB 01_임도/04_수량분석정보/수량산출_일반.md 3절) # · 관급 파형강관 0 -# 콘크리트 골재 · 시멘트 = 실무 엑셀 값(노반 모래 6 · 자갈 4 · 시멘트 정치식 2) -# — 원문 1-3-1 현장비빔(기타)이면 잔골재 12 · 굵은골재 5 · 시멘트 3 — 사용자 확인 대기 -SURCHARGE_SAND = 6 # 노반재료 모래 -SURCHARGE_GRAVEL = 4 # 노반재료 부순돌 · 자갈 -SURCHARGE_CEMENT = 2 # 시멘트 정치식 +# 모래 · 자갈 · 시멘트 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 테이블 사용자 값은 무시) +# 기본 = 1-3-1 현장비빔(기타) 잔골재 12 · 굵은골재 5 · 시멘트 3 · 파일에 안 쓰고 읽을 때 채움 +PANEL_KEY = "material_surcharge_pct" # 프로젝트 설정 quantity 칸 +PANEL_SURCHARGE: dict[str, float] = {"sand": 12, "gravel": 5, "cement": 3} TOTAL_ROWS: list[tuple[str, str, str, str, str, float | None, int]] = [ - ("sand", "골재", "모래", "㎥", "c:sand", SURCHARGE_SAND, 0), - ("gravel", "골재", "자갈", "㎥", "c:gravel", SURCHARGE_GRAVEL, 0), + ("sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE["sand"], 0), + ("gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE["gravel"], 0), ("mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, 0), ("remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, 0), # 철근 ("remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, 0), # 무근 @@ -96,7 +96,7 @@ TOTAL_ROWS: list[tuple[str, str, str, str, str, float | None, int]] = [ ("rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, 3), ("rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, 3), ("wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", None, 0), - ("cement", "시멘트", "40kg/대", "대", "c:cement/40", SURCHARGE_CEMENT, 0), + ("cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE["cement"], 0), ("PIPES", "", "", "", "", 0, 0), ("sod", "떼", "", "㎡", "m:sod", 10, 0), ("stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", None, 0), @@ -146,6 +146,16 @@ def clean_overrides(raw: Any) -> dict[str, Any]: return out +def panel_surcharge(raw: Any) -> dict[str, float]: + """좌측 패널 할증 % — 저장된 칸 · 없거나 수가 아니면 기본.""" + raw = raw if isinstance(raw, dict) else {} + out: dict[str, float] = {} + for key, default in PANEL_SURCHARGE.items(): + value = _num(raw.get(key)) + out[key] = default if value is None else value + return out + + def _pick(table: dict[str, Any], key: str, default: Any) -> Any: return table[key] if key in table else default @@ -181,8 +191,10 @@ def _structure_row(column: dict[str, Any]) -> tuple[str, str, str]: return name, " ".join([*spec, label]), "개소" -def compute(summary: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: - """구조물 집계표 결과(98 `_summary` 꼴: columns · total) + 사용자 값 → 세 표.""" +def compute( + summary: dict[str, Any], overrides: dict[str, Any], panel: Any = None +) -> dict[str, Any]: + """구조물 집계표 결과(98 `_summary` 꼴: columns · total) + 사용자 값 + 좌측 패널 할증 → 세 표.""" columns = summary.get("columns") or [] total = summary.get("total") or {} units_in = overrides.get("units") or {} @@ -245,7 +257,7 @@ def compute(summary: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any concrete = {"rows": concrete_rows, "total": concrete_total} # 총괄자재수량집계표 - surcharge_in = overrides.get("surcharge") or {} + surcharge_in = {**(overrides.get("surcharge") or {}), **panel_surcharge(panel)} couplings = { int(m.group(1)): _num(total.get(c["id"])) for c in columns @@ -284,6 +296,7 @@ def compute(summary: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any "surcharge": pct, "amount": amount, "places": places, + "panel": key in PANEL_SURCHARGE, } ) return {"quantity": quantity, "concrete": concrete, "material": {"rows": total_rows}} diff --git a/B08_Quantity/B08_Quantity_Material_Calc.ts b/B08_Quantity/B08_Quantity_Material_Calc.ts index c64ac57fd..ba8cd9382 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.ts +++ b/B08_Quantity/B08_Quantity_Material_Calc.ts @@ -9,6 +9,7 @@ * 구조물 집계표 계(98) = 구조물 수량 C → 자재수량집계표(C × 단위수량) → 콘크리트소요자재집계표 * (㎥당 배합 × 콘크리트 · 모르터 ㎥) → 총괄자재수량집계표(올림(수량 × (1 + 할증)) · 커플링 = 집계표 계) * 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리 + * 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬) * ========================================================================== */ export type Cells = Record; @@ -60,6 +61,8 @@ export interface TotalRow { surcharge: number | null; amount: number | null; places: number; + /** 할증 = 좌측 패널(표에서 안 고침) */ + panel: boolean; } export interface MaterialSheets { @@ -107,11 +110,14 @@ const CONCRETE_ROWS: Array<[string, string, string, string | null, Cells]> = [ ]; export const MIX_FIELDS = ["cement", "sand", "gravel"] as const; -/** 총괄 줄 — 파이썬 짝 TOTAL_ROWS(할증 = 건설품셈 공통 1-3-1 · 골재 · 시멘트는 실무 엑셀 값) */ +/** 좌측 패널 할증(PLAN 124) — 총괄 모래 · 자갈 · 시멘트 줄 · 기본 = 1-3-1 현장비빔(기타) 12 · 5 · 3 */ +export const PANEL_SURCHARGE: Record = { sand: 12, gravel: 5, cement: 3 }; + +/** 총괄 줄 — 파이썬 짝 TOTAL_ROWS(할증 = 건설품셈 공통 1-3-1 · 모래 · 자갈 · 시멘트는 좌측 패널) */ type TotalEntry = [string, string, string, string, string, number | null, number]; const TOTAL_ROWS: TotalEntry[] = [ - ["sand", "골재", "모래", "㎥", "c:sand", 6, 0], - ["gravel", "골재", "자갈", "㎥", "c:gravel", 4, 0], + ["sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE.sand, 0], + ["gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE.gravel, 0], ["mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, 0], ["remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, 0], ["remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, 0], @@ -122,7 +128,7 @@ const TOTAL_ROWS: TotalEntry[] = [ ["rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, 3], ["rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, 3], ["wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", null, 0], - ["cement", "시멘트", "40kg/대", "대", "c:cement/40", 2, 0], + ["cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE.cement, 0], ["PIPES", "", "", "", "", 0, 0], ["sod", "떼", "", "㎡", "m:sod", 10, 0], ["stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", null, 0], @@ -151,6 +157,14 @@ const num = (value: unknown): number | null => const pick = (table: Cells, key: string, fallback: number | null | undefined): unknown => key in table ? table[key] : fallback; +/** 좌측 패널 할증 % — 받은 칸 · 없거나 수가 아니면 기본 */ +export function panelSurcharge(raw: Cells | null | undefined): Record { + const out: Record = {}; + for (const [key, fallback] of Object.entries(PANEL_SURCHARGE)) + out[key] = num(raw?.[key]) ?? fallback; + return out; +} + export const emptyOverrides = (): MaterialOverrides => ({ units: {}, mix: {}, surcharge: {} }); export function diameters(columns: SummarySource["columns"]): number[] { @@ -185,6 +199,7 @@ const add = (sums: Record, key: string, value: number | null): v export function computeMaterialSheets( summary: SummarySource, overrides: MaterialOverrides, + panel?: Cells | null, ): MaterialSheets { const columns = summary.columns ?? []; const total = summary.total ?? {}; @@ -235,7 +250,7 @@ export function computeMaterialSheets( concreteRows.push({ key, name, spec, qty, per, amounts }); } - const surchargeIn = overrides.surcharge ?? {}; + const surchargeIn: Cells = { ...(overrides.surcharge ?? {}), ...panelSurcharge(panel) }; const couplings = new Map(); for (const column of columns) { const match = COUPLING_ID.exec(column.id); @@ -277,7 +292,17 @@ export function computeMaterialSheets( if (qty !== null && divisor) qty = round6(qty / Number(divisor)); const pct = num(pick(surchargeIn, key, pctDefault)); const amount = qty !== null ? roundup(qty * (1 + (pct ?? 0) / 100), places) : null; - return { key, group, spec, unit, qty, surcharge: pct, amount, places }; + return { + key, + group, + spec, + unit, + qty, + surcharge: pct, + amount, + places, + panel: key in PANEL_SURCHARGE, + }; }); return { diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 1db711aeb..bd19e0d57 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -5,18 +5,25 @@ PUT /api/projects/{id}/quantity/materials {overrides} [저장] — 사용자 값을 프로젝트 설정 quantity › material_sheets 에 · 서버가 정본으로 다시 셈 -계산 = `B08_Quantity_Material_Calc.py`(화면 짝 `.ts` 와 같은 식) +계산 = `B08_Quantity_Material_Calc.py`(화면 짝 `.ts` 와 같은 식) · 총괄 모래 · 자갈 · 시멘트 할증 = +좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options) 기본값은 파일에 안 씀(읽을 때 채움). """ from __future__ import annotations +from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter, Body -from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute +from B08_Quantity.B08_Quantity_Material_Calc import ( + PANEL_KEY, + clean_overrides, + compute, + panel_surcharge, +) from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Summary import structure_summary from common_util.common_util_project_settings import quantity_settings, save_section @@ -26,16 +33,23 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Material"]) SETTINGS_KEY = "material_sheets" -async def _sheets(project_id: UUID, overrides: dict[str, Any]) -> dict[str, Any]: +async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) -> dict[str, Any]: summary = await structure_summary(project_id) source = {"columns": summary["columns"], "total": summary["total"]} - return {"source": source, "overrides": overrides, "sheets": compute(source, overrides)} + panel = panel_surcharge(quantity_settings(root).get(PANEL_KEY)) + return { + "source": source, + "overrides": overrides, + "surcharge_pct": panel, + "sheets": compute(source, overrides, panel), + } @router.get("/{project_id}/quantity/materials") async def material_sheets(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) - return await _sheets(project_id, clean_overrides(quantity_settings(root).get(SETTINGS_KEY))) + overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY)) + return await _sheets(project_id, root, overrides) @router.put("/{project_id}/quantity/materials") @@ -45,4 +59,4 @@ async def save_material_sheets( root = await _project_root(project_id) overrides = clean_overrides(body.get("overrides")) save_section(root, "quantity", {SETTINGS_KEY: overrides}, replace_keys=(SETTINGS_KEY,)) - return await _sheets(project_id, overrides) + return await _sheets(project_id, root, overrides) diff --git a/B08_Quantity/B08_Quantity_Router_Options.py b/B08_Quantity/B08_Quantity_Router_Options.py index 27fbaae3b..ff4aa21b3 100644 --- a/B08_Quantity/B08_Quantity_Router_Options.py +++ b/B08_Quantity/B08_Quantity_Router_Options.py @@ -1,4 +1,4 @@ -"""B08 좌측 패널 옵션 길(PLAN 122) — 배관 1본 길이 · 연암 / 경암 비율. +"""B08 좌측 패널 옵션 길(PLAN 122 · 124) — 배관 1본 길이 · 연암 / 경암 비율 · 할증 모래 · 자갈 · 시멘트. GET /api/projects/{id}/quantity/options 지금 값 PUT /api/projects/{id}/quantity/options [저장] — 받은 칸만 정본 자리에 씀 @@ -8,18 +8,21 @@ M02 표 식 `[$파형강관_1본_m]` 도 같은 값) · 프로젝트 사본이 M02 [초기화]면 같이 되돌아감. 연암 % = `project_settings.json › quantity › rock_ratios_pct`(연암 · 경암 = 100 − 연암) · 기본 90 은 파일에 안 쓰고 읽을 때 채움(`soft_rock_pct`). +할증 % = `quantity › material_surcharge_pct`(모래 · 자갈 · 시멘트 · 받은 칸만 · 기본 12 · 5 · 3 은 읽을 때 채움 +`panel_surcharge`) — 총괄자재수량집계표 그 세 줄이 씀. """ from __future__ import annotations import asyncio from pathlib import Path -from typing import Any +from typing import Annotated, Any, Literal from uuid import UUID from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field +from B08_Quantity.B08_Quantity_Material_Calc import PANEL_KEY, panel_surcharge 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 @@ -33,13 +36,18 @@ class OptionsSave(BaseModel): 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) + material_surcharge_pct: ( + dict[Literal["sand", "gravel", "cement"], Annotated[float, Field(ge=0, le=100)]] | None + ) = None def read_options(root: str | Path) -> dict[str, Any]: - soft = soft_rock_pct(quantity_settings(root)) + settings = quantity_settings(root) + soft = soft_rock_pct(settings) return { "one_pipe_length_m": one_pipe_length(root), "rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft}, + PANEL_KEY: panel_surcharge(settings.get(PANEL_KEY)), } @@ -71,6 +79,10 @@ def save_options(root: str | Path, body: OptionsSave) -> dict[str, Any]: save_section( root, "quantity", {"rock_ratios_pct": ratios}, replace_keys=("rock_ratios_pct",) ) + if body.material_surcharge_pct: + stored = quantity_settings(root).get(PANEL_KEY) + merged = {**(stored if isinstance(stored, dict) else {}), **body.material_surcharge_pct} + save_section(root, "quantity", {PANEL_KEY: merged}, replace_keys=(PANEL_KEY,)) return read_options(root) diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 6850c7ae4..a484165b1 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -5,6 +5,7 @@ * * 자료 = GET …/quantity/materials(구조물 집계표 계 · 저장된 사용자 값 · 서버가 셈한 세 표) * 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로 + * 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈) * [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음 * ========================================================================== */ @@ -25,12 +26,15 @@ import { type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; +import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; export type MaterialSheetId = "material" | "concrete" | "quantity"; interface Payload { source: SummarySource; overrides: MaterialOverrides; + /** 좌측 패널 할증 저장본(기본 채움) */ + surcharge_pct: Cells; sheets: MaterialSheets; } @@ -38,7 +42,7 @@ const BASIS = "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { material: - "합계 = 올림(수량 × (1 + 할증)) · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음", + "합계 = 올림(수량 × (1 + 할증)) · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널", concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1", }; @@ -194,6 +198,7 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { numberColumn({ key: "qty", label: "수량", width: 96 }), numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), numberColumn({ key: "amount", label: "합계", width: 96 }), + { key: "note", label: "비고", width: 96 }, ], rows: rows.map((row, i) => ({ group: i > 0 && rows[i - 1].group === row.group ? "" : row.group, @@ -202,10 +207,11 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { qty: row.qty, surcharge: row.surcharge, amount: row.amount, + note: row.panel ? "할증 = 좌측 패널" : "", })), total: null, frozen: 2, - editable: (_i, key) => key === "surcharge", + editable: (i, key) => key === "surcharge" && !rows[i].panel, target: (i) => [o.surcharge, rows[i].key, null], }; } @@ -232,7 +238,13 @@ export async function renderMaterialSheet( } const draft = readState("b08-materials", projectId); let overrides: MaterialOverrides = draft ?? payload.overrides; - let sheets = draft ? computeMaterialSheets(payload.source, overrides) : payload.sheets; + const calc = (): MaterialSheets => + computeMaterialSheets( + payload.source, + overrides, + liveSurcharge(projectId, payload.surcharge_pct), + ); + let sheets = calc(); const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() }); save.dataset.b08MaterialSave = ""; @@ -262,7 +274,7 @@ export async function renderMaterialSheet( [cell]: value, }; writeState("b08-materials", overrides, projectId); - sheets = computeMaterialSheets(payload.source, overrides); + sheets = calc(); } redraw(); }, @@ -273,6 +285,10 @@ export async function renderMaterialSheet( table.setRows(built.rows, built.total); }; host.replaceChildren(toolbar, table.root); + onOptionsChange(host, () => { + sheets = calc(); + redraw(); + }); /** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */ const saveAll = async (): Promise => { @@ -283,7 +299,7 @@ export async function renderMaterialSheet( body: JSON.stringify({ overrides }), }); overrides = payload.overrides; - sheets = payload.sheets; + sheets = calc(); // 서버 정본 + 아직 저장 안 한 좌측 패널 할증 clearState("b08-materials", projectId); redraw(); showToast("자재 집계표를 저장했습니다.", "success"); diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index d994e765d..2016d91ce 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -3,6 +3,7 @@ * 좌측 패널(PLAN 122) — 메인 탭과 같은 컨테이너 셋(구조물 · 토공 · 수리집수면적표 · 접이) + [저장] 줄. * * 구조물 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` + * 구조물 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100 * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) * 수리집수면적표 — 빈 자리(다음 옵션) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · @@ -21,6 +22,7 @@ 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"; +import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -30,13 +32,22 @@ function L(key: keyof typeof ui_locales): string { export interface OptionsDraft { one_pipe_length_m?: number | null; soft_rock_pct?: number; + material_surcharge_pct?: Cells; } interface SavedOptions { one_pipe_length_m: number | null; rock_ratios_pct: { 연암: number; 경암: number }; + material_surcharge_pct: Cells; } +/** 할증 칸 — [키, 이름] · 키 = 총괄 줄 */ +const SURCHARGE_FIELDS: Array<[string, string]> = [ + ["sand", "모래"], + ["gravel", "자갈"], + ["cement", "시멘트"], +]; + const changes = new EventTarget(); const readDraft = (projectId: string): OptionsDraft => @@ -53,6 +64,11 @@ export function liveSoftRock(projectId: string, saved: number): number { return readDraft(projectId).soft_rock_pct ?? saved; } +/** 지금 할증 % — 캐시 칸 → 저장본. */ +export function liveSurcharge(projectId: string, saved: Cells): Cells { + return { ...saved, ...readDraft(projectId).material_surcharge_pct }; +} + /** host 가 화면에 붙어 있는 동안 옵션이 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌. */ export function onOptionsChange(host: HTMLElement, fn: () => void): void { const listener = (): void => { @@ -107,11 +123,30 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void one.input.dataset.b08Option = "one_pipe_length_m"; soft.input.dataset.b08Option = "soft_rock_pct"; hard.input.dataset.b08Option = "hard_rock_pct"; + const surcharge = SURCHARGE_FIELDS.map(([key, name]) => { + const field = createInputField({ + label: `${name} (%)`, + type: "number", + compact: true, + min: 0, + max: 100, + }); + field.input.dataset.b08Option = `surcharge_${key}`; + return [key, field.input, field.root] as const; + }); + const surchargeRow = document.createElement("div"); + surchargeRow.className = "b08-options__row b08-options__row--3"; + surchargeRow.append(...surcharge.map(([, , root]) => root)); + const surchargeBlock = [surchargeRow, hint("할증 — 총괄 자재 표 세 줄 · 기본 품셈 1-3-1")]; 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 }; + let saved: { one: number | null; soft: number; surcharge: Cells } = { + one: 8, + soft: 90, + surcharge: PANEL_SURCHARGE, + }; const show = (): void => { const draft = projectId ? readDraft(projectId) : {}; const oneM = draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved.one; @@ -119,6 +154,8 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void one.input.value = oneM == null ? "" : String(oneM); soft.input.value = pctText(softPct); hard.input.value = pctText(100 - softPct); + const pct = { ...saved.surcharge, ...draft.material_surcharge_pct }; + for (const [key, input] of surcharge) input.value = pctText(pct[key] ?? 0); }; const put = (patch: OptionsDraft): void => { if (!projectId) return; @@ -142,6 +179,15 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void }; pair(soft.input, hard.input, true); pair(hard.input, soft.input, false); + for (const [key, input] of surcharge) { + input.addEventListener("input", () => { + const value = input.value.trim() ? clampPct(Number(input.value)) : null; + if (value === null || !projectId) return; + const pct = readDraft(projectId).material_surcharge_pct; + put({ material_surcharge_pct: { ...pct, [key]: value } }); + }); + input.addEventListener("change", show); + } const saveAll = async (): Promise => { if (!projectId) return; @@ -156,7 +202,11 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void method: "PUT", body: JSON.stringify(draft), }); - saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 }; + saved = { + one: body.one_pipe_length_m, + soft: body.rock_ratios_pct.연암, + surcharge: body.material_surcharge_pct, + }; clearState("b08-options", projectId); show(); showToast("수량 산출 옵션을 저장했습니다.", "success"); @@ -181,7 +231,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void const panel = document.createElement("div"); panel.className = "ui-sidebar-panel b08-options"; panel.append( - card("structure", "B08_Quantity_Tab_Structure", one.root), + card("structure", "B08_Quantity_Tab_Structure", one.root, ...surchargeBlock), card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), actions, @@ -192,7 +242,11 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void if (projectId) request(`/projects/${projectId}/quantity/options`).then( (body) => { - saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 }; + saved = { + one: body.one_pipe_length_m, + soft: body.rock_ratios_pct.연암, + surcharge: body.material_surcharge_pct, + }; show(); }, (error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"), diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 0cc52ce3a..9848cf30a 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -45,6 +45,10 @@ gap: var(--spacing-8); } +.b08-options__row--3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .b08-options__hint { margin: var(--spacing-4) 0 0; color: var(--color-text-secondary); diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py index f1b105f44..2b6f8f182 100644 --- a/resources/tester/test_121_b08_material_sheets.py +++ b/resources/tester/test_121_b08_material_sheets.py @@ -60,6 +60,8 @@ SUMMARY = { UNITS = {cid: units for (cid, _, _), units in SAMPLE if units} #: 실무 엑셀 ㎥당 배합(모래 · 자갈 ㎥ 은 엑셀 값) — 엑셀 계와 맞춰 보기 EXCEL_MIX = {"c40": {"sand": 0.48, "gravel": 0.65}} +#: 실무 엑셀 할증(모래 6 · 자갈 4 · 시멘트 2) — 기본은 좌측 패널 12 · 5 · 3(124) +EXCEL_PANEL = {"sand": 6, "gravel": 4, "cement": 2} def _over(**parts): @@ -115,7 +117,7 @@ def test_자재수량_계가_엑셀_66행과_같다(): def test_콘크리트소요_엑셀_배합이면_엑셀_계(): - sheets = compute(SUMMARY, _over(mix=EXCEL_MIX)) + sheets = compute(SUMMARY, _over(mix=EXCEL_MIX), EXCEL_PANEL) total = sheets["concrete"]["total"] assert total["cement"] == 9549.41 # E11 assert total["sand"] == 14.5886 # G11 @@ -155,7 +157,7 @@ def test_총괄_할증_올림_관_커플링(): assert rows["stone_buy_35"]["surcharge"] is None # 끌어올 값이 없는 줄 = 빈칸 assert rows["rebar_d13"]["qty"] is None and rows["rebar_d13"]["amount"] is None - assert rows["sod"]["surcharge"] == 10 and rows["cement"]["surcharge"] == 2 + assert rows["sod"]["surcharge"] == 10 and rows["cement"]["surcharge"] == 3 def test_사용자_값이_기본을_이김(): @@ -222,7 +224,7 @@ def test_화면_연결(): page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") assert "renderMaterialSheet(body, projectId, picked)" in page ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") - assert "computeMaterialSheets(payload.source, overrides)" in ui # 조작 중 = 짝 계산 + assert "computeMaterialSheets(" in ui # 조작 중 = 짝 계산 assert 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음 assert "일위대가 재료와 별개" in ui main = (ROOT / "main.py").read_text(encoding="utf-8") diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index 8e07ad11c..7b38b50f6 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -44,6 +44,7 @@ def test_기본값은_8_m_와_90_10_파일에_안_씀(root): assert options.read_options(root) == { "one_pipe_length_m": 8, "rock_ratios_pct": {"연암": 90.0, "경암": 10.0}, + "material_surcharge_pct": {"sand": 12, "gravel": 5, "cement": 3}, # 124 } assert not settings_path(root).exists() assert not layers.template_path(layers.project_dir(root), "table", "구조물집계표").exists() @@ -143,7 +144,7 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): 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 + assert len(re.findall(r'type: "number",\s+compact: true', panel)) == 4 # 할증 셋은 한 번(124) # 조작 = 캐시 · [저장] = PUT 하나 · 자동 저장 없음 assert 'writeState("b08-options"' in panel and 'clearState("b08-options"' in panel assert panel.count('method: "PUT"') == 1 diff --git a/resources/tester/test_124_b08_surcharge_panel.py b/resources/tester/test_124_b08_surcharge_panel.py new file mode 100644 index 000000000..6b8ea454b --- /dev/null +++ b/resources/tester/test_124_b08_surcharge_panel.py @@ -0,0 +1,131 @@ +"""124-1 B08 좌측 패널 할증 모래 · 자갈 · 시멘트 — 기본 12 · 5 · 3 · 패널 값 = 총괄 세 줄 · 저장 · 거울. + +정본 = `project_settings.json › quantity › material_surcharge_pct`(받은 칸만 · 기본은 읽을 때 채움) · +총괄자재수량집계표 그 세 줄은 패널 값만 씀(표 사용자 값 무시 · 표에서 안 고침). +견본 = 121 시험의 2공구 꼴 구조물 집계표 계. +""" + +import asyncio +import json +import subprocess +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from B08_Quantity import B08_Quantity_Router_Material as material_router +from B08_Quantity import B08_Quantity_Router_Options as options +from B08_Quantity.B08_Quantity_Material_Calc import PANEL_SURCHARGE, compute +from common_util.common_util_project_settings import settings_path +from M02_MasterTemplete import M02_Template_Layers as layers +from test_121_b08_material_sheets import EXCEL_MIX, IMPORTS, SUMMARY, _over + +ROOT = Path(__file__).resolve().parents[2] +CALC_TS = ROOT / "B08_Quantity" / "B08_Quantity_Material_Calc.ts" + + +def _ts(summary, overrides, panel): + body = ( + f"\nconst s = {json.dumps(summary, ensure_ascii=False)};" + f"\nconst o = {json.dumps(overrides, ensure_ascii=False)};" + f"\nconst p = {json.dumps(panel, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify({sheets: computeMaterialSheets(s, o, p)," + " defaults: PANEL_SURCHARGE, filled: panelSurcharge(p)}));" + ) + script = IMPORTS.sub("", CALC_TS.read_text(encoding="utf-8")) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _rows(sheets): + return {row["key"]: row for row in sheets["material"]["rows"]} + + +@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}}), encoding="utf-8" + ) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + project = tmp_path / "project" + project.mkdir() + return project + + +def test_기본_12_5_3_파일에_안_씀(root): + assert PANEL_SURCHARGE == {"sand": 12, "gravel": 5, "cement": 3} + rows = _rows(compute(SUMMARY, _over(mix=EXCEL_MIX))) + assert [rows[k]["surcharge"] for k in ("sand", "gravel", "cement")] == [12, 5, 3] + # 올림(14.5886 × 1.12) · 올림(18.0245 × 1.05) · 올림(238.73525 × 1.03) + assert [rows[k]["amount"] for k in ("sand", "gravel", "cement")] == [17, 19, 246] + assert [k for k, row in rows.items() if row["panel"]] == ["sand", "gravel", "cement"] + assert options.read_options(root)["material_surcharge_pct"] == PANEL_SURCHARGE + assert not settings_path(root).exists() + + +def test_패널_값이_총괄_세_줄을_바로_바꿈_표_값은_무시(): + over = _over(mix=EXCEL_MIX, surcharge={"sand": 50, "sod": 20}) + panel = {"sand": 6, "gravel": 4, "cement": 2} # 실무 엑셀 값 + out = _ts(SUMMARY, over, panel) + rows = _rows(out["sheets"]) + assert [rows[k]["amount"] for k in ("sand", "gravel", "cement")] == [ + 16, + 19, + 244, + ] # 엑셀 G4 · G5 · G18 + assert rows["sand"]["surcharge"] == 6 # 표 사용자 값 50 은 무시 + assert rows["sod"]["surcharge"] == 20 # 나머지 줄은 표 값 그대로 + assert out["sheets"] == compute(SUMMARY, over, panel) + + +def test_거울_빈칸_깨진_값은_기본(): + for panel in (None, {}, {"gravel": 0}, {"sand": "x", "cement": 7.5}): + out = _ts(SUMMARY, _over(), panel) + assert out["defaults"] == PANEL_SURCHARGE + assert out["sheets"] == compute(SUMMARY, _over(), panel), panel + assert _rows(compute(SUMMARY, _over(), {"gravel": 0}))["gravel"]["surcharge"] == 0 + + +def test_저장_뒤_남고_받은_칸만_씀(root, monkeypatch): + out = options.save_options(root, options.OptionsSave(material_surcharge_pct={"sand": 8})) + assert out["material_surcharge_pct"] == {"sand": 8, "gravel": 5, "cement": 3} + options.save_options(root, options.OptionsSave(material_surcharge_pct={"cement": 2.5})) + stored = json.loads(settings_path(root).read_text(encoding="utf-8")) + assert stored["quantity"]["material_surcharge_pct"] == {"sand": 8, "cement": 2.5} + # 다른 옵션 저장은 할증을 안 건드림 + options.save_options(root, options.OptionsSave(soft_rock_pct=70)) + assert options.read_options(root)["material_surcharge_pct"]["sand"] == 8 + + async def summary(_project_id): + return {**SUMMARY, "rows": [], "notes": []} + + async def project_root(_project_id): + return root + + monkeypatch.setattr(material_router, "structure_summary", summary) + monkeypatch.setattr(material_router, "_project_root", project_root) + got = asyncio.run(material_router.material_sheets("p")) + assert got["surcharge_pct"] == {"sand": 8, "gravel": 5, "cement": 2.5} + assert _rows(got["sheets"])["sand"]["surcharge"] == 8 + + +def test_범위_밖_모르는_칸은_거절(): + for bad in ({"sand": 120}, {"sand": -1}, {"rebar_d13": 3}): + with pytest.raises(ValidationError): + options.OptionsSave(material_surcharge_pct=bad) + + +def test_화면_연결(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + assert "liveSurcharge(projectId, payload.surcharge_pct)" in ui + assert "onOptionsChange(host," in ui + assert 'key === "surcharge" && !rows[i].panel' in ui and "할증 = 좌측 패널" in ui + panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8") + assert "put({ material_surcharge_pct:" in panel and "body.material_surcharge_pct" in panel