Files
Aislo/resources/tester/test_719_3_sangeun_cover.py
T
eomsangdonandClaude Opus 5.5 51f07499f5 feat(719-4 · 719-3): B09 단가산출근거 탭 = 공용 계산서 카드 · 이 공사 산근 덮개
- 719-4 근거 탭 = 산근 블록마다 카드 · 공용 calcSheetRows 묶음 꼴(인력 · 재료 · 경비(장비 한 줄 + 중기사용료 펼침 넉 줄) · 하위 = 산근 n호표 한 줄) · 서버 줄 덧칸 sheet{쓰는입력 · 기준단위 · 환산근거}(master_sheet_line 한 벌) · 블록 기준단위 · 입력 · 중간값 글은 고치기 펼침 때 · 옛 풀이표는 곁 창에만
- 719-3 산근 덮개 sg_cover {산근 키: {「n:이름」: {qty · 노 · 재 · 경 단가}}} · 셈 짝 B09_Estimation_Sangeun_Cover.py ↔ .ts(서버 풀이 뒤 덧씌움 · 하위 산근 이어짐 · % 덧줄 밑수 비례 · 총계 · 목록표 · 빈칸 목록 · raw 로 두 번 덮어도 같음 · 짝 잃음 sg_orphans 알림)
- 고침 표(702 꼴) = 품 · 단가 두 번 눌러 고침 · 고친 칸 색 · 원래 값 칸 · 「이 공사에서 고침」 꼬리표 · 우클릭 · 카드 단추 [단가산출 값으로] · 고친 뒤 보던 자리 지킴
- 저장 다듬기 sg_cover · [업데이트] 알림 고친 칸 수에 산근 칸 · 셈 길(compute_sheets) · 화면 짝(computeSheets) 둘 다 덮개
- 금액 전후 같음 — 덮개 없음 = 서버 풀이 그대로 · 덧칸 뺀 옛 풀이와 13 블록 같음 · 일위대가 합 검증_랩탑메인 13,662,560 · 검증_sub5 10,149,562 그대로
- 시험 새로 test_719_3_sangeun_cover(8 · 거울 · 대가 · 원가계산서 · 업데이트 뒤 지킴) · test_719_4_sangeun_sheet(2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:28:11 +09:00

229 lines
9.9 KiB
Python

"""719-3 B09 산근 덮개 — 이 공사 단가산출근거 고친 칸(품 · 단가) · 거울(`Sangeun_Cover.py` ↔ `.ts`).
- 고침 없음 = 서버 풀이 그대로(금액 전후 같음)
- 품 · 단가 고침 = 줄 금액 ROUNDDOWN(단가 × 품, 1) · 총계 · 목록표 · 고친 줄 base · covered
- 하위 산근 고침 = 부르는 블록 산근 줄 단가 · 총계로 이어짐 · % 덧줄(인건비의 %) = 밑수 비례
- 줄 이름 다름 · 없는 블록 = 짝 잃음(알림만) · 저장 다듬기에 남음
- 덮개 → 설계내역서 손 줄 단가 · 원가계산서 직접비 · [업데이트](새 단가 판) 뒤 덮개 지킴
"""
import copy
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base, rounddown # noqa: E402
from B09_Estimation.B09_Estimation_Sangeun_Cover import cover_sangeun, line_id # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
CODES = ["GF000169", "GC001018", "GC000337", "GF000103"] # 하위 산근 · 인건비 % · 중기 · 중기
GIVEN = {"GF000169": {"콘크리트량": 0.2, "거푸집면적": 1.5, "연결철근량": 0.01, "비닐면적": 0.5}}
def _user(cover=None):
rows = [
{"key": f"hand:t{i}", "name": f"시험 {c}", "spec": "", "unit": "식", "qty": 10,
"ref": {"tab": "logic", "code": c, "given": GIVEN.get(c, {})}}
for i, c in enumerate(CODES)
] # fmt: skip
return {"bill": {"v": 2, "rows": rows}, **({"sg_cover": cover} if cover else {})}
def _inputs():
return prices.build_inputs(None, sangeun.hand_resource_keys(_user()))
@pytest.fixture(scope="module")
def sg():
sheets = compute_base(_inputs(), {})
return sangeun.collect([], sheets, sangeun.hand_sources(_user()))
def _block(got, code):
return next(b for b in got["sg"] if b["logic"] == code)
def _at(block, kind):
return next((n, ln) for n, ln in enumerate(block["lines"]) if ln["kind"] == kind)
def _ts(sg, cover):
base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8"))
calc = IMPORTS.sub(
"", (ROOT / "B09_Estimation/B09_Estimation_Sangeun_Cover.ts").read_text("utf-8")
)
body = (
f"\nconst sg = {json.dumps(sg, ensure_ascii=False)};"
f"\nconst c = {json.dumps(cover, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(coverSangeun(sg, c)));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=base + calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _same(a, b):
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return abs(a - b) < 1e-9
return a == b
def _cover(sg):
"""품 · 단가 · 하위 산근 · 율 · 중기 단가 고침 한 벌 + 짝 잃은 칸 둘."""
sub = _block(sg, "GF000155")
n_l, labor = _at(sub, "labor")
people = _block(sg, "GC001018")
n_p, first = _at(people, "labor")
n_x, _extra = _at(people, "extra")
net = _block(sg, "GC000337")
n_m, machine = _at(net, "machine")
n_t, mat = _at(net, "material")
parent = _block(sg, "GF000169")
n_r, remicon = _at(parent, "material") # 단가 빈칸(대비표에 없음) → 덮개로 채움
return {
parent["key"]: {line_id(n_r, remicon): {"material": 90000}},
sub["key"]: {line_id(n_l, labor): {"qty": labor["qty"] * 2}},
people["key"]: {line_id(n_p, first): {"labor": 300000}, line_id(n_x, _extra): {"qty": 5}},
net["key"]: {
line_id(n_m, machine): {"expense": 20000},
line_id(n_t, mat): {"material": 4500},
},
"SG:없는블록": {"0:x": {"qty": 1}},
net["key"] + "x": {"0:다른이름": {"qty": 1}},
}
def test_고침_없음_그대로(sg):
got = cover_sangeun(sg, {})
assert got["sg"] is sg["sg"] and got["sg_list"] is sg["sg_list"] and got["sg_orphans"] == []
only_orphans = cover_sangeun(sg, {"SG:없는블록": {"0:x": {"qty": 1}}})
assert only_orphans["sg"] == sg["sg"] and only_orphans["sg_orphans"] == [
{"key": "SG:없는블록", "line": "0:x"}
]
def test_품_단가_고침_줄_금액_총계(sg):
before = copy.deepcopy(sg)
got = cover_sangeun(sg, _cover(sg))
assert sg == before # 받은 답 안 고침
net0, net = _block(sg, "GC000337"), _block(got, "GC000337")
n, m = _at(net, "machine")
assert (
m["covered"] == ["expense"]
and m["base"]["expense"] == _at(net0, "machine")[1]["price"]["expense"]
)
assert (
m["expense"] == rounddown(20000 * m["qty"], 1)
and m["labor"] == _at(net0, "machine")[1]["labor"]
)
_n, t = _at(net, "material")
assert t["material"] == rounddown(4500 * t["qty"], 1)
for part in ("labor", "material", "expense"):
assert net[part] == rounddown(sum(ln[part] for ln in net["lines"]), 0)
assert net["sum"] == net["labor"] + net["material"] + net["expense"] != net0["sum"]
row = next(r for r in got["sg_list"] if r["key"] == net["key"])
assert row["sum"] == net["sum"] and row["covered"] == 2
untouched = _block(got, "GF000103")
assert untouched == _block(sg, "GF000103") # 덮개 안 닿은 블록 = 서버 값
def test_하위_산근_이어짐_덧줄_비례(sg):
got = cover_sangeun(sg, _cover(sg))
sub, parent = _block(got, "GF000155"), _block(got, "GF000169")
line = next(ln for ln in parent["lines"] if ln["ref"] and ln["ref"]["key"] == sub["key"])
assert line["price"]["labor"] == sub["labor"] != _block(sg, "GF000155")["labor"]
assert line["labor"] == rounddown(sub["labor"] * line["qty"], 1)
assert parent["labor"] > _block(sg, "GF000169")["labor"]
assert _block(sg, "GF000169")["sum"] is None and parent["sum"] is not None # 빈칸 채움
assert parent["why"] is None and parent["key"] not in {g["key"] for g in got["sg_gaps"]}
people0, people = _block(sg, "GC001018"), _block(got, "GC001018")
n, extra = _at(people, "extra")
assert extra["of"] == "people" and extra["qty"] == 5
base0 = sum(ln["labor"] for ln in people0["lines"][:n] if ln["kind"] == "labor")
base1 = sum(ln["labor"] for ln in people["lines"][:n] if ln["kind"] == "labor")
value0 = _at(people0, "extra")[1]["price"]["expense"]
assert extra["price"]["expense"] == pytest.approx(
value0 * base1 / base0 * 5 / extra["base"]["qty"]
)
def test_짝_잃음(sg):
got = cover_sangeun(sg, _cover(sg))
assert {o["key"] for o in got["sg_orphans"]} == {
"SG:없는블록",
_block(sg, "GC000337")["key"] + "x",
}
def test_거울_파이썬_TS(sg):
cover = _cover(sg)
assert _same(cover_sangeun(sg, cover), _ts(sg, cover))
assert _same(cover_sangeun(sg, {}), _ts(sg, {}))
def test_저장_다듬기_남음(sg):
cover = _cover(sg)
clean = store.clean_user({"sg_cover": cover})["sg_cover"]
assert clean == cover
def test_덮개_대가_원가계산서_업데이트_뒤_지킴(sg, tmp_path):
inputs = _inputs()
plain = router_mod.compute_sheets(tmp_path, copy.deepcopy(inputs), _user())
net = _block(sg, "GC000337")
n, mat = _at(net, "material")
cover = {net["key"]: {line_id(n, mat): {"qty": mat["qty"] + 1}}}
covered = router_mod.compute_sheets(tmp_path, copy.deepcopy(inputs), _user(cover))
gap = rounddown(rounddown(mat["price"]["material"] * (mat["qty"] + 1), 1) - mat["material"], 0)
row0 = next(r for r in plain["sg_list"] if r["key"] == net["key"])
row1 = next(r for r in covered["sg_list"] if r["key"] == net["key"])
assert row1["material"] - row0["material"] in (gap, gap + 1, gap - 1)
hand = next(r for r in covered["bill"]["rows"] if r["key"] == "hand:t2")
hand0 = next(r for r in plain["bill"]["rows"] if r["key"] == "hand:t2")
assert hand["material"] - hand0["material"] == (row1["material"] - row0["material"]) * 10
m0, m1 = plain["total"]["sums"]["material"], covered["total"]["sums"]["material"]
assert m1 - m0 == hand["material"] - hand0["material"]
# [업데이트] = 새 단가 판 — 산근 키 같음 · 덮개 칸 그대로 · 밑 값 = 새 단가
newer = copy.deepcopy(inputs)
for row in newer.get("materials") or []:
if row.get("key") == mat["ref"]["key"]:
for k in ("price", "applied", "lowest"):
if isinstance(row.get(k), (int, float)):
row[k] = row[k] * 2
again = router_mod.compute_sheets(tmp_path, newer, _user(cover))
line = _block(again, "GC000337")["lines"][n]
assert line["covered"] == ["qty"] and line["qty"] == mat["qty"] + 1
assert line["base"]["qty"] == mat["qty"] and again["sg_orphans"] == []
def test_덮인_답_다시_덮음_되돌림(sg):
"""서버 답이 덮인 채 옴([저장] · 셈 답) — 화면 짝이 벗기고 다시 덮음 · 덮개 지우면 처음 값."""
cover = _cover(sg)
once = cover_sangeun(sg, cover)
assert cover_sangeun(once, cover) == once # 두 번 덮어도 같음(원래 값 = 처음 값)
back = cover_sangeun(once, {})
assert back["sg"] == sg["sg"] and back["sg_list"] == sg["sg_list"]
assert back["sg_gaps"] == sg["sg_gaps"]
assert _same(cover_sangeun(once, cover), _ts(once, cover))
assert _same(back, _ts(once, {}))