feat(b09): 설계내역서 자유 편집 셈 · 저장 — user.bill v2 덮개 · 차례 · 부모 · gone · 새 1단 공종 · 블록 키 · 옛 꼴 읽음(184-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-10 13:07:26 +09:00
co-authored by Claude Opus 5.5
parent 0970a2665c
commit d7df6dec14
9 changed files with 928 additions and 265 deletions
+11 -7
View File
@@ -199,12 +199,15 @@ def test_거울_TS_가_같은_값():
assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user)
#: 사본 주인 → 대가 1 단위(145-2 `daega_price` 자리 견본) · 엑셀 일위대가목록표 대가17 · 24
#: 사본 주인 → 대가 1 단위(145-2 `daega_price` 자리 견본 · key = 대가 블록 키) ·
#: 엑셀 일위대가목록표 대가17 · 24
A17 = {"no": "대가17", "key": "blk:a", "labor": 96868, "material": 11309, "expense": 16417}
DAEGA = {
"pipe@100.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
"pipe@200.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
"pipe@300.0__gsoiladd1": {"no": "대가24", "labor": 129370, "material": 15059, "expense": 21833},
}
"pipe@100.0__gsoiladd1": A17,
"pipe@200.0__gsoiladd1": A17,
"pipe@300.0__gsoiladd1": {"no": "대가24", "key": "blk:b", "labor": 129370, "material": 15059,
"expense": 21833},
} # fmt: skip
REVET = {
"key": "st:pipe:revet|돌쌓기(찰)|2.0", "section": "2.1", "name": "흙막이",
"spec": "찰쌓기 H=2.0", "unit": "m", "qty": 42.5, "note": "",
@@ -219,9 +222,10 @@ def test_구조물_열_대가_블록마다_나눔():
sheets = compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)
rows = [r for r in sheets["bill"]["rows"] if r["key"].startswith("st:")]
got = [(r["key"].split("#")[-1], r["qty"], r["sum"], r["note"]) for r in rows]
# 184 — 줄 키 꼬리 = 대가 블록 키(번호 밀림에 덮개 안 흔들림) · 비고 = 대가 번호
assert got == [
("대가17", 20, 2491880, "대가17"), # 124,594 × 20 — 비목마다 ROUNDDOWN
("대가24", 12.5, 2078274, "대가24"), # 129,370 · 15,059 · 21,833 × 12.5
("blk:a", 20, 2491880, "대가17"), # 124,594 × 20 — 비목마다 ROUNDDOWN
("blk:b", 12.5, 2078274, "대가24"), # 129,370 · 15,059 · 21,833 × 12.5
("st:pipe:revet|돌쌓기(찰)|2.0", 10, None, ""), # 대가 없는 주인 = 「연결 없음」
]
assert rows[0]["labor"] == 1937360 and rows[1]["material"] == 188237 # 15,059 × 12.5 버림
+3 -3
View File
@@ -1,6 +1,6 @@
"""166-2 B09 설계내역서 손질 — 수량 덮어쓰기 · 손 줄 · 호표 연결 · 새 산근 · 거울 · 우클릭 메뉴.
- 수량 = `user.bill.qty[줄 키]` 가 있으면 그 값(`qty_auto` = 자동 값) · 키 지우면 자동(B08)
- 수량 = 옛 꼴(184 전) `user.bill.qty[줄 키]` 가 있으면 그 값(`auto_qty` = 자동 값) · 키 지우면 자동(B08)
- 손 줄 = `user.bill.rows` — `after` 줄 뒤(제목 = 공종 첫 자리 · 못 찾으면 공종 끝) ·
단가 = `ref_price` 하나(dg 대가 목록 · sg 산근 목록 · logic = `sg_hand` 산근) ·
비고 = 「대가n」 · 「산근 n호표」
@@ -92,9 +92,9 @@ def _ts(names: list[str]) -> str:
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
assert (row["qty"], row["auto_qty"], 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
assert plain["qty"] == 5675 and "auto_qty" not in plain # 184 auto_{칸} 꼴
# 단가 그대로 · 수량 없던 줄도 덮어쓰면 금액이 생김
rows = _rows(compute_estimation(INPUTS, {"bill": {"qty": {"earth:1": 1}}}, None, BOOKS))
assert rows["earth:1"]["sum"] == 25821
@@ -13,7 +13,7 @@ SPOTS = {
"B08_Quantity/B08_Quantity_UI_Material.ts": ["2공구 엑셀 수기 입력값과 다름"],
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'],
"B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
"ui_template/ui_template_locale_b2.ts": [
@@ -49,4 +49,4 @@ def test_저장_키는_그대로():
assert 'export type QtySource = "손" | "칸" | "없음";' in calc
assert "export type Qty = { 손: number } | null;" in calc
assert "손?: boolean;" in _text("ui_template/sheet/ui_template_sheet_types.ts")
assert "rows[] 손 줄" in _text("B09_Estimation/B09_Estimation_Calc_Bill.ts") # 주석 그대로
assert "손 줄 after" in _text("B09_Estimation/B09_Estimation_Calc_Bill.ts") # 주석 그대로
+6 -4
View File
@@ -100,14 +100,15 @@ def test_레벨_차례_부모():
] # fmt: skip
rows = _rows(sheets)
assert rows["sec:1"]["level"] == 1 and rows["sec:3"]["level"] == 1
# 184 — parent = 실제 부모 키(공종 바로 아래 = 그 제목 키)
assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == (
2,
2,
None,
"sec:3",
)
assert rows["hand:x"]["parent"] is None and rows["hand:b"]["parent"] == "hand:g2"
assert rows["hand:x"]["parent"] == "sec:1.2" and rows["hand:b"]["parent"] == "hand:g2"
# 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래)
assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == (None, 3)
assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == ("sec:1.1", 3)
assert (rows["hand:q"]["parent"], rows["hand:q"]["level"]) == ("hand:p", 4)
# 묶음 줄 = 이름만 · 「연결 없음」 목록에 안 듦
g1 = rows["hand:g1"]
@@ -211,7 +212,8 @@ def test_우클릭_레벨_메뉴_TS():
assert pick("auto") == [] and "줄 지우기" not in out["auto"]
assert {k: out["child"][k] for k in ("section", "after", "parent")} == {
"section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip
assert (out["below"]["after"], "parent" in out["below"]) == ("hand:c", False) # g1 자손 끝
# g1 자손 끝 · 부모 = 셈 parent 그대로(184 공종 제목 키 — 옛 꼴 다듬기가 hand: 아닌 부모 버림)
assert (out["below"]["after"], out["below"]["parent"]) == ("hand:c", "sec:1.2")
assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2")
assert out["indent"]["parent"] == "hand:g1"
assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래
@@ -0,0 +1,363 @@
"""184-2 B09 설계내역서 자유 편집 — 셈 · 저장(`user.bill` v2 덮개 · 계약 = `Calc_Bill.py` 머리).
- 덮개 없음 · 굳힘(보이는 줄 키 · 부모 · 차례만) = 기본 표 그대로 ·
번호 자동(1 · 1.1 · 관급 없음)
- 자동 줄 칸 덮기(이름 · 규격 · 단위 · 수량 · 연결) = 그 값 + auto_{칸} · null = 빈칸 ·
안 고친 칸 = B08 따라감
- gone = 안 살아남(「연결 없음」에도 없음) · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」 ·
원천이 안 낸 키 = 안 보임
- 새 1단 공종 = 직접비(sums) · 관급 자재 줄 = 관급자재대 밖 못 감 · 조달수수료 = 관급자재대 끝 ·
깊이 5 · 돎 · 옛 꼴 = 같은 표의 v2 · 구조물 블록 키 · 자동 줄 로직 연결 → sg_hand · 거울 · 다듬기
"""
import copy
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_Sangeun as sg # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
ITEMS = [
{"key": "earth:1", "section": "1.1", "name": "규준틀", "unit": "개소", "qty": 14,
"ref": {"tab": "dg_list", "key": "B00194"}},
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "spec": "기계", "unit": "㎥",
"qty": 10, "ref": {"tab": "sg_list", "key": "D01341"}},
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 9, "ref": None,
"why": "조합 밖 토공 산근 — 연결 없음"},
{"key": "haul:1", "section": "3", "name": "운반", "unit": "㎥", "qty": 2,
"ref": {"tab": "sg_list", "key": "D01341"}},
] # fmt: skip
HOPYO = {
"B00194": {"labor": 21936, "material": 3885, "expense": 0},
"D01341": {"labor": 1116, "material": 360, "expense": 463},
}
MATERIALS = [
{"key": "b08:pipe_800", "name": "파형강관", "spec": "Φ800", "unit": "M", "origin": "B08",
"prices": {"관급": 157000}, "qty": 212},
{"key": "b08:sand", "name": "골재", "spec": "모래", "unit": "㎥", "origin": "B08",
"prices": {"물가정보": 30000}, "qty": 16},
] # fmt: skip
INPUTS = {"materials": MATERIALS, "bill": {"items": ITEMS}, "hopyo": HOPYO}
BASE = {
"materials": {"b08:pipe_800": {"supply": "관급"}, "b08:sand": {"supply": "별도"}},
"estimate": {"procurement_fee_pct": 0.54},
}
BOOKS = {
"daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30,
"sum": 1230}],
"sg_list": [{"no": 2, "key": "SG:new", "labor": 10, "material": 1, "expense": 0, "sum": 11}],
"sg_hand": {"earth:5": "SG:new"},
} # fmt: skip
DG = {"tab": "dg", "key": "DG:a"}
MAT = "mat:b08:pipe_800"
def _calc(bill=None, inputs=INPUTS, price_of=None):
user = {**BASE, **({"bill": bill} if bill is not None else {})}
return compute_estimation(inputs, user, price_of, BOOKS)
def _rows(sheets):
return {row["key"]: row for row in sheets["bill"]["rows"]}
def _keys(sheets):
return [row["key"] for row in sheets["bill"]["rows"]]
def _freeze(sheets, **more):
"""화면 첫 손질 굳힘 — 보이는 줄의 키 · 부모 · 차례(새 줄은 모든 칸) · fee 뺌."""
rows = []
for r in sheets["bill"]["rows"]:
if r["key"] == "fee":
continue
row = {"key": r["key"], "parent": r["parent"]}
if r.get("hand"):
row.update({k: r[k] for k in ("name", "spec", "unit", "qty", "ref")})
row.update({"kind": "group"} if r["kind"] == "group" else {})
rows.append(row)
return {"v": 2, "rows": rows, "gone": [], **more}
def _put(bill, key, **cover):
"""굳힌 덮개의 한 줄 칸 고침 · 부모 바꿈."""
out = copy.deepcopy(bill)
next(r for r in out["rows"] if r["key"] == key).update(cover)
return out
def test_덮개_없음_빈_굳힘_기본_표_번호():
plain = _calc()
assert _calc({})["bill"] == plain["bill"] # [초기화] = bill 지움(빈 v2 는 다듬기가 버림)
assert _calc(_freeze(plain))["bill"] == plain["bill"] # 굳혀도 표 · 합 · 연결 없음 그대로
rows = _rows(plain)
got = [(r["key"], r["no"], r["name"], r["level"], r["parent"]) for r in plain["bill"]["rows"]
if r["kind"] == "title"][:3] # fmt: skip
assert got == [("sec:1", "1", "토공사", 1, None), ("sec:1.1", "1.1", "준비공", 2, "sec:1"),
("sec:1.2", "1.2", "절토", 2, "sec:1")] # fmt: skip
assert (rows["sec:4"]["no"], rows["sec:G"]["no"], rows["fee"]["no"]) == ("4", "", "")
assert (rows["earth:4"]["auto"], rows["earth:4"]["parent"], rows["earth:4"]["cost"]) == (
True, "sec:1.2", "direct") # fmt: skip
assert (rows[MAT]["cost"], rows["fee"]["cost"], _keys(plain)[-1]) == ("supply", "supply", "fee")
assert not any(k.startswith("auto_") for r in rows.values() for k in r)
def test_자동_줄_칸_덮기_자동값_남김():
bill = _put(
_freeze(_calc()), "earth:4", name="절토 고침", spec=None, unit="m3", qty=100, ref=DG
)
row = _rows(_calc(bill))["earth:4"]
assert (row["name"], row["spec"], row["unit"], row["qty"]) == ("절토 고침", "", "m3", 100)
assert (row["auto_name"], row["auto_spec"], row["auto_unit"], row["auto_qty"]) == (
"절토(토사)", "기계", "㎥", 10) # fmt: skip
assert (row["ref"], row["auto_ref"], row["note"]) == (DG, {"tab": "sg_list", "key": "D01341"},
"대가3") # fmt: skip
assert (row["labor"], row["sum"]) == (100000, 123000) # 대가3 1,230 × 100
# 제목 이름 덮개 · 제목 = 이름만(수량 덮개 안 받음)
title = _rows(_calc(_put(bill, "sec:1.2", name="절토공", qty=5)))["sec:1.2"]
assert (title["name"], title["auto_name"], title["no"], "auto_qty" in title) == (
"절토공", "절토", "1.2", False) # fmt: skip
def test_B08_바뀜_안_고친_칸_따라감_고친_칸_지킴_null_빈칸():
bill = _put(_freeze(_calc()), "earth:4", qty=100)
bill = _put(bill, "earth:1", qty=None)
changed = copy.deepcopy(INPUTS)
for item in changed["bill"]["items"]:
item["qty"] = item["qty"] * 2
item["name"] = item["name"] + "*"
sheets = _calc(bill, changed)
rows = _rows(sheets)
assert (rows["earth:4"]["qty"], rows["earth:4"]["auto_qty"], rows["earth:4"]["name"]) == (
100, 20, "절토(토사)*") # fmt: skip
assert (rows["earth:5"]["qty"], rows["haul:1"]["qty"]) == (18, 4) # 안 고친 줄 = 따라감
assert rows["earth:1"]["qty"] is None and rows["earth:1"]["sum"] is None # 일부러 빈칸
assert ("earth:1", "수량 연결 없음") in {(g["key"], g["why"]) for g in sheets["gaps"]}
def test_gone_안_살아남_새_자동_줄_기본_부모_끝_원천_사라짐_안_보임():
bill = _freeze(_calc(), gone=["earth:5", "sec:G", MAT]) # sec:G · 자재 줄 = 못 지움
bill["rows"].append({"key": "earth:99", "parent": "sec:1.2", "qty": 3}) # 원천 없음
more = copy.deepcopy(INPUTS)
more["bill"]["items"] += [
{"key": "earth:5", "section": "1.2", "name": "되살아남?", "unit": "㎥", "qty": 1},
{"key": "earth:6", "section": "1.2", "name": "암발파", "unit": "㎥", "qty": 4, "ref": None},
]
sheets = _calc(bill, more)
keys = _keys(sheets)
assert "earth:5" not in keys and "earth:99" not in keys
assert {"sec:G", MAT} <= set(keys)
assert "earth:5" not in {g["key"] for g in sheets["gaps"]} # 지운 줄 = 연결 없음에도 없음
at = keys.index("earth:6")
assert keys[at - 1] == "earth:4" and keys[at + 1] == "sec:1.3" # 절토 끝
assert _rows(sheets)["earth:6"]["note"] == "새 자동 줄"
# 제목을 지웠으면 새 자동 줄 = 표 끝 맨 위
gone = _calc(_freeze(_calc(), gone=["sec:1.2", "earth:4", "earth:5"]), more)
assert (_keys(gone)[-1], _rows(gone)["earth:6"]["parent"]) == ("earth:6", None)
def test_새_1단_공종_직접비_번호_옮김_들여_내어():
plain = _calc()
bill = _freeze(plain)
keys = [r["key"] for r in bill["rows"]]
at = keys.index("sec:G") # 4 자재대 뒤 · 관급자재대 앞에 새 1단 공종
bill["rows"][at:at] = [
{"key": "hand:g", "kind": "group", "cost": "direct", "name": "부대공", "spec": "",
"unit": "", "qty": None, "ref": None},
{"key": "hand:a", "parent": "hand:g", "name": "a", "spec": "", "unit": "㎡", "qty": 10,
"ref": DG},
{"key": "hand:s", "parent": "hand:g", "kind": "group", "name": "소공종", "spec": "",
"unit": "", "qty": None, "ref": None},
] # fmt: skip
sheets = _calc(bill)
rows = _rows(sheets)
g = rows["hand:g"]
assert (g["no"], g["level"], g["cost"], g["sum"], g["section"]) == ("5", 1, "direct", 12300,
None) # fmt: skip
assert (rows["hand:s"]["no"], rows["hand:a"]["no"]) == ("5.1", "") # 레벨 2 묶음만 번호
totals = sheets["bill"]["totals"]["direct"]
assert totals["labor"] - plain["bill"]["totals"]["direct"]["labor"] == 10000
# 형제 차례 바꿈(위로 ↑) = 번호 따라감 · 들여(다른 묶음 아래) · 공종 제목도 옮김
up = copy.deepcopy(bill)
keys = [r["key"] for r in up["rows"]]
up["rows"].insert(keys.index("sec:1.1"), up["rows"].pop(keys.index("sec:1.2")))
rows = _rows(_calc(up))
assert (rows["sec:1.2"]["no"], rows["sec:1.1"]["no"]) == ("1.1", "1.2")
moved = _put(_put(bill, "earth:4", parent="sec:1.1"), "sec:3", parent="hand:g")
rows = _rows(_calc(moved))
assert (rows["earth:4"]["parent"], rows["earth:4"]["level"], rows["earth:4"]["section"]) == (
"sec:1.1", 3, "1.1") # fmt: skip
assert (rows["sec:3"]["level"], rows["sec:3"]["no"], rows["hand:s"]["no"]) == (2, "4.1", "4.2")
assert (rows["sec:4"]["no"], rows["hand:g"]["no"]) == ("3", "4")
assert rows["sec:1.1"]["sum"] == _rows(plain)["sec:1.1"]["sum"] + _rows(plain)["earth:4"]["sum"]
assert _calc(moved)["bill"]["sums"] == sheets["bill"]["sums"] # 옮겨도 직접비 합 같음
def test_관급_줄_관급자재대_밖_못_감_수수료_끝():
plain = _calc()
bill = _put(_freeze(plain), MAT, parent="sec:1")
bill["rows"].append({"key": "hand:k", "parent": "sec:G", "name": "관급 손", "spec": "",
"unit": "개", "qty": 10, "ref": DG}) # fmt: skip
sheets = _calc(bill)
rows = _rows(sheets)
assert (rows[MAT]["parent"], rows[MAT]["cost"]) == ("sec:G", "supply")
assert _keys(sheets)[-3:] == [MAT, "hand:k", "fee"] # 조달수수료 = 관급자재대 끝
net = 212 * 157000 + 2000 # + 손 줄 재료비 200 × 10
assert sheets["bill"]["sums"]["supply_net"] == net
assert sheets["bill"]["sums"]["labor"] == plain["bill"]["sums"]["labor"] # 관급 = 직접비 밖
assert rows["fee"]["material"] == int(net * 0.54 / 100)
def test_깊이_5_돎_부모_바로잡기():
bill = _freeze(_calc())
chain = [("hand:g1", "sec:3"), ("hand:g2", "hand:g1"), ("hand:g3", "hand:g2"),
("hand:g4", "hand:g3"), ("hand:g5", "hand:g4"), ("hand:g6", "hand:g5")] # fmt: skip
for key, parent in chain:
bill["rows"].append({"key": key, "parent": parent, "kind": "group", "name": key})
bill["rows"].append({"key": "hand:i", "parent": "hand:g6", "name": "i", "qty": 1, "ref": DG})
bill["rows"] += [
{"key": "hand:p", "parent": "hand:q", "kind": "group", "name": "p"},
{"key": "hand:q", "parent": "hand:p", "kind": "group", "name": "q"},
]
bill = _put(_put(bill, "sec:1", parent="earth:4"), "sec:G", parent="sec:1")
rows = _rows(_calc(bill))
got = [(k, rows[k]["level"], rows[k]["parent"]) for k in ("hand:g4", "hand:g5", "hand:g6",
"hand:i")] # fmt: skip
assert got == [("hand:g4", 5, "hand:g3"), ("hand:g5", 5, "hand:g3"),
("hand:g6", 5, "hand:g3"), ("hand:i", 5, "hand:g3")] # fmt: skip
assert rows["hand:g1"]["sum"] == 1230 and rows["sec:3"]["sum"] == rows["haul:1"]["sum"] + 1230
assert (rows["hand:p"]["parent"], rows["hand:q"]["parent"]) == (None, "hand:p") # 돎 = 맨 위
assert (rows["sec:1"]["parent"], rows["sec:G"]["parent"]) == (None, None) # 항목 부모 · G 고정
#: 옛 꼴(166-2 수량 · 182 손 줄 after · parent)
OLD = {
"qty": {"earth:4": 100},
"rows": [
{"key": "hand:g1", "section": "1.2", "after": "earth:4", "kind": "group", "name": "묶음"},
{"key": "hand:a", "section": "1.2", "after": "hand:g1", "parent": "hand:g1", "name": "a",
"unit": "㎡", "qty": 10, "ref": DG},
{"key": "hand:b", "section": "3", "after": "sec:3", "name": "b", "qty": 2, "ref": None},
],
} # fmt: skip
def test_옛_꼴_그대로_읽음_굳히면_같은_표():
old = _calc(OLD)
rows = _rows(old)
keys = _keys(old)
assert keys[keys.index("sec:1.2") :][:5] == ["sec:1.2", "earth:4", "hand:g1", "hand:a",
"earth:5"] # fmt: skip
assert (rows["earth:4"]["qty"], rows["earth:4"]["auto_qty"]) == (100, 10)
assert (rows["hand:a"]["parent"], rows["hand:b"]["parent"]) == ("hand:g1", "sec:3")
v2 = _put(_freeze(old), "earth:4", qty=100) # 화면 첫 손질 = v2 로 굳힘
assert _calc(v2)["bill"] == old["bill"]
REVET = {"key": "st:r", "section": "2.1", "name": "흙막이", "unit": "m", "qty": 30, "note": "",
"ref": {"tab": "dg_list", "key": "r"}, "owners": {"o1": 10, "o2": 20}} # fmt: skip
def test_구조물_블록_키_대가_번호_밀려도_덮개_같은_줄():
inputs = {**INPUTS, "bill": {"items": [REVET]}}
first = {"no": "대가17", "key": "blk:a", "labor": 100, "material": 0, "expense": 0}
shifted = {**first, "no": "대가18"} # 앞에 블록이 끼어 번호만 밀림
bill = _put(_freeze(_calc({}, inputs, lambda o: first)), "st:r#blk:a", qty=7)
row = _rows(_calc(bill, inputs, lambda o: shifted))["st:r#blk:a"]
assert (row["qty"], row["auto_qty"], row["note"], row["labor"]) == (7, 30, "대가18", 700)
assert row["ref"] == {"tab": "dg", "key": "blk:a"}
def test_자동_줄_로직_연결_새_산근_쓰는_곳():
logic = {"tab": "logic", "code": "GF000219", "name": "돌쌓기", "given": {}}
bill = _put(_freeze(_calc()), "earth:5", ref=logic)
user = {**BASE, "bill": store.clean_bill(bill)}
assert [(k, u) for k, u, _s in sg.hand_sources(user)] == [("earth:5", "내역 줄 돌쌓기")]
row = _rows(compute_estimation(INPUTS, user, None, BOOKS))["earth:5"]
assert (row["note"], row["ref"], row["sum"]) == (
"산근 2호표",
{"tab": "sg", "key": "SG:new"},
99,
)
assert row["auto_ref"] is None # 원래 연결 없음
def test_저장_다듬기_v2_왕복(tmp_path):
raw = {
"v": 2,
"rows": [
{"key": "sec:1", "name": None, "qty": 3, "junk": 1},
{"key": "earth:4", "parent": "sec:1.1", "qty": "x", "unit": None, "ref": {"tab": "zz"}},
{"key": "earth:5", "qty": None, "ref": None, "name": 12},
{"key": "earth:4", "qty": 1}, # 겹친 키
{"key": "fee", "qty": 9}, # 조달수수료 = 덮개 없음
{"key": 3},
{"key": "hand:g", "kind": "group", "cost": "direct", "qty": 5, "ref": DG},
{"key": "hand:a", "parent": "hand:g", "kind": "zz", "cost": "direct", "qty": "1"},
],
"gone": ["earth:1", "earth:1", "hand:a", 3, ""],
}
clean = store.write_user(tmp_path, {"bill": raw})["bill"]
assert store.read_user(tmp_path)["bill"] == clean
assert clean["gone"] == ["earth:1"]
assert clean["rows"] == [
{"key": "sec:1", "name": None, "qty": 3},
{"key": "earth:4", "parent": "sec:1.1", "unit": None},
{"key": "earth:5", "name": "12", "qty": None, "ref": None},
{"key": "hand:g", "kind": "group", "cost": "direct", "name": "", "spec": "", "unit": "",
"qty": None, "ref": None},
{"key": "hand:a", "parent": "hand:g", "name": "", "spec": "", "unit": "", "qty": None,
"ref": None},
] # fmt: skip
assert "bill" not in store.clean_user({"bill": {"v": 2, "rows": [], "gone": []}})
back = store.read_user(tmp_path)
rows = _rows(compute_estimation(INPUTS, {**BASE, **back}, None, BOOKS))
assert "earth:1" not in rows and rows["earth:4"]["parent"] == "sec:1.1"
assert (rows["sec:1"]["name"], rows["earth:5"]["qty"]) == ("", None)
def _node(source: str) -> str:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
def test_거울_TS_가_같은_값():
calc = "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("Calc_Base", "Calc_Bill")
)
frozen = _freeze(_calc())
bills = [
None,
OLD,
frozen,
_put(_put(frozen, "earth:4", name=None, qty=3, ref=DG), MAT, parent="sec:1"),
{**frozen, "gone": ["earth:5", "sec:1.1"], "rows": frozen["rows"][3:]},
{"v": 2, "rows": [{"key": "hand:g", "kind": "group", "name": "g"},
{"key": "hand:a", "parent": "hand:g", "qty": 1, "ref": DG},
{"key": "sec:1", "parent": "hand:a"}], "gone": []},
] # fmt: skip
users = [{**BASE, **({"bill": b} if b else {})} for b in bills]
body = (
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
f"\nconst us = {json.dumps(users, ensure_ascii=False)};"
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, b))));"
)
got = json.loads(_node(calc + body))
assert got == [compute_estimation(INPUTS, u, None, BOOKS) for u in users]