From 54fc156f1870df90a363fd11561cddf5e8ae578d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:21:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(713-3):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=8D=A7=EC=B9=B8=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=E2=80=94=20/logics=20=EC=A4=84=EB=A7=88=EB=8B=A4=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C(=EA=B0=99=EC=9D=80=20=EC=A0=88)=20=C2=B7=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EB=9F=89=20=EB=B6=80=EB=AA=A8=20=C2=B7=20=EB=B6=80?= =?UTF-8?q?=EB=A5=B4=EB=8A=94/=EB=B6=88=EB=A6=AC=EB=8A=94=20=EC=88=98=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=EB=A0=A8(=EB=B9=84=EA=B3=A0=20=EC=A0=88=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8)=20=C2=B7=20=EC=A0=95=EB=B3=B8=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=C2=B7=20=EC=83=88=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?Store=5FList=20=C2=B7=20=EC=9D=B8=EC=9E=AC=EA=B2=BD=20=EC=A4=84?= =?UTF-8?q?=20=EC=88=98=EB=8A=94=20712-1=20=EB=92=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Router.py | 4 +- M01_MasterData/M01_MasterData_Store_List.py | 96 +++++++++++++++++++++ resources/tester/test_713_3_list.py | 31 +++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 M01_MasterData/M01_MasterData_Store_List.py create mode 100644 resources/tester/test_713_3_list.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index d3d083af4..02b30265a 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -15,6 +15,7 @@ from pydantic import BaseModel from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Flow as flow +from M01_MasterData import M01_MasterData_Store_List as store_list from M01_MasterData import M01_MasterData_Store_Make as make from M01_MasterData import M01_MasterData_Store_Revise as revise @@ -193,7 +194,8 @@ def get_sections( def get_logics( sub: str = "", detail: str = "", q: str = "", blocked: int | None = None, owner: str = "" ) -> dict: - return {"logics": _call(store.logics, sub, detail, q, blocked, owner)} + """줄마다 덧칸(묶음 · 작업량 · 부르는/불리는 수 · 관련 · 713-3) — 읽기만.""" + return {"logics": store_list.attach(_call(store.logics, sub, detail, q, blocked, owner))} @router.get("/logic") diff --git a/M01_MasterData/M01_MasterData_Store_List.py b/M01_MasterData/M01_MasterData_Store_List.py new file mode 100644 index 000000000..18a0ba796 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_List.py @@ -0,0 +1,96 @@ +"""M01 로직 목록 덧칸 (PLAN 713-3 · 요구서 내역12 4장 7) — 정본은 그대로 · 목록에서만 묶음. + +`/logics` 줄마다 붙이는 읽기 칸(파일에 안 씀): +- `묶음` = 같은 파일 · 같은 원문번호 로직이 둘 넘으면 그 번호 · `묶음수` = 몇 개. +- `작업량` = 돈 아닌 로직(`결과` 칸) · `부모` = 그 작업량을 부르는 로직(목록에서 그 밑으로 접음). +- `부르는수` · `불리는수` = 로직끼리 부르기(기계경비 포함 · `Store_Cache.counted`). +- `관련` = 비고 글의 절 번호(「1-5-6(주③)」 · 「별도」 참조)가 가리키는 로직 — 계산 밖 링크. +""" + +from __future__ import annotations + +import re +from collections import Counter, defaultdict + +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Cache as cache + +_REF = re.compile( + r"(?:(산림품셈|건설품셈)\s*(공통|토목|건축|기계설비|유지관리)?\s*)?(? tuple[str, str]: + return str(data.get("원문") or ""), str(data.get("부문") or "") + + +def _related(row: dict, book: tuple[str, str], by_section: dict) -> list[str]: + """비고 글 절 번호 → 같은 원문(책 이름이 앞에 있으면 그 책) 로직 키 · 자기 빼고 · 차례대로.""" + out: dict[str, None] = {} + clauses = [c for c in str(row.get("비고") or "").split(" · ") if not c.startswith("해석")] + for name, division, number in _REF.findall(" · ".join(clauses)): + where = (name, division or ("" if name == "산림품셈" else book[1])) if name else book + keys = by_section.get((*where, number), []) + if len(keys) > _WIDE: # 장 · 큰 절 통째(「1-1」)는 링크 아님 + continue + out |= dict.fromkeys(k for k in keys if k != str(row.get("키"))) + return list(out) + + +def extras() -> dict[str, dict]: + """로직 키 → 덧칸 — 목록 전체로 셈(거른 목록이어도 묶음 수 · 부르는 수는 전체 기준).""" + files, _ = store.loaded() + rows = [ + (name, data, row) + for name, data in files.items() + if data.get("그룹") == "로직" + for row in data.get("줄") or [] + ] + calls = {key: got[1] for key, got in cache.counted(store.FOLDER).items()} + called = Counter(k for got in calls.values() for k in got) + parents: dict[str, list[str]] = defaultdict(list) + for key, got in calls.items(): + for k in got: + parents[k].append(key) + groups = Counter((name, str(row.get("원문번호") or "")) for name, _, row in rows) + by_section: dict[tuple, list[str]] = defaultdict(list) + for _, data, row in rows: + number = str(row.get("원문번호") or "") + for at in range(1, number.count("-") + 1): # 「1-5-6」 은 「1-5」 로도 찾힘 + by_section[(*_book(data), "-".join(number.split("-")[: at + 1]))].append( + str(row.get("키")) + ) + out = {} + for name, data, row in rows: + key, number = str(row.get("키")), str(row.get("원문번호") or "") + many = groups[(name, number)] if number else 1 + work = "결과" in row + out[key] = { + "묶음": number if many > 1 else "", + "묶음수": many if many > 1 else 0, + "작업량": work, + "부모": sorted(parents.get(key, [])) if work else [], + "부르는수": len(calls.get(key, ())), + "불리는수": called.get(key, 0), + "관련": _related(row, _book(data), by_section), + } + return out + + +def attach(logics: list[dict]) -> list[dict]: + """`/logics` 줄에 덧칸을 붙임(없는 키 = 저장 안 한 초안 — 빈 칸).""" + more = extras() + blank = { + "묶음": "", + "묶음수": 0, + "작업량": False, + "부모": [], + "부르는수": 0, + "불리는수": 0, + "관련": [], + } + return [{**one, **more.get(str(one.get("키")), blank)} for one in logics] diff --git a/resources/tester/test_713_3_list.py b/resources/tester/test_713_3_list.py new file mode 100644 index 000000000..6631933f3 --- /dev/null +++ b/resources/tester/test_713_3_list.py @@ -0,0 +1,31 @@ +"""713-3 지킴 — 로직 목록 덧칸(묶음 · 작업량 부모 · 부르는/불리는 수 · 관련) · 정본 그대로 · 줄 수 그대로.""" + +from __future__ import annotations + +from M01_MasterData import M01_MasterData_Router as router +from M01_MasterData import M01_MasterData_Store_List as store_list + +COLS = {"묶음", "묶음수", "작업량", "부모", "부르는수", "불리는수", "관련"} + + +def test_목록_줄_수_그대로_덧칸_다_있음() -> None: + got = router.get_logics()["logics"] + assert len(got) == 1469 # 묶음은 화면 접기만 — 목록 줄은 그대로 + assert all(COLS <= set(x) for x in got) + + +def test_묶음_작업량_부르기() -> None: + more = store_list.extras() + assert sum(1 for v in more.values() if v["묶음"]) == 323 # 한 절에 로직 둘 넘음(조사 12-B ㉯) + work = {k for k, v in more.items() if v["작업량"]} + assert len(work) == 27 + assert more["GF000102"]["부모"] == ["GF000103"] # 구조물터파기 작업량 → 단가 로직 밑 + assert more["GC000268"]["불리는수"] >= 300 # 기계경비 로직 + assert sum(1 for v in more.values() if v["부르는수"]) == 506 + assert more["GC000294"]["관련"] == ["GC000292"] # 비고 「1-5-6(주③)」 + + +def test_관련은_해석_글과_넓은_절을_뺌() -> None: + more = store_list.extras() + assert all(len(v["관련"]) <= store_list._WIDE for v in more.values()) + assert not any(k in v["관련"] for k, v in more.items()) # 자기 자신 없음 From d27dba4f231c45691506a4d1c2887bf3479ac705 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:21:56 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(716-3):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EB=91=98=EC=9D=84=20716-3=20=EA=BC=B4?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20M02=20=EC=99=BC?= =?UTF-8?q?=EC=AA=BD=20=EC=B9=B8=20=EC=B0=A8=EB=A1=80=20=C2=B7=20reveal=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EA=B0=88=EB=9E=98=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_m02_hopyo_screen::test_좌측_판_자리와_권한 — 옛 `extras ?` 자리 대신 일위대가 › 구조물 조합 › 구분선 › 불러오기 차례 확인 - test_197_1_dashboard_option_card::test_reveal_옵션만_줄을_누름 → test_reveal_옵션은_M01_로 — 옵션 정의가 M01 로 옮겨 M02 reveal 은 일위대가 · 도면만 펼침 - 기능 흠 아님 · 716-3 이 UI_Side.ts 를 바꿔 옛 꼴 기대가 낡음 Co-Authored-By: Claude Opus 5.5 --- .../tester/test_197_1_dashboard_option_card.py | 17 +++++------------ resources/tester/test_m02_hopyo_screen.py | 5 +++-- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/resources/tester/test_197_1_dashboard_option_card.py b/resources/tester/test_197_1_dashboard_option_card.py index 12c9b6c4b..fe1701798 100644 --- a/resources/tester/test_197_1_dashboard_option_card.py +++ b/resources/tester/test_197_1_dashboard_option_card.py @@ -82,20 +82,13 @@ def test_해시_진입_셋(): assert got["open"] == want and got["root"] == "master" and got["company"] == "3" -def test_reveal_옵션만_줄을_누름(): +def test_reveal_옵션은_M01_로(): side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") body = side.split("const reveal = ")[1].split("\n };")[0] - assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음 - assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만 - assert ( - 'if (target === "옵션") options.body.querySelector("button")?.click();' in body - ) - # 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐 - options = _text(M02 / "StructOptions" / "M02_SO_Options.ts") - assert options.count('el("button"') == 1 - assert "openDefScreen" in options - # 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만 - assert "옵션: section(options.title, options.body)," in side + # 716-1 · 716-3 — 옵션 정의는 M01 로 옮김 · M02 reveal 은 일위대가 · 도면만 펼침 + assert 'target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null' in body + assert 'sec?.classList.remove("is-collapsed");' in body + assert "options" not in body and "옵션: section(" not in side page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index bf8781d47..1714b7fe0 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -338,8 +338,9 @@ def test_좌측_판_자리와_권한(): side = _text("M02_MasterTemplete_UI_Side.ts") assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] - assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") - assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + # 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 불러오기 + order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),") + assert [host.index(k) for k in order] == sorted(host.index(k) for k in order) assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") From cc588889f6cac223a064b5b77446fb48043b80a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:22:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(718-1):=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20=E3=80=8C=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=E3=80=8D=20=EC=BC=AC=20=E2=80=94=20=EC=82=B0?= =?UTF-8?q?=EA=B7=BC=20=EB=B8=94=EB=A1=9D=EB=A7=88=EB=8B=A4=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=EC=97=86=EB=8A=94=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C(715-2=20=EB=B0=9B=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Sangeun.py — 단산 블록을 부르는 첫 엑셀 줄 1 단위 단가를 고정 더한줄 하나로 · 본뜬 「HP-###### 단산N」 · 같은 본뜬은 그대로(겹침 없음) - 길 POST /layers/{층}/hopyo/{키}/sangeun · [끝내기] 지문 기록에 단가산출도 셈 - 마법사: 결과 「단가산출」 체크 켬 → ② 걸음 [단가산출 만들기] · 알림 글 「일위대가 · 단가산출」 - 시험: test_718_1 단가산출 더함 · 낡은 대조 고침 — 716-3 왼쪽 판 차례(extras 없어짐) · SANGEUN_READY 대조 - 고정 더한줄 셈 값(엔진 값묶음)은 715-1b 뒤 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Sangeun.py | 103 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 15 ++- .../M02_MasterTemplete_Hopyo_Wizard.ts | 58 +++++++--- resources/tester/test_718_1_hopyo_import.py | 33 +++++- resources/tester/test_m02_hopyo_screen.py | 6 +- 5 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Sangeun.py diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py new file mode 100644 index 000000000..af0c86cda --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -0,0 +1,103 @@ +"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 요구서 내역12 4장 22) — +산근(단산) 블록 = 바탕 없는 단가산출. + +블록마다 SK 하나 — 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 1 단위 단가 +(노무비 · 재료비 · 경비)를 고정 더한줄 하나로(`M02_Hopyo_Fixed` 꼴 · 엑셀 숫자 그대로 · +서버가 파일에서 읽음) · 이름 = 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · +구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본, +확인} · 같은 본뜬이 이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층(마법사에서 고른 층). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from common_util.common_util_time import utc_now_text +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs + +ORIGIN = "실무자료" +KIND = "sangeun" + + +def mark(key: str, no: int) -> str: + return f"{key} 단산{no}" + + +def made(folder: str | Path, key: str | None = None) -> dict[str, str]: + """이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만).""" + out = {} + for path in sorted((Path(folder) / KIND).glob("SK-*.json")): + try: + got = json.loads(path.read_bytes()).get("본뜬") + except (OSError, ValueError, AttributeError): + continue + if isinstance(got, dict) and got.get("층") == ORIGIN: + if key is None or str(got.get("키") or "").startswith(f"{key} "): + out[str(got.get("키"))] = path.stem + return out + + +def _doc(line: dict, block: dict, no: int, hp: dict, stamp: str, who: Any) -> dict[str, Any]: + name = str(block.get("이름") or line.get("이름") or "").strip() or f"단산{no}" + unit = str(line.get("단위") or block.get("단위") or "").strip() or "식" + spec = str(line.get("규격") or "").strip() + price = {k: hfix._plain(v) for k, v in hfix._parts(line.get("단가")).items()} + fx = { + "이름": name, + "규격": spec, + "단위": unit, + "단가": price, + "이음": dict.fromkeys(fixed.LINKS), + "출처": {"파일": hp.get("파일"), "호표": line.get("_호표"), "줄": int(line.get("차례", 0))}, + } + return { + "종류": "단가산출", + "이름": name, + "단위": unit, + "구분": ORIGIN, + "상세구분": str(hp.get("파일") or ""), + "본뜬": { + "층": ORIGIN, + "키": mark(hp["키"], no), + "파일": hp.get("파일"), + "원본": list(refs.here()), + "확인": {"누가": who, "때": stamp}, + }, + "바탕": None, + "입력값": {}, + "열린입력": [], + "고름": {}, + "자재표시": {}, + "덮개": {}, + "더한줄": [{"고정": fx, "이름": name, "단위": unit, "수량": 1}], + "뺀줄": [], + } + + +def build(folder: Path, key: str, who: Any = None) -> dict[str, Any]: + """[단가산출 만들기] — 산근 블록마다 바탕 없는 단가산출 → + `{블록, 만듦 [{번, 이름, 키}], 그대로}`.""" + hp = hopyo_store.read(folder, key) + blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None} + calls: dict[int, dict] = {} + for h in hp.get("호표") or []: + for line in h.get("줄") or []: + ref = line.get("참조") or {} + no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0 + if no in blocks and no not in calls: + calls[no] = {**line, "_호표": h.get("번")} + have, stamp = made(folder, key), utc_now_text() + out: dict[str, Any] = {"블록": len(calls), "만듦": [], "그대로": 0} + for no in sorted(calls): + if mark(key, no) in have: + out["그대로"] += 1 + continue + doc = _doc(calls[no], blocks[no], no, hp, stamp, who) + got = refs.create(KIND, doc) + out["만듦"].append({"번": no, "이름": doc["이름"], "키": got["이름"]}) + return out diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 5931b2b67..03da5df8c 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -32,6 +32,7 @@ from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink +from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -206,7 +207,8 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: when = str(old.get("끝낸때") or "")[:10] raise store.StoreError( 409, - f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음", + f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 만든 일위대가 · 단가산출 {made})" + " — 다시 만들지 않음", ) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): return {**same, "이어서": True} @@ -465,6 +467,7 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: made = sorted( {sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)} ) + made += sorted(hopyo_sangeun.made(folder, key).values()) # 단가산출(SK) if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None: try: got = cache_path(folder, key) @@ -477,6 +480,16 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: return {"지움": key, "만든": len(made)} +@router.post("/layers/{layer}/hopyo/{key}/sangeun") +def hopyo_make_sangeun( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) → + `{블록, 만듦, 그대로}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_sangeun.build, folder, key, _who(session)) + + class PasteBody(_Strict): """엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) · `줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함).""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts index f76885502..302eccf0e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -1,11 +1,12 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Wizard.ts * 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋: - * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림 + * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림 * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서 * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → - * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 - * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록 + * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 · + * 단가산출 체크면 [단가산출 만들기] = 산근 블록마다 바탕 없는 단가산출(서버 `M02_Hopyo_Sangeun` · 겹침 없음) + * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록 * 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김. * ========================================================================== */ @@ -23,6 +24,7 @@ import { type HopyoFile, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; const LAYERS: { layer: ManageLayer; text: string }[] = [ @@ -35,9 +37,6 @@ const STEPS = [ { value: "2", label: "② 보기 · 확인 · 만들기" }, { value: "3", label: "③ 끝내기" }, ]; -/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */ -export const SANGEUN_READY = false; -const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬"; type Got = HopyoFile & { 이어서?: boolean }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -70,6 +69,7 @@ export function buildHopyoWizard(): HTMLElement { }); let layers: ManageLayer[] = []; let pick: ManageLayer = "personal"; + let wantSangeun = false; // 결과 「단가산출」 체크 const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); async function first(): Promise { @@ -112,15 +112,17 @@ export function buildHopyoWizard(): HTMLElement { }); seg.root.dataset.hopyoLayer = ""; const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true }); - const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY }); - sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY; + const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { + checked: wantSangeun, + onChange: (on) => (wantSangeun = on), + }); + sangeun.root.dataset.hopyoSangeun = ""; const busy = el("div", { className: "m02-hopyo" }); body.replaceChildren( createText("결과 층", "muted", 14), el("div", { className: "ui-toolbar", children: [seg.root] }), createText("결과", "muted", 14), el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }), - ...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]), el("div", { className: "ui-toolbar", children: [upload, input] }), createPanelHead({ title: "작업 중", level: 4, divider: true }).root, busy, @@ -169,6 +171,14 @@ export function buildHopyoWizard(): HTMLElement { ) .catch((error: unknown) => showToast(why(error), "error")), }); + const sangeun = createButton({ + label: "단가산출 만들기", + variant: "filled", + onClick: () => void makeSangeun(), + }); + sangeun.title = + "엑셀 산근 블록마다 바탕 없는 단가산출(엑셀 단가 그대로 · 이미 만든 블록은 그대로)"; + sangeun.dataset.hopyoSangeunMake = ""; const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() }); finish.dataset.hopyoFinish = ""; const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() }); @@ -177,22 +187,44 @@ export function buildHopyoWizard(): HTMLElement { body.replaceChildren( createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14), createText( - "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감", + "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감", "hint", ), - el("div", { className: "ui-toolbar", children: [view, finish, back] }), + el("div", { + className: "ui-toolbar", + children: [view, ...(wantSangeun ? [sangeun] : []), finish, back], + }), ); + async function makeSangeun(): Promise { + sangeun.disabled = true; + try { + const got = await call<{ 블록: number; 만듦: unknown[]; 그대로: number }>( + `/layers/${layer}/hopyo/${encodeURIComponent(file.키)}/sangeun`, + { method: "POST" }, + ); + const kind = got.블록 ? "success" : "warning"; + showToast( + `단가산출 ${got.만듦.length} 만듦 · 그대로 ${got.그대로} · 산근 블록 ${got.블록}`, + kind, + ); + } catch (error) { + showToast(why(error), "error"); + } finally { + sangeun.disabled = false; + } + } + async function end(): Promise { steps.set("3"); const ok = await showConfirmDialog( - `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`, + `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함`, "끝내기", ); if (!ok) return steps.set("2"); try { const got = (await deleteHopyo(scope, file.키)) as { 만든?: number }; - showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); + showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); } catch (error) { showToast(why(error), "error"); return steps.set("2"); diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index c16492b5e..72f75bb11 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -45,7 +45,7 @@ def test_이어서_끝내기_지움_겹침_막음(client): block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내 assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"] - assert "일위대가 68" in block.json()["detail"] and _keys(client) == [] + assert "단가산출 68" in block.json()["detail"] and _keys(client) == [] assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before @@ -65,3 +65,34 @@ def test_옛_HP_목록에서만_숨김(client): assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == [] assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로 assert old.is_file() and _keys(client) == ["HP-000008"] + + +def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client): + folder = layers.personal_dir(7, 42) + assert _post(client).json()["키"] == "HP-000001" + url = URL.format("personal") + "/HP-000001/sangeun" + got = client.post(url) + assert got.status_code == 200, got.text + got = got.json() + assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0 + docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))] + assert len(docs) == got["블록"] + one = docs[0] + assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산") + assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"] + hp = hs.read(folder, "HP-000001") + no = int(one["본뜬"]["키"].rsplit("단산", 1)[1]) + line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로 + x + for h in hp["호표"] + for x in h["줄"] + if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no + ) + price = one["더한줄"][0]["고정"]["단가"] + assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price) + again = client.post(url).json() # 다시 = 그대로(겹침 없음) + assert again["만듦"] == [] and again["그대로"] == got["블록"] + res = client.delete(URL.format("personal") + "/HP-000001").json() + assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록 + assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음 + assert _post(client).status_code == 409 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index bf8781d47..4a3702686 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -338,8 +338,10 @@ def test_좌측_판_자리와_권한(): side = _text("M02_MasterTemplete_UI_Side.ts") assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] + # 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 일위대가 불러오기 › 도면 양식 assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") - assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + assert host.index("createDivider(),") < host.index("section(hopyo.title, hopyo.body),") + assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?") assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") @@ -354,7 +356,7 @@ def test_좌측_판_자리와_권한(): assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만 assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인 - assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬 + assert "let wantSangeun = false;" in wizard and "/sangeun`" in wizard # 결과 단가산출 체크 def test_로직_추가_한_곳과_파일_규칙():