Files
Aislo/resources/tester/test_166_2_b09_bill_hand.py
T
eomsangdonandClaude Opus 5.5 ac1961c0a3 fix(ui): 화면 글 「손입력 · 손 입력 · 손 줄」 → 「수기 입력 · 수기 줄」 전역(171-1)
- 화면 글 10 자리(8 파일) — 세월교 힌트 · B08 재료 엑셀 알림 · B09 내역 수기 줄 까닭(거울 둘) · 산근 쓰는 곳 · 단가 힌트 · 총괄 수기 입력 칸 · 로케일 둘
- 저장 키 · 주석 · 손료는 그대로 · 로케일 줄 수 그대로
- 걸린 시험 셋 글 맞춤 · 새 시험 test_171_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-10 08:34:28 +09:00

232 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""166-2 B09 설계내역서 손질 — 수량 덮어쓰기 · 손 줄 · 호표 연결 · 새 산근 · 거울 · 우클릭 메뉴.
- 수량 = `user.bill.qty[줄 키]` 가 있으면 그 값(`qty_auto` = 자동 값) · 키 지우면 자동(B08)
- 손 줄 = `user.bill.rows` — `after` 줄 뒤(제목 = 공종 첫 자리 · 못 찾으면 공종 끝) ·
단가 = `ref_price` 하나(dg 대가 목록 · sg 산근 목록 · logic = `sg_hand` 산근) ·
비고 = 「대가n」 · 「산근 n호표」
- 새 산근(M01 로직) = 사본 산근 뒤 번호 · 쓰는 곳 = 「손 줄 …」 · 같은 로직 · 입력 = 같은 산근
- 호표 탭 연동 = 손 줄이 연결한 산근이 단가산출근거(목록)에 실리고 집계표 역전개에 듦
"""
import json
import re
import subprocess
import sys
from pathlib import Path
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_Sangeun as sg # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import WHY_LOGIC, compute_estimation # noqa: E402
from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402
from B09_Estimation.B09_Estimation_Store import clean_user # noqa: E402
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines # noqa: E402
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡
NET = "GC000337"
NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"}
ITEMS = [
{"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14,
"ref": {"tab": "dg_list", "key": "B00194"}},
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675,
"ref": {"tab": "sg_list", "key": "D01341"}},
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972,
"ref": None, "why": "조합 밖 토공 산근 — 연결 없음"},
] # fmt: skip
HOPYO = {
"B00194": {"labor": 21936, "material": 3885, "expense": 0},
"D01341": {"labor": 1116, "material": 360, "expense": 463},
}
INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": HOPYO}
BOOKS = {
"daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30,
"sum": 1230}],
"sg_list": [{"no": 1, "key": "SG:x", "labor": 500, "material": 0, "expense": 7, "sum": 507},
{"no": 2, "key": "SG:new", "labor": 10, "material": 1, "expense": 0, "sum": 11}],
"sg_hand": {"hand:c": "SG:new"},
} # fmt: skip
HAND = [
{"key": "hand:a", "section": "1.1", "after": "earth:1", "name": "제근", "spec": "",
"unit": "㎡",
"qty": 10, "ref": {"tab": "dg", "key": "DG:a"}},
{"key": "hand:b", "section": "1.2", "after": "sec:1.2", "name": "벌개", "spec": "",
"unit": "㎡",
"qty": 4, "ref": {"tab": "sg", "key": "SG:x"}},
{"key": "hand:c", "section": "1.2", "after": "없는줄", "name": "메쌓기", "spec": "",
"unit": "㎡",
"qty": 2.5, "ref": {"tab": "logic", "code": STONE, "given": {}, "rate": None, "picks": None}},
{"key": "hand:d", "section": "1.2", "after": "hand:b", "name": "새 산근", "spec": "",
"unit": "㎡", "qty": 3, "ref": {"tab": "logic", "code": NET, "given": NET_IN}},
] # fmt: skip
USER = {"bill": {"qty": {"earth:4": 100}, "rows": HAND}}
def _rows(sheets):
return {row["key"]: row for row in sheets["bill"]["rows"]}
def _node(source: str) -> str:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", source],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
def _ts(names: list[str]) -> str:
return "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in names
)
def test_수량_덮어쓰기_자동_값_남김_지우면_자동():
rows = _rows(compute_estimation(INPUTS, USER, None, BOOKS))
row = rows["earth:4"]
assert (row["qty"], row["qty_auto"], row["labor"]) == (100, 5675, 111600) # 1,116 × 100
plain = _rows(compute_estimation(INPUTS, {}, None, BOOKS))["earth:4"]
assert plain["qty"] == 5675 and "qty_auto" not in plain
# 단가 그대로 · 수량 없던 줄도 덮어쓰면 금액이 생김
rows = _rows(compute_estimation(INPUTS, {"bill": {"qty": {"earth:1": 1}}}, None, BOOKS))
assert rows["earth:1"]["sum"] == 25821
def test_손_줄_자리_단가_비고():
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
keys = [r["key"] for r in sheets["bill"]["rows"] if r["section"] in ("1.1", "1.2")]
assert keys == [
"sec:1.1", "earth:1", "hand:a", # earth:1 뒤
# 제목 = 첫 자리 · 못 찾음 = 끝
"sec:1.2", "hand:b", "hand:d", "earth:4", "earth:5", "hand:c",
] # fmt: skip
rows = _rows(sheets)
a = rows["hand:a"]
assert (a["note"], a["ref"], a["hand"]) == ("대가3", {"tab": "dg", "key": "DG:a"}, True)
assert (a["labor"], a["material"], a["expense"], a["sum"]) == (10000, 2000, 300, 12300)
assert rows["hand:b"]["note"] == "산근 1호표" and rows["hand:b"]["sum"] == 2028
c = rows["hand:c"] # 로직 = 서버가 준 sg_hand 산근
assert (c["note"], c["ref"], c["sum"]) == ("산근 2호표", {"tab": "sg", "key": "SG:new"}, 27)
d = rows["hand:d"] # 새 산근 [저장] 전 = 빈칸 + 까닭
assert d["sum"] is None and d["ref"] is None
assert ("bill", "hand:d", WHY_LOGIC) in {(g["tab"], g["key"], g["why"]) for g in sheets["gaps"]}
assert rows["sec:1.1"]["sum"] == 361494 + 12300 # 제목 합에 손 줄
assert rows["sec:1.2"]["blank"] == 2 # 절토(연암) · 새 산근
def test_거울_TS_가_같은_값():
calc = _ts(["Calc_Base", "Calc_Bill"])
for user in (USER, {}, {"bill": {"qty": {"earth:5": None}}}):
body = (
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeEstimation(i, u, null, b)));"
)
assert json.loads(_node(calc + body)) == compute_estimation(INPUTS, user, None, BOOKS)
def test_저장_다듬기_bill():
raw = {
"bill": {
"qty": {"earth:4": 100, "earth:5": "x", "st:a#대가1": 2.5},
"rows": [
*HAND[:1],
{"key": "x:1", "section": "1.1"}, # hand: 아님
{"key": "hand:z", "section": "9"}, # 모르는 공종
{**HAND[2], "ref": {**HAND[2]["ref"], "given": {"a": 1, "b": [1]}}, "junk": 1},
{**HAND[1], "ref": {"tab": "zz", "key": "q"}},
],
}
}
bill = clean_user(raw)["bill"]
assert bill["qty"] == {"earth:4": 100, "st:a#대가1": 2.5}
assert [r["key"] for r in bill["rows"]] == ["hand:a", "hand:c", "hand:b"]
assert bill["rows"][1]["ref"] == {"tab": "logic", "code": STONE, "name": "", "given": {"a": 1},
"rate": None, "picks": None} # fmt: skip
assert "junk" not in bill["rows"][1] and bill["rows"][2]["ref"] is None
assert "bill" not in clean_user({"bill": {"qty": {}, "rows": []}})
def _snapshot(folder: Path, rows, values=None) -> Path:
folder.mkdir(parents=True, exist_ok=True)
files = {
"단가.json": item_prices.to_json(item_prices.collect([STONE, NET])),
"일위대가.json": {"줄": {"종류": "일위대가", "담은로직": rows}},
"입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()},
}
for name, data in files.items():
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return folder
def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN})
user = {"bill": {"rows": [{**HAND[2], "qty": 10}, HAND[3]]}}
hand_keys = sg.hand_resource_keys(user)
assert {"LB000033", "LB000002"} <= set(hand_keys) # 석공 · 보통인부 → 단가 책 수요
keys = list(dict.fromkeys([*used_keys(a), *hand_keys]))
base = compute_base(prices.build_inputs(None, keys), {})
got = sg.collect([("s1", a)], base, sg.hand_sources(user))
listed = [(r["no"], r["logic"], r["uses"]) for r in got["sg_list"]]
assert listed == [(1, NET, ["s1", "수기 줄 새 산근"]), (2, STONE, ["수기 줄 메쌓기"])]
assert got["sg_hand"] == {
"hand:c": got["sg_list"][1]["key"],
"hand:d": got["sg_list"][0]["key"],
}
stone = got["sg"][1]
assert stone["sum"] is not None and stone["why"] is None
# 내역서 — 손 줄 단가 = 새 산근 · 비고 「산근 2호표」 · 집계표 역전개에 듦
books = {"daega": [], "sg_list": got["sg_list"], "sg_hand": got["sg_hand"]}
sheets = {**compute_estimation({**INPUTS, "bill": {"items": []}}, user, None, books), **got}
row = _rows(sheets)["hand:c"]
assert (row["note"], row["price_sum"]) == ("산근 2호표", stone["sum"])
labor = compute_summary(sheets, hopyo_lines(sheets))["sum_labor"]
qty = {r["key"]: r["qty"] for r in labor}
assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망)
def test_우클릭_메뉴_줄_추가_지우기_연결_TS():
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit"])
rows = [
{"kind": "title", "key": "sec:1.2", "section": "1.2"},
{"kind": "item", "key": "earth:4", "section": "1.2"},
{"kind": "item", "key": "hand:b", "section": "1.2", "hand": True},
]
body = f"""
const rows = {json.dumps(rows, ensure_ascii=False)};
const first = {json.dumps(HAND[1], ensure_ascii=False)};
let user = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }};
const ctx = {{ sheets: {{}}, payload: {{}}, get user() {{ return user; }},
setUser: (u) => (user = u) }};
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
const labels = (i) => billMenu(ctx, rows, i).map(name);
const out = {{ title: labels(0), auto: labels(1), hand: labels(2) }};
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "위에 줄 추가")[1]();
out.above = user.bill.rows[1];
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "수량 자동으로 되돌리기")[1]();
out.qty = user.bill.qty;
billMenu(ctx, rows, 2).find((x) => Array.isArray(x) && x[0] === "줄 지우기")[1]();
out.left = user.bill.rows.map((r) => [r.key.slice(0, 5), r.after]);
out.edit = ["name", "qty", "sum"].map(handEditable);
console.log(JSON.stringify(out));
"""
out = json.loads(_node(src + body))
assert out["title"] == ["아래에 줄 추가"]
assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"]
assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-",
"줄 지우기"] # fmt: skip
assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄"
assert out["qty"] == {}
# hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로
assert out["left"] == [["hand:", "sec:1.2"]]
assert out["edit"] == [True, True, False]