From f9ffd5fc774b0e8f3ef3682b3e9183991351950b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:14:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02=20=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=20=E2=86=92=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=20=ED=95=9C=20=EB=8B=A8=EA=B3=84):=20=EC=8B=A4=EB=AC=B4?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=ED=99=95=EC=9D=B8=20=EC=B0=BD=20[?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C]=20=C2=B7=20=EA=B7=9C=EA=B2=A9=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20=EC=A0=9C=EC=95=88=20(PLAN=20704-4=20=EB=92=B7=EB=B6=80?= =?UTF-8?q?=EB=B6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확인 창 몸 그대로(체크 = 품셈 줄 · 나머지 = 고정 줄)로 구조물 조합 항목(SU)을 바로 만듦 — 사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길 그대로 - 규격 묶음 제안 — 같은 이름 · 단위 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열(H=1.5 → 이름 H · 열 1.5) · 열마다 같은 몸으로 만든 줄 수량 · 기본 열 = 연 호표 - 서버 M02_Hopyo_Item · /hopyo/{키}/{번}/siblings · /hopyo-item(이 층 쓰기 · 「실무자료」 폴더) · Builder.build(save=False) - 규격 열 줄 id 가 고정 줄을 못 알아보던 흠 — line_key = fixed_id(TS lineId 와 같음) - 만든 뒤 나무 · 옆 목록 다시 - 시험: 울진 대흥 돌기슭막이 H 1.5 / 2.0 / 2.5 = 한 항목 열 셋 · 열마다 = 그 호표 엑셀 합계 · 줄 구성 다른 호표 400 · 남의 층 403 · 사전조합 0 · 마스터 그대로 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Build.py | 12 +- M02_MasterTemplete/M02_Hopyo_Item.py | 145 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 38 +++++ M02_MasterTemplete/M02_Labor_Spec.py | 6 +- .../M02_MasterTemplete_Hopyo_Api.ts | 22 +++ .../M02_MasterTemplete_Hopyo_Check.ts | 13 +- .../M02_MasterTemplete_Hopyo_ToItem.ts | 112 ++++++++++++++ .../M02_MasterTemplete_UI_Page.ts | 7 + resources/tester/test_705_2_hopyo_paste.py | 84 ++++++++++ 9 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Item.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index fa83470af..38aaf6f1a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -221,7 +221,10 @@ class Builder: "확인": sure, } - def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: + def build( + self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]], save: bool = True + ) -> dict: + """`save` 거짓 = 사전조합 안 씀 · `{문서, 빠진줄, 줄수}`(704-4 [구조물 조합으로]).""" stamp = utc_now_text() hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used, mat_lines = [], {}, [], set(), {} @@ -329,12 +332,15 @@ class Builder: if nation: doc["본뜬"]["전국자재"] = list(nation.values()) doc["본뜬"]["품셈"] = poomsem - ref, again = _save(doc) - wrote = ledger.write(self.folder, book, self.who, file or None) fixed_n = sum(1 for x in lines if "고정" in x) links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values())) count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n} count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links} + if not save: + ledger.write(self.folder, book, self.who, file or None) + return {"문서": doc, "빠진줄": missing, "줄수": {**count, "이음후보": linker.near}} + ref, again = _save(doc) + wrote = ledger.write(self.folder, book, self.who, file or None) out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} return {**out, "줄수": {**count, "이음후보": linker.near}} diff --git a/M02_MasterTemplete/M02_Hopyo_Item.py b/M02_MasterTemplete/M02_Hopyo_Item.py new file mode 100644 index 000000000..dec7e4710 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Item.py @@ -0,0 +1,145 @@ +"""실무 호표 → 구조물 조합 항목 한 단계(PLAN 704-4 뒷부분 · `_제안_704-4_규격표.md` 6장 끝) — 사전조합 안 거침. + +[구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` · + 사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item` + · 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`). +규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) → + 한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 · + 열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Item_Personal as personal +from M02_MasterTemplete import M02_Labor_Spec as spec +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +PATH = ["실무자료"] +_TOKEN = re.compile(r"[\s,]+") +_PAIR = re.compile(r"^([A-Za-z가-힣Φφ]+)\s*[=:]?\s*([\d.]+)\D{0,3}$") + + +def _flat(text: Any) -> str: + return re.sub(r"\s+", "", str(text or "")) + + +def _shape(h: dict[str, Any]) -> tuple: + return tuple((_flat(x.get("이름")), _flat(x.get("단위"))) for x in h.get("줄") or []) + + +def columns(specs: list[str]) -> tuple[str, list[str]]: + """규격 글들 → (규격 이름, 열 이름) — 처음 달라지는 칸이 모두 「H=1.5」 꼴이면 (H, [1.5 …]) · + 아니면 (「규격」, 그 칸 글) · 20 자 자름 · 겹치면 뒤에 차례.""" + parts = [[t for t in _TOKEN.split(s.strip()) if t] for s in specs] + width = max((len(p) for p in parts), default=0) + at = next((i for i in range(width) if len({p[i] if i < len(p) else "" for p in parts}) > 1), 0) + cells = [p[at] if at < len(p) else "" for p in parts] + hits = [_PAIR.match(c) for c in cells] + if all(hits) and len({h.group(1) for h in hits}) == 1: + name, cols = hits[0].group(1), [h.group(2) for h in hits] + else: + name, cols = "규격", [c or s for c, s in zip(cells, specs, strict=True)] + out: list[str] = [] + for c in cols: + c = (c.strip() or "열")[: spec.NAME_MAX] + while c in out: + c = f"{c[: spec.NAME_MAX - 2]}·{len(out) + 1}" + out.append(c) + return name[: spec.NAME_MAX], out + + +def siblings(source: Path, key: str, no: int) -> dict[str, Any]: + """규격 묶음 제안 — 같은 이름 · 단위 · 줄 구성 · 규격 다른 호표(연 호표 먼저) → + `{묶음 [{번, 규격, 열}], 이름}` · 없으면 묶음 빈 것.""" + units = hopyo_store.read(source, key).get("호표") or [] + me = next((h for h in units if int(h["번"]) == int(no)), None) + if me is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + same = [ + h + for h in units + if _flat(h.get("이름")) == _flat(me.get("이름")) + and _flat(h.get("단위")) == _flat(me.get("단위")) + and _shape(h) == _shape(me) + ] + same.sort(key=lambda h: (int(h["번"]) != int(no), int(h["번"]))) + specs = list(dict.fromkeys(_flat(h.get("규격")) for h in same)) + if len(same) < 2 or len(specs) != len(same): + return {"묶음": [], "이름": None} + name, cols = columns([str(h.get("규격") or "") for h in same]) + rows = [ + {"번": int(h["번"]), "규격": h.get("규격") or "", "열": c} + for h, c in zip(same, cols, strict=True) + ] + return {"묶음": rows, "이름": name} + + +def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict: + maker = hb.Builder(folder, source, key, no, who) + checked, pairs = maker.check(list(lines or []), whole) + return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo} + + +def make_item( + folder: Path, + source: Path, + key: str, + no: int, + lines: list[dict[str, Any]] | None = None, + whole: dict[str, Any] | None = None, + bundle: list[int] | None = None, + name: str | None = None, + who: Any = None, +) -> dict[str, Any]: + """[구조물 조합으로] → `{key, 열, 이름, 줄수, 열수, 빠진줄}` — `bundle` = 규격 열로 묶을 호표 번(연 호표 빼고).""" + main = _doc(folder, source, key, no, lines, whole, who) + doc, hopyo = main["문서"], main["호표"] + title = (name or "").strip() or doc["이름"] + cols_doc = None + if bundle: + found = {r["번"]: r for r in siblings(source, key, no)["묶음"]} + want = [int(no), *(int(b) for b in bundle if int(b) != int(no))] + if any(b not in found for b in want): + raise store.StoreError(400, "규격 묶음은 이름 · 단위 · 줄 구성이 같은 호표만") + title = (name or "").strip() or str(hopyo.get("이름") or doc["이름"]) + qty: dict[str, dict[str, Any]] = {} + for b in want: + got = ( + main["문서"] + if b == int(no) + else _doc(folder, source, key, b, lines, whole, who)["문서"] + ) + col = found[b]["열"] + for part in ("담은로직", "자재"): + mine, theirs = doc[part], got[part] + if len(mine) != len(theirs): + raise store.StoreError(400, f"제{b}호표 줄 구성이 다름 — 묶지 않고 따로 만듦") + for one, other in zip(mine, theirs, strict=True): + hand = (other.get("수량") or {}).get("손") + if hand is not None: + qty.setdefault(str(spec.line_key(one)), {})[col] = hand + cols_doc = { + "이름": siblings(source, key, no)["이름"], + "열": [found[b]["열"] for b in want], + "기본": found[int(no)]["열"], + "수량": qty, + } + with store.at(folder): + made = personal.create_item(folder, PATH, title, doc.get("단위")) + have = store.read("labor", made["열"]) + body = {**have["문서"], "담은로직": doc["담은로직"], "자재": doc["자재"]} + body["단위"] = doc.get("단위") or body.get("단위") + if cols_doc: + body["규격"] = cols_doc + store.write("labor", made["열"], have["판"], body) + count = { + "줄수": len(doc["담은로직"]) + len(doc["자재"]), + "열수": len(cols_doc["열"]) if cols_doc else 1, + } + return {**made, **count, "빠진줄": main["빠진줄"]} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 67b805f96..25ed692aa 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -25,6 +25,7 @@ from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid +from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item 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 @@ -505,3 +506,40 @@ def hopyo_relink_apply( """[바꿈] · [되돌리기] — 이 층 사전조합 이음 칸 고쳐 씀(층 쓰기) → `{바뀜, 문서, 되돌림}`.""" folder = _root(session, layer, owner, write=True) return _call(folder, hopyo_relink.apply, body.바꿈) + + +@router.get("/layers/{layer}/hopyo/{key}/{no}/siblings") +def hopyo_siblings( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표 `{묶음 [{번, 규격, 열}], 이름}`.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_item.siblings, folder, key, no) + + +class ItemBody(BuildBody): + """[구조물 조합으로](704-4) — 확인 창 몸 그대로 + `묶음` = 규격 열로 묶을 호표 번 · `이름` = 항목 이름.""" + + 묶음: list[int] = Field(default_factory=list, max_length=20) + 이름: str | None = Field(None, max_length=120) + + +@router.post("/layers/{layer}/hopyo-item") +def hopyo_to_item( + layer: Layer, + body: ItemBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """실무 호표 → 이 층 구조물 조합 항목(사전조합 안 만듦 · 이 층 쓰기 · 파일 층 읽기) → + `{key, 열, 이름, 줄수, 열수, 빠진줄}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole, body.묶음, body.이름, _who(session)) + return _call(folder, hopyo_item.make_item, *args) diff --git a/M02_MasterTemplete/M02_Labor_Spec.py b/M02_MasterTemplete/M02_Labor_Spec.py index a7d3b168f..dcd7a8c0a 100644 --- a/M02_MasterTemplete/M02_Labor_Spec.py +++ b/M02_MasterTemplete/M02_Labor_Spec.py @@ -16,6 +16,7 @@ from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any +from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음 @@ -46,7 +47,10 @@ def _text(raw: Any, what: str) -> str: def line_key(one: dict) -> Any: - """줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드.""" + """줄 id — 참조 줄 = `ref_id` · 고정 줄 = `fixed_id`(705 · 짝 TS `lineId`) · 로직 줄 = 로직 키 · + 자재 줄 = 코드.""" + if fixed.is_fixed(one): + return fixed.fixed_id(one[fixed.FIXED]) if isinstance(one.get("참조"), dict): return refs.ref_id(one["참조"]) return one.get("로직") or one.get("코드") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 558be0c42..d9f8188c9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -492,3 +492,25 @@ export const relinkApply = ( 문서: { 키: string; 이름: string; 전: number | null; 후: number | null }[]; 되돌림: RelinkChange[]; }> => call(at(scope, "/hopyo-relink"), { method: "POST", body: JSON.stringify({ 바꿈 }) }); + +/** 규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표(연 호표 먼저) */ +export const hopyoSiblings = ( + scope: HopyoScope, + key: string, + no: number, +): Promise<{ 묶음: { 번: number; 규격: string; 열: string }[]; 이름: string | null }> => + call(at(scope, `/hopyo/${enc(key)}/${no}/siblings`)); + +/** [구조물 조합으로](704-4) — 확인 창 몸 그대로 → 이 층 구조물 조합 항목(사전조합 안 만듦) */ +export const buildHopyoItem = ( + target: HopyoScope, + from: { 층: Layer; 주인: string | null; 키: string; 번: number }, + 줄: BuildLine[], + 통째: { 대상: BuildTarget[] } | null, + 묶음: number[], + 이름: string, +): Promise<{ key: string; 열: string; 이름: string; 줄수: number; 열수: number }> => + call(at(target, "/hopyo-item"), { + method: "POST", + body: JSON.stringify({ ...from, ...(통째 ? { 통째 } : { 줄 }), 묶음, 이름 }), + }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index 92867ef87..f293e9ce3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -12,6 +12,7 @@ * · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) · * [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) · * 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수. + * · 704-4 — [구조물 조합으로] = 같은 몸으로 구조물 조합 항목 바로(`Hopyo_ToItem` · 규격 묶음 제안) * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만. * ========================================================================== */ @@ -49,6 +50,7 @@ import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft"; +import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; import "./M02_MasterTemplete_Hopyo.css"; export const POLL_MS = 3000; @@ -500,6 +502,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { }); } + const toItemButton = (h: HopyoOne["호표"], whole: boolean): HTMLElement => + createButton({ + label: "구조물 조합으로", + size: "sm", + onClick: () => + void toItem({ scope: opt.scope, target, key: opt.key, no: opt.no, hopyo: h, + lines: whole ? [] : bodyOf(h.줄, d), whole: whole ? { 대상: h.통째!.대상.map(toTarget) } : null }), // prettier-ignore + }); + function draw(): void { if (!got) return; const h = got.호표; @@ -545,7 +556,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { }); tools.push(poom, back); } - tools.push(go); + tools.push(go, toItemButton(h, whole)); // 704-4 — 사전조합 안 거치고 구조물 조합 } const head = el("div", { className: "ui-toolbar m02-hopyo__bar", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts new file mode 100644 index 000000000..429e3c8ba --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts @@ -0,0 +1,112 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_ToItem.ts + * [구조물 조합으로](PLAN 704-4 뒷부분) — 실무자료 확인 창 몸 그대로 구조물 조합 항목(SU)을 바로 만듦(사전조합 안 거침). + * 창 = 항목 이름(기본 = 호표 이름 · 규격) · 규격 묶음 제안(같은 이름 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열 · + * 기본 켬 · 끄면 이 호표 하나) · [만들기] → 서버 `/hopyo-item`(이 층 쓰기 · 「실무자료」 폴더) → 나무 다시 + * 숫자는 서버가 파일에서 — 화면은 체크 · 고름만 + * ========================================================================== */ + +import { + createButton, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createInputField } from "@ui/ui_template_input"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { + buildHopyoItem, + hopyoSiblings, + type BuildLine, + type BuildTarget, + type HopyoOne, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; + +/** 항목을 만들었음(document 알림) — 구조물 조합 나무 · 옆 목록 다시 */ +export const ITEM_BUILT = "m02-hopyo-item-built"; + +export interface ToItemOptions { + scope: HopyoScope; + target: HopyoScope; + key: string; + no: number; + hopyo: HopyoOne["호표"]; + lines: BuildLine[]; + whole: { 대상: BuildTarget[] } | null; +} + +export async function toItem(o: ToItemOptions): Promise { + let sib: Awaited>; + try { + sib = await hopyoSiblings(o.scope, o.key, o.no); + } catch (error) { + showToast( + `묶음 제안 실패 — ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return; + } + const h = o.hopyo; + const one = [h.이름, h.규격].filter(Boolean).join(" "); + const bundle = sib.묶음.length > 1; + const name = createInputField({ label: "구조물 조합 이름", value: bundle ? h.이름 : one }); + const pack = bundle + ? createCheckbox( + `규격 열로 묶음 — ${sib.이름}: ${sib.묶음.map((r) => `${r.열}(제${r.번}호표)`).join(" · ")}`, + { + checked: true, + onChange: (on) => (name.input.value = on ? h.이름 : one), + }, + ) + : null; + openModal({ + title: `구조물 조합으로 — 제${o.no}호표`, + closeLabel: "취소", + width: "40rem", + mount: (body, close, actions) => { + const go = createButton({ + label: "만들기", + onClick: async () => { + const label = name.input.value.trim(); + if (!label) return name.setError("이름이 빔"); + const from = { 층: o.scope.layer, 주인: o.scope.owner, 키: o.key, 번: o.no }; + const with_ = pack?.input.checked + ? sib.묶음.map((r) => r.번).filter((n) => n !== o.no) + : []; + showLoadingOverlay(); + try { + const got = await buildHopyoItem(o.target, from, o.lines, o.whole, with_, label); + close(); + document.dispatchEvent(new Event(ITEM_BUILT)); + showToast( + `구조물 조합 「${got.이름}」 만듦 — 「실무자료」 폴더 · 줄 ${got.줄수}` + + (got.열수 > 1 ? ` · 규격 열 ${got.열수}` : ""), + "success", + 6000, + ); + } catch (error) { + showToast( + `못 만듦 — ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } finally { + hideLoadingOverlay(); + } + }, + }); + actions.append(go); + body.append( + createText( + "체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦", + "muted", + ), + name.root, + ...(pack ? [el("div", { children: [pack.root] })] : []), + ); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index e64672796..69d869319 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -50,6 +50,7 @@ import { } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem"; import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo"; import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; @@ -156,6 +157,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void go("personal", next, open), }) : panel; + // 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 다시(페이지가 내려가면 듣기 끝) + const onItem = (): void => + tree.root.isConnected + ? void Promise.all([side.refresh(), tree.refresh()]) + : document.removeEventListener(ITEM_BUILT, onItem); + document.addEventListener(ITEM_BUILT, onItem); const main = createMain( isAdmin, () => void Promise.all([side.refresh(), tree.refresh()]), diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index a88248d15..65456b3f6 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -273,3 +273,87 @@ def test_B09_붙인_줄_거울_정리_수요(): assert stone["prices"]["labor"] == 258935 and stone["qty"] == 0.1 # 이은 단가 · 덮개 pct = next(ln for ln in dg9["lines"] if ln["kind"] == "pct" and ln["cid"].startswith(ADD)) assert pct["amounts"]["expense"] > 0 and py["daega_orphans"] == [] + + +# ── 704-4 뒷부분 — [구조물 조합으로] · 규격 묶음 ───────────────────────────────── +def _upload(client, tag): + from resources.tester.test_m02_hopyo_excel import _post + + path = DOCS / FILES[tag][0] + res = _post(client, data=path.read_bytes(), name=path.name) + assert res.status_code == 200, res.text + return res.json()["키"] + + +def _item_doc(column): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + from M02_MasterTemplete import M02_Template_Layers as layers + + with store.at(layers.personal_dir(7, 42)): + table = store.read("table", store.STRUCTURE_TABLE)["문서"] + col = next(c for c in table["열"] if c.get("id") == column) + return store.read("labor", column)["문서"], col + + +def _total(doc, col=None): + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete import M02_Labor_Spec as spec + + doc = {**doc, "규격": {**doc["규격"], "기본": col}} if col else doc # 셈 = 기본 열 + return labor.calc(labor.clean(spec.at(doc, col)), {})["반영합"]["계"] + + +def layers_dir(): + from M02_MasterTemplete import M02_Template_Layers as layers + + return layers.personal_dir(7, 42) + + +def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client): + before = master_hash() + key = _upload(client, "대흥") + sib = client.get(f"/api/m02/layers/personal/hopyo/{key}/17/siblings").json() + assert sib["이름"] == "H" and [(r["번"], r["열"]) for r in sib["묶음"]] == [ + (17, "1.5"), (24, "2.0"), (25, "2.5") + ] # fmt: skip + body = {"층": "personal", "키": key, "번": 17, "묶음": [24, 25]} + res = client.post("/api/m02/layers/personal/hopyo-item", json=body) + assert res.status_code == 200, res.text + got = res.json() + assert got["열수"] == 3 and got["이름"].endswith("돌기슭막이(찰쌓기,기초무,구입)") + doc, col = _item_doc(got["열"]) + assert col["경로"] == ["실무자료", "돌기슭막이(찰쌓기,기초무,구입)"] and doc["규격"]["열"] == [ + "1.5", + "2.0", + "2.5", + ] + assert doc["규격"]["기본"] == "1.5" and all("고정" in x for x in doc["담은로직"]) + excel = { + h["번"]: h["금액"]["계"] + for h in client.get(f"/api/m02/layers/personal/hopyo/{key}").json()["호표"] + if h["번"] in (17, 24, 25) + } + for no, c in ((17, "1.5"), (24, "2.0"), (25, "2.5")): + assert abs(_total(doc, c) - excel[no]) <= excel[no] * 1e-4 # 열마다 = 그 호표 엑셀 합계(끝수 차) + one = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": []}).json() + single, _ = _item_doc(one["열"]) + assert not single.get("규격") and one["이름"].endswith("H=1.5 L3=45cm") + bad = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": [3]}) + assert bad.status_code == 400 # 줄 구성 다른 호표 + precombo = layers_dir() / "precombo" + assert not list(precombo.glob("SC-*.json")) # 사전조합 문서 안 만듦 + assert master_hash() == before + + +def test_구조물_조합으로_남의_층_403(client): + key = _upload(client, "대흥") + body = {"층": "personal", "키": key, "번": 17} + assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403 + + +def test_규격_열_이름(): + from M02_MasterTemplete import M02_Hopyo_Item as item + + assert item.columns(["H=1.5 L3=45cm", "H=2.0 L3=45cm"]) == ("H", ["1.5", "2.0"]) + assert item.columns(["일반형", "ㄷ형"]) == ("규격", ["일반형", "ㄷ형"]) + assert item.columns(["D800", "D1000"]) == ("D", ["800", "1000"])