- 자리 = SK 수식.문서.기계덮음 {기계 입력 글: {값 · 조종원 줄 · 잡품 줄}} · 사본 줄 그대로
- 계산기(파이썬 · TS 짝)가 기계 식 부를 때 그 기계 덮음을 얹음 · 덮음 없음 = 문서 그대로
- 서버 저장 · 미리 셈 = 몸 검사 + 원본과 같은 칸 · 안 부르는 기계 뺌
- 거울 · 손셈(굴착기 1.0㎥ 108,958) · 저장 길 지킴 시험 test_836_1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
15 KiB
Python
337 lines
15 KiB
Python
"""836-1 기계 덮음 — 셈 규칙 · 기본값 · 정리 · 몸 검사 · 거울(파이썬 ↔ TS) 지킴 시험.
|
|
|
|
짝 = `resources/master_data/scripts/master_doc_cover.py` ↔ `common_util/common_util_formula_doc_cover.ts`
|
|
(계산기 부름 자리 = `master_doc_calc` ↔ `common_util_formula_doc_calc.ts`).
|
|
① 덮음 없음 = 문서 그대로(같은 물건 · 같은 금액) · 남의 기계 덮음 = 안 얹음.
|
|
② 굴착기(무한궤도) 1.0㎥(0201-0100 · GC000267) — 값 · 조종원 · 잡품 덮음 = 손셈(M01 값).
|
|
③ 조종원 없는 식에 조종원 더함(시간환산 표 더함) · 잡품 없는 식에 잡품 더함 · 연료 + 전력 식 기본 잡품 둘.
|
|
④ 정리 — 원본과 같으면 지움 · 안 부르는 기계 지움 · 몸 검사 틀림.
|
|
⑤ 거울 — 같은 연결 값으로 파이썬 · TS 답 · 얹은 문서 · 기본값 · 정리가 같음.
|
|
"""
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from decimal import Decimal as D
|
|
|
|
import pytest
|
|
|
|
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
|
|
|
|
mc = importlib.import_module("master_doc_calc")
|
|
ml = importlib.import_module("master_doc_link")
|
|
mcv = importlib.import_module("master_doc_cover")
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
COSTS = ("노무비", "재료비", "경비", "계")
|
|
NO = "0201-0100" # 굴착기(무한궤도) 1.0㎥ — 조사 846 1 장 견본
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def links():
|
|
_, whole = m01.loaded()
|
|
return ml.Links(whole)
|
|
|
|
|
|
def _one(logic: str, no: str, cover: dict | None = None) -> dict:
|
|
"""기계 한 줄 문서(1 hr) — 덮음 = 문서 칸."""
|
|
var = {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "기계", "단위": "",
|
|
"연결": {"갈래": "단가산출", "키": logic, "입력": {"기계": f"'{no}'"}}} # fmt: skip
|
|
line = {"이름": "기계", "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_기계"}
|
|
doc = {"꼴": "수식문서", "판": 1, "키": "_t", "이름": "t", "결과단위": "원",
|
|
"변수": [var], "식": [], "금액": [line]} # fmt: skip
|
|
return {**doc, mcv.COVER: cover} if cover else doc
|
|
|
|
|
|
def _money(ans):
|
|
return {k: ans.get(k) for k in COSTS}
|
|
|
|
|
|
def _first(links, logic: str) -> str:
|
|
var = next(v for v in links.doc(logic)["변수"] if v.get("열쇠") == "기계")
|
|
return str(var["연결"]["목록"][0])
|
|
|
|
|
|
def _orig(links, logic: str, no: str) -> dict:
|
|
memo: dict = {}
|
|
mc.calc(_one(logic, no), links, None, (), memo)
|
|
return next(a for (k, _), a in memo.items() if k == logic)
|
|
|
|
|
|
# ── ① 덮음 없음 ────────────────────────────────────────────────────────
|
|
def test_덮음_없음_같은_물건_같은_금액(links):
|
|
doc = links.doc("GC000267")
|
|
assert mcv.cover_doc(doc, {"기계": NO}, {}) is doc
|
|
assert mcv.cover_doc(doc, {"기계": NO}, {"0201-0120": {"값": {"가격": 1}}}) is doc
|
|
assert mcv.cover_doc(doc, {}, {NO: {"값": {"가격": 1}}}) is doc
|
|
base = mc.calc(_one("GC000267", NO), links)
|
|
other = mc.calc(_one("GC000267", NO, {"0201-0120": {"값": {"가격": 1}}}), links)
|
|
assert _money(base) == _money(other)
|
|
assert base["계"] == D(108958) or base["셈됨"] # M01 값이 바뀌면 금액은 달라도 됨(값 대조는 ②)
|
|
|
|
|
|
# ── ② 손셈 ────────────────────────────────────────────────────────────
|
|
def _cut1(x):
|
|
return D(str(x)).quantize(D("0.1"), rounding="ROUND_DOWN")
|
|
|
|
|
|
def test_굴착기_덮음_손셈(links):
|
|
v = _orig(links, "GC000267", NO)["값"]
|
|
price, fuel, oil = v["P_기계"], v["P_기계주연료"], v["P_자동차용경유"]
|
|
wage, rate = v["w_기계운전사"], v["r_기계잡재료"]
|
|
k = (v["k_기계상각비"] + v["k_기계정비비"]) * (1 + v["r_가산"] / 100) + v["k_기계관리비"]
|
|
|
|
def loss(p):
|
|
return (D(p) * 1000 * k / D(10) ** 7).to_integral_value(rounding="ROUND_DOWN")
|
|
|
|
def total(labor, mats, exp):
|
|
return {"노무비": int(labor), "재료비": int(mats), "경비": int(exp)}
|
|
|
|
fuel_amt = _cut1(fuel * oil)
|
|
crew1 = _cut1(D(1) / 8 * wage)
|
|
misc = _cut1(fuel_amt * rate / 100)
|
|
want = total(crew1, fuel_amt + misc, loss(price))
|
|
got = mc.calc(_one("GC000267", NO), links)
|
|
assert {c: int(got[c]) for c in ("노무비", "재료비", "경비")} == want
|
|
|
|
def run(cover):
|
|
a = mc.calc(_one("GC000267", NO, {NO: cover}), links)
|
|
assert a["셈됨"], a["표지"]
|
|
return {c: int(a[c]) for c in ("노무비", "재료비", "경비")}
|
|
|
|
assert run({"값": {"가격": 150000}}) == total(crew1, fuel_amt + misc, loss(150000))
|
|
f20 = _cut1(20 * oil)
|
|
assert run({"값": {"주연료": 20}}) == total(crew1, f20 + _cut1(f20 * rate / 100), loss(price))
|
|
assert run({"조종원": [{"키": "LB:1048", "인": 2}]}) == total(
|
|
_cut1(D(2) / 8 * wage), fuel_amt + misc, loss(price)
|
|
)
|
|
assert run({"조종원": []}) == total(0, fuel_amt + misc, loss(price))
|
|
assert run({"잡품": [{"줄": "주연료", "율": 30}]}) == total(
|
|
crew1, fuel_amt + _cut1(fuel * oil * 30 / 100), loss(price)
|
|
)
|
|
assert run({"잡품": []}) == total(crew1, fuel_amt, loss(price))
|
|
# 다른 직종 · 사람 둘 = 두 줄
|
|
two = run({"조종원": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 1}]})
|
|
labor = mc.calc(_one("GC000267", NO, {NO: {"조종원": [{"키": "LB:1002", "인": 1}]}}), links)
|
|
assert two["노무비"] == int(crew1) + int(labor["노무비"])
|
|
|
|
|
|
# ── ③ 줄 더함 ─────────────────────────────────────────────────────────
|
|
def test_조종원_없는_식에_조종원_잡품_없는_식에_잡품(links):
|
|
no = _first(links, "GC000272")
|
|
base = mc.calc(_one("GC000272", no), links)
|
|
got = mc.calc(_one("GC000272", no, {no: {"조종원": [{"키": "LB:1048", "인": 1}]}}), links)
|
|
assert got["셈됨"], got["표지"]
|
|
assert got["노무비"] > 0 and got["재료비"] == base["재료비"] and got["경비"] == base["경비"]
|
|
no3 = _first(links, "GC000273")
|
|
base3 = mc.calc(_one("GC000273", no3), links)
|
|
got3 = mc.calc(
|
|
_one("GC000273", no3, {no3: {"잡품": [{"키": "LB:1048", "수량": 1, "율": 10}]}}), links
|
|
)
|
|
assert got3["셈됨"] and got3["재료비"] > base3["재료비"] == 0
|
|
|
|
|
|
def test_연료_전력_식_기본_잡품_둘(links):
|
|
doc = links.doc("GC001200")
|
|
parts = mcv.parts_of(doc)
|
|
assert parts["misc"] is not None and len(parts["base"]) == 2
|
|
|
|
|
|
# ── ④ 정리 · 검사 ─────────────────────────────────────────────────────
|
|
def test_원본과_같으면_지움_안_부르는_기계_지움(links):
|
|
v = _orig(links, "GC000267", NO)["값"]
|
|
same = {NO: {"값": {"가격": v["P_기계"]}, "조종원": [{"키": "LB:1048", "인": 1}],
|
|
"잡품": [{"줄": "주연료", "율": v["r_기계잡재료"]}]}} # fmt: skip
|
|
assert mcv.COVER not in mcv.prune_doc(_one("GC000267", NO, same), links)
|
|
mixed = {NO: {"값": {"가격": v["P_기계"], "주연료": 20}}, "9999-9999": {"값": {"가격": 1}}}
|
|
got = mcv.prune_doc(_one("GC000267", NO, mixed), links)[mcv.COVER]
|
|
assert got == {NO: {"값": {"주연료": 20}}}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[
|
|
[],
|
|
{NO: 1},
|
|
{NO: {"값": {"가격": -1}}},
|
|
{NO: {"값": {"조종원": 2}}},
|
|
{NO: {"조종원": [{"키": "", "인": 1}]}},
|
|
{NO: {"조종원": [{"키": "LB:1048", "인": -1}]}},
|
|
{NO: {"잡품": [{"율": 10}]}},
|
|
{NO: {"잡품": [{"줄": "주연료", "율": "x"}]}},
|
|
],
|
|
)
|
|
def test_몸_검사_틀림(bad):
|
|
with pytest.raises(ValueError):
|
|
mcv.clean(bad)
|
|
|
|
|
|
def test_몸_검사_빈_칸_뺌():
|
|
assert mcv.clean({NO: {"값": {"가격": ""}}}) == {}
|
|
assert mcv.clean({NO: {"값": {"가격": 0}}}) == {NO: {"값": {"가격": 0}}}
|
|
|
|
|
|
# ── ⑤ 거울 ────────────────────────────────────────────────────────────
|
|
def _canon(v):
|
|
if isinstance(v, (D, int, float)) and not isinstance(v, bool):
|
|
return mc.plain_text(D(str(v)))
|
|
if isinstance(v, list):
|
|
return [_canon(x) for x in v]
|
|
if isinstance(v, dict):
|
|
return {k: _canon(v[k]) for k in sorted(v)}
|
|
return v
|
|
|
|
|
|
class Rec:
|
|
def __init__(self, links):
|
|
self.links, self.log = links, {}
|
|
|
|
def _keep(self, kind, p, fn):
|
|
key = f"{kind}|{json.dumps(_canon(p), ensure_ascii=False, separators=(',', ':'))}"
|
|
try:
|
|
got = fn(p)
|
|
except mc.Gap as e:
|
|
self.log[key] = {"빈": str(e)}
|
|
raise
|
|
except mc.Broken as e:
|
|
self.log[key] = {"끊김": str(e)}
|
|
raise
|
|
self.log[key] = {"값": got}
|
|
return got
|
|
|
|
def element(self, p):
|
|
return self._keep("요소", p, self.links.element)
|
|
|
|
def table(self, p):
|
|
return self._keep("표", p, self.links.table)
|
|
|
|
def doc(self, key):
|
|
return self.links.doc(key)
|
|
|
|
|
|
def _same(a, b, where=""):
|
|
if isinstance(a, dict):
|
|
assert isinstance(b, dict) and set(a) == set(b), (where, sorted(a), sorted(b or {}))
|
|
for k in a:
|
|
_same(a[k], b[k], f"{where}.{k}")
|
|
elif isinstance(a, list):
|
|
assert isinstance(b, list) and len(a) == len(b), where
|
|
for i, (x, y) in enumerate(zip(a, b)):
|
|
_same(x, y, f"{where}[{i}]")
|
|
elif isinstance(a, (D, int, float)) and not isinstance(a, bool):
|
|
assert isinstance(b, (int, float)) and not isinstance(b, bool), (where, a, b)
|
|
assert float(a) == pytest.approx(b, rel=1e-9, abs=1e-9), (where, a, b)
|
|
else:
|
|
assert a == b, (where, a, b)
|
|
|
|
|
|
COVERS = [
|
|
None,
|
|
{"값": {"가격": 150000, "주연료종류": "휘발유"}},
|
|
{"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]},
|
|
{"조종원": []},
|
|
{"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]},
|
|
{"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]},
|
|
]
|
|
|
|
|
|
def test_거울_파이썬_TS_같은_답(links):
|
|
rec = Rec(links)
|
|
picks = [("GC000267", NO)] + [
|
|
(k, _first(links, k))
|
|
for k in ("GC000272", "GC000273", "GC000275", "GC000276", "GC001196", "GC001197")
|
|
]
|
|
cases, want, shapes, shape_want = [], [], [], []
|
|
for logic, no in picks:
|
|
orig = _orig(Rec(links), logic, no) # 기록에 안 섞임(원본 셈은 아래에서 다시)
|
|
for one in COVERS:
|
|
cover = {no: one} if one else None
|
|
doc = _one(logic, no)
|
|
cases.append({"문서": doc, "덮음": cover})
|
|
want.append(mc.calc({**doc, mcv.COVER: cover} if cover else doc, rec))
|
|
if cover:
|
|
base = mcv.defaults_of(links.doc(logic), orig["값"])
|
|
shapes.append({"문서": links.doc(logic), "입력": {"기계": no}, "덮음": cover,
|
|
"값": orig["값"]}) # fmt: skip
|
|
shape_want.append({"얹음": mcv.cover_doc(links.doc(logic), {"기계": no}, cover),
|
|
"기본": base, "정리": mcv.prune(cover[no], base)}) # fmt: skip
|
|
docs = {k: links.doc(k) for k, _ in picks}
|
|
payload = {"문서들": docs, "대상": cases, "기록": rec.log, "짜임": shapes}
|
|
proc = subprocess.run(
|
|
["node", os.path.join(HERE, "helper_836_1_machine_cover.cjs")],
|
|
input=json.dumps(ml._norm(payload), ensure_ascii=False),
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
check=True,
|
|
)
|
|
got = json.loads(proc.stdout)
|
|
for i, (a, b) in enumerate(zip(want, got["답"])):
|
|
_same(ml._norm(_money(a)), _money(b), f"답{i}")
|
|
_same(ml._norm(a["값"]), b["값"], f"값{i}")
|
|
assert len(got["짜임"]) == len(shape_want)
|
|
for i, (a, b) in enumerate(zip(shape_want, got["짜임"])):
|
|
_same(ml._norm(a), b, f"짜임{i}")
|
|
|
|
|
|
# ── ⑥ 서버 저장 길(임시 층 · 수식 길) ────────────────────────────────────
|
|
@pytest.fixture
|
|
def world(tmp_path, monkeypatch):
|
|
from resources.tester.test_m02_labor_ref import world as make
|
|
|
|
return make.__wrapped__(tmp_path, monkeypatch)
|
|
|
|
|
|
def test_저장_다시_열기_되돌리면_덮음_없음(world):
|
|
import copy as cp
|
|
|
|
from M02_MasterTemplete import M02_Doc_Sk_Path as path
|
|
from M02_MasterTemplete import M02_Labor_Ref as refs
|
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
|
|
|
me = world["personal"]
|
|
|
|
def at(fn, *args):
|
|
with store.at(me):
|
|
return fn(*args)
|
|
|
|
key = at(
|
|
lambda: refs.create("sangeun", {"이름": "sub3임시 덮음", "단위": "시간", "더한줄": []})
|
|
)
|
|
key = key["이름"]
|
|
got = at(path.read, key)
|
|
doc = cp.deepcopy(got["문서"])
|
|
link = {"갈래": "단가산출", "키": "GC000267", "입력": {"기계": f"'{NO}'"}}
|
|
for s in ("기계", "기계둘"):
|
|
doc.setdefault("변수", []).append(
|
|
{"기호": "P", "아래": s, "위": "", "이름": s, "단위": "", "연결": link}
|
|
)
|
|
doc.setdefault("금액", []).append(
|
|
{"이름": s, "단위": "hr", "비목": "묶음", "수량": "1", "단가": f"P_{s}"}
|
|
)
|
|
at(path.save, key, {"문서": doc, "판": got["판"]})
|
|
base = at(path.read, key)
|
|
was = base["셈"]["계"]
|
|
one = base["셈"]["줄"][0]["금액"]
|
|
doc = cp.deepcopy(base["문서"])
|
|
doc[mcv.COVER] = {NO: {"값": {"가격": 150000}}, "9999-9999": {"값": {"가격": 1}}}
|
|
after = at(path.save, key, {"문서": doc, "판": base["판"]})
|
|
assert after["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}} # 안 부르는 기계 = 뺌
|
|
lines = after["셈"]["줄"]
|
|
assert lines[0]["금액"] == lines[1]["금액"] > one # 같은 기계 두 줄 = 같이 바뀜
|
|
again = at(path.read, key)
|
|
assert again["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}}
|
|
assert again["셈"]["계"] == after["셈"]["계"] > was
|
|
v = _orig(ml.Links(m01.loaded()[1]), "GC000267", NO)["값"]
|
|
doc = cp.deepcopy(again["문서"])
|
|
doc[mcv.COVER] = {NO: {"값": {"가격": v["P_기계"]}}} # 되돌림 = 원본 값
|
|
back = at(path.save, key, {"문서": doc, "판": again["판"]})
|
|
assert mcv.COVER not in back["문서"] and back["셈"]["계"] == was
|
|
with store.at(me):
|
|
file = store.read("sangeun", key)["문서"]
|
|
assert mcv.COVER not in file["수식"]["문서"]
|
|
doc[mcv.COVER] = {NO: {"값": {"가격": -5}}}
|
|
with pytest.raises(store.StoreError):
|
|
at(path.save, key, {"문서": doc, "판": back["판"]})
|