diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.py b/B08_Quantity/B08_Quantity_Haul_Cycle.py new file mode 100644 index 000000000..02557fc77 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.py @@ -0,0 +1,102 @@ +"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + +⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + 조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본). + +식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림) + q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) + t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6 + L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0. +로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 · + `own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김. +기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름). +""" + +from __future__ import annotations + +import math +from typing import Any + +DEFAULTS: dict[str, float] = { + "L1": 0.0, + "L2": 0.0, + "V1": 30.0, + "V2": 35.0, + "V3": 10.0, + "V4": 15.0, + "T": 15.0, + "rt": 1.7, + "f": 1.0, + "qs": 1.72, + "k": 1.0, + "Es": 0.6, + "Cms": 37.4, + "t3": 0.8, + "t4": 0.42, + "t5": 0.5, + "t6": 1.5, +} +#: 로직 입력 이름 → 셈 결과 칸 +FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"} + + +def r2(value: float) -> float: + """소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 100 + 0.5) / 100 + + +def _div(a: float, b: float) -> float: + return a / b if b else 0.0 + + +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 cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]: + """사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}.""" + given = {k: _num(v) for k, v in (inputs or {}).items()} + v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()} + q = r2(_div(v["T"], v["rt"]) * v["f"]) + n = r2(_div(q, v["qs"] * v["k"])) + t1 = given.get("t1") + t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1 + l1, l2 = v["L1"], v["L2"] + t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60) + cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"]) + return { + "q": q, + "n": n, + "t1": t1, + "t2": t2, + "cm": cm, + "L": math.floor((l1 + l2) * 1000 + 0.5) / 1000, + } + + +def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]: + """연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로.""" + cycle = dict((ref or {}).get("cycle") or {}) + own = set((ref or {}).get("own") or []) + for name in ("L1", "L2"): + if name not in own and _num((dist or {}).get(name)) is not None: + cycle[name] = dist[name] + if "t1" not in own: + cycle.pop("t1", None) + return cycle + + +def fill_given( + ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None +) -> dict[str, Any]: + """로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로.""" + own = set(ref.get("own") or []) + got = cycle_time(cycle_inputs(ref, dist)) + given = dict(ref.get("given") or {}) + for name, slot in FILL.items(): + if name in names and name not in own: + given[name] = got[slot] + return given diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.ts b/B08_Quantity/B08_Quantity_Haul_Cycle.ts new file mode 100644 index 000000000..7d64079d4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B08_Quantity_Haul_Cycle.ts + * B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + * + * ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + * 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본). + * + * 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) · + * t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 · + * 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 · + * `own` = 사용자가 덮은 칸(그 값이 이김). + * ========================================================================== */ + +export const CYCLE_DEFAULTS: Record = { + L1: 0, + L2: 0, + V1: 30, + V2: 35, + V3: 10, + V4: 15, + T: 15, + rt: 1.7, + f: 1, + qs: 1.72, + k: 1, + Es: 0.6, + Cms: 37.4, + t3: 0.8, + t4: 0.42, + t5: 0.5, + t6: 1.5, +}; +/** 로직 입력 이름 → 셈 결과 칸 */ +export const CYCLE_FILL: Record = { + Cm: "cm", + 사이클시간: "cm", + t2: "t2", + 운반거리: "L", +}; + +export interface CycleResult { + q: number; + n: number; + t1: number; + t2: number; + cm: number; + L: number; +} +export interface CycleRef { + given?: Record | null; + cycle?: Record | null; + own?: string[] | null; +} +export type Dist = { L1?: number | null; L2?: number | null } | null | undefined; + +/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */ +export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100; +const div = (a: number, b: number): number => (b ? a / b : 0); +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */ +export function cycleTime(inputs: Record | null | undefined): CycleResult { + const v: Record = {}; + for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d; + const q = r2(div(v.T, v.rt) * v.f); + const n = r2(div(q, v.qs * v.k)); + const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es)); + const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60); + const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6); + return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 }; +} + +/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */ +export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record { + const cycle: Record = { ...(ref?.cycle || {}) }; + const own = new Set(ref?.own || []); + for (const name of ["L1", "L2"] as const) + if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name]; + if (!own.has("t1")) delete cycle.t1; + return cycle; +} + +/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */ +export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record { + const own = new Set(ref.own || []); + const got = cycleTime(cycleInputs(ref, dist)); + const given: Record = { ...(ref.given || {}) }; + for (const [name, slot] of Object.entries(CYCLE_FILL)) + if (names.includes(name) && !own.has(name)) given[name] = got[slot]; + return given; +} diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 3b7f74dd8..297f9de63 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -37,6 +37,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 @@ -475,13 +476,27 @@ class Solver: LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses") +def _input_names(code: str) -> list[str]: + try: + return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []] + except mf.FormulaError: + return [] + + def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]: - """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)].""" + """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] · + 운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본).""" out = [] + dist = (user or {}).get("haul_dist") or {} for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함 ref = row.get("ref") or {} if ref.get("tab") != "logic" or not ref.get("code"): continue + if row["key"] in dist: + ref = { + **ref, + "given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]), + } src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")), "rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개 diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 07496606d..1016fcac6 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -126,9 +126,15 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None - return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), - "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, - "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + out = {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), + "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, + "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸 + if isinstance(cycle, dict): + out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None} + own = ref.get("own") if isinstance(ref.get("own"), list) else [] + out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40] + return out return None diff --git a/resources/tester/test_253_2_haul_cycle.py b/resources/tester/test_253_2_haul_cycle.py new file mode 100644 index 000000000..59bac4dcb --- /dev/null +++ b/resources/tester/test_253_2_haul_cycle.py @@ -0,0 +1,114 @@ +"""253-2 운반 사이클시간 짝 — 거창 25 설계서 단가산출 1308~1347행 재현 · Py ↔ TS 거울 · +로직 입력 이름 채움 · B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time, fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import hand_sources # noqa: E402 +from B09_Estimation.B09_Estimation_Store import ref_rows # noqa: E402 + +XLSX = next( + (ROOT / "resources/knowledge/original/실무문서").glob( + "01.산불거창*/01.내역서/00.25년_산불_거창*내역+수량.xlsx" + ), + None, +) +#: 칸 → 입력 이름(단가산출 시트 · 32.혼합골재 운반(덤프15톤)) +CELLS = {"S1318": "L1", "AR1318": "L2", "R1320": "V1", "R1321": "V2", "AQ1320": "V3", + "AQ1321": "V4", "K1323": "T", "AB1323": "rt", "Q1325": "f", "L1327": "qs", + "Z1327": "k", "AL1327": "Es", "BA1327": "Cms", "L1337": "t3", "X1337": "t4", + "AK1337": "t5", "AW1337": "t6"} # fmt: skip + + +@pytest.mark.skipif(XLSX is None, reason="거창 설계서 없음") +def test_거창_혼합골재_운반_재현(): + import openpyxl + + ws = openpyxl.load_workbook(XLSX, read_only=False)["단가산출"] + inputs = {} + for cell, name in CELLS.items(): + text = str(ws[cell].value) # =TEXT(25.1,"#,##0.#######") + inputs[name] = float(re.match(r"=TEXT\(([\d.]+),", text).group(1)) + # 식 줄이 같은 꼴인지(t2 · Cm) + assert ( + "(S1318)/(R1320)+(S1318)/(R1321)+(AR1318)/(AQ1320)+(AR1318)/(AQ1321)" in ws["BX1334"].value + ) + assert "BQ1331+BX1334+L1337+X1337+AK1337+AW1337" in ws["CE1339"].value + assert (inputs["L1"], inputs["L2"]) == (25.1, 0.8) + got = cycle_time(inputs) + assert (got["q"], got["n"], got["t1"]) == (8.82, 5.13, 5.33) + assert (got["t2"], got["cm"]) == (101.23, 109.78) + assert cycle_time({"L1": 25.1, "L2": 0.8}) == got # 기본값 = 거창 값 + + +CASES = [ + {"L1": 25.1, "L2": 0.8}, + {"L1": 3.333, "L2": 1.257, "V1": 20, "V2": 25, "V3": 7, "V4": 8, "t5": 3.77}, + {"L1": 0, "L2": 0, "t1": 2.5, "V3": 0}, + {"L1": 12.5, "L2": 4.05, "T": 24, "rt": 1.8, "f": 0.8, "qs": 1.2, "k": 0.9, "Es": 0.75}, +] + + +def test_거울_ts(): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts" + body = ( + f"\nconst cases = {json.dumps(CASES)};" + "\nconsole.log(JSON.stringify(cases.map((c) => cycleTime(c))));" + "\nconst ref = {given: {qt: 1, Cm: 9}, cycle: {L1: 99, t1: 7}, own: ['L1']};" + "\nconst got = fillGiven(ref, ['Cm', 't2', '운반거리'], {L1: 25.1, L2: 0.8});" + "\nconsole.log(JSON.stringify(got));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + ts, ts_fill = (json.loads(line) for line in result.stdout.strip().splitlines()) + assert ts == [cycle_time(c) for c in CASES] + ref = {"given": {"qt": 1, "Cm": 9}, "cycle": {"L1": 99, "t1": 7}, "own": ["L1"]} + assert ts_fill == fill_given(ref, ["Cm", "t2", "운반거리"], {"L1": 25.1, "L2": 0.8}) + + +def test_입력_이름_채움(): + ref = {"given": {"qt": 15}, "cycle": {}} + got = fill_given(ref, ["qt", "Cm", "지역"], {"L1": 25.1, "L2": 0.8}) + assert got == {"qt": 15, "Cm": 109.78} + assert fill_given(ref, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"qt": 15, "운반거리": 25.9} + assert fill_given(ref, ["사이클시간", "t2"], {"L1": 25.1, "L2": 0.8}) == { + "qt": 15, "사이클시간": 109.78, "t2": 101.23} # fmt: skip + # 덮은 칸 = 그 값 · 덮은 L1 = 거리 대신 사이클 값 + own = {"given": {"Cm": 50}, "own": ["Cm"]} + assert fill_given(own, ["Cm"], {"L1": 25.1, "L2": 0.8}) == {"Cm": 50} + mine = {"given": {}, "cycle": {"L1": 10}, "own": ["L1"]} + assert fill_given(mine, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"운반거리": 10.8} + + +def test_서버_셈_거리로_다시_채움(): + ref = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {"Cm": 1}, "cycle": {}} + user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": {"L1": 25.1, "L2": 0.8}}} + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 109.78 + user["haul_dist"]["haul:r-u1"] = {"L1": 10, "L2": 0} # 거리 바뀌면 따라감 + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 45.69 and "운반거리" not in src["given"] # 그 로직 입력만 + + +def test_덮개_ref_사이클_남김(): + from B09_Estimation.B09_Estimation_Store import _clean_ref + + ref = {"tab": "logic", "code": "GF000134", "given": {"Cm": 3}, "cycle": {"V1": 20, "x": "a"}, + "own": ["Cm", 3]} # fmt: skip + got = _clean_ref(ref) + assert got["cycle"] == {"V1": 20} and got["own"] == ["Cm"] + rows = ref_rows({"bill": {"rows": [{"key": "haul:r-u1", "ref": got}]}}) + assert rows[0]["ref"]["cycle"] == {"V1": 20}