feat(B08): 총괄자재 · 콘크리트소요자재 · 자재수량 집계표 세 표(121-1)

- 실무 엑셀(4.2 수량산출(2공구)) 꼴 · 구조물 탭 아래 공용 표 · 구조물 수량 C = 구조물 집계표 계
- 단위수량 · ㎥당 배합 · 할증 % 고칠 칸 · 조작 중 TS 짝 계산 · [저장] 때 서버 정본 · 설정 quantity › material_sheets
- 공용 표 고칠 칸(editable · onEdit) · 끝 0 지움(trim)
- 시험 test_121(엑셀 66행 · 11행 · 총괄 G열 · 거울 · 저장)

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 18:35:33 +09:00
co-authored by Claude Opus 5.5
parent 5f64633df3
commit 449a8cfe9b
12 changed files with 1272 additions and 7 deletions
+2
View File
@@ -164,6 +164,8 @@ export const STATE_REGISTRY = {
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
crossdesign: { bucket: "draft", scope: "route" },
/** B08 자재 집계표 셋 사용자 값 `{units, mix, surcharge}`(고친 칸만) — [저장] 때 프로젝트 설정으로(121). */
"b08-materials": { bucket: "draft", scope: "project" },
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
+289
View File
@@ -0,0 +1,289 @@
"""B08 자재 집계표 셋 계산(PLAN 121-1) — 화면 없음 · 저장 안 함.
⚠ 짝 = `B08_Quantity_Material_Calc.ts`(같은 식 · 같은 상수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_121_b08_material_sheets.py`.
실무 엑셀(`4.2 수량산출(2공구).xlsx`) 꼴 · 끌어오는 차례:
구조물 집계표 계(98 `_summary` total) = 구조물 수량 C
→ 자재수량집계표: 줄마다 단위수량(사용자 입력 · 기본 빈칸) · 수량 = C × 단위수량 · 계
→ 콘크리트소요자재집계표: 시멘트 · 모래 · 자갈 = ㎥당 배합 × 콘크리트 · 모르터 ㎥
→ 총괄자재수량집계표: 합계 = 올림(수량 × (1 + 할증)) · 커플링은 구조물 집계표 계 그대로
구조물 수량 기준 — 일위대가(B09) 재료와 별개 · 두 번 세지 않게 화면 머리에 표시.
사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만:
{"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}},
"surcharge": {총괄 줄: %}}
키가 있으면 그 값(null = 빈칸) · 없으면 아래 기본값.
"""
from __future__ import annotations
import math
import re
from typing import Any
#: 자재수량집계표 자재 열 — 실무 엑셀 F~AD · [키, 자재, 규격, 단위]
# 파형강관은 프로젝트 관경마다 `pipe_{관경}`
MATERIALS: list[tuple[str, str, str, str]] = [
("remicon_25_24_15", "레미콘", "25-24-15", "㎥"),
("remicon_25_21_80", "레미콘", "25-21-80", "㎥"),
("remicon_25_18_80", "레미콘", "25-18-80", "㎥"),
("concrete_machine", "콘크리트", "기계", "㎥"),
("rebar_d13", "이형철근", "D13", "kg"),
("rebar_d16", "이형철근", "D16", "kg"),
("rebar_d19", "이형철근", "D19", "kg"),
("rebar_d22", "이형철근", "D22", "kg"),
("rebar_d25", "이형철근", "D25", "kg"),
("PIPES", "", "", ""),
("mortar_1_3", "모르터", "1:3", "㎥"),
("revet_block", "호안블럭", "", "㎡"),
("wire_mesh", "와이어메쉬", "", "㎡"),
("flume_400c", "수로관", "400C", "본"),
("steel_grating", "스틸그레이팅", "", "EA"),
("stone_buy_35", "돌(구입)", "L3:35", "ton"),
("stone_buy_45", "돌(구입)", "L3:45", "ton"),
("stone_pick_35", "돌(채집)", "L3:35", "㎥"),
("stone_pick_45", "돌(채집)", "L3:45", "㎥"),
("bed_stone", "고임돌", "채집", "㎥"),
("rubble", "막자갈", "채집", "㎥"),
("mixed_aggregate", "혼합골재", "", "㎥"),
("sod", "떼", "", "㎡"),
]
#: 콘크리트소요자재집계표 줄 — [키, 구분, 규격, 끌어올 자재 열, ㎥당 시멘트 kg · 모래 · 자갈 ㎥]
# 콘크리트 = 산림품셈 12-1-3 주⑤ QF000353 (B) 배합
# (25 mm 346 · 828 · 1,011 kg / 40 mm 323 · 775 · 1,101 kg)
# 골재 kg → ㎥ = 건설품셈 공통 1-2-4 CC000337 건조 위끝
# (모래 1,700 · 자갈 1,800 kg/㎥ · GF000156 기본)
# 무근 = 근거 없음(실무 312 kg) → 빈칸
# 모르터 = 건설품셈 건축 9-1-1 QC001347(1:2 = 680 kg · 0.98 ㎥ / 1:3 = 510 kg · 1.10 ㎥)
CONCRETE_ROWS: list[tuple[str, str, str, str | None, dict[str, float | None]]] = [
("c25", "콘크리트", "Φ25mm", None, {"cement": 346, "sand": 0.487, "gravel": 0.562}),
(
"c40",
"콘크리트",
"Φ40mm",
"concrete_machine",
{"cement": 323, "sand": 0.456, "gravel": 0.612},
),
("c40_plain", "콘크리트", "Φ40mm(무근)", None, {"cement": None, "sand": None, "gravel": None}),
("m12", "모르터", "1:2", None, {"cement": 680, "sand": 0.98, "gravel": None}),
("m13", "모르터", "1:3", "mortar_1_3", {"cement": 510, "sand": 1.10, "gravel": None}),
]
MIX_FIELDS = ("cement", "sand", "gravel")
#: 총괄자재수량집계표 줄 — [키, 구분, 규격, 단위, 끌어올 곳, 할증 % 기본, 올림 소수 자리]
# 끌어올 곳 = "m:{자재}"(자재수량집계표 계) · "c:{cement|sand|gravel}"(콘크리트소요 계)
# · "cp:{관경}"(커플링)
# ÷ 는 「/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 # 시멘트 정치식
TOTAL_ROWS: list[tuple[str, str, str, str, str, float | None, int]] = [
("sand", "골재", "모래", "㎥", "c:sand", SURCHARGE_SAND, 0),
("gravel", "골재", "자갈", "㎥", "c:gravel", 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), # 무근
("remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, 0), # 무근
("rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, 3),
("rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, 3),
("rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, 3),
("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),
("PIPES", "", "", "", "", 0, 0),
("sod", "떼", "", "㎡", "m:sod", 10, 0),
("stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", None, 0),
("stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", None, 0),
("revet_block", "호안블럭", "", "㎡", "m:revet_block", None, 0),
("flume_400c", "수로관", "400C", "본", "m:flume_400c", None, 0),
("steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", None, 0),
]
LENGTH_ID = re.compile(r"^pipe:len\|(\d+)$")
COUPLING_ID = re.compile(r"^pipe:cp\|(\d+)$")
UNITS = ("m", "㎡", "㎥", "ea", "개", "개소", "EA")
def round6(value: float) -> float:
"""소수 6 자리 반 올림 — 떠도는 끝수만 지움 · 화면 짝과 같은 식."""
return math.floor(value * 1e6 + 0.5) / 1e6
def roundup(value: float, places: int) -> float:
"""실무 ROUNDUP — 떠도는 끝수(1e-9)는 올리지 않음."""
scale = 10**places
return math.ceil(round6(value) * scale - 1e-9) / scale
def _num(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
def clean_overrides(raw: Any) -> dict[str, Any]:
"""저장 값 다듬기 — 두 겹(units · mix) · 한 겹(surcharge) · 잎은 수 또는 null."""
raw = raw if isinstance(raw, dict) else {}
out: dict[str, Any] = {"units": {}, "mix": {}, "surcharge": {}}
for part in ("units", "mix"):
for row, cells in (raw.get(part) or {}).items():
if isinstance(cells, dict):
kept = {
str(k): _num(v) for k, v in cells.items() if v is None or _num(v) is not None
}
if kept:
out[part][str(row)] = kept
for key, value in (raw.get("surcharge") or {}).items():
if value is None or _num(value) is not None:
out["surcharge"][str(key)] = _num(value)
return out
def _pick(table: dict[str, Any], key: str, default: Any) -> Any:
return table[key] if key in table else default
def diameters(columns: list[dict[str, Any]]) -> list[int]:
"""구조물 집계표 관 열(관매설 · 커플링)의 관경 — 오름차순."""
found = set()
for column in columns:
match = LENGTH_ID.match(column["id"]) or COUPLING_ID.match(column["id"])
if match:
found.add(int(match.group(1)))
return sorted(found)
def material_columns(sizes: list[int]) -> list[tuple[str, str, str, str]]:
out: list[tuple[str, str, str, str]] = []
for entry in MATERIALS:
if entry[0] == "PIPES":
out += [(f"pipe_{d}", "파형강관", f"Φ{d}", "m") for d in sizes]
else:
out.append(entry)
return out
def _structure_row(column: dict[str, Any]) -> tuple[str, str, str]:
"""집계표 열 머리 → (구분, 규격, 단위) — 맨 아래 머리가 단위가 아니면(관보호공 모양) 규격에."""
group = [str(g) for g in column.get("group") or []]
label = str(column.get("label") or "")
name = group[0] if group else ""
spec = group[1:]
if label in UNITS:
return name, " ".join(spec), label
return name, " ".join([*spec, label]), "개소"
def compute(summary: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
"""구조물 집계표 결과(98 `_summary` 꼴: columns · total) + 사용자 값 → 세 표."""
columns = summary.get("columns") or []
total = summary.get("total") or {}
units_in = overrides.get("units") or {}
sizes = diameters(columns)
materials = material_columns(sizes)
keys = [m[0] for m in materials]
# 자재수량집계표 — 집계표 열마다 한 줄(커플링은 총괄로 바로)
rows = []
sums: dict[str, float] = {}
for column in columns:
cid = column["id"]
if COUPLING_ID.match(cid):
continue
name, spec, unit = _structure_row(column)
qty = _num(total.get(cid))
length = LENGTH_ID.match(cid)
defaults = {f"pipe_{length.group(1)}": 1.0} if length else {}
given = units_in.get(cid) or {}
units = {k: _num(_pick(given, k, defaults.get(k))) for k in keys}
amounts: dict[str, float | None] = {}
for k in keys:
u = units[k]
amounts[k] = round6(qty * u) if qty is not None and u is not None else None
if amounts[k] is not None:
sums[k] = round6(sums.get(k, 0.0) + amounts[k])
rows.append(
{
"id": cid,
"name": name,
"spec": spec,
"unit": unit,
"qty": qty,
"units": units,
"amounts": amounts,
}
)
quantity = {
"materials": [{"key": k, "name": n, "spec": s, "unit": u} for k, n, s, u in materials],
"rows": rows,
"total": sums,
}
# 콘크리트소요자재집계표
mix_in = overrides.get("mix") or {}
concrete_rows = []
concrete_total: dict[str, float] = {}
for key, name, spec, source, mix in CONCRETE_ROWS:
qty = sums.get(source) if source else None
given = mix_in.get(key) or {}
per = {f: _num(_pick(given, f, mix[f])) for f in MIX_FIELDS}
out = {}
for f in MIX_FIELDS:
out[f] = round6(qty * per[f]) if qty is not None and per[f] is not None else None
if out[f] is not None:
concrete_total[f] = round6(concrete_total.get(f, 0.0) + out[f])
concrete_rows.append(
{"key": key, "name": name, "spec": spec, "qty": qty, "per": per, "amounts": out}
)
concrete = {"rows": concrete_rows, "total": concrete_total}
# 총괄자재수량집계표
surcharge_in = overrides.get("surcharge") or {}
couplings = {
int(m.group(1)): _num(total.get(c["id"]))
for c in columns
if (m := COUPLING_ID.match(c["id"]))
}
plan: list[tuple[str, str, str, str, str, float | None, int]] = []
for entry in TOTAL_ROWS:
if entry[0] == "PIPES":
plan += [(f"pipe_{d}", "파형강관", f"Φ{d}mm", "m", f"m:pipe_{d}", 0, 0) for d in sizes]
plan += [
(f"coupling_{d}", "커플링밴드", f"Φ{d}mm", "개", f"cp:{d}", 0, 0) for d in sizes
]
else:
plan.append(entry)
total_rows = []
for key, group, spec, unit, source, pct_default, places in plan:
ref, _, divisor = source.partition("/")
kind, _, name = ref.partition(":")
if kind == "m":
qty = sums.get(name)
elif kind == "c":
qty = concrete_total.get(name)
else:
qty = couplings.get(int(name))
if qty is not None and divisor:
qty = round6(qty / float(divisor))
pct = _num(_pick(surcharge_in, key, pct_default))
amount = roundup(qty * (1 + (pct or 0) / 100), places) if qty is not None else None
total_rows.append(
{
"key": key,
"group": group,
"spec": spec,
"unit": unit,
"qty": qty,
"surcharge": pct,
"amount": amount,
"places": places,
}
)
return {"quantity": quantity, "concrete": concrete, "material": {"rows": total_rows}}
+288
View File
@@ -0,0 +1,288 @@
/* =============================================================================
* B08_Quantity_Material_Calc.ts
* B08 자재 집계표 셋 계산(PLAN 121-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림).
*
* ⚠ 짝 = `B08_Quantity_Material_Calc.py`(같은 식 · 같은 상수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_121_b08_material_sheets.py`.
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
*
* 구조물 집계표 계(98) = 구조물 수량 C → 자재수량집계표(C × 단위수량) → 콘크리트소요자재집계표
* (㎥당 배합 × 콘크리트 · 모르터 ㎥) → 총괄자재수량집계표(올림(수량 × (1 + 할증)) · 커플링 = 집계표 계)
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
* ========================================================================== */
export type Cells = Record<string, number | null>;
export interface MaterialOverrides {
units: Record<string, Cells>;
mix: Record<string, Cells>;
surcharge: Cells;
}
/** 구조물 집계표 응답(98)에서 쓰는 칸만 */
export interface SummarySource {
columns: Array<{ id: string; group: string[]; label: string }>;
total: Record<string, number>;
}
export interface MaterialColumn {
key: string;
name: string;
spec: string;
unit: string;
}
export interface QuantityRow {
id: string;
name: string;
spec: string;
unit: string;
qty: number | null;
units: Cells;
amounts: Cells;
}
export interface ConcreteRow {
key: string;
name: string;
spec: string;
qty: number | null;
per: Cells;
amounts: Cells;
}
export interface TotalRow {
key: string;
group: string;
spec: string;
unit: string;
qty: number | null;
surcharge: number | null;
amount: number | null;
places: number;
}
export interface MaterialSheets {
quantity: { materials: MaterialColumn[]; rows: QuantityRow[]; total: Record<string, number> };
concrete: { rows: ConcreteRow[]; total: Record<string, number> };
material: { rows: TotalRow[] };
}
type Entry = [string, string, string, string];
/** 자재수량집계표 자재 열 — 실무 엑셀 F~AD(파형강관은 관경마다) · 파이썬 짝 MATERIALS */
const MATERIALS: Entry[] = [
["remicon_25_24_15", "레미콘", "25-24-15", "㎥"],
["remicon_25_21_80", "레미콘", "25-21-80", "㎥"],
["remicon_25_18_80", "레미콘", "25-18-80", "㎥"],
["concrete_machine", "콘크리트", "기계", "㎥"],
["rebar_d13", "이형철근", "D13", "kg"],
["rebar_d16", "이형철근", "D16", "kg"],
["rebar_d19", "이형철근", "D19", "kg"],
["rebar_d22", "이형철근", "D22", "kg"],
["rebar_d25", "이형철근", "D25", "kg"],
["PIPES", "", "", ""],
["mortar_1_3", "모르터", "1:3", "㎥"],
["revet_block", "호안블럭", "", "㎡"],
["wire_mesh", "와이어메쉬", "", "㎡"],
["flume_400c", "수로관", "400C", "본"],
["steel_grating", "스틸그레이팅", "", "EA"],
["stone_buy_35", "돌(구입)", "L3:35", "ton"],
["stone_buy_45", "돌(구입)", "L3:45", "ton"],
["stone_pick_35", "돌(채집)", "L3:35", "㎥"],
["stone_pick_45", "돌(채집)", "L3:45", "㎥"],
["bed_stone", "고임돌", "채집", "㎥"],
["rubble", "막자갈", "채집", "㎥"],
["mixed_aggregate", "혼합골재", "", "㎥"],
["sod", "떼", "", "㎡"],
];
/** 콘크리트소요 줄 — 파이썬 짝 CONCRETE_ROWS(12-1-3 QF000353 (B) · CC000337 · 건축 9-1-1 QC001347) */
const CONCRETE_ROWS: Array<[string, string, string, string | null, Cells]> = [
["c25", "콘크리트", "Φ25mm", null, { cement: 346, sand: 0.487, gravel: 0.562 }],
["c40", "콘크리트", "Φ40mm", "concrete_machine", { cement: 323, sand: 0.456, gravel: 0.612 }],
["c40_plain", "콘크리트", "Φ40mm(무근)", null, { cement: null, sand: null, gravel: null }],
["m12", "모르터", "1:2", null, { cement: 680, sand: 0.98, gravel: null }],
["m13", "모르터", "1:3", "mortar_1_3", { cement: 510, sand: 1.1, gravel: null }],
];
export const MIX_FIELDS = ["cement", "sand", "gravel"] as const;
/** 총괄 줄 — 파이썬 짝 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],
["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],
["remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, 0],
["rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, 3],
["rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, 3],
["rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, 3],
["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],
["PIPES", "", "", "", "", 0, 0],
["sod", "떼", "", "㎡", "m:sod", 10, 0],
["stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", null, 0],
["stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", null, 0],
["revet_block", "호안블럭", "", "㎡", "m:revet_block", null, 0],
["flume_400c", "수로관", "400C", "본", "m:flume_400c", null, 0],
["steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", null, 0],
];
const LENGTH_ID = /^pipe:len\|(\d+)$/;
const COUPLING_ID = /^pipe:cp\|(\d+)$/;
const UNITS = ["m", "㎡", "㎥", "ea", "개", "개소", "EA"];
/** 소수 6 자리 반올림(떠도는 끝수만 지움 · 엑셀처럼 줄마다 자르지 않음)(파이썬 짝 round6 과 같은 반 올림) */
export const round6 = (value: number): number => Math.floor(value * 1e6 + 0.5) / 1e6;
/** 실무 ROUNDUP — 떠도는 끝수(1e-9)는 올리지 않음 */
export function roundup(value: number, places: number): number {
const scale = 10 ** places;
return Math.ceil(round6(value) * scale - 1e-9) / scale;
}
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const pick = (table: Cells, key: string, fallback: number | null | undefined): unknown =>
key in table ? table[key] : fallback;
export const emptyOverrides = (): MaterialOverrides => ({ units: {}, mix: {}, surcharge: {} });
export function diameters(columns: SummarySource["columns"]): number[] {
const found = new Set<number>();
for (const column of columns) {
const match = LENGTH_ID.exec(column.id) ?? COUPLING_ID.exec(column.id);
if (match) found.add(Number(match[1]));
}
return [...found].sort((a, b) => a - b);
}
function materialColumns(sizes: number[]): Entry[] {
return MATERIALS.flatMap((entry): Entry[] =>
entry[0] === "PIPES"
? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"])
: [entry],
);
}
/** 집계표 열 머리 → [구분, 규격, 단위] — 맨 아래 머리가 단위가 아니면(관보호공 모양) 규격에 붙임 */
function structureRow(column: SummarySource["columns"][number]): [string, string, string] {
const [name = "", ...spec] = (column.group ?? []).map(String);
const label = String(column.label ?? "");
if (UNITS.includes(label)) return [name, spec.join(" "), label];
return [name, [...spec, label].join(" "), "개소"];
}
const add = (sums: Record<string, number>, key: string, value: number | null): void => {
if (value !== null) sums[key] = round6((sums[key] ?? 0) + value);
};
export function computeMaterialSheets(
summary: SummarySource,
overrides: MaterialOverrides,
): MaterialSheets {
const columns = summary.columns ?? [];
const total = summary.total ?? {};
const unitsIn = overrides.units ?? {};
const sizes = diameters(columns);
const materials = materialColumns(sizes);
const keys = materials.map((m) => m[0]);
const rows: QuantityRow[] = [];
const sums: Record<string, number> = {};
for (const column of columns) {
const cid = column.id;
if (COUPLING_ID.test(cid)) continue;
const [name, spec, unit] = structureRow(column);
const qty = num(total[cid]);
const length = LENGTH_ID.exec(cid);
const defaults: Cells = length ? { [`pipe_${length[1]}`]: 1 } : {};
const given = unitsIn[cid] ?? {};
const units: Cells = {};
const amounts: Cells = {};
for (const k of keys) {
const u = num(pick(given, k, defaults[k]));
units[k] = u;
amounts[k] = qty !== null && u !== null ? round6(qty * u) : null;
add(sums, k, amounts[k]);
}
rows.push({ id: cid, name, spec, unit, qty, units, amounts });
}
const quantity = {
materials: materials.map(([key, name, spec, unit]) => ({ key, name, spec, unit })),
rows,
total: sums,
};
const mixIn = overrides.mix ?? {};
const concreteRows: ConcreteRow[] = [];
const concreteTotal: Record<string, number> = {};
for (const [key, name, spec, source, mix] of CONCRETE_ROWS) {
const qty = source ? (sums[source] ?? null) : null;
const given = mixIn[key] ?? {};
const per: Cells = {};
const amounts: Cells = {};
for (const f of MIX_FIELDS) {
per[f] = num(pick(given, f, mix[f]));
amounts[f] = qty !== null && per[f] !== null ? round6(qty * per[f]!) : null;
add(concreteTotal, f, amounts[f]);
}
concreteRows.push({ key, name, spec, qty, per, amounts });
}
const surchargeIn = overrides.surcharge ?? {};
const couplings = new Map<number, number | null>();
for (const column of columns) {
const match = COUPLING_ID.exec(column.id);
if (match) couplings.set(Number(match[1]), num(total[column.id]));
}
const plan = TOTAL_ROWS.flatMap((entry): TotalEntry[] =>
entry[0] === "PIPES"
? [
...sizes.map((d): TotalEntry => [
`pipe_${d}`,
"파형강관",
`Φ${d}mm`,
"m",
`m:pipe_${d}`,
0,
0,
]),
...sizes.map((d): TotalEntry => [
`coupling_${d}`,
"커플링밴드",
`Φ${d}mm`,
"개",
`cp:${d}`,
0,
0,
]),
]
: [entry],
);
const totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, places]) => {
const [ref, divisor = ""] = source.split("/");
const [kind, name = ""] = ref.split(":");
let qty =
kind === "m"
? (sums[name] ?? null)
: kind === "c"
? (concreteTotal[name] ?? null)
: (couplings.get(Number(name)) ?? null);
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 {
quantity,
concrete: { rows: concreteRows, total: concreteTotal },
material: { rows: totalRows },
};
}
@@ -0,0 +1,48 @@
"""B08 자재 집계표 셋 길(PLAN 121-1) — 총괄자재 · 콘크리트소요자재 · 자재수량 집계표.
GET /api/projects/{id}/quantity/materials
구조물 집계표(98 `structure_summary` 그대로 부름) + 저장된 사용자 값 → 세 표
PUT /api/projects/{id}/quantity/materials {overrides}
[저장] — 사용자 값을 프로젝트 설정 quantity › material_sheets 에 · 서버가 정본으로 다시 셈
계산 = `B08_Quantity_Material_Calc.py`(화면 짝 `.ts` 와 같은 식)
기본값은 파일에 안 씀(읽을 때 채움).
"""
from __future__ import annotations
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_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
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]:
summary = await structure_summary(project_id)
source = {"columns": summary["columns"], "total": summary["total"]}
return {"source": source, "overrides": overrides, "sheets": compute(source, overrides)}
@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)))
@router.put("/{project_id}/quantity/materials")
async def save_material_sheets(
project_id: UUID, body: dict[str, Any] = Body(...)
) -> dict[str, Any]:
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)
+296
View File
@@ -0,0 +1,296 @@
/* =============================================================================
* B08_Quantity_UI_Material.ts
* 구조물 탭 보조 탭 셋(PLAN 121-1) — 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표.
* 실무 엑셀(4.2 수량산출(2공구).xlsx) 꼴 · 공용 표(`createDataTable` · 다단 머리 · 합계 · 고정 열).
*
* 자료 = GET …/quantity/materials(구조물 집계표 계 · 저장된 사용자 값 · 서버가 셈한 세 표)
* 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로
* [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import {
computeMaterialSheets,
MIX_FIELDS,
type Cells,
type MaterialOverrides,
type MaterialSheets,
type SummarySource,
} from "./B08_Quantity_Material_Calc";
export type MaterialSheetId = "material" | "concrete" | "quantity";
interface Payload {
source: SummarySource;
overrides: MaterialOverrides;
sheets: MaterialSheets;
}
const BASIS =
"구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)";
const HINTS: Record<MaterialSheetId, string> = {
material:
"합계 = 올림(수량 × (1 + 할증)) · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음",
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1",
};
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
if (!response.ok) {
const detail = body?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return body as T;
}
function note(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = text;
return p;
}
/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */
const numberColumn = (column: DataColumn): DataColumn => ({
kind: "number",
decimals: 3,
trim: true,
...column,
});
/** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */
function parse(text: string): number | null | undefined {
const t = text.replace(/,/g, "").trim();
if (!t) return null;
const v = Number(t);
return Number.isFinite(v) ? v : undefined;
}
interface Table {
columns: DataColumn[];
rows: DataRow[];
total: DataRow | null;
frozen: number;
editable: (index: number, key: string) => boolean;
/** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */
target: (index: number, key: string) => [Record<string, Cells> | Cells, string, string | null];
}
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { materials, rows, total } = s.quantity;
const rowsOut: DataRow[] = rows.flatMap((row) => {
const unit: DataRow = {
name: row.name,
spec: row.spec,
qty: row.qty,
unit: row.unit,
kind: "단위수량",
};
const amount: DataRow = { kind: "수량" };
for (const m of materials) {
unit[`m:${m.key}`] = row.units[m.key];
amount[`m:${m.key}`] = row.amounts[m.key];
}
return [unit, amount];
});
const totalRow: DataRow = { name: "계", kind: "수량" };
for (const m of materials) totalRow[`m:${m.key}`] = total[m.key];
return {
columns: [
{ key: "name", label: "구분", width: 120 },
{ key: "spec", label: "규격", width: 120 },
numberColumn({ key: "qty", label: "수량", width: 72 }),
{ key: "unit", label: "단위", width: 56 },
{ key: "kind", label: "구분", width: 72 },
...materials.map((m) =>
numberColumn({
key: `m:${m.key}`,
label: m.unit,
group: m.spec ? [m.name, m.spec] : [m.name],
title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "),
width: 76,
}),
),
],
rows: rowsOut,
total: totalRow,
frozen: 5,
editable: (i, key) => i % 2 === 0 && key.startsWith("m:"),
target: (i, key) => [o.units, rows[i / 2].id, key.slice(2)],
};
}
const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = {
cement: ["시멘트", "㎥당(kg)", "수량(kg)"],
sand: ["모래", "㎥당(㎥)", "수량(㎥)"],
gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"],
};
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows, total } = s.concrete;
const rowsOut: DataRow[] = rows.map((row, i) => {
const out: DataRow = {
name: i > 0 && rows[i - 1].name === row.name ? "" : row.name,
spec: row.spec,
qty: row.qty,
};
for (const f of MIX_FIELDS) {
out[`per:${f}`] = row.per[f];
out[`amt:${f}`] = row.amounts[f];
}
return out;
});
const totalRow: DataRow = { name: "계" };
for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f];
return {
columns: [
{ key: "name", label: "구분", width: 88 },
{ key: "spec", label: "규격", width: 110 },
numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }),
...MIX_FIELDS.flatMap((f) => [
numberColumn({
key: `per:${f}`,
label: MIX_LABEL[f][1],
group: [MIX_LABEL[f][0]],
width: 84,
}),
numberColumn({
key: `amt:${f}`,
label: MIX_LABEL[f][2],
group: [MIX_LABEL[f][0]],
width: 96,
}),
]),
],
rows: rowsOut,
total: totalRow,
frozen: 2,
editable: (_i, key) => key.startsWith("per:"),
target: (i, key) => [o.mix, rows[i].key, key.slice(4)],
};
}
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows } = s.material;
return {
columns: [
{ key: "group", label: "구분", width: 96 },
{ key: "spec", label: "규격", width: 120 },
{ key: "unit", label: "단위", width: 56 },
numberColumn({ key: "qty", label: "수량", width: 96 }),
numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }),
numberColumn({ key: "amount", label: "합계", width: 96 }),
],
rows: rows.map((row, i) => ({
group: i > 0 && rows[i - 1].group === row.group ? "" : row.group,
spec: row.spec,
unit: row.unit,
qty: row.qty,
surcharge: row.surcharge,
amount: row.amount,
})),
total: null,
frozen: 2,
editable: (_i, key) => key === "surcharge",
target: (i) => [o.surcharge, rows[i].key, null],
};
}
const BUILD: Record<MaterialSheetId, (s: MaterialSheets, o: MaterialOverrides) => Table> = {
material: totalTable,
concrete: concreteTable,
quantity: quantityTable,
};
/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
export async function renderMaterialSheet(
host: HTMLElement,
projectId: string,
sheet: MaterialSheetId,
): Promise<void> {
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
let payload: Payload;
try {
payload = await request<Payload>(`/projects/${projectId}/quantity/materials`);
} catch (error) {
host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`));
return;
}
const draft = readState<MaterialOverrides>("b08-materials", projectId);
let overrides: MaterialOverrides = draft ?? payload.overrides;
let sheets = draft ? computeMaterialSheets(payload.source, overrides) : payload.sheets;
const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() });
save.dataset.b08MaterialSave = "";
const toolbar = document.createElement("div");
toolbar.className = "b08-material__toolbar";
const notes = document.createElement("div");
notes.append(note(BASIS), note(HINTS[sheet]));
toolbar.append(notes, save);
let built = BUILD[sheet](sheets, overrides);
const table = createDataTable({
columns: built.columns,
rows: built.rows,
total: built.total,
frozen: built.frozen,
empty: "",
caption: BASIS,
editable: (i, key) => built.editable(i, key),
onEdit: (i, key, text) => {
const value = parse(text);
if (value !== undefined) {
const [table_, row, cell] = built.target(i, key);
if (cell === null) (table_ as Cells)[row] = value;
else
(table_ as Record<string, Cells>)[row] = {
...(table_ as Record<string, Cells>)[row],
[cell]: value,
};
writeState("b08-materials", overrides, projectId);
sheets = computeMaterialSheets(payload.source, overrides);
}
redraw();
},
});
table.root.dataset.b08Material = sheet;
const redraw = (): void => {
built = BUILD[sheet](sheets, overrides);
table.setRows(built.rows, built.total);
};
host.replaceChildren(toolbar, table.root);
/** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */
const saveAll = async (): Promise<void> => {
showLoadingOverlay();
try {
payload = await request<Payload>(`/projects/${projectId}/quantity/materials`, {
method: "PUT",
body: JSON.stringify({ overrides }),
});
overrides = payload.overrides;
sheets = payload.sheets;
clearState("b08-materials", projectId);
redraw();
showToast("자재 집계표를 저장했습니다.", "success");
} catch (error) {
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
} finally {
hideLoadingOverlay();
}
};
}
+13
View File
@@ -18,3 +18,16 @@
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
/* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */
.b08-material__toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-16);
margin-bottom: var(--spacing-8);
}
.b08-material__toolbar > .ui-btn {
flex: 0 0 auto;
}
+6 -4
View File
@@ -4,7 +4,7 @@
*
* 탭 = 구조물 · 토공 · 수리집수면적표 (PLAN 95-1). 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1).
* 구조물 아래 보조 탭 = 구조물 집계표(61-1) · 총괄자재수량집계표 · 콘크리트소요자재집계표 ·
* 자재수량집계표(셋은 아직 빈 탭) — 구조물도는 집계표 행을 눌러 여는 모달(95-3).
* 자재수량집계표(셋 = 121-1 `B08_Quantity_UI_Material`) — 구조물도는 집계표 행을 눌러 여는 모달(95-3).
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
* ========================================================================== */
@@ -17,6 +17,7 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
import { renderHydro } from "./B08_Quantity_UI_Hydro";
import { renderEarthwork } from "./B08_Quantity_UI_Earthwork";
@@ -32,7 +33,7 @@ const TABS = [
type TabId = (typeof TABS)[number]["id"];
/** 구조물 탭 보조 탭 — 첫째만 내용이 있음. */
/** 구조물 탭 보조 탭 — 실무 엑셀 차례. */
const SHEETS = [
{ id: "summary", label: "B08_Quantity_Tab_StructureSummary" },
{ id: "material", label: "B08_Quantity_Tab_MaterialTotal" },
@@ -72,8 +73,9 @@ function renderStructure(host: HTMLElement, projectId: string | null): void {
);
const body = document.createElement("div");
view.replaceChildren(body);
if (picked === "summary" && projectId) void renderStructureSummary(body, projectId);
else body.append(pendingNote());
if (!projectId) body.append(pendingNote());
else if (picked === "summary") void renderStructureSummary(body, projectId);
else void renderMaterialSheet(body, projectId, picked);
};
host.replaceChildren(tabs, view);
select("summary");
@@ -170,6 +170,9 @@ def default_settings() -> dict[str, Any]:
"tree_waste_unit_price_krw_per_ton": None,
# 폐기물 분리발주 — 기본 아님(경비로 원가에 듦 · 2026-09-14 판정).
"waste_separate_order": False,
# 자재 집계표 셋(PLAN 121) 사용자 값 — `{units, mix, surcharge}` 고친 칸만 ·
# 기본값(배합 · 할증)은 파일에 안 쓰고 읽을 때 채움(`B08_Quantity_Material_Calc.py`).
"material_sheets": {},
"dataset_versions": {},
},
"estimation": {
+2
View File
@@ -66,6 +66,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_pla
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router
from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
@@ -649,6 +650,7 @@ app.include_router(b07_plan_kinds_router, dependencies=protected_with_company)
app.include_router(b08_hydro_router, dependencies=protected_with_company)
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
app.include_router(b08_summary_router, dependencies=protected_with_company)
app.include_router(b08_material_router, dependencies=protected_with_company)
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
@@ -0,0 +1,258 @@
"""121-1 B08 총괄자재 · 콘크리트소요자재 · 자재수량 집계표 — 2공구 꼴 견본 수치 · 거울 · 저장.
견본 = 실무 `4.2 수량산출(2공구).xlsx` 의 구조물 집계표 계 · 단위수량
(기슭막이 · 돌붙임 · 관공 · 돌집수정)
→ 자재수량집계표 66행 계 · 콘크리트소요자재집계표 11행 · 총괄자재수량집계표 G열과 맞춤.
거울 = `B08_Quantity_Material_Calc.ts` 를 node 로 돌려 파이썬 짝과 같은 결과.
"""
import asyncio
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity import B08_Quantity_Router_Material as router_mod # noqa: E402
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
def _col(cid, group, label, total):
return cid, {"id": cid, "group": group, "label": label}, total
# (열, 계, 단위수량) — 실무 엑셀 구조물집계표 16행 · 자재수량집계표 단위수량 줄
SAMPLE = [
(_col("pipe:revet|돌쌓기(찰)|2.0", ["흙막이", "찰쌓기", "H=2.0"], "m", 20),
{"remicon_25_18_80": 0.31, "concrete_machine": 0.42, "mortar_1_3": 0.019,
"stone_buy_45": 1.92, "bed_stone": 0.31, "rubble": 0.75}),
(_col("pipe:revet|돌쌓기(메)|1.5", ["흙막이", "메쌓기", "H=1.5"], "m", 20),
{"remicon_25_18_80": 0.31, "stone_pick_45": 0.54, "bed_stone": 0.24, "rubble": 0.56}),
(_col("pipe:revet|돌쌓기(메)|2.0", ["흙막이", "메쌓기", "H=2.0"], "m", 60),
{"remicon_25_18_80": 0.31, "stone_pick_45": 0.72, "bed_stone": 0.31, "rubble": 0.75}),
(_col("pipe:chute|찰붙임", ["돌붙임", "찰붙임"], "㎡", 20),
{"concrete_machine": 0.16, "mortar_1_3": 0.009, "stone_buy_35": 0.71, "bed_stone": 0.12}),
(_col("pipe:chute|메붙임", ["돌붙임", "메붙임"], "㎡", 90),
{"stone_pick_35": 0.27, "bed_stone": 0.12}),
(_col("pipe:len|800", ["관 공", "Φ800m/m", "관매설"], "m", 84), None),
(_col("pipe:cp|800", ["관 공", "Φ800m/m", "커플링"], "ea", 9), None),
(_col("pipe:basin|800|U", ["관 공", "Φ800m/m", "관보호공"], "돌집수정ㄷ형", 2),
{"concrete_machine": 4.03, "mortar_1_3": 0.157, "stone_pick_35": 1.71,
"stone_pick_45": 13.8, "bed_stone": 2.54, "rubble": 3.75}),
(_col("pipe:basin|800|L", ["관 공", "Φ800m/m", "관보호공"], "돌집수정ㄴ형", 3),
{"concrete_machine": 2.69, "mortar_1_3": 0.096, "stone_pick_35": 1.71,
"stone_pick_45": 7.6, "bed_stone": 1.53, "rubble": 2.08}),
(_col("pipe:len|1000", ["관 공", "Φ1000m/m", "관매설"], "m", 16), None),
(_col("pipe:cp|1000", ["관 공", "Φ1000m/m", "커플링"], "ea", 2), None),
(_col("pipe:len|1200", ["관 공", "Φ1200m/m", "관매설"], "m", 22), None),
(_col("pipe:cp|1200", ["관 공", "Φ1200m/m", "커플링"], "ea", 3), None),
] # fmt: skip
SUMMARY = {
"columns": [col for (_, col, _), _ in SAMPLE],
"total": {cid: total for (cid, _, total), _ in SAMPLE},
}
UNITS = {cid: units for (cid, _, _), units in SAMPLE if units}
#: 실무 엑셀 ㎥당 배합(모래 · 자갈 ㎥ 은 엑셀 값) — 엑셀 계와 맞춰 보기
EXCEL_MIX = {"c40": {"sand": 0.48, "gravel": 0.65}}
def _over(**parts):
return clean_overrides({"units": UNITS, **parts})
def _run_ts(summary, overrides):
calc = ROOT / "B08_Quantity" / "B08_Quantity_Material_Calc.ts"
body = (
f"\nconst s = {json.dumps(summary, ensure_ascii=False)};"
f"\nconst o = {json.dumps(overrides, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeMaterialSheets(s, o)));"
)
script = IMPORTS.sub("", calc.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 _total_rows(sheets):
return {row["key"]: row for row in sheets["material"]["rows"]}
def test_자재수량_계가_엑셀_66행과_같다():
sheets = compute(SUMMARY, _over())
total = sheets["quantity"]["total"]
assert total["remicon_25_18_80"] == 31 # H66
assert total["concrete_machine"] == 27.73 # I66
assert total["mortar_1_3"] == 1.162 # R66
assert total["stone_buy_35"] == 14.2 and total["stone_buy_45"] == 38.4 # W66 · X66
assert total["stone_pick_35"] == 32.85 and total["stone_pick_45"] == 104.4 # Y66 · Z66
assert total["bed_stone"] == 52.47 and total["rubble"] == 84.94 # AA66 · AB66
assert total["pipe_800"] == 84 and total["pipe_1000"] == 16 and total["pipe_1200"] == 22
# 수량 = C × 단위수량 · 커플링은 자재 줄이 아님 · 관보호공 모양은 규격으로
rows = {row["id"]: row for row in sheets["quantity"]["rows"]}
assert "pipe:cp|800" not in rows
revet = rows["pipe:revet|돌쌓기(찰)|2.0"]
assert revet["qty"] == 20 and revet["amounts"]["stone_buy_45"] == 38.4
assert revet["amounts"]["sod"] is None # 빈 단위수량 = 빈칸(0 아님)
basin = rows["pipe:basin|800|U"]
assert (basin["name"], basin["spec"], basin["unit"]) == (
"관 공",
"Φ800m/m 관보호공 돌집수정ㄷ형",
"개소",
)
keys = [m["key"] for m in sheets["quantity"]["materials"]]
assert keys.index("rebar_d25") + 1 == keys.index("pipe_800")
assert keys.index("pipe_1200") + 1 == keys.index("mortar_1_3")
def test_콘크리트소요_엑셀_배합이면_엑셀_계():
sheets = compute(SUMMARY, _over(mix=EXCEL_MIX))
total = sheets["concrete"]["total"]
assert total["cement"] == 9549.41 # E11
assert total["sand"] == 14.5886 # G11
assert total["gravel"] == 18.0245 # I11
rows = _total_rows(sheets)
assert rows["sand"]["amount"] == 16 and rows["gravel"]["amount"] == 19 # G4 · G5
assert rows["cement"]["qty"] == 238.73525 and rows["cement"]["amount"] == 244 # G18
def test_기본_배합_원문값():
sheets = compute(SUMMARY, _over())
per = {row["key"]: row["per"] for row in sheets["concrete"]["rows"]}
assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612} # 775/1700 · 1101/1800
assert per["c25"] == {"cement": 346, "sand": 0.487, "gravel": 0.562}
assert per["c40_plain"] == {"cement": None, "sand": None, "gravel": None} # 근거 없음
assert per["m12"]["cement"] == 680 and per["m13"] == {
"cement": 510,
"sand": 1.1,
"gravel": None,
}
amounts = {row["key"]: row for row in sheets["concrete"]["rows"]}
assert amounts["c40"]["qty"] == 27.73 and amounts["m13"]["qty"] == 1.162
assert amounts["c25"]["qty"] is None # 끌어올 열 없음
assert amounts["c40"]["amounts"]["cement"] == 8956.79 # 323 × 27.73
def test_총괄_할증_올림_관_커플링():
rows = _total_rows(compute(SUMMARY, _over()))
# 파형강관 = 구조물 집계표 관매설 계 · 커플링 = 집계표 계 그대로 · 관급 할증 0
assert [rows[f"pipe_{d}"]["amount"] for d in (800, 1000, 1200)] == [84, 16, 22]
assert [rows[f"coupling_{d}"]["amount"] for d in (800, 1000, 1200)] == [9, 2, 3]
assert rows["pipe_800"]["surcharge"] == 0
# 레미콘 25-18 = 올림(31 × 1.02) — 엑셀은 ROUND 2 (31.62) · 지시 = 올림
assert rows["remicon_25_18_80"]["surcharge"] == 2 and rows["remicon_25_18_80"]["amount"] == 32
# 깬돌 = 할증 빈칸 · 올림(14.2 → 15 · 38.4 → 39) = 엑셀 G26 · G27
assert rows["stone_buy_35"]["amount"] == 15 and rows["stone_buy_45"]["amount"] == 39
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
def test_사용자_값이_기본을_이김():
over = _over(
surcharge={"stone_buy_45": 5, "pipe_800": None},
mix={"c40_plain": {"cement": 312}},
units={**UNITS, "pipe:len|800": {"pipe_800": None}, "pipe:chute|메붙임": {"sod": 1}},
)
sheets = compute(SUMMARY, over)
rows = _total_rows(sheets)
assert rows["stone_buy_45"]["amount"] == 41 # 올림(38.4 × 1.05 = 40.32)
assert rows["pipe_800"]["qty"] is None # 관매설 단위수량을 비우면 파형강관도 빈칸
assert rows["sod"]["qty"] == 90 and rows["sod"]["amount"] == 99 # 떼 10 %
plain = next(r for r in sheets["concrete"]["rows"] if r["key"] == "c40_plain")
assert plain["per"]["cement"] == 312
def test_다듬기():
out = clean_overrides(
{"units": {"a": {"x": "1", "y": True, "z": None, "w": 2}, "b": "깨짐"},
"mix": {"c40": {"cement": float("nan")}}, "surcharge": {"sand": 7, "bad": "x"}, "etc": 1}
) # fmt: skip
assert out == {"units": {"a": {"z": None, "w": 2.0}}, "mix": {}, "surcharge": {"sand": 7.0}}
assert clean_overrides(None) == {"units": {}, "mix": {}, "surcharge": {}}
def test_거울_TS와_파이썬이_같다():
for over in (
_over(),
_over(mix=EXCEL_MIX, surcharge={"sand": 12, "pipe_1000": 3}),
clean_overrides({}),
):
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
empty = {"columns": [], "total": {}}
assert _run_ts(empty, clean_overrides({})) == compute(empty, clean_overrides({}))
def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch):
async def summary(_project_id):
return {**SUMMARY, "rows": [], "notes": []}
async def project_root(_project_id):
return tmp_path
monkeypatch.setattr(router_mod, "structure_summary", summary)
monkeypatch.setattr(router_mod, "_project_root", project_root)
first = asyncio.run(router_mod.material_sheets("p"))
assert first["overrides"] == {"units": {}, "mix": {}, "surcharge": {}}
assert _total_rows(first["sheets"])["pipe_800"]["amount"] == 84
body = {"overrides": {"units": UNITS, "surcharge": {"sand": 12}}}
saved = asyncio.run(router_mod.save_material_sheets("p", body))
assert saved["sheets"] == compute(SUMMARY, clean_overrides(body["overrides"]))
stored = json.loads((tmp_path / "project_settings.json").read_text(encoding="utf-8"))
sheet = stored["quantity"]["material_sheets"]
assert sheet["surcharge"] == {"sand": 12} and sheet["mix"] == {} # 기본값은 안 씀
# 지운 칸은 지워짐(통째 갈아 끼움)
asyncio.run(router_mod.save_material_sheets("p", {"overrides": {"units": {}}}))
again = asyncio.run(router_mod.material_sheets("p"))
assert again["overrides"] == {"units": {}, "mix": {}, "surcharge": {}}
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 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음
assert "일위대가 재료와 별개" in ui
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "include_router(b08_material_router" in main
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
assert '"b08-materials": { bucket: "draft", scope: "project" }' in state
def test_공용_표_고칠_칸():
table = ROOT / "ui_template" / "ui_template_data_table.ts"
body = """
const html = renderDataTableHtml({
columns: [
{ key: "a", label: "A" },
{ key: "b", label: "B", kind: "number", decimals: 3, trim: true },
],
rows: [{ a: "x", b: 0.31 }, { a: "y", b: 6.2 }],
total: { a: "계", b: 6.51 },
editable: (i, key) => i === 0 && key === "b",
});
console.log(JSON.stringify(html));
"""
script = IMPORTS.sub("", table.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
html = json.loads(result.stdout)
assert '<input class="ui-data-table__input" data-i="0" data-k="b" value="0.31"' in html
assert html.count("<input") == 1 # 둘째 줄 · 합계 줄은 읽기만
assert '<td data-num="">6.2</td>' in html # 끝 0 지움(6.200 아님)
+22
View File
@@ -137,3 +137,25 @@
.ui-data-table__table tbody tr.is-clickable {
cursor: pointer;
}
/* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */
.ui-data-table__table td.is-edit {
padding: 0;
}
.ui-data-table__input {
width: 100%;
height: var(--dt-row-h);
padding: 0 var(--spacing-8);
border: 0;
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
text-align: right;
}
.ui-data-table__input:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
}
+45 -3
View File
@@ -7,6 +7,7 @@
* const t = createDataTable({ columns, rows, frozen: 1, total });
* host.append(t.root); t.setRows(rows2, total2);
* 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* ========================================================================== */
import "./ui_template_data_table.css";
@@ -22,6 +23,8 @@ export interface DataColumn {
kind?: "number" | "text";
/** 숫자 소수 자리(기본 0) */
decimals?: number;
/** 끝 0 지움(decimals 는 최대 자리) — 사용자가 넣는 값처럼 자리가 줄마다 다른 열 */
trim?: boolean;
/** 열 폭 px(기본 숫자 84 · 글 96) */
width?: number;
/** 맨 아래 머리 칸 풍선 글(기본 label) */
@@ -45,6 +48,10 @@ export interface DataTableOptions {
empty?: string;
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
onRowClick?: (index: number) => void;
/** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */
editable?: (index: number, key: string) => boolean;
/** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */
onEdit?: (index: number, key: string, text: string) => void;
}
export interface DataTable {
@@ -115,10 +122,20 @@ function rowHtml(
alt = false,
blank = EMPTY,
index: number | null = null,
edit: number | null = null,
editable?: (index: number, key: string) => boolean,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
const [txt, empty] = formatCell(r[cols[j].key], fmts[j], blank);
const key = cols[j].key;
if (edit !== null && editable?.(edit, key)) {
const v = r[key];
const raw = v === null || v === undefined ? "" : String(v);
const tip = esc(cols[j].title ?? cols[j].label);
tr += `<td class="is-edit" data-num=""><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
continue;
}
const [txt, empty] = formatCell(r[key], fmts[j], blank);
const frz = left[j] !== null;
const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"]
.filter(Boolean)
@@ -157,7 +174,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const fmts = cols.map((c) =>
c.kind === "number"
? new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c.decimals ?? 0,
minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0),
maximumFractionDigits: c.decimals ?? 0,
})
: null,
@@ -170,7 +187,17 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const blank = o.empty ?? EMPTY;
const pick = o.onRowClick !== undefined;
for (let i = from; i < to; i++)
body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank, pick ? i : null);
body += rowHtml(
cols,
fmts,
left,
o.rows[i],
i % 2 === 1,
blank,
pick ? i : null,
i,
o.editable,
);
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
@@ -197,7 +224,14 @@ export function createDataTable(opts: DataTableOptions): DataTable {
const to = Math.min(n, from + view + 3 * OVER);
if (!force && from === win.from && to === win.to) return;
win = { ...win, from, to };
// 고치던 칸 — 다시 그린 뒤 같은 칸에 커서를 돌려 놓음(Tab 으로 옮겨 간 칸 포함)
const active = document.activeElement;
const focus =
active instanceof HTMLInputElement && root.contains(active)
? `input[data-i="${active.dataset.i}"][data-k="${CSS.escape(active.dataset.k ?? "")}"]`
: null;
root.innerHTML = renderDataTableHtml(state, win);
if (focus) root.querySelector<HTMLInputElement>(focus)?.focus();
// 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도)
const tr = root.querySelector<HTMLElement>("tbody tr:not(.ui-data-table__pad)");
if (tr && tr.offsetHeight && tr.offsetHeight !== win.rowH) {
@@ -206,6 +240,14 @@ export function createDataTable(opts: DataTableOptions): DataTable {
}
};
root.addEventListener("scroll", () => draw(false), { passive: true });
// 바꾼 칸 — 커서가 다음 칸으로 옮겨 간 뒤 알림(그 사이 다시 그리면 Tab 이 갈 곳을 잃음)
root.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
if (!input.dataset?.k) return;
const index = Number(input.dataset.i);
const key = input.dataset.k;
setTimeout(() => state.onEdit?.(index, key, input.value));
});
root.addEventListener("click", (event) => {
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i));