# -*- coding: utf-8 -*- """PLAN 804-2 — 단가산출 「줄 쌓기」 탭(요구 670~677) · 수식 문서 ↔ 줄 쌓기 한 벌. ① 왕복 1,518 전수 — 문서 → 줄 → 문서 같음 · 금액 줄 · 기호 모두 한 번 · 차림 고침(항목 · 글 줄 · 가나다) 다시 읽어 같음 · 차림 칸 `쌓기` 든 문서도 서버 셈 같음 ② 금액 — 화면 길(연결 기록 + TS 계산기) = 서버 정본 셈(1,518 전수) ③ 기계 펼침 — 시간당 경비 문서를 넘긴 입력으로 셈 = 부른 쪽 단가 묶음(원 단위) · `M02_Machine_Hourly.hourly`(같은 로직 · 같은 입력) 와 같음 ④ 틀 풀기 — 단가산출 결과 연결을 이 문서 식으로 옮김 = 금액 같음(전수) · Cm 고침 → 값 · 지움 = 표지(오류 없음) ⑤ 저장 — `쌓기` 든 문서 저장(임시 폴더) · 다시 읽어 같음 · 정본 폴더 안 바뀜 ⑥ 화면 이음 — M01 · M02 보기 셋 · 같은 길 · 같은 캐시 열쇠 · KaTeX 미리 묶기 뺌 """ from __future__ import annotations import copy import importlib import json import os import shutil import subprocess from decimal import Decimal from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from M01_MasterData import M01_MasterData_Doc as doc_screen from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Doc_Copy as dc from M02_MasterTemplete import M02_Machine_Hourly as mh mr = importlib.import_module("master_doc_record") # M01 store 가 scripts 길을 둔 뒤 ROOT = Path(__file__).resolve().parents[2] HERE = os.path.dirname(os.path.abspath(__file__)) COSTS = ("노무비", "재료비", "경비", "계") def _ts(job: str, cases: list[dict]) -> list: proc = subprocess.run( ["node", os.path.join(HERE, "helper_804_2_line_stack.cjs")], input=json.dumps(dc.ml._norm({"할일": job, "문서들": cases}), ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", ) assert proc.returncode == 0, proc.stderr[-3000:] return json.loads(proc.stdout) @pytest.fixture(scope="module") def world(): docs = dc.folder() links = dc.ml.Links(dc.skc.whole_(), docs) cases, py = [], {} for key, doc in docs.items(): rec = mr.Rec(links) rec.prefetch(doc) py[key] = dc.mc.calc(doc, rec) cases.append({"문서": doc, "연결": rec.out()}) return {"docs": docs, "links": links, "py": py, "cases": dict(zip(docs, cases))} def _num(v): return None if v is None else float(v) def test_왕복_전수_1518(world): docs = world["docs"] assert len(docs) >= 1518 got = _ts("왕복", [{"문서": d} for d in docs.values()]) bad = {f: [k for k, g in zip(docs, got) if not g[f]] for f in ("같음", "금액", "기호", "차림")} assert bad == {"같음": [], "금액": [], "기호": [], "차림": []} assert not any(g["쌓기"] for g in got) # 기본 차림 = 문서에 `쌓기` 안 씀 # 차림 칸이 든 문서 — 서버 셈이 같음(계산기는 차림을 안 봄) for key, g in list(zip(docs, got))[::7]: laid = json.loads(json.dumps(g["차림문서"]), parse_float=Decimal, parse_int=Decimal) assert "쌓기" in laid a, b = dc.mc.calc(docs[key], world["links"]), dc.mc.calc(laid, world["links"]) assert [_num(a.get(f)) for f in (*COSTS, "결과")] == [ _num(b.get(f)) for f in (*COSTS, "결과") ], key # 못 고치는 칸 목록(보존됨) — 지금 문서엔 금액 줄 기호만 notes = {n for g in got for n in g["못"]} assert notes <= {"단계", "조정", "번지", "합밖", "줄 자리", "금액 줄 기호"} def test_금액_화면길_서버정본_전수(world): keys = list(world["docs"]) got = _ts("셈", [world["cases"][k] for k in keys]) diff = [ (k, f) for k, g in zip(keys, got) for f in (*COSTS, "결과") if _num(world["py"][k].get(f)) != (None if g.get(f) is None else pytest.approx(g[f])) ] assert diff == [] and not any(g["누락"] for g in got) world["셈"] = dict(zip(keys, got)) def _hourly_inputs(logic_key: str) -> dict: """hourly 가 고른 로직 · 그 로직의 기본 입력(기계 밖).""" rows = {r["키"]: r for r in mh._family()} row = rows.get(logic_key) if not row: return {} return { i["이름"]: str((i.get("고르기") or [""])[0]) for i in row.get("입력") or [] if i.get("이름") != "기계" } def test_기계펼침_원단위_hourly(world): got = world.get("셈") or dict(zip(world["docs"], _ts("셈", list(world["cases"].values())))) eq = m01.loaded()[1].index["EQ"] by_code: dict[str, list[str]] = {} for key, row in eq.items(): by_code.setdefault(str(row.get("원문번호")), []).append(key) lines = same_parent = same_hourly = 0 memo: dict[str, dict] = {} family = mh._family() for k, g in got.items(): for s in g["펼침"]: if s["갈래"] != "기계": continue lines += 1 # 펼친 셈 = 부른 쪽 단가 묶음(화면 「노무비 : 59,020」 의 그 수) assert all(float(s["값"][f]) == float(s[f]) for f in COSTS), (k, s["키"]) same_parent += 1 code = str(s["입력"].get("기계")) for key in by_code.get(code, []): if key not in memo: # 같은 기계는 한 번만 셈 memo[key] = mh.hourly(key) h = memo[key] if not h.get("ok"): continue pick = min( (r for r in family if code in [str(c) for c in mh._codes(r)]), key=mh._rank, ) want = _hourly_inputs(pick["키"]) given = {n: str(v) for n, v in s["입력"].items() if n != "기계"} if pick["키"] != s["키"] or any(want.get(n) != v for n, v in given.items()): break # 다른 로직 · 다른 입력(조종원 직종 등)을 넘긴 줄 assert all(float(h[f]) == float(s[f]) for f in COSTS), (k, key, h, s) same_hourly += 1 break assert lines >= 1000 and same_parent == lines and same_hourly >= 800 def test_틀풀기_전수_금액같음_Cm고침(world): cases = list(world["cases"].values()) got = [x for g in _ts("펼침", cases) for x in g] assert len(got) >= 20 # 단가산출 결과 연결(굴착기 Q 등) 전부 bad = [g["기호"] for g in got if g["전"] != g["후"] or not g["왕복"]] assert bad == [] one = _ts( "펼침", [{**world["cases"]["GF000099"], "기호": "Q_작업", "고침": [{"기호": "Cm", "글": "20"}]}], )[0][0] assert one["전"]["계"] == 5286 and one["후"]["계"] == 5286 assert one["옮김"][-1] == "Q_작업" and {"q", "K", "f", "E", "Cm"} <= set(one["옮김"]) assert one["고친"]["계"] == 5434 # Cm 18 → 20 = Q 58.212 → 52.391 gone = _ts("펼침", [{**world["cases"]["GF000099"], "기호": "Q_작업", "지움": ["Cm"]}])[0][0] assert gone["고친"]["계"] is None and "정의 필요" in gone["표지"] # 지워도 오류 없음 @pytest.fixture def client(tmp_path: Path, monkeypatch): for path in doc_screen.folder().glob("수식_*.json"): shutil.copy(path, tmp_path / path.name) monkeypatch.setattr(doc_screen, "folder", lambda: tmp_path) doc_screen._memo.clear() app = FastAPI() app.include_router(router_module.router) yield TestClient(app) doc_screen._memo.clear() def test_저장_쌓기_다시읽어같음(client, tmp_path): read = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json() doc = copy.deepcopy(read["문서"]) got = _ts("왕복", [{"문서": doc}])[0] laid = got["차림문서"] res = client.post("/api/m01/logic/doc/save", json={"문서": laid}) assert res.status_code == 200, res.text assert res.json()["셈"]["계"] == read["셈"]["계"] == 5286 again = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json() assert ( again["문서"]["쌓기"] == laid["쌓기"] and _ts("왕복", [{"문서": again["문서"]}])[0]["같음"] ) text = (tmp_path / res.json()["파일"]).read_text(encoding="utf-8") assert '"쌓기": [' in text and "< 근거 0 >" in text real = (ROOT / "resources/master_data/수식문서" / res.json()["파일"]).read_text( encoding="utf-8" ) assert "< 근거 0 >" not in real def _src(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") def test_화면_이음(): lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") assert 'createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" })' in lab assert "stack: stack.root" in lab and "=> stack.canLeave();" in lab # 804-6 수식 탭 = 줄 쌓기 assert '{ value: "stack", label: tl("View_Equation") }' in _src( "M01_MasterData/M01_MasterData_UI_LogicLab_View.ts" ) assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css") tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 1 vite = _src("vite.config.ts") assert 'exclude: ["0_old", "katex"]' in vite and "katex/dist/katex.mjs" in vite pkg = json.loads(_src("config/package.json")) assert "katex" in pkg["dependencies"] lic = json.loads(_src("config/node_modules/katex/package.json"))["license"] assert lic == "MIT" for f in (ROOT / "A00_Common").glob("line_stack*.ts"): assert len(f.read_text(encoding="utf-8").splitlines()) <= 700, f.name # ── 804-3 줄 쌓기 수정 1차(요구 678) ── def _tbl(world, key, **kw): return _ts("표고름", [{**world["cases"][key], **kw}])[0] def _calc(world, doc): return dc.mc.calc( json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal), world["links"] ) def test_표고름_값_출처(world): # 옵션에 매인 조건(규격 = S_폼타이규격) — 줄은 옵션 그대로 · 칸만 바뀜 a = _tbl(world, "GC000211", 기호="Q_3t^표", 줄=1, 칸="4t/㎡") assert a["묶임"] == {"규격": ["폼타이규격"]} and a["전줄"] == 2 assert a["연결"] == { "갈래": "표", "표": "QC000429", "조건": {"규격": "S_폼타이규격"}, "칸": "4t/㎡", } assert a["값"] == 0.72 and a["후줄"] == 2 # 12.7㎜ 줄 · 4t/㎡ # 옵션에 안 매인 조건 — 고른 줄 값이 조건으로 · 값 = 표 칸 · 서버 셈 같음 b = _tbl( world, "GC000211", 기호="Q_시공^표", 줄=4, 칸="시공량", 앞=[{"기호": "Q_시공^표", "조건": {"유형": "'보통'"}}], ) assert b["묶임"] == {} and b["전줄"] == 3 assert b["연결"]["조건"] == {"유형": "'간단'"} and b["연결"]["표"] == "QC000431" assert b["값"] == 50 and b["후줄"] == 4 py = _calc(world, b["문서"]) assert [_num(py.get(f)) for f in COSTS] == [b[f] for f in COSTS] assert b["계"] != world["py"]["GC000211"]["계"] # 시공량 20 → 50 = 금액 바뀜 # 조건 없는 표(한 줄) — 칸만 c = _tbl(world, "GC000211", 기호="Q_합판2^표", 줄=0, 칸="각재") assert ( c["연결"] == {"갈래": "표", "표": "QC000427", "조건": {}, "칸": "각재"} and c["값"] == 0.038 ) def test_찾아넣기_요소키_금액(world): h = mh.hourly("EQ000016") assert h["ok"] and h["로직"] and h["원문번호"] == "0201-0012" picks = [ {"갈래": "인력", "고른": {"키": "LB000002", "이름": "보통인부", "단위": "인"}}, {"갈래": "재료", "고른": {"키": "MT000018", "이름": "합판(내수), 서울", "규격": "12t×1220×2440mm", "단위": "㎡"}}, {"갈래": "기계", "고른": {"키": h["로직"], "이름": "굴착기(무한궤도)", "규격": "0.12㎥", "입력": {"기계": "'0201-0012'"}}}, {"갈래": "단가산출", "고른": {"키": "GF000099", "이름": "측구터파기 토사", "단위": "㎥"}}, ] # fmt: skip got = _ts("넣기", [{"문서": world["docs"]["GC000211"], "넣을": picks}])[0] assert got["갈래"] == ["인력", "재료", "기계", "단가산출"] doc = got["문서"] links = {v["기호"] + ("_" + v["아래"] if v.get("아래") else ""): v["연결"] for v in doc["변수"]} assert [links[i] for i in got["기호"]] == [ {"갈래": "요소", "키": "LB000002"}, {"갈래": "요소", "키": "MT000018"}, {"갈래": "단가산출", "키": h["로직"], "입력": {"기계": "'0201-0012'"}}, {"갈래": "단가산출", "키": "GF000099", "입력": {}}, ] new = doc["금액"][got["처음"] :] assert [(m["단위"], m["비목"], m["수량"]) for m in new] == [ ("인", "노무비", "1"), ("㎡", "재료비", "1"), ("hr", "묶음", "1"), ("㎥", "묶음", "1"), ] # 금액 = 서버 정본 셈 — 넣은 넷 = 노임 + 자재 + 기계 한 시간(hourly) + 단가산출 계 before, after = world["py"]["GC000211"], _calc(world, doc) want = 172068 + 11354 + float(h["계"]) + float(world["py"]["GF000099"]["계"]) assert float(after["계"]) - float(before["계"]) == pytest.approx(want, abs=2) ts = _ts("셈", [{"문서": doc, "연결": _record(world, doc)}])[0] assert not ts["누락"], ts assert [ts[f] for f in COSTS] == [_num(after.get(f)) for f in COSTS] # 다시 고름 — 키 · 이름만 바뀜 again = _ts( "넣기", [{"문서": world["docs"]["GC000211"], "넣을": picks[:1], "바꿈": [{"차례": 0, "고른": {"키": "LB000007", "이름": "형틀목공"}}]}], )[0] # fmt: skip m = again["문서"]["금액"][again["처음"]] assert m["이름"] == "형틀목공" and m["수량"] == "1" assert any(v["연결"] == {"갈래": "요소", "키": "LB000007"} for v in again["문서"]["변수"]) # 기계 다시 고름(같은 로직) — 기종만 바뀌고 지역 · 보정작업 넘김 그대로 gf = world["docs"]["GF000099"] i = next(k for k, m in enumerate(gf["금액"]) if m["단가"] == "P_굴착기무한궤") one = _ts("바꿈", [{"문서": gf, "줄": i, "기호": "P_굴착기무한궤", "갈래": "기계", "고른": picks[2]["고른"]}])[0] # fmt: skip link = next(v["연결"] for v in one["변수"] if v.get("아래") == "굴착기무한궤") assert link["입력"] == {"기계": "'0201-0012'", "지역": "S_지역", "보정작업": "S_보정작업"} assert one["금액"][i]["이름"] == "굴착기(무한궤도)" and one["금액"][i]["규격"] == "0.12㎥" def _record(world, doc): rec = mr.Rec(world["links"]) d = json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal) rec.prefetch(d) dc.mc.calc(d, rec) # 요소 값은 셈하며 기록됨(화면 미리 셈과 같은 길) return rec.out() def test_들여쓰기_수준(): got = _ts("넣기", [{"문서": {"금액": [], "변수": []}, "넣을": []}])[0] assert got["수준"] == [{"item": 2, "line": 3}] # 가나다 항목 = 2 · 줄 3 out = _src("A00_Common/line_stack_out.ts") assert "stackLevels(items)" in out and '"--lstk-depth", String(r.lv)' in out assert "rows(sub, got, h, depth + 1, path, lv + 1)" in out # 펼친 블록 = 그 줄 + 1 edit = _src("A00_Common/line_stack_edit.ts") assert "openStackPick" in edit and "/links" not in edit and "Machine" not in edit