diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 75850c9cb..73d433151 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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). diff --git a/B08_Quantity/B08_Quantity_Material_Calc.py b/B08_Quantity/B08_Quantity_Material_Calc.py new file mode 100644 index 000000000..d59e36aad --- /dev/null +++ b/B08_Quantity/B08_Quantity_Material_Calc.py @@ -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}} diff --git a/B08_Quantity/B08_Quantity_Material_Calc.ts b/B08_Quantity/B08_Quantity_Material_Calc.ts new file mode 100644 index 000000000..c64ac57fd --- /dev/null +++ b/B08_Quantity/B08_Quantity_Material_Calc.ts @@ -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; + +export interface MaterialOverrides { + units: Record; + mix: Record; + surcharge: Cells; +} + +/** 구조물 집계표 응답(98)에서 쓰는 칸만 */ +export interface SummarySource { + columns: Array<{ id: string; group: string[]; label: string }>; + total: Record; +} + +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 }; + concrete: { rows: ConcreteRow[]; total: Record }; + 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(); + 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, 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 = {}; + 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 = {}; + 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(); + 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 }, + }; +} diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py new file mode 100644 index 000000000..1db711aeb --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts new file mode 100644 index 000000000..6850c7ae4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -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 = { + material: + "합계 = 올림(수량 × (1 + 할증)) · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음", + concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", + quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1", +}; + +async function request(path: string, init: RequestInit = {}): Promise { + 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 | 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 Table> = { + material: totalTable, + concrete: concreteTable, + quantity: quantityTable, +}; + +/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ +export async function renderMaterialSheet( + host: HTMLElement, + projectId: string, + sheet: MaterialSheetId, +): Promise { + host.replaceChildren(note("자재 집계표를 불러오는 중…")); + let payload: Payload; + try { + payload = await request(`/projects/${projectId}/quantity/materials`); + } catch (error) { + host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`)); + return; + } + const draft = readState("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)[row] = { + ...(table_ as Record)[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 => { + showLoadingOverlay(); + try { + payload = await request(`/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(); + } + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index a74e7d678..6add90a85 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -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; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index c2621bab7..70ecdce05 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -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"); diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 68c8fcc21..67b0cf2b9 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -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": { diff --git a/main.py b/main.py index 1205b1eb6..a50239291 100644 --- a/main.py +++ b/main.py @@ -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)] diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py new file mode 100644 index 000000000..f1b105f44 --- /dev/null +++ b/resources/tester/test_121_b08_material_sheets.py @@ -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 '6.2' in html # 끝 0 지움(6.200 아님) diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index c915b2519..d5e5fcbcc 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -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; +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index a77ef0e5d..a23dcfe0c 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -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 += ``; + 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 ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `${esc(o.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(focus)?.focus(); // 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도) const tr = root.querySelector("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("tr[data-i]"); if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i));