Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Page.css
This commit is contained in:
2026-10-10 13:14:03 +09:00
15 changed files with 1732 additions and 634 deletions
+260 -134
View File
@@ -1,9 +1,10 @@
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집).
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) ·
`test_184_2_b09_bill_free.py`(자유 편집).
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔):
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`)
4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료
@@ -15,31 +16,38 @@
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) · 사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면):
자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) ·
사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면):
{"v": 2,
"rows": [표 차례 전체(order · 부모 먼저 · 자손 이어서) {
"key": 줄 키, "parent": 묶음 줄 키(`sec:*` · `hand:*`) | 없음 = 맨 위,
자동 줄(`sec:*` · `earth:` · `st:` · `haul:` · `mat:`) = 고친 칸만 — 칸 없음 = 자동(B08 따라감) ·
값 = 지킴 · null = 일부러 빈칸 · 칸 = name · spec · unit · qty · ref
(제목 `sec:*` = name 만 · `mat:` = ref 없음 · `fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정)
자동 줄(`sec:*` · `earth:` · `st:` · `haul:` · `mat:`) = 고친 칸만 —
칸 없음 = 자동(B08 따라감) · 값 = 지킴 · null = 일부러 빈칸 ·
칸 = name · spec · unit · qty · ref
(제목 `sec:*` = name 만 · `mat:` = ref 없음 ·
`fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정)
새 줄(`hand:*`) = 모든 칸 + "kind": "group"(묶음 · 수량 · 연결 없음) | 없음 = 항목 ·
"cost": "direct"(맨 위 새 묶음 · 없음도 direct)}],
"gone": [지운 자동 줄 키 — `sec:G` · `fee` · `mat:` 는 안 받음(자재 줄 주인 = 대비표 표시)]}
덮개 없음 = 기본 표 그대로 · [초기화] = `bill` 지움 ·
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) = 기본 부모 끝 + 비고 「새 자동 줄」
(기본 부모가 없으면 표 끝 맨 위) · 원천이 안 낸 키 = 안 보임 ·
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) =
기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) ·
원천이 안 낸 키 = 안 보임 ·
부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래.
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` · `after` 앞 줄
뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2 로 굳힘).
셈 출력 줄 칸(계약) = kind("title" 자동 묶음 · "group" 새 묶음 · "item") · key · name(번호 뺀 이름) ·
parent(실제 부모 키 · None = 맨 위) · level(1~5 = 깊이) · no(레벨 1 · 2 묶음 차례 번호 「1」 · 「1.1」 ·
관급 · 항목 = "") · cost("direct" | "supply" = 맨 위 조상 갈래 · `sec:G` 만 supply) ·
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` ·
`after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2).
셈 출력 줄 칸(계약) = kind("title" 자동 묶음 · "group" 새 묶음 · "item") · key ·
name(번호 뺀 이름) · parent(실제 부모 키 · None = 맨 위) · level(1~5 = 깊이) ·
no(레벨 1 · 2 묶음 차례 번호 「1」 · 「1.1」 · 관급 · 항목 = "") ·
cost("direct" | "supply" = 맨 위 조상 갈래 · `sec:G` 만 supply) ·
auto(True = 자동 줄) | hand(True = 새 줄) · auto_{name|spec|unit|qty|ref}(고친 칸만 = 자동값) ·
section(가장 가까운 제목 공종 · 없으면 None).
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 · supply 항목의 관급
순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 ·
구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 ·
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) ·
화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
"""
from __future__ import annotations
@@ -66,9 +74,11 @@ SECTIONS: list[tuple[str, str, str | None]] = [
("4", "자재대", None),
("G", "관급자재대", None),
]
#: 순공사비에 드는 맨 위 공종(관급자재대 = 제외금액)
DIRECT = ("1", "2", "3", "4")
PARTS = ("labor", "material", "expense")
#: 184 덮개로 고칠 칸 · 묶음 깊이 한도
FIELDS = ("name", "spec", "unit", "qty", "ref")
DEPTH = 5
NEW_AUTO = "새 자동 줄"
def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]:
@@ -86,32 +96,29 @@ def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[
return out
def _item_row(
item: dict[str, Any], prices: dict[str, Any] | None, gaps: list, qty_of: dict | None = None
) -> dict[str, Any]:
def _item_row(item: dict[str, Any], prices: dict[str, Any] | None) -> dict[str, Any]:
"""항목 줄 — 「연결 없음」은 `_gaps` 에 담아 둠(보이는 줄만 표 차례로 모음)."""
qty = num(item.get("qty"))
over = {}
if qty_of and item["key"] in qty_of and not item.get("hand"): # 166-2 수량 덮어쓰기
over = {"qty_auto": qty}
qty = num(qty_of[item["key"]])
row = {
"kind": "item",
"key": item["key"],
"section": item["section"],
"section": item.get("section"),
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
"qty": qty,
"note": item.get("note") or "",
"ref": item.get("ref"),
**over,
**({"hand": True} if item.get("hand") else {}),
**{k: v for k, v in item.items() if k.startswith("auto_")},
**({"hand": True} if item.get("hand") else {"auto": True}),
**_amounts(qty, prices),
}
row["_gaps"] = []
if qty is None:
gaps.append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
row["_gaps"].append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
if prices is None:
gaps.append(gap("bill", item["key"], row["name"], item.get("why") or "단가 연결 없음"))
why = item.get("why") or "단가 연결 없음"
row["_gaps"].append(gap("bill", item["key"], row["name"], why))
return row
@@ -162,9 +169,11 @@ def ref_price(
def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]:
"""손 줄 → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
"""새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
out = []
for hand in (user.get("bill") or {}).get("rows") or []:
if not str(hand.get("key") or "").startswith("hand:"):
continue
price = ref_price(hand.get("ref"), books, hopyo, hand["key"])
ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None
why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND
@@ -173,12 +182,51 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list
return out
def _covered(
item: dict[str, Any], prices: Any, cover: dict | None, books: dict, hopyo: dict
) -> tuple[dict[str, Any], Any]:
"""자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · `auto_{칸}` = 자동값 ·
연결 덮개 = 그 단가(자재 줄 = 단가 주인 대비표라 연결 안 받음)."""
if not cover:
return item, prices
out = dict(item)
for field in FIELDS:
if field not in cover or (field == "ref" and item["key"].startswith("mat:")):
continue
if field == "qty":
out["auto_qty"] = num(item.get("qty"))
elif field == "ref":
out["auto_ref"] = item.get("ref")
else:
out[f"auto_{field}"] = item.get(field) or ""
out[field] = cover[field]
if "auto_ref" in out:
prices = ref_price(cover["ref"], books, hopyo, item["key"])
tab = (prices or {}).get("tab")
out["ref"] = {"tab": tab, "key": prices["key"]} if tab else None
out["note"] = (prices or {}).get("no") or ""
if (cover["ref"] or {}).get("tab") == "logic":
out["why"] = WHY_LOGIC
return out, prices
def _title_row(code: str, name: str, parent: str | None, cover: dict | None) -> dict[str, Any]:
"""기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 `no`(차례 자동)."""
row = {"kind": "title", "key": f"sec:{code}", "section": code, "name": name, "auto": True}
if cover and "name" in cover:
row["auto_name"] = name
row["name"] = cover["name"] or ""
row["parent"] = f"sec:{parent}" if parent else None
row["_home"] = row["parent"]
return row
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
"""묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`)."""
"""새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합."""
return {
"kind": "group",
"key": item["key"],
"section": item["section"],
"section": item.get("section"),
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
@@ -186,51 +234,17 @@ def _group_row(item: dict[str, Any]) -> dict[str, Any]:
"note": "",
"ref": None,
"hand": True,
"sum": 0.0,
"labor": 0.0,
"material": 0.0,
"expense": 0.0,
"blank": 0,
}
def _nest(rows: list[dict[str, Any]]) -> None:
"""182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액."""
groups = {row["key"]: row for row in rows if row["kind"] == "group"}
for row in rows:
if not row.get("hand"):
continue
up = groups.get(row.get("parent") or "")
if up is None or up["section"] != row["section"]:
row["parent"] = None
continue
at, n = up, 0
while at is not None and n <= len(groups):
if at["key"] == row["key"]:
row["parent"] = None
break
at, n = groups.get(at.get("parent") or ""), n + 1
for row in rows:
chain = []
at = groups.get(row.get("parent") or "")
while at is not None:
chain.append(at)
at = groups.get(at.get("parent") or "")
depth = 2 if next(p for c, _n, p in SECTIONS if c == row["section"]) else 1
row["level"] = depth if row["kind"] == "title" else depth + 1 + len(chain)
if row["kind"] != "item":
continue
for g in chain:
for p in ("sum", *PARTS):
g[p] += row[p] or 0
if row["sum"] is None:
g["blank"] += 1
for g in groups.values():
g["note"] = f"빈 줄 {g['blank']}" if g["blank"] else ""
def _hand_row(item: dict[str, Any], prices: Any, why: str) -> dict[str, Any]:
if item.get("kind") == "group":
return _group_row(item)
return _item_row({**item, "why": why}, prices)
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
"""옛 꼴 손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
if at is None and after == f"sec:{hand['section']}":
at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None)
@@ -239,21 +253,23 @@ def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> Non
def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]:
"""구조물 열 → 대가 블록마다 한 줄 — 수량 = 그 블록 사본 주인 값 합(ROUND 3) ·
대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례."""
대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례 ·
줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄)."""
groups: dict[str, dict[str, Any]] = {}
for owner, value in (item.get("owners") or {}).items():
price = price_of(owner)
tag = str(price.get("no") or "") if price else ""
tag = str(price.get("key") or price.get("no") or "") if price else ""
group = groups.setdefault(tag, {"qty": 0.0, "price": price, "owners": []})
group["qty"] += num(value) or 0
group["owners"].append(owner)
out = []
for tag, group in groups.items():
no = str((group["price"] or {}).get("no") or "")
part = {
**item,
"key": f"{item['key']}#{tag}" if tag else item["key"],
"qty": round_half(group["qty"], 3),
"note": " · ".join(t for t in (tag, item.get("note") or "") if t),
"note": " · ".join(t for t in (no, item.get("note") or "") if t),
"owners": {o: item["owners"][o] for o in group["owners"]},
}
if group["price"] and group["price"].get("key"): # 대가 블록(145-2) — 이동 · 집계표 역전개
@@ -262,6 +278,114 @@ def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict,
return out
def _order_old(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list:
"""옛 꼴(166-2 · 182) — 손 줄 = `after` 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목)."""
groups = {i["key"]: i for i, _p, _w in hands if i.get("kind") == "group"}
for item, prices, why in hands:
row = _hand_row(item, prices, why)
up = groups.get(item.get("parent") or "")
row["parent"] = up["key"] if up and up.get("section") == item.get("section") else None
_place(items, row, item.get("after") or "")
out = []
for title in titles:
out.append(title)
for row in items:
if row["section"] == title["section"]:
row["_home"] = title["key"]
row["parent"] = row.get("parent") or title["key"]
out.append(row)
return out
def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list:
"""184 v2 — 저장 차례 · 부모 · `gone` 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」."""
auto = {row["key"]: row for row in titles}
for row in items:
row["_home"] = f"sec:{row['section']}"
auto[row["key"]] = row
gone = {k for k in bill.get("gone") or [] if k != "sec:G" and not k.startswith("mat:")}
new = {item["key"]: (item, prices, why) for item, prices, why in hands}
out, seen = [], set()
for saved in bill.get("rows") or []:
key = saved["key"]
if key in seen:
continue
if key in new:
row = _hand_row(*new[key])
row["_home"] = None
elif key in auto and key not in gone:
row = auto[key]
else:
continue
seen.add(key)
row["parent"] = saved.get("parent")
out.append(row)
for row in [*titles, *items]:
if row["key"] in seen or row["key"] in gone:
continue
row["parent"] = row["_home"]
if row["kind"] == "item":
row["note"] = " · ".join(t for t in (row["note"], NEW_AUTO) if t)
out.append(row)
return out
def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section
(깊이 5 넘음 = 레벨 4 조상 아래)."""
by_key = {row["key"]: row for row in order}
def loops(row: dict[str, Any]) -> bool:
at, n = by_key.get(row["parent"] or ""), 0
while at is not None and n <= len(order):
if at is row:
return True
at, n = by_key.get(at["parent"] or ""), n + 1
return False
def root(row: dict[str, Any]) -> dict[str, Any]:
while row["parent"]:
row = by_key[row["parent"]]
return row
for row in order:
up = by_key.get(row.get("parent") or "")
if row["key"] == "sec:G":
row["parent"] = None
elif up is None or up["kind"] == "item" or up is row:
row["parent"] = row["_home"] if row["_home"] in by_key else None
for row in order:
if loops(row):
row["parent"] = row["_home"] if row["_home"] in by_key else None
if loops(row):
row["parent"] = None
for row in order:
if (
row["key"].startswith("mat:")
and row["_home"] == "sec:G"
and root(row)["key"] != "sec:G"
):
row["parent"] = "sec:G"
kids: dict[Any, list[dict[str, Any]]] = {}
for row in order:
kids.setdefault(row["parent"], []).append(row)
rows: list[dict[str, Any]] = []
stack: list[tuple[dict, list]] = [(row, []) for row in reversed(kids.get(None, []))]
while stack:
row, chain = stack.pop()
if len(chain) >= DEPTH:
chain = chain[: DEPTH - 1]
row["parent"] = chain[-1]["key"]
row["level"] = len(chain) + 1
row["cost"] = "supply" if (chain[0] if chain else row)["key"] == "sec:G" else "direct"
if row["kind"] != "title":
row["section"] = chain[-1]["section"] if chain else None
rows.append(row)
stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], [])))
return rows
def compute_bill(
inputs: dict[str, Any],
user: dict[str, Any],
@@ -272,30 +396,30 @@ def compute_bill(
"""`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) —
주면 구조물 열을 블록마다 나눔 ·
`books` = 호표 목록(`daega` · `sg_list` · `sg_hand` · 166-2)."""
gaps: list[dict[str, str]] = []
hopyo = inputs.get("hopyo") or {}
books = books or {}
qty_of = (user.get("bill") or {}).get("qty") or {}
bill = user.get("bill") or {}
v2 = bill.get("v") == 2
if v2:
cover = {r["key"]: r for r in bill.get("rows") or [] if not r["key"].startswith("hand:")}
else:
cover = {k: {"qty": v} for k, v in (bill.get("qty") or {}).items()}
items: list[dict[str, Any]] = []
for item in (inputs.get("bill") or {}).get("items") or []:
if price_of is not None and item.get("owners"):
for part, prices in split_by_block(item, price_of):
items.append(_item_row(part, prices, gaps, qty_of))
continue
prices = ref_price(item.get("ref"), books, hopyo)
items.append(_item_row(item, prices, gaps, qty_of))
parts = split_by_block(item, price_of)
else:
parts = [(item, ref_price(item.get("ref"), books, hopyo))]
for part, prices in parts:
items.append(_item_row(*_covered(part, prices, cover.get(part["key"]), books, hopyo)))
for item in _material_items(inputs, base["compare"]):
price = item["_price"]
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
items.append(_item_row(item, prices, gaps, qty_of))
for item, prices, why in hand_items(user, books, hopyo):
if item.get("kind") == "group":
row = _group_row(item)
else:
row = _item_row({**item, "why": why}, prices, gaps)
row["parent"] = item.get("parent")
_place(items, row, item.get("after") or "")
owner = [row for row in items if row["section"] == "G" and row["kind"] == "item"]
items.append(_item_row(*_covered(item, prices, cover.get(item["key"]), books, hopyo)))
titles = [_title_row(c, n, p, cover.get(f"sec:{c}")) for c, n, p in SECTIONS]
hands = hand_items(user, books, hopyo)
rows = _tree((_order_v2 if v2 else _order_old)(bill, titles, items, hands))
owner = [row for row in rows if row["kind"] == "item" and row["cost"] == "supply"]
net = 0.0
for row in owner:
net += row["material"] or 0
@@ -311,6 +435,7 @@ def compute_bill(
"qty": pct,
"note": "관급 순자재대 밑수",
"ref": None,
"auto": True,
"price_labor": 0,
"price_material": net,
"price_expense": 0,
@@ -319,53 +444,54 @@ def compute_bill(
"material": fee,
"expense": 0 if pct is not None else None,
"sum": fee,
"parent": "sec:G",
"level": 2,
"cost": "supply",
"_gaps": [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")]
if pct is None
else [],
}
if owner:
items.append(fee_row)
if pct is None:
gaps.append(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"))
rows: list[dict[str, Any]] = []
sums: dict[str, dict[str, Any]] = {}
for code, name, parent in SECTIONS:
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
for row in items:
if owner: # 조달수수료 = `sec:G` 끝 고정
at = next(n for n, row in enumerate(rows) if row["key"] == "sec:G") + 1
while at < len(rows) and rows[at]["level"] > 1:
at += 1
rows.insert(at, fee_row)
by_key = {row["key"]: row for row in rows}
for row in rows:
if row["kind"] != "item":
continue
code = row["section"]
chain = [code]
parent = next(p for c, _n, p in SECTIONS if c == code)
if parent:
chain.append(parent)
for c in chain:
for p in ("sum", *PARTS):
sums[c][p] += row[p] or 0
if row["sum"] is None:
sums[c]["blank"] += 1
for code, name, parent in SECTIONS:
s = sums[code]
rows.append(
{
"kind": "title",
"key": f"sec:{code}",
"section": code,
"name": f"{code} {name}" if code != "G" else name,
"sum": s["sum"],
"labor": s["labor"],
"material": s["material"],
"expense": s["expense"],
"blank": s["blank"],
"note": ("(제외금액)" if code == "G" else "")
+ (f" 빈 줄 {s['blank']}" if s["blank"] else ""),
}
)
rows.extend(row for row in items if row["section"] == code)
_nest(rows)
row.update({"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0})
direct = {p: 0.0 for p in ("sum", *PARTS)}
blank = 0
for code in DIRECT:
for p in direct:
direct[p] += sums[code][p]
blank += sums[code]["blank"]
for row in rows:
if row["kind"] != "item":
continue
at = by_key.get(row["parent"] or "")
while at is not None:
for p in ("sum", *PARTS):
at[p] += row[p] or 0
if row["sum"] is None:
at["blank"] += 1
at = by_key.get(at["parent"] or "")
if row["cost"] == "direct":
for p in direct:
direct[p] += row[p] or 0
if row["sum"] is None:
blank += 1
gaps: list[dict[str, str]] = []
seq: dict[Any, int] = {}
for row in rows:
gaps.extend(row.pop("_gaps", []))
row.pop("_home", None)
row["no"] = ""
if row["kind"] == "title":
empty = f" 빈 줄 {row['blank']}" if row["blank"] else ""
row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty
elif row["kind"] == "group":
row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else ""
if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2:
up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
seq[up] = seq.get(up, 0) + 1
row["no"] = f"{by_key[up]['no']}.{seq[up]}" if up else str(seq[up])
totals = {
"direct": {**direct, "blank": blank},
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
+239 -112
View File
@@ -43,8 +43,11 @@ export const SECTIONS: Array<[string, string, string | null]> = [
["4", "자재대", null],
["G", "관급자재대", null],
];
const DIRECT = ["1", "2", "3", "4"];
const PARTS = ["labor", "material", "expense"];
/** 184 덮개로 고칠 칸 · 묶음 깊이 한도 */
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
const DEPTH = 5;
const NEW_AUTO = "새 자동 줄";
function amounts(qty: Num, prices: Row | null): Row {
const unit: Record<string, Num> = {};
@@ -60,30 +63,27 @@ function amounts(qty: Num, prices: Row | null): Row {
return out;
}
function itemRow(item: Row, prices: Row | null, gaps: Gap[], qtyOf: Row | null = null): Row {
let qty = num(item.qty);
const over: Row = {};
// 166-2 수량 덮어쓰기
if (qtyOf && item.key in qtyOf && !item.hand) {
over.qty_auto = qty;
qty = num(qtyOf[item.key]);
}
/** 항목 줄 — 「연결 없음」은 _gaps 에 담아 둠(보이는 줄만 표 차례로 모음) */
function itemRow(item: Row, prices: Row | null): Row {
const qty = num(item.qty);
const row: Row = {
kind: "item",
key: item.key,
section: item.section,
section: item.section ?? null,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
qty,
note: item.note || "",
ref: item.ref ?? null,
...over,
...(item.hand ? { hand: true } : {}),
...Object.fromEntries(Object.entries(item).filter(([k]) => k.startsWith("auto_"))),
...(item.hand ? { hand: true } : { auto: true }),
...amounts(qty, prices),
};
if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
if (prices === null) gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
row._gaps = [];
if (qty === null) row._gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
if (prices === null)
row._gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
return row;
}
@@ -133,10 +133,11 @@ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""):
return key ? (hopyo[key] ?? null) : null;
}
/** 손 줄 → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> {
const out: Array<[Row, Row | null, string]> = [];
for (const hand of (user.bill || {}).rows || []) {
if (!String(hand.key || "").startsWith("hand:")) continue;
const price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
const ref = price?.tab ? { tab: price.tab, key: price.key } : null;
const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND;
@@ -145,12 +146,51 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row |
return out;
}
/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */
/** 자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · auto_{칸} = 자동값 · 연결 덮개 = 그 단가(자재 줄 = 연결 안 받음) */
function covered(
item: Row,
prices: Row | null,
cover: Row | undefined,
books: Row,
hopyo: Row,
): [Row, Row | null] {
if (!cover) return [item, prices];
const out: Row = { ...item };
for (const field of FIELDS) {
if (!(field in cover) || (field === "ref" && item.key.startsWith("mat:"))) continue;
if (field === "qty") out.auto_qty = num(item.qty);
else if (field === "ref") out.auto_ref = item.ref ?? null;
else out[`auto_${field}`] = item[field] || "";
out[field] = cover[field];
}
if ("auto_ref" in out) {
prices = refPrice(cover.ref, books, hopyo, item.key);
const tab = prices?.tab;
out.ref = tab ? { tab, key: prices!.key } : null;
out.note = prices?.no || "";
if (cover.ref?.tab === "logic") out.why = WHY_LOGIC;
}
return [out, prices];
}
/** 기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 no(차례 자동) */
function titleRow(code: string, name: string, parent: string | null, cover: Row | undefined): Row {
const row: Row = { kind: "title", key: `sec:${code}`, section: code, name, auto: true };
if (cover && "name" in cover) {
row.auto_name = name;
row.name = cover.name || "";
}
row.parent = parent ? `sec:${parent}` : null;
row._home = row.parent;
return row;
}
/** 새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합 */
function groupRow(item: Row): Row {
return {
kind: "group",
key: item.key,
section: item.section,
section: item.section ?? null,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
@@ -158,50 +198,14 @@ function groupRow(item: Row): Row {
note: "",
ref: null,
hand: true,
sum: 0,
labor: 0,
material: 0,
expense: 0,
blank: 0,
};
}
/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */
function nest(rows: Row[]): void {
const groups = new Map<string, Row>();
for (const row of rows) if (row.kind === "group") groups.set(row.key, row);
for (const row of rows) {
if (!row.hand) continue;
const up = groups.get(row.parent ?? "");
if (!up || up.section !== row.section) {
row.parent = null;
continue;
}
let at: Row | undefined = up;
for (let n = 0; at && n <= groups.size; n += 1) {
if (at.key === row.key) {
row.parent = null;
break;
}
at = groups.get(at.parent ?? "");
}
}
for (const row of rows) {
const chain: Row[] = [];
for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? ""))
chain.push(at);
const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1;
row.level = row.kind === "title" ? depth : depth + 1 + chain.length;
if (row.kind !== "item") continue;
for (const g of chain) {
for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0;
if (row.sum === null) g.blank += 1;
}
}
for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : "";
function handRow(item: Row, prices: Row | null, why: string): Row {
return item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices);
}
/** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
/** 옛 꼴 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
function place(items: Row[], hand: Row, after: string): void {
let at = items.findIndex((row) => row.key === after);
if (at >= 0) at += 1;
@@ -209,12 +213,13 @@ function place(items: Row[], hand: Row, after: string): void {
items.splice(at < 0 ? items.length : at, 0, hand);
}
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) ·
* 줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄) */
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
const groups = new Map<string, Row>();
for (const [owner, value] of Object.entries(item.owners || {})) {
const price = priceOf(owner);
const tag = price ? String(price.no || "") : "";
const tag = price ? String(price.key || price.no || "") : "";
if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] });
const group = groups.get(tag)!;
group.qty += num(value) || 0;
@@ -222,11 +227,12 @@ export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | nul
}
const out: Array<[Row, Row | null]> = [];
for (const [tag, group] of groups) {
const no = String(group.price?.no || "");
const part: Row = {
...item,
key: tag ? `${item.key}#${tag}` : item.key,
qty: roundHalf(group.qty, 3),
note: [tag, item.note || ""].filter((t) => t).join(" · "),
note: [no, item.note || ""].filter((t) => t).join(" · "),
owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])),
};
// 대가 블록(145-2) — 이동 · 집계표 역전개
@@ -236,6 +242,121 @@ export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | nul
return out;
}
type Hands = Array<[Row, Row | null, string]>;
/** 옛 꼴(166-2 · 182) — 손 줄 = after 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목) */
function orderOld(_bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
const groups = new Map<string, Row>();
for (const [item] of hands) if (item.kind === "group") groups.set(item.key, item);
for (const [item, prices, why] of hands) {
const row = handRow(item, prices, why);
const up = groups.get(item.parent ?? "");
row.parent = up && (up.section ?? null) === (item.section ?? null) ? up.key : null;
place(items, row, item.after || "");
}
const out: Row[] = [];
for (const title of titles) {
out.push(title);
for (const row of items) {
if (row.section !== title.section) continue;
row._home = title.key;
row.parent = row.parent || title.key;
out.push(row);
}
}
return out;
}
/** 184 v2 — 저장 차례 · 부모 · gone 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」 */
function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
const auto = new Map<string, Row>();
for (const row of titles) auto.set(row.key, row);
for (const row of items) {
row._home = `sec:${row.section}`;
auto.set(row.key, row);
}
const gone = new Set<string>(
(bill.gone || []).filter((k: string) => k !== "sec:G" && !k.startsWith("mat:")),
);
const fresh = new Map<string, [Row, Row | null, string]>();
for (const hand of hands) fresh.set(hand[0].key, hand);
const out: Row[] = [];
const seen = new Set<string>();
for (const saved of bill.rows || []) {
const key = saved.key;
if (seen.has(key)) continue;
let row: Row;
if (fresh.has(key)) {
row = handRow(...fresh.get(key)!);
row._home = null;
} else if (auto.has(key) && !gone.has(key)) row = auto.get(key)!;
else continue;
seen.add(key);
row.parent = saved.parent ?? null;
out.push(row);
}
for (const row of [...titles, ...items]) {
if (seen.has(row.key) || gone.has(row.key)) continue;
row.parent = row._home;
if (row.kind === "item") row.note = [row.note, NEW_AUTO].filter((t) => t).join(" · ");
out.push(row);
}
return out;
}
/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
* 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */
function tree(order: Row[]): Row[] {
const byKey = new Map<string, Row>();
for (const row of order) byKey.set(row.key, row);
const home = (row: Row) => (row._home != null && byKey.has(row._home) ? row._home : null);
const loops = (row: Row): boolean => {
let at = byKey.get(row.parent ?? "");
for (let n = 0; at && n <= order.length; n += 1) {
if (at === row) return true;
at = byKey.get(at.parent ?? "");
}
return false;
};
const root = (row: Row): Row => {
while (row.parent) row = byKey.get(row.parent)!;
return row;
};
for (const row of order) {
const up = byKey.get(row.parent ?? "");
if (row.key === "sec:G") row.parent = null;
else if (!up || up.kind === "item" || up === row) row.parent = home(row);
}
for (const row of order) {
if (!loops(row)) continue;
row.parent = home(row);
if (loops(row)) row.parent = null;
}
for (const row of order)
if (row.key.startsWith("mat:") && row._home === "sec:G" && root(row).key !== "sec:G")
row.parent = "sec:G";
const kids = new Map<string | null, Row[]>();
for (const row of order) {
if (!kids.has(row.parent)) kids.set(row.parent, []);
kids.get(row.parent)!.push(row);
}
const rows: Row[] = [];
const stack: Array<[Row, Row[]]> = [...(kids.get(null) || [])].reverse().map((r) => [r, []]);
while (stack.length) {
let [row, chain] = stack.pop()!;
if (chain.length >= DEPTH) {
chain = chain.slice(0, DEPTH - 1);
row.parent = chain[chain.length - 1].key;
}
row.level = chain.length + 1;
row.cost = (chain.length ? chain[0] : row).key === "sec:G" ? "supply" : "direct";
if (row.kind !== "title") row.section = chain.length ? chain[chain.length - 1].section : null;
rows.push(row);
for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]);
}
return rows;
}
export function computeBill(
inputs: Row,
user: Row,
@@ -243,31 +364,32 @@ export function computeBill(
priceOf: PriceOf | null = null,
books: Row | null = null,
): Row {
const gaps: Gap[] = [];
const hopyo: Row = inputs.hopyo || {};
const book: Row = books || {};
const qtyOf: Row = (user.bill || {}).qty || {};
const bill: Row = user.bill || {};
const v2 = bill.v === 2;
const cover: Record<string, Row> = {};
if (v2) {
for (const r of bill.rows || []) if (!r.key.startsWith("hand:")) cover[r.key] = r;
} else for (const [k, v] of Object.entries(bill.qty || {})) cover[k] = { qty: v };
const items: Row[] = [];
for (const item of (inputs.bill || {}).items || []) {
if (priceOf !== null && item.owners && Object.keys(item.owners).length) {
for (const [part, prices] of splitByBlock(item, priceOf))
items.push(itemRow(part, prices, gaps, qtyOf));
continue;
}
const prices = refPrice(item.ref ?? null, book, hopyo);
items.push(itemRow(item, prices, gaps, qtyOf));
const parts: Array<[Row, Row | null]> =
priceOf !== null && item.owners && Object.keys(item.owners).length
? splitByBlock(item, priceOf)
: [[item, refPrice(item.ref ?? null, book, hopyo)]];
for (const [part, prices] of parts)
items.push(itemRow(...covered(part, prices, cover[part.key], book, hopyo)));
}
for (const item of materialItems(inputs, base.compare)) {
const price: Num = item._price;
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
items.push(itemRow(item, prices, gaps, qtyOf));
items.push(itemRow(...covered(item, prices, cover[item.key], book, hopyo)));
}
for (const [item, prices, why] of handItems(user, book, hopyo)) {
const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps);
row.parent = item.parent ?? null;
place(items, row, item.after || "");
}
const owner = items.filter((row) => row.section === "G" && row.kind === "item");
const titles = SECTIONS.map(([c, n, p]) => titleRow(c, n, p, cover[`sec:${c}`]));
const hands = handItems(user, book, hopyo);
const rows = tree((v2 ? orderV2 : orderOld)(bill, titles, items, hands));
const owner = rows.filter((row) => row.kind === "item" && row.cost === "supply");
let net = 0;
for (const row of owner) net += row.material || 0;
const pct = num((user.estimate || {}).procurement_fee_pct);
@@ -282,6 +404,7 @@ export function computeBill(
qty: pct,
note: "관급 순자재대 밑수",
ref: null,
auto: true,
price_labor: 0,
price_material: net,
price_expense: 0,
@@ -290,48 +413,52 @@ export function computeBill(
material: fee,
expense: pct !== null ? 0 : null,
sum: fee,
parent: "sec:G",
level: 2,
cost: "supply",
_gaps: pct === null ? [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")] : [],
};
if (owner.length) {
items.push(feeRow);
if (pct === null) gaps.push(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"));
// 조달수수료 = sec:G 끝 고정
let at = rows.findIndex((row) => row.key === "sec:G") + 1;
while (at < rows.length && rows[at].level > 1) at += 1;
rows.splice(at, 0, feeRow);
}
const sums: Record<string, Row> = {};
for (const [code] of SECTIONS)
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
for (const row of items) {
if (row.kind !== "item") continue;
const code = row.section;
const chain = [code];
const parent = SECTIONS.find(([c]) => c === code)![2];
if (parent) chain.push(parent);
for (const c of chain) {
for (const p of ["sum", ...PARTS]) sums[c][p] += row[p] || 0;
if (row.sum === null) sums[c].blank += 1;
}
}
const rows: Row[] = [];
for (const [code, name] of SECTIONS) {
const s = sums[code];
rows.push({
kind: "title",
key: `sec:${code}`,
section: code,
name: code !== "G" ? `${code} ${name}` : name,
sum: s.sum,
labor: s.labor,
material: s.material,
expense: s.expense,
blank: s.blank,
note: (code === "G" ? "(제외금액)" : "") + (s.blank ? ` 빈 줄 ${s.blank}` : ""),
});
rows.push(...items.filter((row) => row.section === code));
}
nest(rows);
const byKey = new Map<string, Row>();
for (const row of rows) byKey.set(row.key, row);
for (const row of rows)
if (row.kind !== "item")
Object.assign(row, { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 });
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
let blank = 0;
for (const code of DIRECT) {
for (const p of Object.keys(direct)) direct[p] += sums[code][p];
blank += sums[code].blank;
for (const row of rows) {
if (row.kind !== "item") continue;
for (let at = byKey.get(row.parent ?? ""); at; at = byKey.get(at.parent ?? "")) {
for (const p of ["sum", ...PARTS]) at[p] += row[p] || 0;
if (row.sum === null) at.blank += 1;
}
if (row.cost === "direct") {
for (const p of Object.keys(direct)) direct[p] += row[p] || 0;
if (row.sum === null) blank += 1;
}
}
const gaps: Gap[] = [];
const seq = new Map<string | null, number>();
for (const row of rows) {
gaps.push(...(row._gaps || []));
delete row._gaps;
delete row._home;
row.no = "";
if (row.kind === "title")
row.note =
(row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : "");
else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : "";
if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) {
// 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
const up = row.parent;
seq.set(up, (seq.get(up) || 0) + 1);
row.no = up ? `${byKey.get(up)!.no}.${seq.get(up)}` : String(seq.get(up));
}
}
const totals = {
direct: { ...direct, blank },
+2 -1
View File
@@ -483,7 +483,8 @@ def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str,
continue
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
out.append((row["key"], f"수기 줄 {row.get('name') or row['key']}", src))
where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개
out.append((row["key"], f"{where} {row.get('name') or ref.get('name') or row['key']}", src))
return out
+42 -2
View File
@@ -110,10 +110,50 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
return None
def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
"""184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 ·
자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키."""
rows, seen = [], set()
for row in raw.get("rows") if isinstance(raw.get("rows"), list) else []:
key = row.get("key") if isinstance(row, dict) else None
if not isinstance(key, str) or not key or key in seen or key == "fee":
continue
seen.add(key)
parent = row.get("parent")
out: dict[str, Any] = {"key": key}
if isinstance(parent, str) and parent:
out["parent"] = parent
if key.startswith("hand:"):
group = row.get("kind") == "group"
if group:
out["kind"] = "group"
if row.get("cost") == "direct":
out["cost"] = "direct"
out.update({k: _text(row.get(k)) for k in ("name", "spec", "unit")})
out["qty"] = None if group else _num(row.get("qty"))
out["ref"] = None if group else _clean_ref(row.get("ref"))
else:
for k in ("name", "spec", "unit"):
if k in row:
out[k] = None if row[k] is None else _text(row[k])
if "qty" in row and (row["qty"] is None or _num(row["qty"]) is not None):
out["qty"] = _num(row["qty"])
if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None):
out["ref"] = _clean_ref(row["ref"])
rows.append(out)
given = raw.get("gone") if isinstance(raw.get("gone"), list) else []
gone = list(
dict.fromkeys(k for k in given if isinstance(k, str) and k[:5] not in ("", "hand:"))
)
return {"v": 2, "rows": rows, "gone": gone} if rows or gone else {}
def clean_bill(raw: Any) -> dict[str, Any]:
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) ·
182 레벨 = `kind`(group) · `parent`(`hand:` 키)."""
"""설계내역서 덮개 — 184 v2(`_clean_v2`) · 옛 꼴(166-2 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 ·
아는 공종만) · 182 레벨 = `kind`(group) · `parent`(`hand:` 키))."""
raw = raw if isinstance(raw, dict) else {}
if raw.get("v") == 2:
return _clean_v2(raw)
qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {}
codes = {code for code, _name, _parent in SECTIONS}
rows = []
@@ -131,3 +131,8 @@
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent {
padding-left: calc(var(--spacing-8) + 2em);
}
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
.b09-estimation .ui-data-table__table tbody td.is-edited {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
}
+17 -1
View File
@@ -15,6 +15,7 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import {
createButton,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
@@ -298,6 +299,21 @@ export function renderB09Estimation(root: HTMLElement): void {
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
});
refresh.dataset.b09Refresh = "";
// 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역
const reset = createButton({
label: "내역 초기화",
variant: "ghost",
onClick: () =>
void showConfirmDialog(
"설계내역서에서 고친 칸 · 더한 줄 · 지운 줄 · 차례를 모두 지우고 기본 로직 내역으로 되돌립니다. 바로 저장됩니다.",
"초기화",
).then((ok) => {
if (!ok) return;
const { bill: _bill, ...rest } = user;
void run("/estimation", "PUT", "설계내역서를 초기값으로 되돌렸습니다.", { user: rest });
}),
});
reset.dataset.b09BillReset = "";
const section = document.createElement("section");
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
const heading = document.createElement("h3");
@@ -306,7 +322,7 @@ export function renderB09Estimation(root: HTMLElement): void {
section.append(heading, made, refresh);
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(save);
actions.append(save, reset);
const left = document.createElement("div");
left.className = "ui-sidebar-panel b09-options";
left.append(section, actions);
+37 -26
View File
@@ -1,14 +1,14 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill.ts
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
* B09 설계내역서 탭(PLAN 145-3 · 184-3) — 실무 엑셀 꼴 칸(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
*
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
* 초기값 = 기본 로직이 B08 수량으로 짠 줄 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(184 · `…_UI_Tabs_Bill_Edit`)
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`)
* 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침
* 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호)
* 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS)
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -16,25 +16,32 @@ import type { Row } from "./B09_Estimation_Calc_Base";
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
import {
billMenu,
editQty,
editText,
handEditable,
editCell,
linkable,
openLinkMenu,
type BillTable,
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
const TEXT_KEYS = ["name", "spec", "unit"];
export const buildBill: TabBuild = (ctx): BillTable => {
const { sheets, user, setUser } = ctx;
const bill: Row = sheets.bill || { rows: [], totals: null };
const rows: DataRow[] = bill.rows.map((r: Row) => ({
// 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례)
const raw: DataRow[] = bill.rows;
const rows: DataRow[] = raw.map((r: Row) => ({
...r,
connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "",
name: r.no ? `${r.no} ${r.name}` : r.name,
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "",
}));
const direct: Row = bill.totals?.direct || {};
const pair = (key: string, label: string) => [
money(`price_${key}`, "단가", [label]),
money(key, "금액", [label]),
];
/** 고친 칸 — 셈 줄에 자동 값(auto_칸)이 있음 · 연결 칸 = auto_ref */
const edited = (r: DataRow | undefined, key: string): boolean =>
!!r && `auto_${key === "connect" ? "ref" : key}` in r;
return {
columns: [
text("name", "공종 · 명칭", 220),
@@ -52,41 +59,45 @@ export const buildBill: TabBuild = (ctx): BillTable => {
total: { name: "순공사비 계(관급 제외)", ...direct },
frozen: 1,
hint:
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
"레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " +
"기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " +
"금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " +
"조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " +
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
editable: (i, key) => {
const r = rows[i];
if (r?.kind === "group") return key === "name";
if (!r || r.kind !== "item") return false;
return r.hand ? handEditable(key) : key === "qty";
if (!r) return false;
if (r.key === "fee") return key === "qty";
if (r.kind !== "item") return key === "name";
return key === "qty" || TEXT_KEYS.includes(key);
},
onEdit: (i, key, value) => {
const r = rows[i];
if (!r) return;
if (r.hand && key !== "qty") return editText(ctx, String(r.key), key, value);
if (key !== "qty") return;
// 번호 = 셈이 붙임 — 고친 이름 앞 번호는 뗌
const head = `${r.no} `;
if (key === "name" && r.no && value.startsWith(head)) value = value.slice(head.length);
if (key !== "qty") return editCell(ctx, raw, i, key, value.trim());
const v = parseNumber(value);
if (v === undefined) return;
if (r.key !== "fee") return editQty(ctx, r, v);
if (r.key !== "fee") return editCell(ctx, raw, i, key, v);
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
},
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
tip: (i, key) => {
const r = rows[i];
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
if (!r || !edited(r, key)) return null;
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
},
// 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게
rowClass: (i) => {
const r = rows[i];
if (!r) return null;
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" ");
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
},
menu: (i) => billMenu(ctx, rows, i),
menu: (i) => billMenu(ctx, raw, i),
onCell: (i, key) => {
if (key === "connect") openLinkMenu(ctx, rows, i);
if (key === "connect") openLinkMenu(ctx, raw, i);
},
link: (i, key) => {
if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함)
+266 -287
View File
@@ -1,14 +1,16 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Edit.ts
* B09 설계내역서 손질(PLAN 166-2) — 우클릭 메뉴 · 손 줄 더하기 · 지우기 · 연결 고르기 · 수량 덮어쓰기.
* B09 설계내역서 손질(PLAN 166-2 · 182 · 184-3) — 모든 줄 우클릭 메뉴 · 칸 고침 · 연결 · 차례 · 레벨.
*
* 저장 = 사용자 값 `user.bill`(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
* qty{줄 키: 수} = 자동 줄 수량 덮어쓰기(빈칸 · [수량 자동으로] = 키 지움 → B08 값)
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
* 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 ·
* 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴
* 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
* {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]}
* 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천)
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct
* 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김)
* 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level ·
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -17,322 +19,300 @@ import {
type MapContextMenu,
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import { showToast } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { openModal } from "@ui/ui_template_modal";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
const NEW_NAME = "새 줄";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */
const MAX_GROUP = 2;
const NEW_SECTION = "새 공종";
/** 고칠 칸 — 자동 줄은 덮개 */
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
/** 깊이 한도 — 1 = 맨 위 공종 */
const MAX_LEVEL = 5;
interface Bill {
qty: Record<string, number>;
v: 2;
rows: Row[];
gone: string[];
}
function billOf(user: Row): Bill {
/** 손질 한 번 = 굳힌 덮개 + 보이는 줄(키 → 셈 출력 줄) */
interface Work {
bill: Bill;
shown: Map<string, DataRow>;
}
/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */
const fixed = (key: string): boolean => key === "fee" || key === "sec:G";
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
export const linkable = (row: DataRow | undefined): boolean =>
row?.kind === "item" && !fixed(String(row.key)) && !String(row.key).startsWith("mat:");
const find = (list: Row[], key: string): number => list.findIndex((e) => e.key === key);
const pull = (list: Row[], key: string): Row => list.splice(find(list, key), 1)[0];
const same = (a: unknown, b: unknown): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
/** 옛 꼴(166-2 · 182) 줄 → v2 칸 — 자동 줄 = 수량 덮어쓰기 · 손 줄 = 모든 칸 */
function oldEntry(row: DataRow, bill: Row): Row {
const key = String(row.key);
if (row.auto) return key in (bill.qty || {}) ? { qty: bill.qty[key] } : {};
const hand: Row = (bill.rows || []).find((h: Row) => h.key === key) || {};
const out: Row = row.kind === "item" ? {} : { kind: "group" };
for (const f of FIELDS) if (f in hand) out[f] = hand[f];
return out;
}
/** 굳힘 — v2 면 그대로 + 덮개에 없는 보이는 줄(새 자동 줄)을 보이는 앞 줄 뒤에 · 옛 꼴이면 보이는 줄로 */
function freeze(user: Row, rows: DataRow[]): Work {
const bill: Row = user.bill || {};
return { qty: { ...(bill.qty || {}) }, rows: (bill.rows || []).map((r: Row) => ({ ...r })) };
const v2 = bill.v === 2;
const list: Row[] = v2 ? (bill.rows || []).map((e: Row) => ({ ...e })) : [];
const gone: string[] = v2 ? [...(bill.gone || [])] : [];
const shown = new Map(rows.map((r) => [String(r.key), r]));
let prev = -1;
for (const row of rows) {
const key = String(row.key);
if (key === "fee") continue; // 셈이 관급 묶음 끝에 둠
let at = find(list, key);
if (at < 0 && !gone.includes(key)) {
const entry: Row = { key, ...(row.parent ? { parent: row.parent } : {}) };
list.splice(prev + 1, 0, v2 ? entry : { ...entry, ...oldEntry(row, bill) });
at = prev + 1;
}
if (at >= 0) prev = at;
}
return { bill: { v: 2, rows: list, gone }, shown };
}
function put(ctx: TabContext, bill: Bill): void {
ctx.setUser({ ...ctx.user, bill });
}
/** 손 줄 고칠 칸 — 명칭 · 규격 · 단위 · 수량 */
export const handEditable = (key: string): boolean => key === "qty" || TEXT_KEYS.includes(key);
/** 수량 고침 — 손 줄 = 그 줄 수량 · 자동 줄 = 덮어쓰기(빈칸 = 자동으로) */
export function editQty(ctx: TabContext, row: DataRow, value: number | null): void {
const bill = billOf(ctx.user);
const key = String(row.key);
if (row.hand) {
const own = bill.rows.find((h) => h.key === key);
if (!own) return;
own.qty = value;
} else if (value === null) delete bill.qty[key];
else bill.qty[key] = value;
put(ctx, bill);
}
/** 손 줄 글 칸(명칭 · 규격 · 단위) */
export function editText(ctx: TabContext, key: string, field: string, value: string): void {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own || !TEXT_KEYS.includes(field)) return;
own[field] = value.trim();
put(ctx, bill);
}
const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0);
/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */
function subtreeEnd(rows: DataRow[], i: number): number {
let k = i + 1;
while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1;
return k;
}
/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */
function reparent(h: Row, parent: unknown): void {
if (parent) h.parent = parent;
else delete h.parent;
}
/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */
function fits(bill: Bill): boolean {
const byKey = new Map(bill.rows.map((h) => [String(h.key), h]));
return bill.rows.every((h) => {
let n = 0;
const seen = new Set([h.key]);
let at = byKey.get(String(h.parent ?? ""));
while (at && at.kind === "group" && !seen.has(at.key)) {
seen.add(at.key);
n += 1;
at = byKey.get(String(at.parent ?? ""));
}
return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP);
});
}
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */
function slot(rows: DataRow[], i: number, below: boolean): Row {
const row = rows[i];
const section = String(row.section);
if (row.kind === "title") return { section, after: `sec:${section}` };
const parent = row.hand && row.parent ? { parent: String(row.parent) } : {};
if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent };
const prev = rows[i - 1];
const same = prev && prev.kind !== "title" && prev.section === section;
return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent };
}
function newRow(ctx: TabContext, at: Row): void {
const bill = billOf(ctx.user);
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
put(ctx, bill);
}
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
newRow(ctx, slot(rows, i, below));
}
/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */
function addChild(ctx: TabContext, rows: DataRow[], i: number): void {
const after = String(rows[subtreeEnd(rows, i) - 1].key);
newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) });
}
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */
function removeRow(ctx: TabContext, key: string): void {
const bill = billOf(ctx.user);
const gone = bill.rows.find((h) => h.key === key);
if (!gone) return;
bill.rows = bill.rows.filter((h) => h.key !== key);
for (const h of bill.rows) if (h.after === key) h.after = gone.after;
for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent);
put(ctx, bill);
}
/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */
function levelAction(
ctx: TabContext,
key: string,
change: (own: Row, bill: Bill) => void,
): (() => void) | null {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own) return null;
change(own, bill);
return fits(bill) ? () => put(ctx, bill) : null;
}
/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */
function indentTarget(rows: DataRow[], i: number): DataRow | null {
const row = rows[i];
for (let j = i - 1; j >= 0; j -= 1) {
const r = rows[j];
if (lv(r) > lv(row)) continue;
return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null;
/** 위로 올라가는 줄 — [자기, 부모, …, 맨 위] */
function chain(list: Row[], key: string): Row[] {
const out: Row[] = [];
let at: Row | undefined = list[find(list, key)];
while (at && out.length <= list.length) {
out.push(at);
at = at.parent ? list[find(list, at.parent)] : undefined;
}
return null;
}
/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */
function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
const key = String(row.key);
const out: MapContextMenuItem[] = [];
const add = (label: string, action: (() => void) | null): void => {
if (action) out.push([label, action]);
};
if (row.kind === "group")
add(
"항목으로 바꾸기",
levelAction(ctx, key, (own, bill) => {
delete own.kind;
for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent);
}),
);
else
add(
"묶음 줄로 바꾸기",
levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })),
);
const target = indentTarget(rows, i);
if (target)
add(
"들여쓰기 →",
levelAction(ctx, key, (own) => reparent(own, String(target.key))),
);
if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row))
add(
"내어쓰기 ←",
levelAction(ctx, key, (own, bill) =>
reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent),
),
);
return out;
}
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own) return;
own.ref = ref;
if (from && (!own.name || own.name === NEW_NAME)) {
own.name = from.name || own.name;
own.spec = own.spec || from.spec || "";
own.unit = own.unit || from.unit || "";
}
put(ctx, bill);
/** 차례 바로잡음 — 부모 먼저 · 자손 이어서(목록 차례 = 형제 차례) · 못 찾는 부모 · 돎 = 맨 위 */
function tidy(list: Row[]): Row[] {
const keys = new Set(list.map((e) => e.key));
for (const e of list) if (!e.parent || !keys.has(e.parent)) delete e.parent;
for (const e of list) if (chain(list, e.key).length > list.length) delete e.parent;
const out: Row[] = [];
// ponytail: 형제 찾기 O(n²) — 내역 줄 수(수백)면 충분
const walk = (parent?: string): void => {
for (const e of list)
if (e.parent === parent) {
out.push(e);
walk(e.key);
}
};
walk();
return out;
}
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
function pickHopyo(ctx: TabContext, key: string, tab: "dg" | "sg"): void {
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
openModal({
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
closeLabel: "닫기",
mount: (body, close) => {
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
const found = listing.filter(
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
);
finder.hint.textContent = `${found.length} / ${listing.length}`;
finder.setRows(
found.map((r) => ({
key: String(r.key),
tag: label(r),
name: String(r.name || ""),
sub: String(r.spec || ""),
unit: String(r.unit || ""),
onPick: () => {
close();
setRef(ctx, key, { tab, key: r.key }, r);
},
})),
);
};
finder.search.addEventListener("input", find);
find();
body.append(finder.root);
requestAnimationFrame(() => finder.search.focus());
},
});
/** 맨 위 묶음 돈 갈래 — direct(직접비) | supply(관급자재대) */
function costOf(w: Work, list: Row[], key: string): string {
const top = chain(list, key).at(-1);
return top?.cost ?? w.shown.get(String(top?.key))?.cost ?? "direct";
}
/** M01 로직 → 입력값 창 → 손 줄 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
function askInputs(ctx: TabContext, key: string, code: string, name: string, old?: Row): void {
fetchLogicInputs([code]).then(
(got) => {
const logic = got.로직[0] ?? null;
openLaborInput({
title: `${code} ${name}`.trim(),
logic,
values: old?.given || {},
memo: null,
rate: old?.rate ?? null,
readOnly: false,
okLabel: "연결",
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
done: (next) => {
const unit = String(logic?.결과단위 || "")
.replace(/^원\//, "")
.replace(/^[\d.]+/, "");
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
setRef(ctx, key, { ...ref, picks: old?.picks ?? null }, { name, unit });
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
},
});
},
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
/** 바꾼 뒤 지킬 것 — 깊이 5 · 자재 줄 돈 갈래 그대로 */
function fits(w: Work, before: Row[]): boolean {
return w.bill.rows.every(
(e) =>
chain(w.bill.rows, e.key).length <= MAX_LEVEL &&
(!String(e.key).startsWith("mat:") ||
costOf(w, w.bill.rows, e.key) === costOf(w, before, e.key)),
);
}
function pickLogic(ctx: TabContext, key: string): void {
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
/** 손질 시도 — 지킬 것 안이면 [동작] · 아니면 null(메뉴에 안 보임) */
function attempt(
ctx: TabContext,
rows: DataRow[],
change: (w: Work) => boolean | void,
): (() => void) | null {
const w = freeze(ctx.user, rows);
const before = w.bill.rows.map((e) => ({ ...e }));
if (change(w) === false) return null;
w.bill.rows = tidy(w.bill.rows);
return fits(w, before) ? () => put(ctx, w.bill) : null;
}
type Leaf = [string, () => void];
const entryOf = (w: Work, key: string): Row | undefined => w.bill.rows[find(w.bill.rows, key)];
const isGroup = (w: Work, key: string): boolean =>
(entryOf(w, key)?.kind ?? w.shown.get(key)?.kind ?? "item") !== "item";
/** 보이는 형제(차례대로) */
const siblings = (w: Work, parent: unknown): Row[] =>
w.bill.rows.filter((e) => (e.parent ?? null) === (parent ?? null) && w.shown.has(e.key));
/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */
function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] {
const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null;
const more: Leaf[] = [];
if (ref?.tab === "logic")
more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]);
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
];
return [picks, more];
const newKey = (): string =>
`hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newItem = (parent?: string): Row => ({
key: newKey(),
...(parent ? { parent } : {}),
name: NEW_NAME,
spec: "",
unit: "",
qty: null,
ref: null,
});
/** 칸 고침 — 자동 줄은 덮개(자동 값과 같으면 덮개 지움) · 새 줄은 그 칸 */
export function editCell(
ctx: TabContext,
rows: DataRow[],
i: number,
field: string,
value: unknown,
): void {
const row = rows[i];
if (!row || !FIELDS.includes(field)) return;
const w = freeze(ctx.user, rows);
const e = entryOf(w, String(row.key));
if (!e) return;
const auto = `auto_${field}` in row ? row[`auto_${field}`] : row[field];
if (row.auto && same(auto, value)) delete e[field];
else e[field] = value;
put(ctx, w.bill);
}
/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */
/** 연결 적용 — 새 줄이 「새 줄」 · 빈 이름이면 고른 호표 명칭 · 규격 · 단위를 채움 */
function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef {
return (ref, from) => {
const row = rows[i];
const w = freeze(ctx.user, rows);
const e = entryOf(w, String(row?.key));
if (!row || !e) return;
e.ref = ref;
if (from && !row.auto && (!e.name || e.name === NEW_NAME)) {
e.name = from.name || e.name;
e.spec = e.spec || from.spec || "";
e.unit = e.unit || from.unit || "";
}
put(ctx, w.bill);
};
}
/** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */
function linkFor(ctx: TabContext, rows: DataRow[], i: number) {
const row = rows[i];
const e = entryOf(freeze(ctx.user, rows), String(row.key));
const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null);
return linkItems(ctx, ref, applyRef(ctx, rows, i));
}
/** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
if (!row) return [];
const key = String(row.key);
const bill = billOf(ctx.user);
const items: MapContextMenuItem[] = [];
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]);
if (row.hand) {
if (row.kind !== "group") {
const [picks, more] = linkItems(ctx, key);
items.push("-", { label: "연결", children: picks }, ...more);
}
items.push("-", ...levelMenu(ctx, rows, i));
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
} else if (row.kind === "item" && key in bill.qty) {
items.push("-", [
"수량 자동으로 되돌리기",
() => {
delete bill.qty[key];
put(ctx, bill);
},
]);
const parts: MapContextMenuItem[][] = [[], [], [], []];
const add = (part: number, label: string, change: (w: Work, e: Row) => boolean | void): void => {
const run = attempt(ctx, rows, (w) => {
// 조달수수료 = 차례에 없음(셈이 관급자재대 끝에 둠) — 부모만 빌림
const e = entryOf(w, key) ?? (key === "fee" ? { key, parent: row.parent } : undefined);
return e ? change(w, e) : false;
});
if (run) parts[part].push([label, run]);
};
// 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞)
add(0, "위에 줄 추가", (w, e) => {
if (!e.parent) return false;
const at = find(w.bill.rows, key);
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent));
});
add(0, "아래에 줄 추가", (w, e) => {
if (!e.parent || key === "fee") return false;
w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent));
});
add(0, "아래에 하위 줄 추가", (w) => {
if (!isGroup(w, key)) return false;
w.bill.rows.push(newItem(key));
});
add(0, "새 공종 추가(1단)", (w, e) => {
const list = w.bill.rows;
const from = find(list, key) < 0 ? String(e.parent) : key;
const top = chain(list, from).at(-1)!;
const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1);
list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION });
});
if (linkable(row)) {
const [picks, more] = linkFor(ctx, rows, i);
parts[1].push({ label: "연결", children: picks }, ...more);
}
return items;
// 옮김 · 레벨 — 같은 부모 형제와 자손째
if (!fixed(key)) {
add(2, "위로 ↑", (w, e) => {
const sib = siblings(w, e.parent);
const k = sib.indexOf(e);
if (k < 1) return false;
pull(w.bill.rows, key);
w.bill.rows.splice(find(w.bill.rows, sib[k - 1].key), 0, e);
});
add(2, "아래로 ↓", (w, e) => {
const sib = siblings(w, e.parent);
const k = sib.indexOf(e);
if (k < 0 || k + 1 >= sib.length) return false;
pull(w.bill.rows, key);
w.bill.rows.splice(find(w.bill.rows, sib[k + 1].key) + 1, 0, e);
});
add(2, "들여쓰기 →", (w, e) => {
const sib = siblings(w, e.parent);
const up = sib[sib.indexOf(e) - 1];
if (!up || !isGroup(w, up.key)) return false;
e.parent = up.key;
w.bill.rows.push(pull(w.bill.rows, key));
});
add(2, "내어쓰기 ←", (w, e) => {
const up = e.parent ? entryOf(w, e.parent) : undefined;
if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴)
e.parent = up.parent;
pull(w.bill.rows, key);
w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e);
});
}
if (!row.auto && row.kind === "item")
add(2, "묶음 줄로 바꾸기", (_w, e) => {
Object.assign(e, { kind: "group", qty: null, ref: null });
});
if (!row.auto && row.kind !== "item")
add(2, "항목으로 바꾸기", (w, e) => {
if (!e.parent) return false;
delete e.kind;
for (const c of w.bill.rows) if (c.parent === key) c.parent = e.parent;
});
// 자동값으로 — 그 줄 고친 칸 지움(차례 · 부모는 그대로)
if (row.auto && FIELDS.some((f) => `auto_${f}` in row))
add(3, "자동값으로 되돌리기", (_w, e) => {
for (const f of FIELDS) delete e[f];
});
// 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움
if (!fixed(key) && !key.startsWith("mat:"))
add(3, "줄 지우기", (w, e) => {
const list = w.bill.rows;
if (!e.parent && list.some((c) => c.parent === key)) return false;
for (const c of list) if (c.parent === key) c.parent = e.parent;
pull(list, key);
if (row.auto) w.bill.gone.push(key);
});
const out: MapContextMenuItem[] = [];
for (const part of parts)
if (part.length) out.push(...(out.length ? ["-" as const] : []), ...part);
return out;
}
let shared: MapContextMenu | null = null;
@@ -341,10 +321,9 @@ let lastCell: Element | null = null;
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
const row = rows[i];
const box = lastCell?.getBoundingClientRect();
if (!shared || !box || !row?.hand || row.kind !== "item") return;
const [picks, more] = linkItems(ctx, String(row.key));
if (!shared || !box || !linkable(rows[i])) return;
const [picks, more] = linkFor(ctx, rows, i);
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
}
@@ -0,0 +1,104 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Link.ts
* B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) ·
* M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기.
*
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
* 로직 고르기 · 입력값 = M02 창 재사용
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { openModal } from "@ui/ui_template_modal";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
/** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */
export type ApplyRef = (ref: Row | null, from?: Row) => void;
export type Leaf = [string, () => void];
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
function pickHopyo(ctx: TabContext, apply: ApplyRef, tab: "dg" | "sg"): void {
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
openModal({
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
closeLabel: "닫기",
mount: (body, close) => {
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
const found = listing.filter(
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
);
finder.hint.textContent = `${found.length} / ${listing.length}`;
finder.setRows(
found.map((r) => ({
key: String(r.key),
tag: label(r),
name: String(r.name || ""),
sub: String(r.spec || ""),
unit: String(r.unit || ""),
onPick: () => {
close();
apply({ tab, key: r.key }, r);
},
})),
);
};
finder.search.addEventListener("input", find);
find();
body.append(finder.root);
requestAnimationFrame(() => finder.search.focus());
},
});
}
/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
fetchLogicInputs([code]).then(
(got) => {
const logic = got.로직[0] ?? null;
openLaborInput({
title: `${code} ${name}`.trim(),
logic,
values: old?.given || {},
memo: null,
rate: old?.rate ?? null,
readOnly: false,
okLabel: "연결",
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
done: (next) => {
const unit = String(logic?.결과단위 || "")
.replace(/^원\//, "")
.replace(/^[\d.]+/, "");
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
apply({ ...ref, picks: old?.picks ?? null }, { name, unit });
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
},
});
},
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
);
}
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
const more: Leaf[] = [];
if (ref?.tab === "logic")
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
if (ref) more.push(["연결 풀기", () => apply(null)]);
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
[
"단가산출 로직(M01 · 새 산근)",
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
],
];
return [picks, more];
}
+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 버림
+39 -26
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
@@ -195,37 +195,50 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
def test_우클릭_메뉴_줄_추가_지우기_연결_TS():
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit"])
# 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "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},
]
{"kind": "title", "key": "sec:1", "parent": None, "auto": True},
{"kind": "title", "key": "sec:1.2", "parent": "sec:1", "auto": True},
{"kind": "item", "key": "earth:4", "parent": "sec:1.2", "auto": True, "qty": 100,
"auto_qty": 5675},
{"kind": "item", "key": "hand:b", "parent": "sec:1.2", "hand": True},
] # fmt: skip
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 start = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }};
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 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);
const run = (i, label) => {{
user = start;
billMenu(ctx, rows, i).find((x) => Array.isArray(x) && x[0] === label)[1]();
return user.bill;
}};
const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }};
out.above = run(2, "위에 줄 추가");
out.reset = run(2, "자동값으로 되돌리기");
out.left = run(3, "줄 지우기");
console.log(JSON.stringify(out));
"""
out = json.loads(_node(src + body))
assert out["title"] == ["아래에 줄 추가"]
assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"]
assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-",
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 182 레벨
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]
add = ["위에 줄 추가", "아래에 줄 추가"]
assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"]
assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
"-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑",
"내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
# 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
above = out["above"]
assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"]
new, earth, hand = above["rows"][2:]
assert (new["name"], new["parent"], above["v"]) == ("새 줄", "sec:1.2", 2)
assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100}
assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"])
assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움
# 새 줄 지움 = 덮개에서 빠짐(gone 은 자동 줄만)
assert [e["key"] for e in out["left"]["rows"]] == ["sec:1", "sec:1.2", "earth:4"]
assert out["left"]["gone"] == []
@@ -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") # 주석 그대로
+54 -36
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"]
@@ -176,45 +177,62 @@ const labels = (key) => billMenu(ctx, rows, at(key)).map(name);
const run = (key, label) => {
user = start;
billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label)[1]();
return user.bill.rows;
return user.bill;
};
// 1.2 공종 차례 [키(새 줄 = NEW), 부모]
const order = (bill) => {
const list = bill.rows.map((r) => [r.name === "새 줄" ? "NEW" : r.key, r.parent ?? null]);
const from = list.findIndex(([k]) => k === "sec:1.2");
return list.slice(from + 1, list.findIndex(([k]) => k === "earth:5") + 1);
};
const out = {
g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"),
b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"),
};
out.child = run("hand:g2", "아래에 하위 줄 추가").at(-1);
out.below = run("hand:g1", "아래에 줄 추가").at(-1);
out.belowB = run("hand:b", "아래에 줄 추가").at(-1);
out.indent = run("hand:x", "들여쓰기 →").find((r) => r.key === "hand:x");
out.outdent = run("hand:g2", "내어쓰기 ←").find((r) => r.key === "hand:g2");
out.toItem = run("hand:g2", "항목으로 바꾸기").filter((r) => ["hand:g2", "hand:b"].includes(r.key));
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").find((r) => r.key === "hand:x");
out.removed = run("hand:g1", "줄 지우기").filter((r) => ["hand:a", "hand:g2"].includes(r.key));
out.child = order(run("hand:g2", "아래에 하위 줄 추가"));
out.below = order(run("hand:g1", "아래에 줄 추가"));
out.belowB = order(run("hand:b", "아래에 줄 추가"));
out.indentBill = run("hand:x", "들여쓰기 →");
out.indent = order(out.indentBill);
out.outdent = order(run("hand:g2", "내어쓰기 ←"));
out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key));
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x");
out.removed = order(run("hand:g1", "줄 지우기"));
console.log(JSON.stringify(out));
"""
#: 1.2 공종 기본 차례(셈 출력) — [키, 부모]
BASE = [
["earth:4", "sec:1.2"], ["hand:g1", "sec:1.2"], ["hand:a", "hand:g1"], ["hand:g2", "hand:g1"],
["hand:b", "hand:g2"], ["hand:c", "hand:g2"], ["hand:x", "sec:1.2"], ["earth:5", "sec:1.2"],
] # fmt: skip
def _put(order, at, *pairs):
return [*order[:at], *[list(p) for p in pairs], *order[at:]]
def test_우클릭_레벨_메뉴_TS():
# 184-3 — 모든 줄 메뉴 · 손질 = user.bill v2(차례 · 부모)
rows = compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"]
body = MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)).replace(
"__USER__", json.dumps(USER, ensure_ascii=False)
)
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit"]) + body))
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body))
level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"}
pick = lambda key: [x for x in out[key] if x in level] # noqa: E731
assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"]
assert "연결" not in out["g1"] and pick("g1") == ["항목으로 바꾸기"]
assert pick("a") == ["묶음 줄로 바꾸기"] # 뒤에 형제(g2) → 내어쓰기 없음
assert pick("g2") == ["항목으로 바꾸기", "내어쓰기 ←"] # 막내
assert pick("b") == [] # 묶음 안 묶음 안 = 깊이 한도 · 형제 c 뒤 → 내어쓰기 없음
assert pick("x") == ["묶음 줄로 바꾸기", "들여쓰기 →"] # 위 형제 g1
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 자손 끝
assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2")
assert out["indent"]["parent"] == "hand:g1"
assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래
assert "연결" not in out["g1"] and pick("g1") == ["내어쓰기 ←", "항목으로 바꾸기"]
assert pick("a") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음
assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목
assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지
assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1
assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움
assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤
assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모
assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"])
assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식
assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # g1 뒤
assert [(r["key"], r.get("kind"), r["parent"]) for r in out["toItem"]] == [
("hand:g2", None, "hand:g1"), ("hand:b", None, "hand:g1")] # fmt: skip
assert (out["toGroup"]["kind"], out["toGroup"]["qty"], out["toGroup"]["ref"]) == (
@@ -222,12 +240,10 @@ def test_우클릭_레벨_메뉴_TS():
None,
None,
)
# g1 지움 — 자식은 공종 아래로 · 바로 뒤 줄은 g1 자리(earth:4 뒤)로
assert [(r["key"], r["after"], r.get("parent")) for r in out["removed"]] == [
("hand:a", "earth:4", None), ("hand:g2", "hand:a", None)] # fmt: skip
# g1 지움 — 자식은 g1 부모(공종) 아래 · 그 자리
assert out["removed"] == [BASE[0], ["hand:a", "sec:1.2"], ["hand:g2", "sec:1.2"], *BASE[4:]]
# 들여쓴 x = g1 금액에 듦(셈 짝 그대로)
user = {"bill": {"rows": [r if r["key"] != "hand:x" else out["indent"] for r in HAND]}}
rows2 = _rows(compute_estimation(INPUTS, user, None, BOOKS))
rows2 = _rows(compute_estimation(INPUTS, {"bill": out["indentBill"]}, None, BOOKS))
assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230)
@@ -272,7 +288,7 @@ table.onCell(at("hand:g1"), "connect");
table.onCell(at("earth:4"), "connect");
table.onCell(at("hand:c"), "connect");
out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]);
opened[1].items[0][1](); // hand:c → 일위대가 호표 고르기 창
opened[2].items[0][1](); // hand:c → 일위대가 호표 고르기 창
picked.rows[0].onPick();
out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)];
out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref;
@@ -287,17 +303,18 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
.replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False))
.replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False))
)
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
out = json.loads(_node(src + body))
assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸
assert out["connect"] == ["대가3 ▾", "연결 ▾", "", ""] # 손 항목 줄만 · 연결됨 = 호표 번호
# 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
assert out["cls"] == [
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"]
# 누른 칸 아래(left · bottom) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함
assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]]
# 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
unlink = [10, 40, [*picks, "-", "연결 풀기"]]
assert out["menus"] == [unlink, unlink, [10, 40, picks]]
assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용
assert out["ref"] == DG
@@ -325,6 +342,7 @@ console.log(JSON.stringify(out));
def test_글_칸_위_우클릭도_줄_메뉴_TS():
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + ROW_MENU_JS))
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
out = json.loads(_node(src + ROW_MENU_JS))
assert out["onInput"] is True and out["onCell"] is True
assert out["opened"] == [[5, 6, ["줄 7"]], [5, 6, ["줄 7"]]]
@@ -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]
@@ -0,0 +1,291 @@
"""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
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