- M01 단가산출 기초 · M02 단가산출 상세 보기 = 수식(줄 쌓기 · 기본) · 단가산출 상세 둘 - 줄 쌓기에 M02 사본 [단가 갱신] · [재생성](미리 셈 → 알림 → 저장 · 고침 있으면 막음) · 판 바뀜(409) 문서 그대로면 새 판으로 다시 저장 옮김 - 옛 수식 문서 화면 걷음(formula_doc_screen · defs · edit · lines · math · math_model · rows · sub · formula_doc.css) · 이미 안 부르던 796-2 수식 나무 화면(LogicLab_Equation · Defs · Var · css · M01_Logic_Equation_Model · sheet_formula_cell · ui_template_equation_defs · edit) 걷음 — 18 파일 4,860 줄 - formula_doc_render = 줄 쌓기가 쓰는 reach 만 · 옵션 가로 CSS 는 line_stack.css 로 - 남김 = 계산기 common_util_formula_doc* · 서버 길 · 옵션 · 사본 표지 · 합계 상자 · KaTeX · 시험 페이지 tools/line-stack-test - 시험: test_796_2 지움(걷은 화면 원본 검사뿐) · 799_1 · 800_2 걷은 화면 줄만 뺌 · 799_2 · 802_4b2 · 804_2 보기 · 화면 파일 대상 바꿈 · test_804_6 새로(걷은 파일 · 부르는 곳 없음 · 첫 탭 · 사본 할일) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
16 KiB
Python
339 lines
16 KiB
Python
# -*- 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
|