Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_7

This commit is contained in:
2026-11-01 16:20:02 +09:00
23 changed files with 861 additions and 87 deletions
+2 -1
View File
@@ -111,7 +111,8 @@ def test_글_속_금액은_산근_풀이_값과_같음():
whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code])))
sheets, price_of = _solver_prices(whole, code, given)
block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1")
times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
# 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
times = sg._unit(whole.logic(code))[1]
got = st.render_sangeun_text(whole, code, given, price_of, times=times)
parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS)
assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code
+2 -1
View File
@@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None:
assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick
assert "(!work.select.value || x.공종 === work.select.value)" in pick
sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8")
assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet
# 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀)
assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet
for name in ("Labor_Pick", "Labor_Sheet"):
text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8")
assert len(text.splitlines()) <= 700
+47
View File
@@ -0,0 +1,47 @@
"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀
- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버)
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SHEET = ROOT / "A00_Common" / "calc_sheet.ts"
def _text(p: Path) -> str:
return p.read_text(encoding="utf-8")
def test_한_벌을_씀():
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts")
est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts")
detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02
assert "calcSheetRows(" in est and "buildEst(" in detail
def test_부품_상자만():
s = _text(SHEET)
assert "<button" not in s and "document.createElement" not in s and "<table" not in s
assert "@ui/ui_template_calc_row" in s and "@ui/ui_template_input" in s
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_식_글():
s = _text(SHEET)
pure = s[s.index("export const sheetFormula") : s.index("/** 변수 칸 하나")]
body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))'
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", pure + "\n" + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert out.returncode == 0, out.stderr
assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""]
+267
View File
@@ -0,0 +1,267 @@
"""719-2 공사 쪽 M02 단가산출(SK) — B09 대가 블록 · 산근 · [업데이트] · 내 자료집 SK(임시 storage ·
실제 M01 읽기만).
- 굳힌 SK = 품셈 꼴 대가 블록(`Labor_Source.sangeun`) · 금액 = M02 SK 셈 · 단가산출 속 일위대가 줄 =
사전조합 블록을 부르는 줄 · 로직 더한줄의 하위 로직 = 산근 목록(`Sangeun.rows_of`)
- [업데이트] = SK 고친 뒤 다시 굳힘 — 굳힌 키 · cover · cid · 입력값 · 고른자재 · B09 덮개 그대로 ·
덮개 밑 값(base) = 새 단가산출 값 · 서고 사본(`sangeun`)도 원본판 다르면 다시 굳힘 ·
미리 보기 SK 줄
- 내 자료집 = SK 블록 · 품셈 블록 → 개인 층 SK(B09 덮개 = SK 덮개) → 서고로 다시 불러와 같은 금액
"""
# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀
import json
import pytest
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Daega_Personal as personal
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Refresh as refresh
from B09_Estimation import B09_Estimation_Sangeun as sangeun
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from M02_MasterTemplete import M02_Item_Copy as item_copy
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete import M02_Item_Source as source
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_145_2_b09_labor_calc import STONE, _item, _project, _snap
from resources.tester.test_715_sangeun_store import PCT, _calc, _sk
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
TWO_MATS = "GC000565" # 재료 줄 둘(고른자재 열쇠)
COVER = {"labor:LB000036#0": {"qty": 0.5}}
PRICE = {"labor:LB000002#0": {"labor": 200000}}
OWNER = "7/42"
def _json(value):
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
def _sc(world, lines):
with store.at(world["personal"]):
doc = {"이름": "속 일위대가", "단위": "㎡", "담은로직": lines}
return refs.create("precombo", doc)["이름"]
def _mine(world, full=False):
"""SK = 품 덮개 · 인력 더한줄 · 로직 더한줄(하위 산근) · full = 단가 덮개 + SC 더한줄."""
added = [
{"종류": "인력", "요소": "LB000002", "수량": 0.1},
{"종류": "로직", "로직": PCT, "입력값": {}, "수량": 0.5},
]
cover = COVER
if full:
sc = _sc(world, [{"로직": PCT, "수량": {"손": 1}}])
added.append({"참조": {**MINE, "키": sc}, "수량": 2}) # 단가산출 속 일위대가
cover = {**COVER, **PRICE}
return _sk(world["personal"], 바탕={"로직": PCT}, 덮개=cover, 더한줄=added)
def _engine_ready(world):
"""엔진 입력 덧칸(715-1b `단가` · `값묶음`)이 붙었나 — 아니면 M02 도 못 셈."""
key = _sk(
world["personal"], 이름="엔진", 바탕={"로직": PCT}, 덮개={"labor:LB000002#0": {"labor": 1}}
)
got = _calc(world["personal"], key)
return not got.get("까닭") and got["줄"][1]["단가"] == 1
def _b09(root, cover=None):
keys = linked_resource_keys(root)
inputs = prices.build_inputs(None, keys)
inputs["daega"] = daega_inputs(root)
base = compute_base(inputs, {})
sg = sangeun.build(root, base)
rows = {r["key"]: r for r in sg["sg_list"]}
out = compute_labor(inputs["daega"], base, rows, cover)
return inputs["daega"], {b["id"]: b for b in out["daega_blocks"]}, rows
def _lib(world, tmp_path, key, kind="sangeun"):
root = _project(tmp_path / "proj", [])
made = library.import_copy(root, kind, "personal", OWNER, key)
return root, made["폴더"]
def _set_cover(world, key, cover):
with store.at(world["personal"]):
now = store.read("sangeun", key)
store.write("sangeun", key, now["판"], {**now["문서"], "덮개": cover})
def _same_money(block, live):
assert block["sum"] == pytest.approx(float(live["1단위"]["계"]), abs=5) # 끝수(내림)만 다름
for part, name in (("labor", "노무비"), ("material", "재료비"), ("expense", "경비")):
assert block[part] == pytest.approx(float(live["1단위"][name]), abs=3)
def test_SK_대가_블록_금액_M02_같음_하위_산근(world, tmp_path):
key = _mine(world)
live = _calc(world["personal"], key)
assert not live.get("까닭") and live["1단위"]["계"] > 0
root, folder = _lib(world, tmp_path, key)
daega, done, sg_rows = _b09(root)
block = done[daega["libs"][folder]]
assert block["cover"].startswith("P:") and block["kind"] == "품셈"
lines = block["lines"]
assert all(ln.get("sk") for ln in lines)
assert lines[0]["kind"] == "labor" and lines[0]["qty"] == 0.5
assert lines[0]["poom"] == {"qty": 0.018, "단가": 233250} # M02 에서 고친 줄 = 품셈 값 곁
assert "poom" not in lines[1]
sub = next(ln for ln in lines if ln["kind"] == "sangeun")
assert sub["key"] in sg_rows and sub["key"].startswith(f"SG:{PCT}") and sub["qty"] == 0.5
_same_money(block, live)
def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path):
if not _engine_ready(world):
pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함")
key = _mine(world, full=True)
live = _calc(world["personal"], key)
root, folder = _lib(world, tmp_path, key)
daega, done, _ = _b09(root)
block = done[daega["libs"][folder]]
lines = block["lines"]
assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000)
sc_line = next(ln for ln in lines if ln["kind"] == "daega")
assert done[sc_line["key"]]["kind"] == "사전조합" and sc_line["qty"] == 2
_same_money(block, live)
def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로(
world, tmp_path, monkeypatch
):
key = _mine(world)
ref = {**MINE, "키": key}
with store.at(world["personal"]):
from M02_MasterTemplete import M02_Labor_Doc as labor
doc = labor.clean(
{
"열": "rv",
"담은로직": [
{"참조": ref, "수량": {"손": 2}},
{"로직": "GF000219", "입력값": {}, "수량": {"손": 1}},
{"로직": TWO_MATS, "수량": {"손": 1}},
],
}
)
def build(_item_): # 원본 층 → 사본 파일(구조물 원본 대신 이 일위대가 문서)
rows = source.labor_doc_rows(doc)
return _json(
{
source.LABOR_FILE: rows,
source.PRICE_FILE: source.price_bundle(rows, world["personal"]),
snapshots.VALUES_FILE: {},
}
)
monkeypatch.setattr(item_copy, "build_snapshot", build)
root = _project(tmp_path / "proj", [(100, "SS000901")])
folder = snapshots.structures_dir(root) / snapshots.folder_name("pipe@100.0")
folder.mkdir(parents=True)
values = {"GF000219": {k: {"값": v} for k, v in STONE.items()}}
picks = {TWO_MATS: {"1": "MT000001"}}
for name, data in {
**build(None),
snapshots.VALUES_FILE: values,
snapshots.PICKS_FILE: picks,
snapshots.MANIFEST_NAME: {"원본": {**_item("SS000901"), "경로": ["x"]}},
}.items():
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
rid = refs.ref_id(ref)
frozen_key = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid]
frozen_key = frozen_key["바탕"]["로직"]
daega, _, _ = _b09(root)
combo = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"])
sk_block = next(b for b in daega["blocks"] if b["id"] == combo["lines"][0]["key"])
cid = sk_block["lines"][0]["cid"]
cover = {sk_block["cover"]: {cid: {"qty": 0.9}}}
# SK 고침(품 0.5 → 0.7) → [재적용](업데이트) = 다시 풀어 굳힘
_set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}})
item_copy.reapply(root, folder.name)
after = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid]
assert after["바탕"]["로직"] == frozen_key # 굳힌 키 그대로
assert (
json.loads((folder / snapshots.VALUES_FILE).read_text("utf-8"))["GF000219"]
== (values["GF000219"])
)
assert json.loads((folder / snapshots.PICKS_FILE).read_text("utf-8")) == picks
daega2, done, _ = _b09(root, cover)
block = next(b for b in done.values() if b["cover"] == sk_block["cover"])
line = block["lines"][0]
assert line["cid"] == cid and line["covered"] == ["qty"] and line["qty"] == 0.9
assert line["base"]["qty"] == 0.7 # 덮개 밑 = 새 단가산출 값(「단가산출 값으로」 되돌림 값)
assert daega2["owners"]["pipe@100.0"] in done
def test_서고_SK_업데이트_미리보기_다시_굳힘(world, tmp_path):
key = _mine(world)
root, folder = _lib(world, tmp_path, key)
assert library.changed(root) == []
before = daega_inputs(root)
cover_key = next(b for b in before["blocks"] if b["id"] == before["libs"][folder])["cover"]
_set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}})
got = refresh.preview(root, None, [], None)
assert [(x["키"], x["판"]) for x in got["단가산출"]] == [(key, "바뀜")]
assert [c["폴더"] for c in got["사본"]] == [folder]
assert refresh.reapply_changed(root)["다시"] == ["내 단가산출"]
assert library.changed(root) == [] and [f.name for f, _ in library.copies(root)] == [folder]
after = daega_inputs(root)
block = next(b for b in after["blocks"] if b["id"] == after["libs"][folder])
assert block["cover"] == cover_key and block["lines"][0]["qty"] == 0.7
def test_내_자료집_SK_왕복_같은_금액(world, tmp_path):
key = _mine(world)
root, folder = _lib(world, tmp_path, key)
daega, _, _ = _b09(root)
block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder])
first, second = block["lines"][0]["cid"], block["lines"][1]["cid"]
priced = {block["cover"]: {first: {"qty": 0.9}, second: {"labor": 210000}}}
made = personal.build(root, block["cover"], priced, "내 SK 2")
doc = made["문서"]
assert doc["종류"] == "단가산출" and doc["바탕"] == {"로직": PCT}
assert doc["덮개"]["labor:LB000036#0"] == {"qty": 0.9}
assert doc["덮개"]["labor:LB000002#0"] == {"labor": 210000}
assert doc["더한줄"][0]["요소"] == "LB000002"
assert made["담음"] == {"품": 1, "단가": 1} and made["안담음"] == {"품": 0, "단가": 0}
third = block["lines"][2]["cid"] # 더한줄 인력 = 그 더한줄 수량
cover = {block["cover"]: {first: {"qty": 0.9}, third: {"qty": 0.3}}}
doc = personal.build(root, block["cover"], cover, "내 SK 2")["문서"]
assert doc["더한줄"][0]["수량"] == 0.3 and doc["덮개"] == {"labor:LB000036#0": {"qty": 0.9}}
_, done, _ = _b09(root, cover)
want = done[daega["libs"][folder]]["sum"]
with store.at(world["personal"]):
new = personal.write(doc)["이름"]
with pytest.raises(store.StoreError) as same:
personal.write(doc)
assert same.value.status == 409 and same.value.detail["같은이름"] == new
assert new.startswith("SK-") and new != key
root2, folder2 = _lib(world, tmp_path / "b", new)
daega2, done2, _ = _b09(root2)
assert done2[daega2["libs"][folder2]]["sum"] == pytest.approx(want, abs=2)
def test_내_자료집_품셈_블록_SK(world, tmp_path):
root = _project(tmp_path / "proj", [(100, "SS000901")])
_snap(root, "pipe@100.0", "SS000901", [{"로직": PCT, "승격": True, "수량": {"손": 1}}], {})
daega = daega_inputs(root)
top = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"])
block = next(b for b in daega["blocks"] if b["id"] == top["lines"][0]["key"])
assert block["kind"] == "품셈" and block["origin"]["로직"] == PCT
cover = {block["cover"]: {block["lines"][0]["cid"]: {"qty": 0.5}}}
doc = personal.build(root, block["cover"], cover, "내 해체")["문서"]
assert doc["바탕"] == {"로직": PCT} and doc["단위"] == "㎡"
assert doc["덮개"] == {"labor:LB000036#0": {"qty": 0.5}}
with store.at(world["personal"]):
new = personal.write(doc)["이름"]
plain = _sk(world["personal"], 이름="견줌", 바탕={"로직": PCT}, 덮개=doc["덮개"])
a, b = _calc(world["personal"], new), _calc(world["personal"], plain)
assert a["1단위"]["계"] == b["1단위"]["계"] > 0