feat(b08): 할증 모래 · 자갈 · 시멘트 = 좌측 패널 구조물 칸 · 기본 12 · 5 · 3(품셈 1-3-1 현장비빔) · 총괄 세 줄은 패널 값(표에서 안 고침 · 비고 「좌측 패널」) · 조작 중 바로 · [저장] = 옵션 길 · 거울 시험(124-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-08 19:40:56 +09:00
co-authored by Claude Opus 5.5
parent 681efb23d1
commit 9125e03ac9
10 changed files with 311 additions and 39 deletions
+24 -11
View File
@@ -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}}
+31 -6
View File
@@ -9,6 +9,7 @@
* 구조물 집계표 계(98) = 구조물 수량 C → 자재수량집계표(C × 단위수량) → 콘크리트소요자재집계표
* (㎥당 배합 × 콘크리트 · 모르터 ㎥) → 총괄자재수량집계표(올림(수량 × (1 + 할증)) · 커플링 = 집계표 계)
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
* 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬)
* ========================================================================== */
export type Cells = Record<string, number | null>;
@@ -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<string, number> = { 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<string, number> {
const out: Record<string, number> = {};
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<string, number>, 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<number, number | null>();
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 {
+20 -6
View File
@@ -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)
+15 -3
View File
@@ -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)
+21 -5
View File
@@ -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<MaterialSheetId, string> = {
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<MaterialOverrides>("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<void> => {
@@ -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");
+58 -4
View File
@@ -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<void> => {
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<SavedOptions>(`/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"),
+4
View File
@@ -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);
@@ -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")
+2 -1
View File
@@ -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
@@ -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