diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 581099089..c9f4db36e 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -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)}, diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 00adfd349..249a23463 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -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 = {}; @@ -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(); - 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(); 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(); + 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(); + 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( + (bill.gone || []).filter((k: string) => k !== "sec:G" && !k.startsWith("mat:")), + ); + const fresh = new Map(); + for (const hand of hands) fresh.set(hand[0].key, hand); + const out: Row[] = []; + const seen = new Set(); + 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(); + 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(); + 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 = {}; + 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 = {}; - 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(); + 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(); + 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 }, diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index e8c05bcf9..0ee98c311 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index fb208992c..da7557fbd 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -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 = [] diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 909506900..9f9a4b97a 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -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)); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index fbc1cb7a2..cc619ea1f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index c9e2fce17..c14b71c14 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -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; // 「연결 ▾」 = 메뉴(이동 안 함) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 51e586c27..105b3ff80 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -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; + v: 2; rows: Row[]; + gone: string[]; } -function billOf(user: Row): Bill { +/** 손질 한 번 = 굳힌 덮개 + 보이는 줄(키 → 셈 출력 줄) */ +interface Work { + bill: Bill; + shown: Map; +} + +/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */ +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); } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts new file mode 100644 index 000000000..4bd17684e --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -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]; +} diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index 942782b65..87a11b144 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -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 버림 diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index cba6989d6..9b2c75113 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -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"] == [] diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index 2969bb99c..de80196ea 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -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") # 주석 그대로 diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index e68350ffa..f9f68a211 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -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"]]] diff --git a/resources/tester/test_184_2_b09_bill_free.py b/resources/tester/test_184_2_b09_bill_free.py new file mode 100644 index 000000000..1a262d49e --- /dev/null +++ b/resources/tester/test_184_2_b09_bill_free.py @@ -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] diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py new file mode 100644 index 000000000..c9062f9a3 --- /dev/null +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -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