- 고른 줄 방향키 → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로 · Alt+←→ 안 받음) - 줄 끌기 = 위아래 자리 · 좌우 레벨 · 미리 보기 선 · 못 놓는 자리 빨간 점선(공용 표 안 고침) - 자식이 생긴 항목 = 저절로 묶음(Calc_Bill 짝 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음) - 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) · 자재 · 조달수수료 줄 = 부모 못 됨 - 시험 test_204_1 새로 · 184_3 · 166_2 · 200_2 메뉴 · 키 기대값 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
295 lines
16 KiB
Python
295 lines
16 KiB
Python
"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 ·
|
|
자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선.
|
|
|
|
- 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄
|
|
- 저장 = `user.bill` v2 {v: 2, rows: 표 차례 {key · parent · 고친 칸}, gone}
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
|
|
|
DG = {"tab": "dg", "key": "DG:a"}
|
|
|
|
|
|
def _row(key, parent, level, kind="item", cost="direct", **more):
|
|
auto = not key.startswith("hand:")
|
|
row = {"key": key, "kind": kind, "parent": parent, "level": level, "cost": cost, "no": "",
|
|
"name": key, "spec": "", "unit": "", "qty": None, "ref": None, "note": ""} # fmt: skip
|
|
return {**row, **({"auto": True} if auto else {"hand": True}), **more}
|
|
|
|
|
|
ROWS = [
|
|
_row("sec:1", None, 1, "title", no="1", name="토공사"),
|
|
_row("sec:1.2", "sec:1", 2, "title", no="1.1", name="절토"),
|
|
_row("earth:4", "sec:1.2", 3, qty=10, ref={"tab": "sg_list", "key": "D01341"}),
|
|
_row("earth:5", "sec:1.2", 3, qty=9, auto_qty=5),
|
|
_row("hand:g", "sec:1.2", 3, "group"),
|
|
_row("hand:a", "hand:g", 4, ref=DG, note="대가3"),
|
|
_row("hand:b", "sec:1.2", 3),
|
|
_row("sec:4", None, 1, "title", no="4", name="자재대"),
|
|
_row("mat:M1", "sec:4", 2, qty=3),
|
|
_row("sec:G", None, 1, "title", "supply", name="관급자재대"),
|
|
_row("mat:M2", "sec:G", 2, cost="supply", qty=4),
|
|
_row("fee", "sec:G", 2, cost="supply", qty=None),
|
|
]
|
|
#: 옛 꼴 덮개(166-2 · 182) — 자동 줄 수량 · 손 줄
|
|
OLD = {
|
|
"bill": {
|
|
"qty": {"earth:5": 9},
|
|
"rows": [
|
|
{"key": "hand:g", "section": "1.2", "after": "earth:5", "kind": "group", "name": "g"},
|
|
{"key": "hand:a", "section": "1.2", "after": "hand:g", "parent": "hand:g", "name": "a",
|
|
"qty": 2, "ref": DG},
|
|
{"key": "hand:b", "section": "1.2", "after": "hand:a", "name": "b", "qty": 1},
|
|
],
|
|
}
|
|
} # fmt: skip
|
|
|
|
MENU_JS = """
|
|
const rows = __ROWS__;
|
|
const start = __USER__;
|
|
let user = start;
|
|
const ctx = { sheets: {}, payload: {}, get user() { return user; }, setUser: (u) => (user = u) };
|
|
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
|
|
const at = (key) => rows.findIndex((r) => r.key === key);
|
|
const labels = (key) => billMenu(ctx, rows, at(key)).map(name);
|
|
const run = (key, label) => {
|
|
user = start;
|
|
const item = billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label);
|
|
if (!item) return null;
|
|
item[1]();
|
|
return user.bill;
|
|
};
|
|
const edit = (key, field, value) => {
|
|
user = start;
|
|
editCell(ctx, rows, at(key), field, value);
|
|
return user.bill;
|
|
};
|
|
const out = { labels: {}, runs: {}, edits: {} };
|
|
for (const key of __LABELS__) out.labels[key] = labels(key);
|
|
for (const [key, label] of __RUNS__) out.runs[`${key}|${label}`] = run(key, label);
|
|
for (const [key, field, value] of __EDITS__) out.edits[`${key}|${field}`] = edit(key, field, value);
|
|
console.log(JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
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 _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
|
|
)
|
|
|
|
|
|
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]
|
|
|
|
|
|
def _menu(user, labels=(), runs=(), edits=(), rows=ROWS):
|
|
body = (
|
|
MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
|
.replace("__USER__", json.dumps(user, ensure_ascii=False))
|
|
.replace("__LABELS__", json.dumps(list(labels), ensure_ascii=False))
|
|
.replace("__RUNS__", json.dumps([list(r) for r in runs], ensure_ascii=False))
|
|
.replace("__EDITS__", json.dumps([list(e) for e in edits], ensure_ascii=False))
|
|
)
|
|
return json.loads(_node(_ts(SRC) + body))
|
|
|
|
|
|
def _order(bill):
|
|
return [(e["key"], e.get("parent")) for e in bill["rows"]]
|
|
|
|
|
|
ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"]
|
|
|
|
|
|
def test_메뉴_모든_줄():
|
|
out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2",
|
|
"mat:M1", "sec:G"])["labels"] # fmt: skip
|
|
add = ["위에 줄 추가", "아래에 줄 추가"]
|
|
# 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움)
|
|
assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"]
|
|
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
|
|
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
|
|
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
|
|
# 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음)
|
|
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
|
|
"들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기",
|
|
"줄 지우기"] # fmt: skip
|
|
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
|
|
"아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-",
|
|
"줄 지우기"] # fmt: skip
|
|
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
|
|
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
|
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
|
|
assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정
|
|
assert out["mat:M2"] == [*add, "새 공종 추가(1단)"]
|
|
assert out["mat:M1"] == [*add, "새 공종 추가(1단)"]
|
|
assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"]
|
|
|
|
|
|
def test_칸_고침_덮개_옛_꼴_굳힘():
|
|
out = _menu(OLD, edits=[("earth:4", "name", "절토A"), ("earth:5", "qty", 5),
|
|
("earth:4", "qty", 10), ("earth:4", "spec", None),
|
|
("hand:a", "qty", 7), ("sec:1.2", "name", "절토공")])["edits"] # fmt: skip
|
|
bill = out["earth:4|name"]
|
|
# 첫 손질 = 보이는 줄 키 · 부모 · 차례로 v2(조달수수료 빼고) · 옛 손 줄 칸 · 수량 덮개 옮김
|
|
assert (bill["v"], bill["gone"], _order(bill)) == (2, [], ORDER)
|
|
rows = {e["key"]: e for e in bill["rows"]}
|
|
assert rows["earth:4"] == {"key": "earth:4", "parent": "sec:1.2", "name": "절토A"}
|
|
assert rows["earth:5"] == {"key": "earth:5", "parent": "sec:1.2", "qty": 9}
|
|
assert rows["hand:g"] == {"key": "hand:g", "parent": "sec:1.2", "kind": "group", "name": "g"}
|
|
assert rows["hand:a"] == {"key": "hand:a", "parent": "hand:g", "name": "a", "qty": 2, "ref": DG}
|
|
assert "section" not in rows["hand:b"] and "after" not in rows["hand:b"]
|
|
# 자동 값과 같게 고침 = 덮개 지움 · 안 고친 값 그대로 = 덮개 안 생김 · null = 일부러 빈칸
|
|
assert "qty" not in {e["key"]: e for e in out["earth:5|qty"]["rows"]}["earth:5"]
|
|
assert "qty" not in {e["key"]: e for e in out["earth:4|qty"]["rows"]}["earth:4"]
|
|
assert {e["key"]: e for e in out["earth:4|spec"]["rows"]}["earth:4"]["spec"] is None
|
|
assert {e["key"]: e for e in out["hand:a|qty"]["rows"]}["hand:a"]["qty"] == 7
|
|
assert {e["key"]: e for e in out["sec:1.2|name"]["rows"]}["sec:1.2"]["name"] == "절토공"
|
|
|
|
|
|
V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in ORDER
|
|
if k != "hand:b"], "gone": []}} # fmt: skip
|
|
|
|
|
|
def test_지움_옮김_새_공종_자동값으로():
|
|
user = {"bill": {**V2["bill"], "rows": [
|
|
{**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip
|
|
runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"),
|
|
("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"),
|
|
("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"),
|
|
("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"),
|
|
("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"),
|
|
("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"),
|
|
("fee", "새 공종 추가(1단)")] # fmt: skip
|
|
out = _menu(user, runs=runs)["runs"]
|
|
keys = lambda b: [k for k, _ in _order(b)] # noqa: E731
|
|
# 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘
|
|
base = keys(out["earth:5|위로 ↑"])
|
|
assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1
|
|
# 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로
|
|
b = out["earth:4|줄 지우기"]
|
|
assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b)
|
|
b = out["hand:g|줄 지우기"]
|
|
assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b)
|
|
# 위로 · 아래로 = 같은 부모 형제와 자리 바꿈
|
|
for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"):
|
|
got = keys(out[k])
|
|
assert got[got.index("earth:5") + 1] == "earth:4"
|
|
# 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞
|
|
b = out["earth:4|새 공종 추가(1단)"]
|
|
new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0]
|
|
assert (new["kind"], new["cost"], new["name"], "parent" in new) == (
|
|
"group", "direct", "새 공종", False) # fmt: skip
|
|
assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4"
|
|
b = out["mat:M2|새 공종 추가(1단)"]
|
|
assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:")
|
|
# 자동값으로 = 고친 칸 지움(차례 · 부모 그대로)
|
|
b = out["earth:5|자동값으로 되돌리기"]
|
|
assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"}
|
|
# 들여쓰기 · 내어쓰기
|
|
assert ("hand:b", "hand:g") in _order(out["hand:b|들여쓰기 →"])
|
|
got = _order(out["hand:a|내어쓰기 ←"])
|
|
assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2")
|
|
got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤)
|
|
assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0
|
|
# 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목)
|
|
got = out["sec:1.2|아래에 하위 줄 추가"]["rows"]
|
|
kids = [e for e in got if e.get("parent") == "sec:1.2"]
|
|
assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1]
|
|
got = out["earth:4|위에 줄 추가"]["rows"]
|
|
at = [e["key"] for e in got].index("earth:4")
|
|
assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2")
|
|
a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"]
|
|
assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None)
|
|
got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]}
|
|
assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2"
|
|
# 자동 줄 연결 풀기 = ref 덮개 null
|
|
assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None
|
|
# 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞
|
|
got = out["fee|위에 줄 추가"]["rows"]
|
|
assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2")
|
|
assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2
|
|
|
|
|
|
def test_관급_옮김_막힘_깊이_5():
|
|
# 차례 = 1 · 관급자재대 · 4 자재대(별도 자재 mat:M1) · 새 공종(자재 없음)
|
|
order = [*[(k, p) for k, p in ORDER if not k.startswith(("sec:4", "mat:M1"))],
|
|
("sec:4", None), ("mat:M1", "sec:4"), ("hand:n", None)] # fmt: skip
|
|
by = {r["key"]: r for r in ROWS}
|
|
rows = [by.get(k) or _row(k, None, 1, "group") for k, _ in order]
|
|
user = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in order],
|
|
"gone": []}} # fmt: skip
|
|
out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"]
|
|
assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음
|
|
assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대)
|
|
# 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음
|
|
deep = [
|
|
_row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"),
|
|
_row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"),
|
|
_row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5),
|
|
] # fmt: skip
|
|
out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"]
|
|
assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"]
|
|
assert "아래에 하위 줄 추가" in out["hand:g2"]
|
|
|
|
|
|
TABLE_JS = """
|
|
let user = __USER__;
|
|
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
|
|
get user() { return user; }, setUser: (u) => (user = u) };
|
|
const table = buildBill(ctx);
|
|
const at = (key) => table.rows.findIndex((r) => r.key === key);
|
|
const out = {
|
|
names: ["sec:1", "sec:1.2", "earth:4"].map((k) => table.rows[at(k)].name),
|
|
connect: ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"].map((k) => table.rows[at(k)].connect),
|
|
cls: [["earth:5", "qty"], ["earth:5", "name"], ["earth:4", "qty"]].map(([k, c]) => table.cellClass(at(k), c)),
|
|
tip: table.tip(at("earth:5"), "qty"),
|
|
edit: [["sec:1", "name"], ["sec:1", "qty"], ["earth:4", "spec"], ["fee", "qty"], ["fee", "name"]]
|
|
.map(([k, c]) => table.editable(at(k), c)),
|
|
hint: table.hint,
|
|
};
|
|
table.onEdit(at("sec:1.2"), "name", "1.1 절토공");
|
|
out.renamed = user.bill.rows.find((e) => e.key === "sec:1.2").name;
|
|
console.log(JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
def test_표_칸_강조_번호_연결_칸():
|
|
body = TABLE_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)).replace(
|
|
"__USER__", "{}"
|
|
)
|
|
out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body))
|
|
assert out["names"] == ["1 토공사", "1.1 절토", "earth:4"] # 번호 = 셈 no
|
|
assert out["connect"] == ["연결됨 ▾", "연결 ▾", "대가3 ▾", "", "", ""]
|
|
assert out["cls"] == ["is-edited", None, None]
|
|
assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]"
|
|
assert out["edit"] == [True, False, True, True, False]
|
|
assert "엑셀 꼴 고정" not in out["hint"] and "내역 초기화" in out["hint"]
|
|
assert out["renamed"] == "절토공" # 번호는 떼고 이름만
|
|
|
|
|
|
def test_내역_초기화_배선():
|
|
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
|
block = src[src.index('label: "내역 초기화"') :]
|
|
block = block[: block.index("b09BillReset")]
|
|
# 확인 창 → bill 뺀 사용자 값으로 바로 PUT(덮개 빔) · 좌측 단추 줄
|
|
assert "showConfirmDialog(" in block and "if (!ok) return;" in block
|
|
assert "const { bill: _bill, ...rest } = user;" in block
|
|
assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block
|
|
assert "actions.append(save, reset);" in src
|