From e1d83398d4392bf1b95fa96eafb208dae63c417d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:01:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=EB=8B=A8=EA=B0=80=20=E2=80=94=20=EB=BC=88=EB=8C=80?= =?UTF-8?q?=20=C2=B7=20=EB=85=B8=EB=AC=B4=20=C2=B7=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=C2=B7=20=ED=99=98=EC=9C=A8=20=C2=B7=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EB=8C=80=EB=B9=84=20=C2=B7=20=EC=A4=91=EA=B8=B0=20=ED=83=AD(14?= =?UTF-8?q?4-1~4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 연결 점검) · 탭 하나 = 공용 표 하나 · 호표 칸 누름 = 그 탭 그 줄로 이동 - 저장 = 프로젝트 B09_Estimation/v1(사용자 값 · 굳힌 단가 한 벌) · 결과는 GET 때 서버 셈 · [저장] · [단가 갱신] 길 - 줄 = 수요 따라(연결된 사본 원천 자리 · 기계 → 운전사 · 연료 · B08 총괄 자재) · 연결 없으면 빈칸 + 연결 점검 목록 - 셈 = 파이썬 · TS 짝 + 거울(운전사 시간 노임 · 자재 최저 자동 + 사용자 고침 · 관급 · 별도 · 중기 손료 + 운전사 + 연료 + 잡품 · 엑셀 끝수) - 공용 표 = 칸 누름 · 줄로 이동(강조) 더함 - 시험 = test_144_b09_skeleton · test_144_b09_base_calc(엑셀 중기 126,000 · 76,436 · 96,829 · 40,659 · 운전사 55,700 · 47,231 · 33,571) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- A00_Common/b_page_state.ts | 2 + B09_Estimation/B09_Estimation_Calc_Base.py | 319 ++++++++++++++++++ B09_Estimation/B09_Estimation_Calc_Base.ts | 289 ++++++++++++++++ B09_Estimation/B09_Estimation_Prices.py | 266 +++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 84 +++++ B09_Estimation/B09_Estimation_Store.py | 109 ++++++ B09_Estimation/B09_Estimation_UI_Page.css | 32 ++ B09_Estimation/B09_Estimation_UI_Shell.ts | 228 ++++++++++++- B09_Estimation/B09_Estimation_UI_Tabs.ts | 164 +++++++++ B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 275 +++++++++++++++ main.py | 2 + resources/tester/test_144_b09_base_calc.py | 170 ++++++++++ resources/tester/test_144_b09_skeleton.py | 114 +++++++ ui_template/ui_template_data_table.css | 6 + ui_template/ui_template_data_table.ts | 26 +- 15 files changed, 2075 insertions(+), 11 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Calc_Base.py create mode 100644 B09_Estimation/B09_Estimation_Calc_Base.ts create mode 100644 B09_Estimation/B09_Estimation_Prices.py create mode 100644 B09_Estimation/B09_Estimation_Router.py create mode 100644 B09_Estimation/B09_Estimation_Store.py create mode 100644 B09_Estimation/B09_Estimation_UI_Page.css create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Base.ts create mode 100644 resources/tester/test_144_b09_base_calc.py create mode 100644 resources/tester/test_144_b09_skeleton.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index f2aa42d78..bd0f26bd5 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -178,6 +178,8 @@ export const STATE_REGISTRY = { "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ "b08-options": { bucket: "draft", scope: "project" }, + /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ + "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ "alignment-draft": { diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py new file mode 100644 index 000000000..0c0677cac --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -0,0 +1,319 @@ +"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. + +⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_144_b09_base_calc.py`. + 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. + +실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로: + 노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로) + 환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0) + (분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌) + 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값 + · 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가 + 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) + 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 +빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None · +「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀). +""" + +from __future__ import annotations + +import math +from typing import Any + +#: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 +EPS = 1e-7 + +#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료 차례(같은 값이면 앞 칸) ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 +SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가") + +#: 자재 줄 사용자 표시 +SUPPLY = ("관급", "별도") + + +def _scaled(value: float, places: int, fn) -> float: + a = abs(value) + if places >= 0: + scale = 10**places + out = fn(a * scale) / scale + else: + unit = 10 ** (-places) + out = fn(a / unit) * unit + return -out if value < 0 else out + + +def rounddown(value: float, places: int = 0) -> float: + """엑셀 ROUNDDOWN — 0 쪽으로 버림.""" + return _scaled(value, places, lambda x: math.floor(x + EPS)) + + +def roundup(value: float, places: int = 0) -> float: + """엑셀 ROUNDUP — 0 에서 먼 쪽으로 올림.""" + return _scaled(value, places, lambda x: math.ceil(x - EPS)) + + +def round_half(value: float, places: int = 0) -> float: + """엑셀 ROUND — 반 올림(0 에서 먼 쪽).""" + return _scaled(value, places, lambda x: math.floor(x + 0.5 + EPS)) + + +def num(value: Any) -> float | None: + """수 · 빈칸 — 수가 아니면 None.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + +def total(values: list[float | None]) -> float | None: + """합 — 하나라도 빈칸이면 빈칸(연결 안 된 값은 비움).""" + out = 0.0 + for v in values: + if v is None: + return None + out += v + return out + + +def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]: + return {"tab": tab, "key": key, "name": name, "why": why} + + +def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None: + """운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0).""" + if day is None or not factor or not factor.get("den"): + return None + return rounddown(day * factor["num"] / factor["den"], 0) + + +def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: + rows = [] + for no, row in enumerate(labor, 1): + price = num(row.get("price")) + if price is None: + gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음")) + rows.append( + { + **_head(row, no), + "code": row.get("code"), + "unit": row.get("unit") or "인", + "price": price, + } + ) + return rows + + +def _head(row: dict[str, Any], no: int) -> dict[str, Any]: + return { + "no": no, + "key": row["key"], + "name": row.get("name") or "", + "spec": row.get("spec") or "", + "source": row.get("source") or "", + } + + +def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: + """경비목록표 — 기계 손료 기준가(천원).""" + rows = [] + for no, m in enumerate(machines, 1): + price = num(m.get("price")) + if price is None: + gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음")) + rows.append({**_head(m, no), "unit": "천원", "price": price}) + return rows + + +def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]: + """출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸.""" + best: tuple[float | None, str | None] = (None, None) + for source in SOURCES: + v = num(prices.get(source)) + if v is not None and v > 0 and (best[0] is None or v < best[0]): + best = (v, source) + return best + + +def compare_sheet( + materials: list[dict[str, Any]], user: dict[str, Any], gaps: list +) -> list[dict[str, Any]]: + """자재단가대비표 — 출처 · 최저 · 적용(사용자 값이 앞섬) · 관급 · 별도 표시.""" + rows = [] + for no, m in enumerate(materials, 1): + mine = user.get(m["key"]) or {} + prices = m.get("prices") or {} + low, low_from = lowest(prices) + if "applied" in mine: + applied, applied_from = num(mine["applied"]), "사용자" + else: + applied, applied_from = low, low_from + if applied is None: + gaps.append(gap("compare", m["key"], m["name"], "적용 단가 없음(출처 없음)")) + supply = mine.get("supply") if mine.get("supply") in SUPPLY else None + rows.append( + { + **_head(m, no), + "unit": m.get("unit") or "", + "origin": m.get("origin") or "", + "prices": {s: num(prices.get(s)) for s in SOURCES}, + "pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES}, + "lowest": low, + "lowest_from": low_from, + "applied": applied, + "applied_from": applied_from if applied is not None else None, + "supply": supply, + } + ) + return rows + + +def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]: + """재료비목록표 — 단가 = 대비표 적용 단가.""" + keep = ("no", "key", "name", "spec", "unit", "supply", "origin") + return [{**{k: row[k] for k in keep}, "price": row["applied"]} for row in compare] + + +def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]: + if price is None or qty is None: + amount = None + elif pct: + amount = rounddown(price * qty / 100, 1) + else: + amount = rounddown(price * qty, 1) + return { + "name": name, + "spec": spec, + "qty": qty, + "unit": unit, + "kind": kind, + "price": price, + "amount": amount, + "ref": ref, + } + + +def machine_block( + m: dict[str, Any], + drivers: dict[str, dict[str, Any]], + fuels: dict[str, dict[str, Any]], + gaps: list, +) -> dict[str, Any]: + """중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합.""" + lines = [] + loss = num(m.get("loss")) + coef = loss / 10000 if loss is not None else None + price = num(m.get("price")) + lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price, + {"tab": "expense", "key": m["key"]})) # fmt: skip + crew = num(m.get("crew")) + if crew: + code = str(m.get("crew_code") or "") + driver = drivers.get(code) or {} + hour = driver.get("hour") + if hour is None: + gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음")) + lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour, + {"tab": "basis", "key": code})) # fmt: skip + lph = num(m.get("fuel_lph")) + if lph: + kind = str(m.get("fuel") or "") + fuel = fuels.get(kind) or {} + fprice = fuel.get("applied") + if fprice is None: + gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음")) + fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material", + fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip + lines.append(fuel_line) + misc = num(m.get("misc_pct")) + if misc: + lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"], + None, pct=True)) # fmt: skip + if price is None or coef is None: + gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음")) + sums: dict[str, float | None] = {} + for part in ("labor", "material", "expense"): + s = total([ln["amount"] for ln in lines if ln["kind"] == part]) + sums[part] = None if s is None else rounddown(s, 0) + return { + "key": m["key"], + "name": m["name"], + "spec": m.get("spec") or "", + "unit": "시간", + "lines": lines, + "labor": sums["labor"], + "material": sums["material"], + "expense": sums["expense"], + "sum": total([sums["labor"], sums["material"], sums["expense"]]), + } + + +def basis_sheet( + inputs: dict[str, Any], labor_rows: list[dict[str, Any]] +) -> dict[str, list[dict[str, Any]]]: + """환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만).""" + factor = inputs.get("driver_factor") + by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None} + codes: list[str] = [] + for m in inputs.get("machines") or []: + code = str(m.get("crew_code")) if m.get("crew_code") else None + if code and num(m.get("crew")) and code not in codes: + codes.append(code) + drivers = [] + for code in codes: + row = by_code.get(code) or {} + day = row.get("price") + drivers.append( + { + "key": code, + "labor_key": row.get("key"), + "name": row.get("name") or f"직종 {code}", + "day": day, + "factor": (factor or {}).get("text") or "", + "hour": driver_hour(day, factor), + } + ) + fx = [ + {"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "", + "value": num(f.get("value")), "date": f.get("date") or ""} + for f in inputs.get("fx") or [] + ] # fmt: skip + return {"fx": fx, "drivers": drivers} + + +def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: + """144 탭 일곱 + 「연결 없음」 목록.""" + gaps: list[dict[str, str]] = [] + labor_rows = labor_sheet(inputs.get("labor") or [], gaps) + expense = expense_sheet(inputs.get("machines") or [], gaps) + compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps) + basis = basis_sheet(inputs, labor_rows) + drivers = {d["key"]: d for d in basis["drivers"]} + by_key = {row["key"]: row for row in compare} + fuels = { + kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key + } + blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []] + machine = [ + { + "no": no, + "key": b["key"], + "name": b["name"], + "spec": b["spec"], + "unit": b["unit"], + "sum": b["sum"], + "labor": b["labor"], + "material": b["material"], + "expense": b["expense"], + } + for no, b in enumerate(blocks, 1) + ] + return { + "labor": labor_rows, + "expense": expense, + "basis": basis, + "compare": compare, + "material": material_sheet(compare), + "machine_blocks": blocks, + "machine": machine, + "gaps": gaps, + } diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts new file mode 100644 index 000000000..4213f70a3 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -0,0 +1,289 @@ +/* ============================================================================= + * B09_Estimation_Calc_Base.ts + * B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). + * + * ⚠ 짝 = `B09_Estimation_Calc_Base.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_144_b09_base_calc.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. + * + * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) + * 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표 + * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 + * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 + * ========================================================================== */ + +export type Num = number | null; +export type Row = Record; + +export interface Gap { + tab: string; + key: string; + name: string; + why: string; +} + +/** 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 */ +const EPS = 1e-7; + +/** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */ +export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const; + +/** 자재 줄 사용자 표시 */ +export const SUPPLY = ["관급", "별도"] as const; + +function scaled(value: number, places: number, fn: (x: number) => number): number { + const a = Math.abs(value); + let out: number; + if (places >= 0) { + const scale = 10 ** places; + out = fn(a * scale) / scale; + } else { + const unit = 10 ** -places; + out = fn(a / unit) * unit; + } + return value < 0 ? -out : out; +} + +/** 엑셀 ROUNDDOWN */ +export const rounddown = (value: number, places = 0): number => + scaled(value, places, (x) => Math.floor(x + EPS)); + +/** 엑셀 ROUNDUP */ +export const roundup = (value: number, places = 0): number => + scaled(value, places, (x) => Math.ceil(x - EPS)); + +/** 엑셀 ROUND(반 올림) */ +export const roundHalf = (value: number, places = 0): number => + scaled(value, places, (x) => Math.floor(x + 0.5 + EPS)); + +export function num(value: unknown): Num { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** 합 — 하나라도 빈칸이면 빈칸 */ +export function total(values: Num[]): Num { + let out = 0; + for (const v of values) { + if (v === null) return null; + out += v; + } + return out; +} + +export const gap = (tab: string, key: string, name: string, why: string): Gap => ({ + tab, + key, + name, + why, +}); + +export function driverHour(day: Num, factor: Row | null | undefined): Num { + if (day === null || !factor || !factor.den) return null; + return rounddown((day * factor.num) / factor.den, 0); +} + +const head = (row: Row, no: number): Row => ({ + no, + key: row.key, + name: row.name || "", + spec: row.spec || "", + source: row.source || "", +}); + +function laborSheet(labor: Row[], gaps: Gap[]): Row[] { + return labor.map((row, i) => { + const price = num(row.price); + if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음")); + return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price }; + }); +} + +function expenseSheet(machines: Row[], gaps: Gap[]): Row[] { + return machines.map((m, i) => { + const price = num(m.price); + if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음")); + return { ...head(m, i + 1), unit: "천원", price }; + }); +} + +export function lowest(prices: Row): [Num, string | null] { + let best: [Num, string | null] = [null, null]; + for (const source of SOURCES) { + const v = num(prices[source]); + if (v !== null && v > 0 && (best[0] === null || v < best[0])) best = [v, source]; + } + return best; +} + +function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { + return materials.map((m, i) => { + const mine: Row = user[m.key] || {}; + const prices: Row = m.prices || {}; + const [low, lowFrom] = lowest(prices); + let applied: Num; + let appliedFrom: string | null; + if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"]; + else [applied, appliedFrom] = [low, lowFrom]; + if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)")); + const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null; + const pages: Row = m.pages || {}; + return { + ...head(m, i + 1), + unit: m.unit || "", + origin: m.origin || "", + prices: Object.fromEntries(SOURCES.map((s) => [s, num(prices[s])])), + pages: Object.fromEntries(SOURCES.map((s) => [s, pages[s] ?? null])), + lowest: low, + lowest_from: lowFrom, + applied, + applied_from: applied !== null ? appliedFrom : null, + supply, + }; + }); +} + +function materialSheet(compare: Row[]): Row[] { + const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; + return compare.map((row) => ({ + ...Object.fromEntries(keep.map((k) => [k, row[k]])), + price: row.applied, + })); +} + +function line( + name: string, + spec: string, + qty: Num, + unit: string, + kind: string, + price: Num, + ref: Row | null, + pct = false, +): Row { + let amount: Num; + if (price === null || qty === null) amount = null; + else if (pct) amount = rounddown((price * qty) / 100, 1); + else amount = rounddown(price * qty, 1); + return { name, spec, qty, unit, kind, price, amount, ref }; +} + +function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row { + const lines: Row[] = []; + const loss = num(m.loss); + const coef = loss !== null ? loss / 10000 : null; + const price = num(m.price); + lines.push( + line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }), + ); + const crew = num(m.crew); + if (crew) { + const code = String(m.crew_code || ""); + const driver: Row = drivers[code] || {}; + const hour: Num = driver.hour ?? null; + if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음")); + lines.push( + line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }), + ); + } + const lph = num(m.fuel_lph); + if (lph) { + const kind = String(m.fuel || ""); + const fuel: Row = fuels[kind] || {}; + const fprice: Num = fuel.applied ?? null; + if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`)); + const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, { + tab: "compare", + key: fuel.key || "", + }); + lines.push(fuelLine); + const misc = num(m.misc_pct); + if (misc) + lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true)); + } + if (price === null || coef === null) + gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음")); + const sums: Record = {}; + for (const part of ["labor", "material", "expense"]) { + const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount)); + sums[part] = s === null ? null : rounddown(s, 0); + } + return { + key: m.key, + name: m.name, + spec: m.spec || "", + unit: "시간", + lines, + labor: sums.labor, + material: sums.material, + expense: sums.expense, + sum: total([sums.labor, sums.material, sums.expense]), + }; +} + +function basisSheet(inputs: Row, laborRows: Row[]): Row { + const factor = inputs.driver_factor; + const byCode: Row = {}; + for (const row of laborRows) + if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row; + const codes: string[] = []; + for (const m of inputs.machines || []) { + const code = m.crew_code ? String(m.crew_code) : null; + if (code && num(m.crew) && !codes.includes(code)) codes.push(code); + } + const drivers = codes.map((code) => { + const row: Row = byCode[code] || {}; + const day: Num = row.price ?? null; + return { + key: code, + labor_key: row.key ?? null, + name: row.name || `직종 ${code}`, + day, + factor: (factor || {}).text || "", + hour: driverHour(day, factor), + }; + }); + const fx = (inputs.fx || []).map((f: Row) => ({ + key: f.key, + name: f.name || "", + unit: f.unit || "", + value: num(f.value), + date: f.date || "", + })); + return { fx, drivers }; +} + +/** 144 탭 일곱 + 「연결 없음」 목록 */ +export function computeBase(inputs: Row, user: Row): Row { + const gaps: Gap[] = []; + const laborRows = laborSheet(inputs.labor || [], gaps); + const expense = expenseSheet(inputs.machines || [], gaps); + const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps); + const basis = basisSheet(inputs, laborRows); + const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d])); + const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row])); + const fuels: Row = {}; + for (const [kind, key] of Object.entries(inputs.fuel_keys || {})) + if ((key as string) in byKey) fuels[kind] = byKey[key as string]; + const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps)); + const machine = blocks.map((b: Row, i: number) => ({ + no: i + 1, + key: b.key, + name: b.name, + spec: b.spec, + unit: b.unit, + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + })); + return { + labor: laborRows, + expense, + basis, + compare, + material: materialSheet(compare), + machine_blocks: blocks, + machine, + gaps, + }; +} diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py new file mode 100644 index 000000000..03ecb38b0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -0,0 +1,266 @@ +"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나). + +원가 단가 = 프로젝트 한 벌 — 원가산출에서 M01 단가를 한 번 복사해 굳힘(`price_book.json`) · +[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가). + +줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음: + 연결된 사본이 쓰는 자원 키(`linked_resource_keys` — 143-1 「연결된 사본」 원천 · 145-1 · 2 호표가 + 들어오면 그 결과를 돌려줌 · 그전엔 빈 목록 = 빈 표 「연결 없음」) + + 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」) + + B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력) +운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱). +연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230) +DRIVER_TABLE = "CC000336" +DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수") +#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값 +FUEL_REGION = "전국평균" +#: 자재품목 값 열 → 대비표 출처 칸 +MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격") + + +def linked_resource_keys(project_root: str | Path) -> list[str]: + """연결된 사본(구조물 조합 사본)이 쓰는 자원 키(LB · EQ · MT · MO) — 자리만. + + 143-1 「연결된 사본」 원천 함수 · 145-1 · 2 산근 · 대가 호표가 들어오면 그 자원 키를 돌려줌 · + 그전엔 빈 목록(구조물 조합 사본 없으면 그 구조물은 없음 · 연결 안 된 값은 비움). + """ + return [] + + +def _f(value: Any) -> float | None: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) + + +def _master(): + return m01.loaded()[1] + + +def _row(master, key: str) -> dict[str, Any] | None: + return master.index.get(key[:2], {}).get(key) + + +def labor_input(row: dict[str, Any]) -> dict[str, Any]: + return { + "key": row["키"], + "code": str(row.get("원문번호") or ""), + "name": row.get("이름") or "", + "spec": row.get("규격") or "", + "unit": "인", + "price": _f(row.get("값")), + "source": row.get("출처") or "", + } + + +def machine_input(row: dict[str, Any]) -> dict[str, Any]: + v = row.get("값") or {} + return { + "key": row["키"], + "code": str(row.get("원문번호") or ""), + "name": row.get("이름") or "", + "spec": row.get("규격") or "", + "price": _f(v.get("가격")), + "loss": _f(v.get("손료계수")), + "fuel": v.get("주연료종류") or None, + "fuel_lph": _f(v.get("주연료")), + "misc_pct": _f(v.get("잡재료율")), + "crew": _f(v.get("조종원")), + "crew_code": str(v["조종원직종"]) if v.get("조종원직종") else None, + "source": (row.get("출처") or {}).get("가격") or row.get("원문") or "", + } + + +def material_input(row: dict[str, Any]) -> dict[str, Any]: + """자재품목(MT) · 유가(MO) 줄 → 대비표 입력 줄.""" + if row["키"].startswith("MO"): + prices = {"유가": _f(row.get("값"))} + pages = {"유가": row.get("규격") or None} + origin = "유가" + spec = f"{row.get('상세구분') or ''} {row.get('규격') or ''}".strip() + unit = (row.get("단위") or "").replace("원/", "") + else: + prices = {s: _f(row.get(s)) for s in MT_SOURCES} + pages = {s: (row.get("면수") or {}).get(s) for s in MT_SOURCES if s != "관급"} + origin = "M01" + spec = row.get("규격") or "" + unit = row.get("단위") or "" + return { + "key": row["키"], + "name": row.get("이름") or "", + "spec": spec, + "unit": unit, + "origin": origin, + "prices": prices, + "pages": pages, + "source": row.get("출처") or "", + } + + +def driver_factor(master) -> dict[str, Any] | None: + table = _row(master, DRIVER_TABLE) + if not table: + return None + parts = {r.get("구분"): r for r in table.get("줄") or []} + if any(p not in parts for p in DRIVER_PARTS): + return None + top = bottom = 1 + text = [] + for p in DRIVER_PARTS: + top *= int(parts[p]["분자"]) + bottom *= int(parts[p]["분모"]) + text.append(f"{parts[p]['분자']}/{parts[p]['분모']}") + return {"num": top, "den": bottom, "text": " × ".join(text)} + + +def fx_inputs(master) -> list[dict[str, Any]]: + return [ + {"key": key, "name": row.get("이름") or "", "unit": row.get("단위") or "", + "value": _f(row.get("값")), "date": row.get("규격") or ""} + for key, row in sorted(master.index.get("FX", {}).items()) + ] # fmt: skip + + +def _fuel_row(master, kind: str) -> dict[str, Any] | None: + return master.numbers.get("MO", {}).get(f"{kind}:{FUEL_REGION}") + + +def demand(master, keys: list[str]) -> dict[str, Any]: + """수요 키 → 줄 차례(노무 · 기계 · 자재) + 연료 짝 · 찾지 못한 키.""" + labor: list[str] = [] + machines: list[str] = [] + materials: list[str] = [] + missing: list[str] = [] + fuel_keys: dict[str, str] = {} + + def add(bucket: list[str], key: str) -> None: + if key not in bucket: + bucket.append(key) + + for key in keys: + if _row(master, key) is None: + missing.append(key) + elif key.startswith("LB"): + add(labor, key) + elif key.startswith("EQ"): + add(machines, key) + elif key.startswith(("MT", "MO")): + add(materials, key) + for key in machines: + v = _row(master, key).get("값") or {} + code = v.get("조종원직종") + driver = master.numbers.get("LB", {}).get(str(code)) if code and v.get("조종원") else None + if driver: + add(labor, driver["키"]) + kind = v.get("주연료종류") + fuel = _fuel_row(master, kind) if kind and v.get("주연료") else None + if fuel: + add(materials, fuel["키"]) + fuel_keys[kind] = fuel["키"] + return { + "labor": labor, + "machines": machines, + "materials": materials, + "fuel_keys": fuel_keys, + "missing": missing, + } + + +def _convert(master, key: str) -> dict[str, Any]: + row = _row(master, key) + if key.startswith("LB"): + return labor_input(row) + if key.startswith("EQ"): + return machine_input(row) + return material_input(row) + + +def refresh_book(keys: list[str]) -> dict[str, Any]: + """[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘.""" + master = _master() + want = demand(master, keys) + rows = { + key: _convert(master, key) + for key in [*want["labor"], *want["machines"], *want["materials"]] + } + return { + "made": datetime.now().isoformat(timespec="seconds"), + "rows": rows, + "driver_factor": driver_factor(master), + "fx": fx_inputs(master), + } + + +def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]: + """[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘.""" + if book is None: + return refresh_book(keys) + master = _master() + want = demand(master, keys) + rows = dict(book["rows"]) + for key in [*want["labor"], *want["machines"], *want["materials"]]: + if key not in rows: + rows[key] = _convert(master, key) + return {**book, "rows": rows} + + +def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]: + """B08 총괄자재수량집계표 줄(수량 있는 줄만) → 자재 입력 줄(M01 짝 없음 · 출처 빈칸).""" + rows = ((b08_sheets or {}).get("material") or {}).get("rows") or [] + out = [] + for row in rows: + if row.get("amount") is None: + continue + out.append( + { + "key": f"b08:{row['key']}", + "name": row.get("group") or "", + "spec": row.get("spec") or "", + "unit": row.get("unit") or "", + "origin": "B08", + "prices": {}, + "pages": {}, + "source": "B08 총괄자재수량집계표", + "qty": row.get("amount"), + } + ) + return out + + +def build_inputs( + book: dict[str, Any] | None, keys: list[str], b08_sheets: dict[str, Any] | None = None +) -> dict[str, Any]: + """표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄은 지금 M01 값 · `fresh` 표시).""" + master = _master() + want = demand(master, keys) + saved = (book or {}).get("rows") or {} + + def pick(key: str) -> dict[str, Any]: + if key in saved: + return saved[key] + return {**_convert(master, key), "fresh": True} + + factor = (book or {}).get("driver_factor") if book else driver_factor(master) + return { + "labor": [pick(k) for k in want["labor"]], + "machines": [pick(k) for k in want["machines"]], + "materials": [pick(k) for k in want["materials"]] + b08_material_rows(b08_sheets), + "fuel_keys": want["fuel_keys"], + "driver_factor": factor, + "fx": (book or {}).get("fx") if book else fx_inputs(master), + "missing": want["missing"], + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py new file mode 100644 index 000000000..d46f5fbe9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router.py @@ -0,0 +1,84 @@ +"""B09 원가계산 길(PLAN 144 · 145) — 결과는 저장하지 않고 GET 때 서버가 셈(B08 꼴). + + GET /api/projects/{id}/estimation 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부 + PUT /api/projects/{id}/estimation {user} [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 + → 서버가 정본으로 다시 셈 + POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + +저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · +셈 = `B09_Estimation_Calc`(화면 짝과 같은 식). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Body + +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Store as store +from B09_Estimation.B09_Estimation_Calc_Base import compute_base + +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) + + +async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: + """B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음).""" + out: dict[str, Any] = {} + try: + out["materials"] = await material_sheets(project_id) + except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림 + notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}") + out["materials"] = None + return out + + +async def payload(project_id: UUID, root: Path) -> dict[str, Any]: + notes: list[str] = [] + b08 = await _b08(project_id, notes) + keys = prices.linked_resource_keys(root) + if not keys: + notes.append( + "연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김" + ) + book = await asyncio.to_thread(store.read_book, root) + inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) + user = await asyncio.to_thread(store.read_user, root) + return { + "inputs": inputs, + "user": user, + "book": {"made": book.get("made")} if book else None, + "sheets": compute_base(inputs, user), + "notes": notes, + } + + +@router.get("/{project_id}/estimation") +async def estimation(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + return await payload(project_id, root) + + +@router.put("/{project_id}/estimation") +async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: + root = await _project_root(project_id) + await asyncio.to_thread(store.write_user, root, body.get("user")) + keys = prices.linked_resource_keys(root) + book = await asyncio.to_thread(store.read_book, root) + book = await asyncio.to_thread(prices.extend_book, book, keys) + await asyncio.to_thread(store.write_book, root, book) + return await payload(project_id, root) + + +@router.post("/{project_id}/estimation/prices") +async def refresh_prices(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + keys = prices.linked_resource_keys(root) + book = await asyncio.to_thread(prices.refresh_book, keys) + await asyncio.to_thread(store.write_book, root, book) + return await payload(project_id, root) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py new file mode 100644 index 000000000..a97b14bce --- /dev/null +++ b/B09_Estimation/B09_Estimation_Store.py @@ -0,0 +1,109 @@ +"""B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`. + + price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}, "driver_factor": …} + — [단가 갱신] 때만 M01 로 새로 · [저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈) + user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?}}, "estimate": {…}} + — 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본) + +결과(표)는 저장하지 않음 — GET 때 서버가 셈(B08 꼴). +""" + +from __future__ import annotations + +import json +import math +import os +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY + +FOLDER = ("B09_Estimation", "v1") +BOOK = "price_book.json" +USER = "user_values.json" + +#: 원가계산 사용자 입력 칸 — 요율 고르는 값 · 손입력(엑셀 총괄 · 원가계산서 · 내역 손입력 칸) +ESTIMATE_FIELDS = ( + "estimated_price", # 추정가격(원) — 요율 구간 고름 + "duration_days", # 공사기간(일) + "work_type", # 공종(글) + "safety_base", # 산업안전보건관리비 기초액(원) + "profit_adjust", # 이윤 보정(원 · 빼는 값) + "vat_adjust", # 부가가치세 조정(원) + "indirect_material", # 간접재료비(원) + "scrap", # 작업설 부산물(원 · 빼는 값) + "procurement_fee_pct", # 조달수수료율(%) +) + + +def folder(project_root: str | Path) -> Path: + return Path(project_root).joinpath(*FOLDER) + + +def _read(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def _write(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_suffix(".tmp") + temp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(temp, path) + + +def read_book(project_root: str | Path) -> dict[str, Any] | None: + book = _read(folder(project_root) / BOOK) + if book is None or not isinstance(book.get("rows"), dict): + return None + return book + + +def write_book(project_root: str | Path, book: dict[str, Any]) -> None: + _write(folder(project_root) / BOOK, book) + + +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_user(raw: Any) -> dict[str, Any]: + """사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만.""" + raw = raw if isinstance(raw, dict) else {} + materials: dict[str, dict[str, Any]] = {} + for key, cell in (raw.get("materials") or {}).items(): + if not isinstance(cell, dict): + continue + out: dict[str, Any] = {} + if "applied" in cell: + out["applied"] = _num(cell["applied"]) + if cell.get("supply") in SUPPLY: + out["supply"] = cell["supply"] + if out: + materials[str(key)] = out + estimate: dict[str, Any] = {} + given = raw.get("estimate") if isinstance(raw.get("estimate"), dict) else {} + for field in ESTIMATE_FIELDS: + if field not in given: + continue + value = given[field] + estimate[field] = (str(value) if value else None) if field == "work_type" else _num(value) + rates = given.get("rates") + if isinstance(rates, dict): + estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} + return {"materials": materials, "estimate": estimate} + + +def read_user(project_root: str | Path) -> dict[str, Any]: + return clean_user(_read(folder(project_root) / USER)) + + +def write_user(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]: + clean = clean_user(user) + _write(folder(project_root) / USER, clean) + return clean diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css new file mode 100644 index 000000000..f8c01c2d6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -0,0 +1,32 @@ +/* B09 원가계산 — 탭 줄 · 안내 글. 색은 테마 변수만(밝음 · 어두움 같이). */ + +.b09-estimation { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-24); /* B08 본문과 같은 값 */ +} + +.b09-estimation .ui-general-block__empty, +.b09-estimation__hint { + color: var(--color-text-secondary); +} + +.b09-estimation__hint { + margin: 0 0 var(--spacing-8); + font-size: var(--text-caption); +} + +.b09-estimation__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ +[data-b09-table] thead th { + white-space: normal; + overflow-wrap: anywhere; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index a82ec689d..c90e24ec7 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -1,39 +1,251 @@ /* ============================================================================= * B09_Estimation_UI_Shell.ts - * 로그인 후 09: 6차 워크플로우 (원가계산) — **빈 화면** (PLAN 7-3) + * 로그인 후 09: 6차 워크플로우 (원가계산) — 탭 화면 (PLAN 144-1) * - * - 옛 원가계산 코드는 통째로 `old_code/B09_Estimation/` 에 있음(지우지 않음). - * - 다시 짤 때까지 메뉴와 주소만 남김 — 제목·단계 막대만 뜨고 탭·계산은 없음. + * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 + * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices + * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ +import "@ui/ui_template_general_layout.css"; +import "./B09_Estimation_UI_Page.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { + createButton, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { createDataTable } from "@ui/ui_template_data_table"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; +import { + buildCheck, + rowIndex, + TABS, + type Link, + type TabBuild, + type TabContext, + type TabId, +} from "./B09_Estimation_UI_Tabs"; +import { + buildBasis, + buildCompare, + buildExpense, + buildLabor, + buildMachine, + buildMachineBlocks, + buildMaterial, +} from "./B09_Estimation_UI_Tabs_Base"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +const LATER: Record = { + dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김", + dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김", + sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김", + sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김", +}; + +const BUILDERS: Partial> = { + check: buildCheck, + basis: buildBasis, + machine: buildMachine, + machine_blocks: buildMachineBlocks, + material: buildMaterial, + labor: buildLabor, + expense: buildExpense, + compare: buildCompare, +}; + +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(textValue: string): HTMLElement { + const p = document.createElement("p"); + p.className = "ui-general-block__empty"; + p.textContent = textValue; + return p; +} + +/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */ +export function computeSheets(payload: Row, user: Row): Row { + return computeBase(payload.inputs, user); +} + export function renderB09Estimation(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + let payload: Row | null = null; + let user: Row = {}; + let sheets: Row = {}; + let current: TabId = "check"; - const note = document.createElement("p"); - note.className = "b09-estimation__rebuilding"; - note.textContent = L("B09_Estimation_Rebuilding"); + const tabBar = document.createElement("div"); + tabBar.className = "b09-estimation__tabs"; + tabBar.setAttribute("role", "tablist"); + const panel = document.createElement("div"); + panel.className = "b09-estimation__panel"; + panel.setAttribute("role", "tabpanel"); const body = document.createElement("div"); - body.append(note); + body.className = "b09-estimation"; + body.append(tabBar, panel); + + const made = document.createElement("p"); + made.className = "b09-estimation__hint"; + + const recompute = (): void => { + if (payload) sheets = computeSheets(payload, user); + }; + + const setUser = (next: Row): void => { + user = next; + if (projectId) writeState("b09-estimation", user, projectId); + recompute(); + show(current); + }; + + /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ + const show = (tab: TabId, focus?: string): void => { + current = tab; + tabBar.replaceChildren( + ...TABS.map(([id, label]) => { + const button = createButton({ + label, + variant: id === tab ? "filled" : "ghost", + size: "sm", + onClick: () => id !== current && show(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === tab)); + button.dataset.b09Tab = id; + return button; + }), + ); + if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); + if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); + const build = BUILDERS[tab]; + if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); + const ctx: TabContext = { sheets, payload, user, setUser }; + const spec = build(ctx); + if (typeof spec === "string") return void panel.replaceChildren(note(spec)); + const goto = (link: Link | null): void => { + if (link) show(link.tab, link.key); + }; + const table = createDataTable({ + columns: spec.columns, + rows: spec.rows, + total: spec.total ?? null, + frozen: spec.frozen ?? 0, + empty: "", + caption: TABS.find(([id]) => id === tab)?.[1], + editable: spec.editable, + onEdit: spec.onEdit, + onCellClick: (i, key) => { + if (spec.onCell) spec.onCell(i, key); + if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); + }, + }); + table.root.dataset.b09Table = tab; + panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root); + if (focus !== undefined) { + const index = rowIndex(spec.rows, focus); + if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); + } + }; + + const apply = (next: Row, fromServer: boolean): void => { + payload = next; + const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; + user = draft ?? next.user ?? {}; + recompute(); + made.textContent = next.book?.made + ? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로` + : "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘"; + show(current); + }; + + const run = async (path: string, method: string, done: string, init?: Row): Promise => { + if (!projectId) return; + showLoadingOverlay(); + try { + const next = await request(`/projects/${projectId}${path}`, { + method, + ...(init ? { body: JSON.stringify(init) } : {}), + }); + clearState("b09-estimation", projectId); + apply(next, true); + showToast(done, "success"); + } catch (error) { + showToast(`하지 못했습니다 — ${String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } + }; + + const save = createButton({ + label: "저장", + variant: "filled", + onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }), + }); + save.dataset.b09Save = ""; + const refresh = createButton({ + label: "단가 갱신", + variant: "ghost", + onClick: () => + void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), + }); + refresh.dataset.b09Refresh = ""; + const section = document.createElement("section"); + section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = "원가 단가"; + section.append(heading, made, refresh); + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(save); + const left = document.createElement("div"); + left.className = "ui-sidebar-panel b09-options"; + left.append(section, actions); + attachCollapsible(left); const layout = createWorkflowLayout({ title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, mainContent: body, + leftPanel: left, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); + show(current); + + if (projectId) + request(`/projects/${projectId}/estimation`).then( + (next) => apply(next, false), + (error: unknown) => + panel.replaceChildren(note(`원가 자료를 불러오지 못했습니다 — ${String(error)}`)), + ); } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts new file mode 100644 index 000000000..8ffb8f9c9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -0,0 +1,164 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs.ts + * B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례). + * + * 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 + * 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`) + * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 + * ========================================================================== */ + +import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; + +export type TabId = + | "check" + | "cost" + | "total" + | "bill" + | "dg_list" + | "dg" + | "sg_list" + | "sg" + | "basis" + | "machine" + | "machine_blocks" + | "material" + | "labor" + | "expense" + | "compare" + | "sum_material" + | "sum_labor" + | "sum_expense" + | "sum_machine"; + +/** 엑셀 탭 차례 — [id, 탭 이름] */ +export const TABS: Array<[TabId, string]> = [ + ["check", "연결 점검"], + ["cost", "공사원가계산서"], + ["total", "총괄설계내역서"], + ["bill", "설계내역서"], + ["dg_list", "일위대가목록표"], + ["dg", "일위대가표"], + ["sg_list", "단가산출근거목록표"], + ["sg", "단가산출근거"], + ["basis", "환율및기초자료"], + ["machine", "중기목록표"], + ["machine_blocks", "중기사용료"], + ["material", "재료비목록표"], + ["labor", "노무비목록표"], + ["expense", "경비목록표"], + ["compare", "자재단가대비표"], + ["sum_material", "재료비수량금액집계표"], + ["sum_labor", "노무비수량금액집계표"], + ["sum_expense", "경비수량금액집계표"], + ["sum_machine", "중기시간금액집계표"], +]; + +export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id; + +/** 다른 탭 줄 — 키가 같은 줄로 이동 */ +export interface Link { + tab: TabId; + key: string; +} + +/** 페이지가 탭 짜기에 주는 것 */ +export interface TabContext { + sheets: Row; + payload: Row; + user: Row; + /** 사용자 값 고침 → 캐시 · 바로 다시 셈 */ + setUser: (user: Row) => void; +} + +/** 탭 한 장 = 표 하나 */ +export interface TabTable { + columns: DataColumn[]; + rows: DataRow[]; + total?: DataRow | null; + frozen?: number; + /** 표 위 안내 글(근거 · 끝수) */ + hint?: string; + /** 칸 누름 → 갈 줄(호표 칸) */ + link?: (index: number, key: string) => Link | null; + editable?: (index: number, key: string) => boolean; + onEdit?: (index: number, key: string, text: string) => void; + /** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */ + onCell?: (index: number, key: string) => void; +} + +export type TabBuild = (ctx: TabContext) => TabTable | string; + +/* ── 열 꼴 ────────────────────────────────────────────────────────────── */ + +export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({ + key, + label, + width, + group, +}); + +export const money = (key: string, label: string, group?: string[], decimals = 0): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals, + width: 96, +}); + +export const qty = (key: string, label: string, decimals = 4, group?: string[]): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals, + trim: true, + width: 76, +}); + +/** 글 → 값 · 빈칸 = null · 수가 아니면 undefined(무시) */ +export function parseNumber(value: string): number | null | undefined { + const t = value.replace(/,/g, "").trim(); + if (!t) return null; + const v = Number(t); + return Number.isFinite(v) ? v : undefined; +} + +/** 줄 키로 줄 번호 찾기(이동) */ +export function rowIndex(rows: DataRow[], key: string): number { + return rows.findIndex((row) => row.key === key); +} + +/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */ + +export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => { + const gaps: Row[] = sheets.gaps || []; + const rows: DataRow[] = gaps.map((g, i) => ({ + no: i + 1, + key: `${g.tab}|${g.key}|${i}`, + tab: tabName(g.tab), + name: g.name, + why: g.why, + })); + const notes: string[] = [...(payload.notes || [])]; + const missing: string[] = payload.inputs?.missing || []; + if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); + return { + columns: [ + qty("no", "번호", 0), + text("tab", "탭", 150), + text("name", "명칭", 260), + text("why", "까닭", 320), + ], + rows, + frozen: 1, + hint: + `연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` + + (notes.length ? ` · ${notes.join(" · ")}` : ""), + link: (i) => { + const g = gaps[i]; + return g ? { tab: g.tab as TabId, key: g.key } : null; + }, + }; +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts new file mode 100644 index 000000000..fef27bb1f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -0,0 +1,275 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Base.ts + * B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 · + * 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`). + * + * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 + * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; +import { + money, + parseNumber, + qty, + text, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +const LINK_HINT = "줄을 누르면 단가를 낸 탭 · 줄로 이동"; + +/** 단가 줄 표(노무비 · 경비목록표) — 호표 · 명칭 · 규격 · 단위 · 단가 · 비고(출처) */ +function priceList(rows: Row[], unitPrice: string, hint: string): TabTable { + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 140), + text("unit", "단위", 56), + money("price", unitPrice), + text("source", "비고", 220), + ], + rows: rows.map((r) => ({ ...r })), + frozen: 2, + hint, + }; +} + +export const buildLabor: TabBuild = ({ sheets, payload }) => + priceList( + sheets.labor || [], + "단가(원/인)", + `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`, + ); + +export const buildExpense: TabBuild = ({ sheets, payload }) => + priceList( + sheets.expense || [], + "기준가(천원)", + `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`, + ); + +function madeText(payload: Row): string { + return payload.book?.made ? `${String(payload.book.made).slice(0, 10)} 굳힘` : "아직 안 굳힘"; +} + +export const buildBasis: TabBuild = ({ sheets }): TabTable => { + const basis: Row = sheets.basis || { fx: [], drivers: [] }; + const rows: DataRow[] = [ + ...basis.fx.map((f: Row) => ({ + part: "1. 환율", + key: f.key, + name: f.name, + spec: f.date, + unit: f.unit, + day: null, + factor: "", + value: f.value, + })), + ...basis.drivers.map((d: Row) => ({ + part: "2. 인건비", + key: d.key, + name: d.name, + spec: `직종 ${d.key}`, + unit: "원/시간", + day: d.day, + factor: d.factor, + value: d.hour, + labor_key: d.labor_key, + })), + ]; + return { + columns: [ + text("part", "구분", 90), + text("name", "명칭", 160), + text("spec", "규격 · 기준일", 120), + text("unit", "단위", 80), + money("day", "일당(원)"), + text("factor", "시간 환산", 160), + money("value", "값", undefined, 2), + ], + rows, + frozen: 2, + hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표", + link: (i) => { + const row = rows[i]; + return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null; + }, + }; +}; + +const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; + +export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { + const rows: Row[] = sheets.compare || []; + const out: DataRow[] = rows.map((r) => { + const row: DataRow = { + no: r.no, + key: r.key, + name: r.name, + spec: r.spec, + unit: r.unit, + lowest: r.lowest, + applied: r.applied, + applied_from: r.applied_from === "사용자" ? "사용자" : (r.applied_from ?? ""), + supply: r.supply ?? "", + }; + for (const s of SOURCES) { + row[`p:${s}`] = r.prices[s]; + row[`g:${s}`] = r.pages[s] ?? ""; + } + return row; + }); + const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) }); + const put = (key: string, cell: Row): void => { + const materials = { ...(user.materials || {}) }; + if (Object.keys(cell).length) materials[key] = cell; + else delete materials[key]; + setUser({ ...user, materials }); + }; + return { + columns: [ + qty("no", "번호", 0), + text("name", "품명", 180), + text("spec", "규격", 140), + text("unit", "단위", 56), + ...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]), + money("lowest", "최저"), + money("applied", "적용 단가", ["적용"]), + text("applied_from", "출처", 80, ["적용"]), + text("supply", "관급 · 별도", 80), + ], + rows: out, + frozen: 2, + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도", + editable: (_i, key) => key === "applied", + onEdit: (i, key, value) => { + const row = out[i]; + if (!row || key !== "applied") return; + const v = parseNumber(value); + if (v === undefined) return; + const cell = mine(String(row.key)); + if (v === null) delete cell.applied; + else cell.applied = v; + put(String(row.key), cell); + }, + onCell: (i, key) => { + const row = out[i]; + if (!row || key !== "supply") return; + const cell = mine(String(row.key)); + const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null; + if (next) cell.supply = next; + else delete cell.supply; + put(String(row.key), cell); + }, + }; +}; + +export const buildMaterial: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({ + ...r, + supply: r.supply ?? "", + })); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 160), + text("unit", "단위", 56), + money("price", "단가"), + text("supply", "관급 · 별도", 80), + text("origin", "비고", 90), + ], + rows, + frozen: 2, + hint: `단가 = 자재단가대비표 적용 단가 · ${LINK_HINT}`, + link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), + }; +}; + +export const buildMachine: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = (sheets.machine || []).map((r: Row) => ({ ...r })); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 120), + text("unit", "단위", 56), + money("sum", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + ], + rows, + frozen: 2, + hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`, + link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null), + }; +}; + +const KIND_TAB: Record = { + expense: "expense", + labor: "basis", + material: "compare", +}; + +export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = []; + const links: Array<{ tab: TabId; key: string } | null> = []; + (sheets.machine_blocks || []).forEach((b: Row, n: number) => { + rows.push({ key: b.key, name: `제 ${n + 1} 호표 ${b.name}`, spec: b.spec, unit: b.unit }); + links.push(null); + for (const ln of b.lines) { + const at = (part: string): number | null => (ln.kind === part ? ln.price : null); + const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null); + rows.push({ + key: `${b.key}|${ln.name}`, + name: ` ${ln.name}`, + spec: ln.spec, + qty: ln.qty, + unit: ln.unit, + sum_price: ln.price, + sum: ln.amount, + labor_price: at("labor"), + labor: amt("labor"), + material_price: at("material"), + material: amt("material"), + expense_price: at("expense"), + expense: amt("expense"), + }); + links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null); + } + rows.push({ + key: `${b.key}|합계`, + name: " 합 계", + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + }); + links.push(null); + }); + const pair = (key: string, label: string) => [ + money(`${key}_price`, "단가", [label], 1), + money(key, "금액", [label], 1), + ]; + return { + columns: [ + text("name", "명칭", 220), + text("spec", "규격", 120), + qty("qty", "수량"), + text("unit", "단위", 56), + ...pair("sum", "합계"), + ...pair("labor", "노무비"), + ...pair("material", "재료비"), + ...pair("expense", "경비"), + ], + rows, + frozen: 1, + hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭", + link: (i) => links[i] ?? null, + }; +}; diff --git a/main.py b/main.py index 7f56d5acd..080e5adc0 100644 --- a/main.py +++ b/main.py @@ -68,6 +68,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_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 B09_Estimation.B09_Estimation_Router import router as b09_estimation_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 @@ -653,6 +654,7 @@ 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) app.include_router(b08_options_router, dependencies=protected_with_company) +app.include_router(b09_estimation_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_144_b09_base_calc.py b/resources/tester/test_144_b09_base_calc.py new file mode 100644 index 000000000..22cccf081 --- /dev/null +++ b/resources/tester/test_144_b09_base_calc.py @@ -0,0 +1,170 @@ +"""144-2~4 B09 바탕 단가 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Base.py` ↔ `.ts`). + +견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 같은 입력: + 노무비목록표 E10~E12 · 환율및기초자료 G6~G8 · 경비목록표 E4 · E5 · E7 · E13 · E16 · E21 · + 자재단가대비표 5~12줄 · 중기사용료 1 · 2 · 4 · 11 · 14 · 16 호표(F11 126,000 …) +""" + +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 B09_Estimation.B09_Estimation_Calc_Base import ( # noqa: E402 + compute_base, + rounddown, + round_half, + roundup, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +FACTOR = {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"} + +LABOR = [ + {"key": "LB000048", "code": "1048", "name": "건설기계운전사", "price": 267360}, + {"key": "LB000049", "code": "1049", "name": "화물차운전사", "price": 226709}, + {"key": "LB000050", "code": "1050", "name": "일반기계운전사", "price": 161142}, + {"key": "LB000002", "code": "1002", "name": "보통인부", "price": None}, +] + + +def _m(key, name, price, loss, fuel, lph, misc, crew_code): + return {"key": key, "name": name, "spec": "", "price": price, "loss": loss, "fuel": fuel, + "fuel_lph": lph, "misc_pct": misc, "crew": 1 if crew_code else None, + "crew_code": crew_code} # fmt: skip + + +MACHINES = [ + _m("X00001", "불도우저19ton", 184499, 1811, "경유", 25, 16, "1048"), + _m("X00003", "굴삭기(0.2m3)", 62550, 2085, "경유", 5, 21, "1048"), + _m("X00005", "굴삭기(0.7m3)", 110926, 2085, "경유", 11.6, 22, "1048"), + _m("X00032", "덤프자동덮개", 1546, 2684, None, None, None, None), + _m("X00048", "콘크리트믹서0.45m3", 5409, 3039, "휘발유", 3.9, 2, "1050"), + _m("X00074", "덤프트럭2.5ton(토사)", 20793, 2967, "경유", 2.9, None, "1049"), + _m("X09999", "기준가 없는 기계", None, 2000, "중유", 3, 10, "1048"), +] + + +def _mt(key, name, **prices): + return {"key": key, "name": name, "spec": "", "unit": "", "origin": "M01", "prices": prices} + + +MATERIALS = [ + _mt("MO1", "휘발유", 관급=1369, 물가정보=0, 물가자료=0), + _mt("MO2", "경유", 관급=1272), + _mt("M3", "각재", 관급=500661, 물가정보=829341, 물가자료=748503), + _mt("M4", "못", 관급=1503, 물가정보=1935, 물가자료=1665), + _mt("M8", "화강석", 관급=0, 물가정보=288000, 물가자료=288000), + _mt("M9", "사용자 값 줄", 물가자료=1000), + _mt("b08:sand", "골재"), +] + +INPUTS = { + "labor": LABOR, + "machines": MACHINES, + "materials": MATERIALS, + "fuel_keys": {"경유": "MO2", "휘발유": "MO1"}, + "driver_factor": FACTOR, + "fx": [ + {"key": "FX000001", "name": "USD", "unit": "원/USD", "value": 1414.9, "date": "2026-08-14"} + ], +} +USER = {"materials": {"M9": {"applied": 950, "supply": "관급"}, "M4": {"supply": "별도"}, + "b08:sand": {"applied": None}}} # fmt: skip + + +def _run_ts(inputs, user): + calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts" + body = ( + f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};" + f"\nconst u = {json.dumps(user, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(computeBase(i, u)));" + ) + 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 test_끝수_엑셀_자리(): + assert rounddown(33412.7689, 1) == 33412.7 and rounddown(126000.9) == 126000 + assert rounddown(-1.25, 1) == -1.2 # 0 쪽으로 + assert roundup(69849380, -3) == 69850000 and roundup(69850000, -3) == 69850000 + assert round_half(2.5) == 3 and round_half(0.125, 2) == 0.13 and round_half(-2.5) == -3 + assert rounddown(0.1 + 0.2, 1) == 0.3 # 떠도는 끝수는 버리지 않음 + + +def test_144_2_노무_경비_환율_기초자료(): + s = compute_base(INPUTS, {}) + assert [r["price"] for r in s["labor"]] == [267360, 226709, 161142, None] + assert ("labor", "LB000002") in {(g["tab"], g["key"]) for g in s["gaps"]} + hours = {d["key"]: d["hour"] for d in s["basis"]["drivers"]} + assert hours == {"1048": 55700, "1050": 33571, "1049": 47231} # 기초자료 G6 · G8 · G7 + assert s["basis"]["drivers"][0]["factor"] == "1/8 × 16/12 × 25/20" + assert [r["price"] for r in s["expense"]][:3] == [ + 184499, + 62550, + 110926, + ] # 경비목록표 E4 · E5 · E7 + assert s["expense"][0]["unit"] == "천원" + assert s["basis"]["fx"][0]["value"] == 1414.9 + + +def test_144_3_자재단가대비_최저_사용자_관급(): + s = compute_base(INPUTS, USER) + rows = {r["key"]: r for r in s["compare"]} + # 엑셀 O 열 = 최저 · P 열 출처(0 은 뺌 · 같은 값이면 앞 칸 — 화강석 「물정」) + assert (rows["MO1"]["applied"], rows["MO1"]["applied_from"]) == (1369, "관급") + assert (rows["M3"]["applied"], rows["M3"]["lowest_from"]) == (500661, "관급") + assert rows["M4"]["applied"] == 1503 and rows["M4"]["supply"] == "별도" + assert (rows["M8"]["applied"], rows["M8"]["applied_from"]) == (288000, "물가정보") + assert (rows["M9"]["lowest"], rows["M9"]["applied"], rows["M9"]["applied_from"]) == ( + 1000, + 950, + "사용자", + ) + assert rows["M9"]["supply"] == "관급" + assert rows["b08:sand"]["applied"] is None # 출처 없음 · 사용자 빈칸 = 빈칸 + material = {r["key"]: r["price"] for r in s["material"]} + assert material == {r["key"]: r["applied"] for r in s["compare"]} # 재료비목록표 = 적용 단가 + gaps = [(g["tab"], g["key"]) for g in s["gaps"] if g["tab"] == "compare"] + assert gaps == [("compare", "b08:sand")] + + +def test_144_4_중기사용료_엑셀_검산(): + s = compute_base(INPUTS, {}) + got = {r["key"]: (r["sum"], r["labor"], r["material"], r["expense"]) for r in s["machine"]} + assert got["X00001"] == (126000, 55700, 36888, 33412) # 중기목록표 4줄 + assert got["X00003"] == (76436, 55700, 7695, 13041) + assert got["X00005"] == (96829, 55700, 18001, 23128) + assert got["X00032"] == (414, 0, 0, 414) # 덮개 — 운전사 · 연료 없음 + assert got["X00048"] == (40659, 33571, 5445, 1643) # 일반기계운전사 · 휘발유 · 잡유 2 % + assert got["X00074"][1:3] == (47231, 3688) # 화물차운전사 · 잡품 없음 + assert got["X09999"] == (None, 55700, None, None) # 기준가 · 중유 단가 없음 = 그 비목 · 합 빈칸 + block = s["machine_blocks"][0] + assert [ln["amount"] for ln in block["lines"]] == [ + 33412.7, + 55700, + 31800, + 5088, + ] # 중기사용료 F7~F10 + assert block["lines"][0]["qty"] == 0.1811 and block["lines"][0]["ref"] == { + "tab": "expense", + "key": "X00001", + } + why = {g["why"] for g in s["gaps"] if g["key"] == "X09999"} + assert why == {"연료(중유) 단가 없음", "기계 기준가 없음", "손료 기준가 · 계수 없음"} + + +def test_거울_TS_가_같은_값(): + for user in ({}, USER): + assert _run_ts(INPUTS, user) == compute_base(INPUTS, user) diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py new file mode 100644 index 000000000..913befed5 --- /dev/null +++ b/resources/tester/test_144_b09_skeleton.py @@ -0,0 +1,114 @@ +"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기. + +- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김) +- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함 +- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸 +""" + +import asyncio +import json +import re +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 B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 + +#: 엑셀 보이는 탭(목차 · 안내 빼고) — 조사 F 1절 차례 +EXCEL_TABS = [ + "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가목록표", "일위대가표", + "단가산출근거목록표", "단가산출근거", "환율및기초자료", "중기목록표", "중기사용료", + "재료비목록표", "노무비목록표", "경비목록표", "자재단가대비표", "재료비수량금액집계표", + "노무비수량금액집계표", "경비수량금액집계표", "중기시간금액집계표", +] # fmt: skip + +B08_SHEETS = { + "material": { + "rows": [ + {"key": "sand", "group": "골재", "spec": "모래", "unit": "㎥", "amount": 16}, + {"key": "rubble", "group": "골재", "spec": "막자갈", "unit": "㎥", "amount": None}, + {"key": "cement", "group": "시멘트", "spec": "40kg/대", "unit": "대", "amount": 244}, + ] + } +} + + +def test_탭_차례가_엑셀과_같다(): + source = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8") + block = source[ + source.index("export const TABS") : source.index("];", source.index("export const TABS")) + ] + names = re.findall(r'\["\w+", "([^"]+)"\]', block) + assert names[0] == "연결 점검" + assert names[1:] == EXCEL_TABS + + +def test_길_셋(): + routes = {(r.path, tuple(sorted(r.methods))) for r in router_mod.router.routes} + assert ("/api/projects/{project_id}/estimation", ("GET",)) in routes + assert ("/api/projects/{project_id}/estimation", ("PUT",)) in routes + assert ("/api/projects/{project_id}/estimation/prices", ("POST",)) in routes + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b09_estimation_router" in main + + +def test_사용자_값_다듬기_저장_자리(tmp_path): + raw = { + "materials": {"MT1": {"applied": 1200, "supply": "관급", "x": 1}, "MT2": {"applied": None}, + "MT3": {"supply": "엉뚱"}}, + "estimate": {"estimated_price": "abc", "profit_adjust": 905980, "work_type": "토목", + "rates": {"RT000001": 3.56, "RT000002": None}, "zzz": 1}, + "other": 1, + } # fmt: skip + clean = store.write_user(tmp_path, raw) + assert clean == { + "materials": {"MT1": {"applied": 1200.0, "supply": "관급"}, "MT2": {"applied": None}}, + "estimate": {"estimated_price": None, "profit_adjust": 905980.0, "work_type": "토목", + "rates": {"RT000001": 3.56, "RT000002": None}}, + } # fmt: skip + path = tmp_path / "B09_Estimation" / "v1" / "user_values.json" + assert json.loads(path.read_text(encoding="utf-8")) == clean + assert store.read_user(tmp_path) == clean + assert store.read_book(tmp_path) is None + + +def test_단가_굳히기_운전사_연료_따라옴(): + book = prices.refresh_book(["EQ000004"]) # 불도저(무한궤도) 19ton · 조종원 1048 · 경유 + rows = book["rows"] + assert set(rows) == {"EQ000004", "LB000048", "MO000002"} + assert rows["EQ000004"]["loss"] == 1811 and rows["EQ000004"]["crew_code"] == "1048" + assert rows["LB000048"]["code"] == "1048" and rows["MO000002"]["prices"]["유가"] > 0 + assert book["driver_factor"] == {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"} + # [저장] = 책에 없는 새 줄만 더함 — 있는 줄 값은 안 바꿈 + stale = {**book, "rows": {**rows, "LB000048": {**rows["LB000048"], "price": 1}}} + grown = prices.extend_book(stale, ["EQ000004", "LB000002"]) + assert grown["rows"]["LB000048"]["price"] == 1 and "LB000002" in grown["rows"] + # 책 밖 줄 = 지금 M01 값 + fresh 표시 + inputs = prices.build_inputs(stale, ["EQ000004", "LB000002"]) + fresh = {row["key"]: row.get("fresh", False) for row in inputs["labor"]} + assert fresh == {"LB000002": True, "LB000048": False} + assert inputs["fuel_keys"] == {"경유": "MO000002"} + + +def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch): + async def fake_materials(_project_id): + return B08_SHEETS + + monkeypatch.setattr(router_mod, "material_sheets", fake_materials) + body = asyncio.run(router_mod.payload("p", tmp_path)) + sheets = body["sheets"] + assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == [] + assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만 + assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님) + assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [ + ("compare", "b08:sand"), + ("compare", "b08:cement"), + ] + assert any("연결된 구조물 조합 사본 없음" in n for n in body["notes"]) + assert body["book"] is None # GET 은 아무것도 쓰지 않음 + assert not (tmp_path / "B09_Estimation").exists() diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index d5e5fcbcc..db528c844 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -138,6 +138,12 @@ cursor: pointer; } +/* 이동해 온 줄(PLAN 144 · focusRow) */ +.ui-data-table__table tbody tr.is-focus td { + background: var(--dt-hover); + box-shadow: inset 0 -2px 0 var(--color-accent); +} + /* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */ .ui-data-table__table td.is-edit { padding: 0; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index a23dcfe0c..e9c682480 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -8,6 +8,7 @@ * host.append(t.root); t.setRows(rows2, total2); * 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 } * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 + * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * ========================================================================== */ import "./ui_template_data_table.css"; @@ -48,6 +49,10 @@ export interface DataTableOptions { empty?: string; /** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */ onRowClick?: (index: number) => void; + /** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */ + onCellClick?: (index: number, key: string) => void; + /** 강조할 줄(`focusRow` 가 정함) */ + focus?: number | null; /** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */ editable?: (index: number, key: string) => boolean; /** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */ @@ -57,6 +62,8 @@ export interface DataTableOptions { export interface DataTable { root: HTMLElement; setRows: (rows: DataRow[], total?: DataRow | null) => void; + /** 그 줄로 스크롤하고 강조 */ + focusRow: (index: number) => void; } const EMPTY = "-"; @@ -124,6 +131,7 @@ function rowHtml( index: number | null = null, edit: number | null = null, editable?: (index: number, key: string) => boolean, + focus = false, ): string { let tr = ""; for (let j = 0; j < cols.length; j++) { @@ -146,7 +154,9 @@ function rowHtml( (fmts[j] ? ' data-num=""' : ""); tr += `${esc(txt)}`; } - const cls = [alt && "is-alt", index !== null && "is-clickable"].filter(Boolean).join(" "); + const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus"] + .filter(Boolean) + .join(" "); const data = index !== null ? ` data-i="${index}"` : ""; return cls || data ? `${tr}` : `${tr}`; } @@ -185,7 +195,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const to = win ? Math.max(from, Math.min(win.to, n)) : n; let body = win ? pad(from * win.rowH, cols.length) : ""; const blank = o.empty ?? EMPTY; - const pick = o.onRowClick !== undefined; + const pick = o.onRowClick !== undefined || o.onCellClick !== undefined; for (let i = from; i < to; i++) body += rowHtml( cols, @@ -197,6 +207,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin pick ? i : null, i, o.editable, + o.focus === i, ); if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; @@ -250,7 +261,11 @@ export function createDataTable(opts: DataTableOptions): DataTable { }); 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)); + if (!row || !root.contains(row)) return; + const cell = (event.target as Element).closest("td"); + const column = cell ? state.columns[cell.cellIndex] : undefined; + if (column) state.onCellClick?.(Number(row.dataset.i), column.key); + state.onRowClick?.(Number(row.dataset.i)); }); draw(true); return { @@ -260,5 +275,10 @@ export function createDataTable(opts: DataTableOptions): DataTable { if (total !== undefined) state.total = total; draw(true); }, + focusRow(index) { + state.focus = index; + root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3); + draw(true); + }, }; }