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:
@@ -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}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user