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