From 1a4c1b5178b0561aef54f465ee6a4d7957a45130 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:58:12 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C):=20=EB=82=B4=EC=97=AD=20=EC=9E=87=EA=B8=B0?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=EB=81=BC=EC=9B=80=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=87=EA=B8=B0=20=ED=92=80=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=20=EC=A7=9D=20=EC=9E=8A=EA=B8=B0=20=C2=B7=20=EC=A4=84?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=88=98=EB=9F=89=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=C2=B7=20=EC=9E=87=EA=B8=B0=20=ED=8C=90=20(PLAN=20707-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Router 에 Router_Link 끼움 - 이은 자동 줄 was = 불러온 줄 · price 안 줄 · 자동 줄 덮개 · 같은 부모 앞 뒤 줄 키 → 풀기 = 제자리 - link/undo · link/forget 길 · suggest 답에 남은 줄 · 빈 자리 · 이은 줄(구조물 묶음에 딸린 줄 뺌) - 시험 test_707_4 4 더함(풀기 차례 바꿔도 제자리 · 줄마다 갈래 · 판 · 일반 계정 회사 기억 못 지움) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Link.py | 98 ++++++++++++++++++-- B09_Estimation/B09_Estimation_Router.py | 2 + B09_Estimation/B09_Estimation_Router_Link.py | 70 ++++++++++++-- B09_Estimation/B09_Estimation_Store.py | 15 +++ resources/tester/test_707_4_bill_link.py | 59 ++++++++++++ 5 files changed, 228 insertions(+), 16 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Link.py b/B09_Estimation/B09_Estimation_Bill_Link.py index 5fee2e7a1..0732de6f8 100644 --- a/B09_Estimation/B09_Estimation_Bill_Link.py +++ b/B09_Estimation/B09_Estimation_Bill_Link.py @@ -15,10 +15,15 @@ 안 줄이 그 아래(저절로 묶음 · 금액 = 안 줄 합) / price = 안 줄 금액 합 ÷ 개수(기본 = B08 수량) = 1 단위 고정 단가 · 안 줄 지움 (B08 수량 × 단가 · 끝수 비목마다 1 원 안). + 이은 자동 줄 `was` = 되돌릴 것 {rows: [불러온 줄 · price 안 줄], old: 자동 줄 덮개, + prev · next: 그때 같은 부모 앞 · 뒤 줄 키} · 수량 갈래 = 줄마다(`modes`) · 없으면 `mode` +풀기(`unlink`) — `was` 대로 불러온 줄 · 자동 줄 제자리(맞춤 기억은 그대로 · 잊기 = 부르는 쪽). +판(`board`) — 남은 불러온 줄 · 빈 자리(이을 수 있는 자동 줄) · 이은 줄. """ from __future__ import annotations +import copy import re from typing import Any @@ -198,11 +203,14 @@ def link( pairs: list[tuple[str, str]], mode: str = "take", counts: dict[str, float] | None = None, + modes: dict[str, str] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """(새 사용자 값, 알림 {이음, 묶음, 지움, 못함 [{key, 까닭}], 기억 {열쇠: 확정}}) — `rows` = 지금 셈 줄 · - `counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량).""" - if mode not in MODES: + `counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량) · + `modes` = 줄마다 수량 갈래 {불러온 줄 키: take | price}.""" + modes = modes or {} + if any(m not in MODES for m in (mode, *modes.values())): raise ValueError(f"수량 갈래는 {', '.join(MODES)} 중 하나") counts = counts or {} by_key = {r["key"]: r for r in rows} @@ -211,6 +219,7 @@ def link( bill = freeze(user, rows) report: dict[str, Any] = {"이음": 0, "묶음": 0, "지움": 0, "못함": [], "기억": {}} for line, target in pairs: + mode_ = modes.get(line, mode) entries = bill["rows"] at = next((n for n, e in enumerate(entries) if e["key"] == line), -1) row, goal = by_key.get(line), targets.get(target) @@ -221,32 +230,37 @@ def link( else "묶음 ↔ 구조물 줄만" if (row["kind"] != "item") != target.startswith("st:") else "" ) # fmt: skip - if not why and row["kind"] != "item" and mode == "price": + if not why and row["kind"] != "item" and mode_ == "price": count = counts.get(line, goal.get("auto_qty", goal.get("qty"))) why = "" if count else "개수 없음(B08 수량 없음) — 넘겨받기로" if why: report["못함"].append({"key": line, "까닭": why}) continue - old = next((e for e in entries if e["key"] == target), None) - if old is not None: - entries.remove(old) + old_at = next((n for n, e in enumerate(entries) if e["key"] == target), -1) + old = entries.pop(old_at) if old_at >= 0 else None + sibs = [e["key"] for e in entries if old and e.get("parent") == old.get("parent")] + cut = sum(1 for e in entries[:old_at] if old and e.get("parent") == old.get("parent")) + was = {"old": copy.deepcopy(old), "prev": sibs[:cut], "next": sibs[cut:]} at = next(n for n, e in enumerate(entries) if e["key"] == line) hand = entries.pop(at) - auto = {k: v for k, v in (old or {"key": target}).items() if k not in ("price", "src_qty")} + was["rows"] = [copy.deepcopy(hand)] + skip = ("price", "src_qty", "was") + auto = {k: v for k, v in (old or {"key": target}).items() if k not in skip} if row["kind"] == "item": auto.update({"ref": hand.get("ref"), "src_qty": hand.get("src_qty", hand.get("qty"))}) if hand.get("price"): auto["price"] = hand["price"] - if mode == "take" or goal.get("auto_qty", goal.get("qty")) is None: + if mode_ == "take" or goal.get("auto_qty", goal.get("qty")) is None: auto["qty"] = hand.get("qty") report["이음"] += 1 - elif mode == "take": + elif mode_ == "take": for e in entries: # 안 줄 = 구조물 줄 아래 · 금액 = 안 줄 합 if e.get("parent") == line: e["parent"] = target report["묶음"] += 1 else: inner = _descendants(entries, line) + was["rows"] += [copy.deepcopy(e) for e in entries if e["key"] in inner] items = [by_key[k] for k in inner if by_key.get(k, {}).get("kind") == "item"] auto.update( { @@ -258,7 +272,73 @@ def link( bill["rows"] = entries = [e for e in entries if e["key"] not in inner] at = min(at, len(entries)) report.update(묶음=report["묶음"] + 1, 지움=report["지움"] + len(inner)) + auto["was"] = was _place(bill, at, auto, hand.get("parent")) used.add(target) report["기억"][key_of(row)] = {"확정": target, "이름": goal["name"]} return {**user, "bill": bill}, report + + +def unlink( + user: dict[str, Any], rows: list[dict[str, Any]], target: str +) -> tuple[dict[str, Any], str]: + """(새 사용자 값, 못한 까닭 · 됨 = "") — 이은 자동 줄 `was` 대로 불러온 줄 · 자동 줄 되돌림.""" + bill = freeze(user, rows) + entries = bill["rows"] + at = next((n for n, e in enumerate(entries) if e["key"] == target), -1) + was = entries[at].get("was") if at >= 0 else None + back = was.get("rows") if isinstance(was, dict) else None + if not back: + return user, "이은 줄이 아님" + entries.pop(at) + head = back[0]["key"] + for e in entries: # take 묶음 안 줄 = 불러온 묶음 아래로 + if e.get("parent") == target: + e["parent"] = head + entries[at:at] = copy.deepcopy(back) + old = was.get("old") + if old: # 자동 줄 = 그때 뒤 줄 앞 · 없으면 앞 줄 뒤 · 둘 다 없음 = 셈이 기본 부모 끝 + where = {e["key"]: n for n, e in enumerate(entries)} + after = [where[k] + 1 for k in was.get("prev") or [] if k in where] + spot = next((where[k] for k in was.get("next") or [] if k in where), None) + spot = spot if spot is not None else (after[-1] if after else None) + if spot is not None: + entries.insert(spot, old) + return {**user, "bill": bill}, "" + + +def _under(rows: list[dict[str, Any]], key: str) -> bool: + """불러온 줄이 이은 자동 줄(구조물 take 묶음) 아래인지.""" + by_key = {r["key"]: r for r in rows} + up = by_key.get(key, {}).get("parent") + while up: + if not str(up).startswith("hand:"): + return up in by_key and str(up).split(":")[0] in HEADS + up = by_key.get(up, {}).get("parent") + return False + + +def board(user: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]: + """잇기 창 판 — 남은 불러온 줄(구조물 묶음에 딸린 줄 뺌) · 빈 자리 · 이은 줄.""" + + def pick(r: dict[str, Any]) -> dict[str, Any]: + return {f: r.get(f) for f in ("key", "kind", "name", "spec", "unit")} + + used = _linked(rows) + entries = {e["key"]: e for e in ((user.get("bill") or {}).get("rows") or [])} + by_key = {r["key"]: r for r in rows} + linked = [] + for key, e in entries.items(): + was = e.get("was") if isinstance(e.get("was"), dict) else None + if was and was.get("rows") and key in by_key: + hand = was["rows"][0] + linked.append({**pick(by_key[key]), "from": pick(hand), "memo": key_of(hand)}) + return { + "남은": [ + pick(r) for r in rows if r["key"].startswith("hand:x") and not _under(rows, r["key"]) + ], + "자리": [ + {**pick(r), "head": k.split(":")[0]} for k, r in _targets(rows).items() if k not in used + ], + "이은": linked, + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 058f9fffb..2e6424a3a 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -88,6 +88,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Import import router as import_router +from B09_Estimation.B09_Estimation_Router_Link import router as link_router from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials from common_util.common_util_auth import verify_session @@ -589,6 +590,7 @@ async def past_import( # 166-4 견적서 파일 길 · 707-1~3 경쟁사 결과물 불러오기 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) router.include_router(import_router) +router.include_router(link_router) # 707-4 내역 줄 ↔ 우리 공종 잇기 # 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한 diff --git a/B09_Estimation/B09_Estimation_Router_Link.py b/B09_Estimation/B09_Estimation_Router_Link.py index a5cb113f1..8ccb754c9 100644 --- a/B09_Estimation/B09_Estimation_Router_Link.py +++ b/B09_Estimation/B09_Estimation_Router_Link.py @@ -1,14 +1,18 @@ """B09 내역 줄 ↔ 우리 공종 잇기 길(PLAN 707-4) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본). POST /api/projects/{id}/estimation/link/suggest {user?} - → {줄: 제안(`B09_Estimation_Bill_Link.suggest`) · 저절로: 지난번 이음 · 하나 수} + → {줄: 제안(`B09_Estimation_Bill_Link.suggest` · 구조물 묶음에 딸린 줄 뺌) · + 저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)} POST /api/projects/{id}/estimation/link/apply - {pairs [{line, target}], auto, mode, counts?, user?} + {pairs [{line, target, mode?}], auto, mode, counts?, user?} 잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 → {user(새 캐시), 알림, payload(그 캐시로 셈)} · 이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층) + POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기(맞춤 기억 그대로) + POST …/link/forget {key, user?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 → + 맞춤 열쇠 · 쓸 수 있는 층에서 지움 · 남음 = 못 지운 위 층 기억) 맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김). -끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) — 화면 고리 일감 몫. +끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`. """ from __future__ import annotations @@ -73,6 +77,17 @@ class SuggestBody(BaseModel): class Pair(BaseModel): line: str = Field(min_length=1, max_length=80) target: str = Field(min_length=1, max_length=200) + mode: str | None = None + + +class UndoBody(BaseModel): + target: str = Field(min_length=1, max_length=200) + user: dict[str, Any] | None = None + + +class ForgetBody(BaseModel): + key: str = Field(min_length=1, max_length=200) + user: dict[str, Any] | None = None class ApplyBody(BaseModel): @@ -96,8 +111,11 @@ async def suggest( root = await _project_root(project_id) draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) rows = await _rows(project_id, root, draft) - lines = linker.suggest(rows, await asyncio.to_thread(recalled, session)) - return {"줄": lines, "저절로": len(linker.auto_pairs(lines))} + board = linker.board(draft, rows) + left = {r["key"] for r in board["남은"]} + memory = await asyncio.to_thread(recalled, session) + lines = [s for s in linker.suggest(rows, memory) if s["key"] in left] + return {"줄": lines, "저절로": len(linker.auto_pairs(lines)), **board} @router.post(PATH + "/apply") @@ -106,7 +124,8 @@ async def apply( ) -> dict[str, Any]: from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 - if body.mode not in linker.MODES: + modes = {p.line: p.mode for p in body.pairs if p.mode} + if any(m not in linker.MODES for m in (body.mode, *modes.values())): raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나") root = await _project_root(project_id) draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) @@ -115,7 +134,7 @@ async def apply( if body.auto: memory = await asyncio.to_thread(recalled, session) pairs += [p for p in linker.auto_pairs(linker.suggest(rows, memory)) if p not in pairs] - made, report = linker.link(draft, rows, pairs, body.mode, body.counts) + made, report = linker.link(draft, rows, pairs, body.mode, body.counts, modes) made = store.clean_user(made) layer = await asyncio.to_thread(keep, session, report.pop("기억")) return { @@ -123,3 +142,40 @@ async def apply( "알림": {**report, "기억층": layer}, "payload": await payload(project_id, root, made, True), } + + +@router.post(PATH + "/undo") +async def undo( + project_id: UUID, body: UndoBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 + + root = await _project_root(project_id) + draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) + made, why = linker.unlink(draft, await _rows(project_id, root, draft), body.target) + if why: + raise HTTPException(status_code=400, detail=why) + made = store.clean_user(made) + return { + "user": made, + "알림": {"풀음": 1}, + "payload": await payload(project_id, root, made, True), + } + + +@router.post(PATH + "/forget") +async def forget( + project_id: UUID, body: ForgetBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root = await _project_root(project_id) + draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) + rows = await _rows(project_id, root, draft) + board = linker.board(draft, rows) + hit = next((r for r in board["남은"] if r["key"] == body.key), None) + memo = linker.key_of(hit) if hit else None + memo = memo or next((r["memo"] for r in board["이은"] if r["key"] == body.key), None) + if not memo: + raise HTTPException(status_code=400, detail="불러온 줄 · 이은 줄이 아님") + layer = await asyncio.to_thread(keep, session, {memo: None}) + left = memo in await asyncio.to_thread(recalled, session) + return {"잊음": memo, "기억층": layer, "남음": left} diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 3ba1c68b3..12026ad0b 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -168,6 +168,19 @@ def _fixed_cells(row: dict[str, Any]) -> dict[str, Any]: return out +def _clean_was(raw: Any) -> dict[str, Any] | None: + """707-4 이은 자동 줄 되돌릴 것 — 불러온 줄(hand 먼저) · 자동 줄 덮개 · 앞 · 뒤 줄 키.""" + if not isinstance(raw, dict) or not isinstance(raw.get("rows"), list): + return None + rows = _clean_v2({"rows": raw["rows"]}).get("rows") or [] + if not rows or not rows[0]["key"].startswith("hand:"): + return None + old = raw.get("old") + old = (_clean_v2({"rows": [old]}).get("rows") or [None])[0] if isinstance(old, dict) else None + near = {k: [x for x in raw.get(k) or [] if isinstance(x, str)][:500] for k in ("prev", "next")} + return {"rows": rows, "old": old, **near} + + def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]: """184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 · 자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키 · @@ -204,6 +217,8 @@ def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]: if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None): out["ref"] = _clean_ref(row["ref"]) out.update(_fixed_cells(row)) + if was := _clean_was(row.get("was")): + out["was"] = was rows.append(out) given = raw.get("gone") if isinstance(raw.get("gone"), list) else [] gone = list( diff --git a/resources/tester/test_707_4_bill_link.py b/resources/tester/test_707_4_bill_link.py index 24b03ff01..13b342a39 100644 --- a/resources/tester/test_707_4_bill_link.py +++ b/resources/tester/test_707_4_bill_link.py @@ -221,3 +221,62 @@ def test_기억_층(tmp_path, monkeypatch, role, layer): assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5") assert got["b||"]["확정"] == "x" assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음 + + +def _same(a, b): + """두 셈 = 같은 줄 · 같은 직접비.""" + assert [r["key"] for r in a["rows"]] == [r["key"] for r in b["rows"]] + assert a["totals"]["direct"]["sum"] == b["totals"]["direct"]["sum"] + + +@pytest.mark.parametrize("mode", ["take", "price"]) +def test_잇기_풀기_제자리(geo, mode): + user, rows = _load(geo) + before = _bill(user) + group = next(r for r in rows if r["name"] == "콘크리트구조물") + pairs = [*_pick(lk.suggest(rows, {})), (group["key"], ST)] + made, report, bill = _link(user, rows, pairs, mode) + assert report["이음"] == 12 and report["묶음"] == 1 + linked = [e for e in made["bill"]["rows"] if e.get("was")] + assert len(linked) == 13 # 저장 다듬기 뒤에도 되돌릴 것 남음 + for entry in linked[::2] + linked[1::2]: # 이은 차례와 다르게 + made, why = lk.unlink(made, bill["rows"], entry["key"]) + assert why == "" + made = store.clean_user(made) + bill = _bill(made) + _same(bill, before) + assert lk.unlink(made, bill["rows"], "earth:4")[1] == "이은 줄이 아님" + + +def test_줄마다_갈래_판(geo): + user, rows = _load(geo) + cut = next(r for r in rows if r["name"] == "토사절취") + side = next(r for r in rows if r["name"] == "측구터파기" and r["spec"] == "토사") + pairs = [(cut["key"], "earth:4"), (side["key"], "earth:11")] + made, report, bill = _link(user, rows, pairs, "take", modes={side["key"]: "price"}) + got = {r["key"]: r for r in bill["rows"]} + assert got["earth:4"]["qty"] == cut["qty"] and got["earth:11"]["qty"] == 100 + with pytest.raises(ValueError): + lk.link(user, rows, pairs, "take", modes={cut["key"]: "both"}) + board = lk.board(made, bill["rows"]) + left = {r["key"] for r in board["남은"]} + assert cut["key"] not in left and side["key"] not in left + assert {r["key"] for r in board["이은"]} == {"earth:4", "earth:11"} + assert board["이은"][0]["memo"] in report["기억"] + assert not {"earth:4", "earth:11"} & {r["key"] for r in board["자리"]} + group = next(r for r in rows if r["name"] == "콘크리트구조물") + inner = {r["key"] for r in rows if r["parent"] == group["key"]} + made, _r, bill = _link(made, bill["rows"], [(group["key"], ST)], "take") + left = {r["key"] for r in lk.board(made, bill["rows"])["남은"]} + assert not inner & left # 구조물 묶음에 딸린 줄 = 남은 줄 아님 + + +def test_이_짝_잊기(tmp_path, monkeypatch): + monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) + user = {"company_id": 7, "user_id": 3, "role": "USER"} + route.keep(user, {"a||": {"확정": "earth:4"}, "b||": {"확정": "earth:5"}}) + hopyo_store.remember(tmp_path / "company", {"c||": {"확정": "x"}}, lk.MEMORY) + assert route.keep(user, {"a||": None}) == "personal" + assert set(route.recalled(user)) == {"b||", "c||"} + route.keep(user, {"c||": None}) + assert "c||" in route.recalled(user) # 일반 = 회사 기억 못 지움(남음) From 56ebe76e7fb0be6426b4c1a43f8159046a106da8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:59:53 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(B09=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0):=20.estx=20=EA=B3=A0=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A4=84=20=C2=B7=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=ED=91=9C=20=EA=B0=99=EC=9D=80=20=EA=B8=B8=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EC=9C=A8=20=3D=20=EC=97=91=EC=85=80=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=C2=B7=20=ED=98=B8=ED=91=9C=20=EB=AA=A9=EB=A1=9D=20=3D=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=20=ED=9B=84=EB=B3=B4=20=EC=88=98=20(PLAN=207?= =?UTF-8?q?07-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 길: 이름 끝 .estx = `B09_Estimation_Estx` read_bill · read_prices(같은 그릇) · 요율 = 400 「같은 공사 엑셀에서」 - 미리 보기 `호표` = 대가 · 산근 목록 수 + 내역 줄이 코드로 가리킨 수(구성 줄 귀속은 안 함) - 창: 파일 고르기 *.estx · [단가표도 같이](받은 patch 를 캐시에 함께) · 지난 파일 요율 실패 = 까닭 알림 뒤 다시 고름 - 시험 test_707_5 8(길 갈림 · 호표 수 · 깨진 파일 400) Co-Authored-By: Claude Opus 5.5 --- .../B09_Estimation_Router_Import.py | 33 +++++++++++-- B09_Estimation/B09_Estimation_UI_Import.ts | 49 +++++++++++++++---- resources/tester/test_707_5_estx.py | 14 ++++++ 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index b51db8407..e2868fde1 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -18,6 +18,9 @@ {file_key, name, sheets, counts, linked, missing, patch{labor_cover, machine_cover, materials}} file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 · + .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) = 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · + 요율 = 400 「같은 공사 엑셀에서」 · + 미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) 화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈. `B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한). """ @@ -37,6 +40,7 @@ from pydantic import BaseModel, Field from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B09_Estimation import B09_Estimation_Bill_Import as importer from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Estx as estx from B09_Estimation import B09_Estimation_Import_Prices as import_prices from B09_Estimation import B09_Estimation_Import_Rates as import_rates from B09_Estimation import B09_Estimation_Prices as prices @@ -82,13 +86,28 @@ def _keep(root: Path, name: str, data: bytes) -> str: return key +def _is_estx(name: str) -> bool: + return name.lower().endswith(".estx") + + def _parse(name: str, data: bytes) -> dict[str, Any]: try: - return read_bill(name, data) + return (estx.read_bill if _is_estx(name) else read_bill)(name, data) except BillExcelError as error: raise HTTPException(status_code=400, detail=str(error)) from error +def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None: + """.estx 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `estx.UNKNOWN`).""" + if not _is_estx(name): + return None + got = estx.read_hopyo(name, data) + codes = {(k, x["코드"]) for k, v in got.items() for x in v} + hits = [h for line in parsed["줄"] if (h := line.get("호표"))] + return {**{k: len(v) for k, v in got.items()}, + "가리킴": sum((h["종류"], h["코드"]) in codes for h in hits)} # fmt: skip + + def _draft(raw: Any) -> dict[str, Any] | None: if raw in (None, ""): return None @@ -158,8 +177,10 @@ async def import_bill( if not file_key: raise HTTPException(status_code=400, detail="파일을 고름") path = _saved(root, file_key) + data = path.read_bytes() if file is None: - parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes()) + parsed = await asyncio.to_thread(_parse, path.name, data) + hopyo = await asyncio.to_thread(_hopyo, path.name, data, parsed) rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"] found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) return { @@ -167,6 +188,7 @@ async def import_bill( "파일": path.name, **importer.preview(parsed, rows), "이음": len(found), + "호표": hopyo, } @@ -234,6 +256,8 @@ async def import_rate_sheet( root = await _project_root(project_id) key, name, data = await _file(file, file_key, root) draft = _draft(user) + if _is_estx(name): + raise HTTPException(400, "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서 불러옴") mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[ "estimate" ] @@ -262,8 +286,9 @@ async def import_price_sheets( draft = _draft(user) mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root) try: - read = await asyncio.to_thread(import_prices.read_prices, name, data) - except ExcelError as error: + reader = estx.read_prices if _is_estx(name) else import_prices.read_prices + read = await asyncio.to_thread(reader, name, data) + except (ExcelError, BillExcelError) as error: raise HTTPException(400, str(error)) from error keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부 b08 = await main._b08_for(project_id, [], True) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index cfe1070fe..24b46fcaa 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -14,6 +14,9 @@ * 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) → * 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본 * 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림 + * + * EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) · + * 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -80,11 +83,17 @@ export function createBillImport( const total = seen.합계 || {}; const lines = [ `${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`, - `엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + + `${seen.꼴 === "estx" ? ".estx" : "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + (seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""), seen.이음 ? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음` : "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)", + ...(seen.호표 + ? [ + `호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`, + "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)", + ] + : []), ]; const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { radio: "b09-import-mode", @@ -106,6 +115,11 @@ export function createBillImport( const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, { disabled: !seen.남는자동, }); + const withPrices = seen.호표 + ? createCheckbox("단가표도 같이 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", { + checked: true, + }) + : null; const run = createButton({ label: "불러오기", variant: "filled" }); run.dataset.b09ImportRun = ""; openModal({ @@ -117,6 +131,7 @@ export function createBillImport( ...lines.map((t) => createText(t, "muted", 14)), el("div", { className: "b09-import__choices", children: [take.root, price.root] }), el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }), + ...(withPrices ? [withPrices.root] : []), ); actions.append(run); run.addEventListener("click", async () => { @@ -132,11 +147,21 @@ export function createBillImport( showLoadingOverlay(); try { const got = await post(projectId, "bill/apply", JSON.stringify(ask), true); + let made = merged(draft, got.user); + let tail = ""; + if (withPrices?.input.checked) { + const sheet = await postSheet("prices", made, null); + made = { ...made, ...sheet.patch }; + const c: Row = sheet.counts || {}; + tail = ` · 단가표 이음 ${Object.entries(PART) + .map(([k, name]) => `${name} ${c[k]?.linked ?? 0}`) + .join(" · ")}`; + } // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); - done(got.payload, merged(draft, got.user)); + done(got.payload, made); const n = got.알림 || {}; - const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; + const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); // 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창 @@ -167,7 +192,7 @@ export function createBillImport( label: "엑셀 내역 불러오기", variant: "ghost", onClick: () => - pickFiles({ accept: ".xls,.xlsx" }, ([file]) => { + pickFiles({ accept: ".xls,.xlsx,.estx" }, ([file]) => { const form = new FormData(); form.append("file", file); form.append("user", JSON.stringify(user())); @@ -206,11 +231,11 @@ const PART: Record = { labor: "노임", material: "자재", mach /** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */ let last: { key: string; name: string } | null = null; -async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise { +async function postSheet(kind: Kind, user: Row, file: File | null): Promise { const form = new FormData(); if (file) form.append("file", file); else if (last) form.append("file_key", last.key); - form.append("user", JSON.stringify(ctx.user)); + form.append("user", JSON.stringify(user)); const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`; const response = await fetch(url, { method: "POST", body: form, credentials: "include" }); const body = (await response.json().catch(() => null)) as Row | null; @@ -332,8 +357,9 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri function pick(kind: Kind, ctx: TabContext): void { pickFiles( - { accept: ".xlsx,.xls" }, - ([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail), + { accept: ".xlsx,.xls,.estx" }, + ([file]) => + void postSheet(kind, ctx.user, file).then((got) => openResult(kind, ctx, got), fail), ); } @@ -347,9 +373,12 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] { size: "sm", onClick: () => last - ? void postSheet(kind, ctx, null).then( + ? void postSheet(kind, ctx.user, null).then( (got) => openResult(kind, ctx, got), - () => pick(kind, ctx), + (error: unknown) => { + fail(error); // 지난 파일이 .estx(요율) · 없음 = 까닭 알리고 다시 고름 + pick(kind, ctx); + }, ) : pick(kind, ctx), }); diff --git a/resources/tester/test_707_5_estx.py b/resources/tester/test_707_5_estx.py index 887c2101c..0c9410248 100644 --- a/resources/tester/test_707_5_estx.py +++ b/resources/tester/test_707_5_estx.py @@ -125,3 +125,17 @@ def test_깨진_파일_표_없음_막힘(): z.writestr("Other.dat", b"") with pytest.raises(ex.EstxError, match="EstMainDat"): ex.read_bill("a.estx", buf.getvalue()) + + +def test_불러오기_길_estx_갈림(data): + """707-5 고리 — 이름 끝 .estx = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보).""" + from B09_Estimation import B09_Estimation_Router_Import as route + + name = "거창.ESTX" + parsed = route._parse(name, data["거창"]) + assert parsed["꼴"] == "estx" and parsed["합계"]["직접"] == 384916227 + assert route._hopyo(name, data["거창"], parsed) == {"대가": 14, "산근": 61, "가리킴": 48} + assert route._hopyo("a.xlsx", data["거창"], parsed) is None + with pytest.raises(route.HTTPException) as got: + route._parse("a.estx", b"not a zip") + assert got.value.status_code == 400 From 1bb3b43064dd0ee4ba17e524d9f84e951e4a8bc9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:07:57 +0900 Subject: [PATCH 03/11] =?UTF-8?q?test(=EB=82=A1=EC=9D=80=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98):=20=ED=95=A9=EC=B9=9C=20=ED=8C=90=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=84=B7=20=E2=80=94=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=9D=A0=20=EC=95=84=EB=8B=98=20=C2=B7=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EB=92=A4=20=EC=9D=BC=EA=B0=90=EC=9D=B4=20=EB=B0=94=EA=BE=BC=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20(PLAN=20707-5=20=EB=94=B8=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_224_1 · helper_280_1: 고르기 창 몸 = 위 표 · 규격 표(704-4 specHost) · 열 — 둘째 자리 → 셋째 자리 - test_280_1 · test_b05_structures_form_summary 도우미: 부품 상자 글꼴 import(ui_template_icons.css)를 .ts 로 찾던 흠 — .css = 빈 모듈 - test_m02_precombo_scope: precombo 배선에 706-1 `more` 덧붙음 — 한 줄 글 대조 → scope · bar 줄 대조 - test_structures_summary_snapshot: robocopy 가 복제 도구 본체(common_util_project_copy)로 옮겨 감 — 껍데기 대신 본체 읽음 - test_202_1 · test_704_4: 합친 판에서 통과 — 고칠 것 없음 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_280_1_combo_pick.cjs | 3 ++- resources/tester/helper_b05_structures_form_summary.cjs | 1 + resources/tester/test_224_1_picker_top_table.py | 2 +- resources/tester/test_m02_precombo_scope.py | 2 +- resources/tester/test_structures_summary_snapshot.py | 4 +++- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/resources/tester/helper_280_1_combo_pick.cjs b/resources/tester/helper_280_1_combo_pick.cjs index 9b4ffd3f9..d0669661c 100644 --- a/resources/tester/helper_280_1_combo_pick.cjs +++ b/resources/tester/helper_280_1_combo_pick.cjs @@ -147,6 +147,7 @@ const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { if (request in STUBS) return STUBS[request]; + if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); return require(request); }, @@ -178,7 +179,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); const out = {}; void openItemPicker({ current: null, canCopy: false }); await flush(); - const [, cols] = modal.body.children; + const [, , cols] = modal.body.children; // 위 표 · 규격 표(704-4) · 열 const [search, layerBar, treeHost] = cols.children[0].children; const searchInput = search.children[0]; const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 4c7ff7c35..62fd8d5b6 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -119,6 +119,7 @@ const { loadTs } = makeLoader({ requireFor: (full) => (request) => { if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) — 트랜스파일 못 함 // 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); // 공용 계산(@util/…) — tsconfig 별칭대로 common_util 폴더에서 읽음 diff --git a/resources/tester/test_224_1_picker_top_table.py b/resources/tester/test_224_1_picker_top_table.py index e9258b32c..c9a5cae3a 100644 --- a/resources/tester/test_224_1_picker_top_table.py +++ b/resources/tester/test_224_1_picker_top_table.py @@ -117,7 +117,7 @@ def test_모달_배선_고르면_보이고_편집_화면에서는_숨김(): ts = _text(UI / "ui_template_item_picker.ts") assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무 assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2) - assert "body.append(tableWrap, cols, editHost," in ts # 맨 위 + assert "body.append(tableWrap, specHost, cols, editHost," in ts # 맨 위(704-4 규격 표 뒤) assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때 assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때 assert "comboTable.destroy();" in ts # 닫을 때 표 거둠 diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 77a4ce3db..fe9bd01fa 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -77,7 +77,7 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움(): # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 assert page.count('layerBar("') == 1 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page - assert "precombo: { scope: () => pre, bar: preBar }," in page + assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음 def test_고름에_owner_권한_서고_길에_명시_owner(): diff --git a/resources/tester/test_structures_summary_snapshot.py b/resources/tester/test_structures_summary_snapshot.py index 2a3910762..8a0c0ac54 100644 --- a/resources/tester/test_structures_summary_snapshot.py +++ b/resources/tester/test_structures_summary_snapshot.py @@ -300,5 +300,7 @@ def test_스냅샷이_없으면_알림(world): def test_초기화는_templates_를_안_건드리고_복제는_통째로(): assert not any(tree.startswith("templates") for tree in initial._FILE_TREES) - tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8") + tool = (config_system.PROJECT_ROOT / "common_util/common_util_project_copy.py").read_text( + "utf-8" + ) # 복제 도구 본체(tools_clone_project = CLI 껍데기) assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함) From a8cf79cee002572691b2b01b8f04fc9258578e4d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:14:51 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix(=EA=B3=B5=EC=9A=A9=20=ED=91=9C=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4):=20=EC=B0=BD=20=EB=81=9D=EA=B9=8C=EC=A7=80?= =?UTF-8?q?=20=EC=85=88(spaceBelow)=EC=9D=B4=20=EC=97=B4=EB=A6=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EA=B0=80=EB=A6=BC=EB=A7=89=20=C2=B7=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC(fixed=20=C2=B7=20absolute)=EC=9D=84=20=ED=91=9C=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20=EC=9E=90=EB=A6=AC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=8D=98=20=ED=9D=A0=20(PLAN=20707-5=20=EB=94=B8=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - body 끝 가림막(창 높이)이 뒤 형제로 잡혀 창이 열린 채 다시 그리면 표가 최소 120px 로 짜부 - 흐름 밖(position fixed · absolute) 형제 = 자리 안 씀 - 부품 모음 견본(화면 끝 표 · 견본 창): 전 794 → 120px · 뒤 794 → 794px(밝음 · 어두움) - 시험 test_202_1 가림막 줄 덧붙임 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_202_1_fill_below.cjs | 25 ++++++++++++------- .../tester/test_202_1_table_hint_bottom.py | 1 + ui_template/ui_template_data_table.ts | 3 ++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/resources/tester/helper_202_1_fill_below.cjs b/resources/tester/helper_202_1_fill_below.cjs index 73fd59feb..190b7517c 100644 --- a/resources/tester/helper_202_1_fill_below.cjs +++ b/resources/tester/helper_202_1_fill_below.cjs @@ -44,17 +44,19 @@ const sty = (n) => ({ paddingBottom: n.css.paddingBottom ?? "0px", borderBottomWidth: n.css.borderBottomWidth ?? "0px", marginBottom: n.css.marginBottom ?? "0px", + position: n.css.position ?? "static", }); const box = { exports: {} }; -new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)( - box.exports, - box, - (p) => (p.endsWith(".css") ? {} : require(p)), - win, - { body: null }, - sty, - RO, -); +new Function( + "exports", + "module", + "require", + "window", + "document", + "getComputedStyle", + "ResizeObserver", + js, +)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO); const m = box.exports; // 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40] @@ -81,4 +83,9 @@ hint.height = 60; observers.forEach((o) => o.fn()); out.afterHintGrow = parseFloat(root.style.maxHeight); out.firstMinusGrow = out.first - 20; + +// 열린 창 가림막(body 끝 · position fixed · 창 높이) = 아래 자리에 안 셈(707-5) +link(body, card, el("backdrop", { position: "fixed" }, 900)); +out.belowWithModal = m.spaceBelow(root); +out.belowAfterGrow = 3 * 8 + 20 + 30 + 60 + 12 + 8; console.log(JSON.stringify(out)); diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 5d89460f6..f79393036 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -177,3 +177,4 @@ def test_창_끝까지_셈에_아래_안내_줄_포함(): assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백 assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0 assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦 + assert r["belowWithModal"] == r["belowAfterGrow"] # 열린 창 가림막(fixed) = 아래 자리 아님 diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 6fd8c5c2d..7ffe3a975 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -410,7 +410,8 @@ export function spaceBelow(el: HTMLElement): number { for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { const h = s.getBoundingClientRect().height; const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top) - if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom); + const out = m.position === "fixed" || m.position === "absolute"; // 열린 창 가림막 · 알림 = 자리 안 씀 + if (h && !out) sum += h + gap + px(m.marginTop) + px(m.marginBottom); } sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); } From ebd33de9c97788a0661fbda197a19d5507cf9e2f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:20:45 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix(B09=20=ED=86=B5=ED=95=A9=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=C2=B7=20703-2=20=C3=97=20703-3):=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=EC=A1=B0=ED=95=A9=EC=97=90=EC=84=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=ED=95=9C=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=E2=80=94=20[=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8]=20=EB=92=A4?= =?UTF-8?q?=20=EA=B8=88=EC=95=A1=200=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=EB=95=8C=20=EC=9B=90=EB=B3=B8=20=EC=82=AC=EB=B3=B8=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흠: 사전조합 연결 줄을 「복사해 새 일위대가」 → [바꿈] 하면 복사본 원본 열쇠가 `C:참조…` 꼴인데 쓰는 사본 셈(used_folders)이 `L:` 만 봄 → [업데이트] 수요에서 원본 사본 노임 줄이 빠져 책에서 지워짐(금액 40,490 → 0 · 빈 줄 3) · [저장] 정리가 원본 사본 폴더를 지울 수 있음 - 고침: 복사본의 복사본(`K:`)은 맨 처음 원본까지 따라감 · `C:참조` = 그 참조를 담은 사본 폴더도 지킴(프로젝트 자리 줄 때) - 고친 파일: B09_Estimation/B09_Estimation_Bill_Library.py · B09_Estimation/B09_Estimation_Router.py · resources/tester/test_703_b09_daega_copy.py - 확인: 시험 test_703 · 703_1 · 703_3 · 204_2 · 251_1 29 통과 · 검증_sub6 [업데이트] 뒤 측구파기(토사) 40,490 그대로 · [저장] 뒤 서버 161,960 = 화면 · 사본 폴더 남음 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Library.py | 43 +++++++++++++------ B09_Estimation/B09_Estimation_Router.py | 2 +- resources/tester/test_703_b09_daega_copy.py | 22 ++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 538f200ff..a53d71108 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -340,24 +340,43 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: return out -def used_folders(user: dict[str, Any]) -> set[str]: +def used_folders(user: dict[str, Any], project_root: str | Path | None = None) -> set[str]: """설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더 + - 일위대가 복사본(703-2 `@copy`)의 원본 사본.""" - copies = [ - str(lines["@copy"].get("from") or "") - for lines in (user.get("daega_cover") or {}).values() + 일위대가 복사본(703-2 `@copy`)의 원본 사본 — 복사본의 복사본(`K:`)은 맨 처음 원본까지 · + 사전조합 원본(`C:참조`) = 그 참조를 담은 사본 폴더(`project_root` 줄 때).""" + covers = user.get("daega_cover") or {} + + def origin(src: str) -> str: + seen: set[str] = set() + while src.startswith("K:") and src not in seen: + seen.add(src) + src = str(((covers.get(src) or {}).get("@copy") or {}).get("from") or "") + return src + + srcs = { + origin(str(lines["@copy"].get("from") or "")) + for lines in covers.values() if isinstance(lines, dict) and isinstance(lines.get("@copy"), dict) - ] - return { - str(ref["lib"]) - for row in ref_rows(user) - if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib") - } | {src[2:] for src in copies if src.startswith("L:")} + } + pre = {src[2:] for src in srcs if src.startswith("C:")} + return ( + { + str(ref["lib"]) + for row in ref_rows(user) + if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib") + } + | {src[2:] for src in srcs if src.startswith("L:")} + | { + folder.name + for folder, manifest in (copies(project_root) if pre and project_root else []) + if refs.ref_id((manifest.get("원본") or {}).get("참조") or {}) in pre + } + ) def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]: """[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름.""" - keep = used_folders(user) + keep = used_folders(user, project_root) base = library_dir(project_root) gone = [] for child in sorted(base.iterdir()) if base.is_dir() else []: diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 2e6424a3a..b414a1727 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -210,7 +210,7 @@ async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]: """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) + 거리이정표 운반 연결(251-5) · draft = 화면 캐시(296 · 서고 사본 = 그 줄이 잇는 것만).""" user = await asyncio.to_thread(calc_user, root, draft) # 운반 값 사본 먼저(263-2) - keep = None if draft is None else library.used_folders(user) + keep = None if draft is None else library.used_folders(user, root) keys = await asyncio.to_thread(linked_resource_keys, root, keep) hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) hand = [*hand, *added_keys(user.get("daega_cover"))] # 705-2 붙인 줄 이음 diff --git a/resources/tester/test_703_b09_daega_copy.py b/resources/tester/test_703_b09_daega_copy.py index b1f272386..c4930ef5c 100644 --- a/resources/tester/test_703_b09_daega_copy.py +++ b/resources/tester/test_703_b09_daega_copy.py @@ -96,6 +96,28 @@ def test_서고_사본_원본_지킴(): assert library.used_folders(user) == {"combo@system@-@SS1"} +def test_사전조합_원본_복사본_사본_지킴(tmp_path): + """통합 점검 — 사전조합(`C:참조`) 복사본 · 복사본의 복사본 = 원본 사본 폴더 지킴(업데이트 수요 · 저장 정리).""" + base = library.library_dir(tmp_path) + for name, ref in ( + ("precombo@personal@1-3@SC-000001", {"층": "personal", "주인": "1/3", "키": "SC-000001"}), + ("precombo@personal@1-3@SC-000002", {"층": "personal", "주인": "1/3", "키": "SC-000002"}), + ): + (base / name).mkdir(parents=True) + manifest = {"종류": "precombo", "원본": {"참조": ref}} + (base / name / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + src = "C:참조|personal|1/3|SC-000001" + user = { + "daega_cover": { + "K:aa": {"@copy": {"from": src, "name": "복사"}}, + "K:bb": {"@copy": {"from": "K:aa", "name": "복사의 복사"}}, + } + } + assert library.used_folders(user) == set() # 폴더 모름 = 옛 꼴 + assert library.used_folders(user, tmp_path) == {"precombo@personal@1-3@SC-000001"} + assert library.prune(tmp_path, user) == ["precombo@personal@1-3@SC-000002"] + + # ── 내 자료집(개인 층 사전조합 · 임시 storage) ───────────────────────────── From 5655c628b92364804aa3e8d261c5e2c8e9014e14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:21:01 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C):=20=EB=82=B4=EC=97=AD=20=EC=A4=84=20?= =?UTF-8?q?=E2=86=94=20=EC=9A=B0=EB=A6=AC=20=EA=B3=B5=EC=A2=85=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=20=EC=B0=BD=20=E2=80=94=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=EB=8C=80=EB=A1=9C=20=C2=B7=20=EC=A4=84?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=9E=87=EA=B8=B0=20=C2=B7=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=20=ED=92=80=EA=B8=B0=20=C2=B7=20=EC=9D=B4=20=EC=A7=9D?= =?UTF-8?q?=20=EC=9E=8A=EA=B8=B0=20(PLAN=20707-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 화면 B09_Estimation_UI_Link — 제안(지난번 이음 · 짝 표 · 이름 같음 꼬리표 · 후보 고름) · 수량 갈래(불러올 때 고른 것 · 줄에서 바꿈) · 묶음 개수 칸 · 제안 없는 줄 = 단가연결 고르기 창 재사용(우리 토공 · 운반 · 구조물 줄만) · 이은 줄 풀기 · 잊기 · 남은 안 이은 줄 수 - 고리 둘 — 불러오기 끝 = 잇기 창 이어 뜸 · 설계내역서 우클릭 = 잇기 · 풀기 · 잊기 - 「고름」 줄 기본 = 앞 줄이 안 쓴 후보 · 모두 제안대로 = 같은 우리 줄 둘째부터 남김 - apply 알림에 안 이은 불러온 항목 수 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Router_Link.py | 11 +- B09_Estimation/B09_Estimation_UI_Import.ts | 2 + B09_Estimation/B09_Estimation_UI_Link.ts | 325 ++++++++++++++++++ .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 2 + 4 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Link.ts diff --git a/B09_Estimation/B09_Estimation_Router_Link.py b/B09_Estimation/B09_Estimation_Router_Link.py index 8ccb754c9..d7f27a368 100644 --- a/B09_Estimation/B09_Estimation_Router_Link.py +++ b/B09_Estimation/B09_Estimation_Router_Link.py @@ -4,7 +4,7 @@ → {줄: 제안(`B09_Estimation_Bill_Link.suggest` · 구조물 묶음에 딸린 줄 뺌) · 저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)} POST /api/projects/{id}/estimation/link/apply - {pairs [{line, target, mode?}], auto, mode, counts?, user?} + {pairs [{line, target, mode?}], auto, mode, counts?, user?} · 알림 남은 = 안 이은 항목 수 잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 → {user(새 캐시), 알림, payload(그 캐시로 셈)} · 이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층) @@ -137,11 +137,10 @@ async def apply( made, report = linker.link(draft, rows, pairs, body.mode, body.counts, modes) made = store.clean_user(made) layer = await asyncio.to_thread(keep, session, report.pop("기억")) - return { - "user": made, - "알림": {**report, "기억층": layer}, - "payload": await payload(project_id, root, made, True), - } + out = await payload(project_id, root, made, True) + left = linker.board(made, out["sheets"]["bill"]["rows"])["남은"] + report["남은"] = sum(1 for r in left if r["kind"] == "item") + return {"user": made, "알림": {**report, "기억층": layer}, "payload": out} @router.post(PATH + "/undo") diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index cfe1070fe..56e1efe3e 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -31,6 +31,7 @@ import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { currentProjectId } from "../A00_Common/b_project_switch"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { openBillLink } from "./B09_Estimation_UI_Link"; import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR"); @@ -135,6 +136,7 @@ export function createBillImport( // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); done(got.payload, merged(draft, got.user)); + void openBillLink(user(), done, mode.value); // 707-4 다음: 우리 공종으로 잇기 const n = got.알림 || {}; const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; diff --git a/B09_Estimation/B09_Estimation_UI_Link.ts b/B09_Estimation/B09_Estimation_UI_Link.ts new file mode 100644 index 000000000..bd87dca85 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Link.ts @@ -0,0 +1,325 @@ +/* ============================================================================= + * B09_Estimation_UI_Link.ts + * B09 설계내역서 내역 줄 ↔ 우리 공종 잇기 창(PLAN 707-4) — 불러온 경쟁사 줄(707-1 `hand:x…`)을 + * 우리 자동 줄(토공 · 운반 · 구조물)에 이어 B08 수량이 붙게. 저장 안 함(받은 캐시 → [저장] 때 서버 정본). + * + * 길 = `B09_Estimation_Router_Link.py` link/suggest(제안 · 남은 줄 · 빈 자리 · 이은 줄) · + * link/apply(잇기 · 맞춤 기억 씀) · link/undo(잇기 풀기) · link/forget(이 짝 잊기) — 셈 = 서버 단독 + * 창 = 제안 줄(꼬리표 지난번 이음 · 짝 표 · 이름 같음 · 후보 고름 · 수량 갈래 · 묶음 개수 · [잇기]) · + * 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기] · + * [이 짝 잊기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장]) + * 수량 갈래 기본 = 불러올 때 고른 것(가 넘겨받기 · 나 단가만) · 줄에서 바꿈 · + * 묶음 나 = 개수 칸(빈칸 = B08 수량 · 다르면 수량 칸 붉게) + * 고리 = 불러오기 끝(`…_UI_Import`) · 설계내역서 우클릭(`…_UI_Tabs_Bill_Edit`) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createListRow } from "@ui/ui_template_list_row"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { + openLogicPicker, + type PickExtra, +} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import { currentProjectId } from "../A00_Common/b_project_switch"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import type { TabContext } from "./B09_Estimation_UI_Tabs"; + +/** 받은 원가 자료 · 새 캐시를 화면에 — 불러오기 고리 = 페이지 `done` · 우클릭 = 탭 ctx */ +export type LinkDone = (payload: Row, user: Row) => void; +type Mode = "take" | "price"; + +const MODES = [ + { value: "take", text: "가 넘겨받기" }, + { value: "price", text: "나 단가만" }, +]; +const HEAD = { earth: "토공", haul: "운반", st: "구조물" } as Record; +const TONE: Record = { "지난번 이음": "success", "짝 표": "info" }; +/** 마지막에 고른 수량 갈래(불러올 때 · 창에서) — 우클릭 길 기본 */ +let lastMode: Mode = "take"; + +const flat = (t: unknown): string => String(t ?? "").replace(/\s+/g, ""); +const label = (r: Row): string => + [r.name, r.spec].filter(Boolean).join(" ") + (r.unit ? ` (${r.unit})` : ""); + +async function post(path: string, body: Row): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${currentProjectId()}/estimation/link/${path}`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return got; +} + +const fail = (head: string) => (error: unknown) => + showToast(`${head} — ${error instanceof Error ? error.message : error}`, "error"); + +/** 잇기 한 번 — 받은 것 화면에 → 새 캐시 · 알림 글 */ +async function linkPairs(user: Row, done: LinkDone, pairs: Row[], counts: Row): Promise { + const got = await post("apply", { pairs, mode: lastMode, counts, user }); + done(got.payload, got.user); + const n = got.알림 || {}; + const miss: Row[] = n.못함 || []; + const head = `이음 ${n.이음} · 묶음 ${n.묶음}${n.지움 ? ` · 안 줄 지움 ${n.지움}` : ""}`; + const tail = + (miss.length ? ` · 못함 ${miss.length}(${miss[0].까닭})` : "") + + (n.남은 != null ? ` · 안 이은 불러온 항목 ${n.남은}` : ""); + showToast(`${head}${tail} — [저장]해야 남음`, miss.length ? "warning" : "success", 6000); + return got.user; +} + +/** 우리 줄 고르기 — 단가연결 고르기 창(덧줄만 · 종류 = 토공 · 운반 · 구조물) */ +function pickOurs(spots: Row[], line: Row, take: (key: string) => void): void { + const group = line.kind !== "item"; + const extra: PickExtra[] = spots + .filter((s) => (s.head === "st") === group) + .map((s) => ({ + kind: String(s.head), + key: String(s.key), + tag: HEAD[s.head] ?? s.head, + name: String(s.name ?? ""), + sub: String(s.spec ?? ""), + unit: String(s.unit ?? ""), + onPick: () => take(String(s.key)), + })); + if (!extra.length) + return void showToast(group ? "빈 구조물 줄 없음" : "빈 토공 · 운반 줄 없음", "warning"); + const kinds = [...new Set(extra.map((x) => x.kind))]; + openLogicPicker(() => undefined, { + layer: "project", + owner: null, + libOnly: true, + title: `우리 공종으로 잇기 — ${label(line)}`, + extra, + kindFilter: kinds.map((k) => ({ value: k, text: HEAD[k] ?? k })), + pick: () => undefined, + }); +} + +/** 잇기 창 — user = 지금 캐시 · mode = 불러올 때 고른 갈래(없으면 마지막) */ +export async function openBillLink(user: Row, done: LinkDone, mode?: string): Promise { + if (mode === "take" || mode === "price") lastMode = mode; + let cur = user; + let seen: Row; + try { + seen = await post("suggest", { user: cur }); + } catch (error) { + return fail("제안을 못 받음")(error); + } + if (!seen.남은.length && !seen.이은.length) return; // 불러온 줄 없음 + let busy = false; + const run = (job: () => Promise): void => { + if (busy) return; + busy = true; + job() + .then((next) => { + if (next) cur = next; + return post("suggest", { user: cur }); + }) + .then((next) => ((seen = next), draw()), fail("잇기 못 함")) + .finally(() => (busy = false)); + }; + const all = createButton({ label: "모두 제안대로", variant: "filled" }); + const modeSel = createSelectField({ + options: MODES, + value: lastMode, + compact: true, + label: "수량 갈래(줄에서 바꿈)", + onChange: (v) => (lastMode = v as Mode), + }); + const body = el("div", { className: "b09-import__choices" }); + // 줄마다 고른 것(후보 · 갈래 · 개수) — 다시 그려도 남음 + const chosen = new Map(); + const pickOf = (s: Row) => { + // 기본 = 앞 줄이 안 고른 첫 후보(암석절취 연암 · 보통암 → 절토(연암) · 암발파) + const taken = new Set([...chosen.values()].map((c) => c.target)); + const first = (s.후보 as Row[]).find((c) => !taken.has(c.key)) ?? s.후보[0]; + const got = chosen.get(s.key) ?? { target: first.key, mode: null, count: "" }; + chosen.set(s.key, got); + return got; + }; + const pairOf = (s: Row): Row => { + const p = pickOf(s); + return { line: s.key, target: p.target, ...(p.mode && { mode: p.mode }) }; + }; + const countsOf = (lines: Row[]): Row => + Object.fromEntries( + lines + .map((s) => [s.key, Number(pickOf(s).count)]) + .filter(([, n]) => Number.isFinite(n) && (n as number) > 0), + ); + const forget = (key: string): void => + run(async () => { + const got = await post("forget", { key, user: cur }); + showToast( + got.남음 ? "이 짝 잊음 — 회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", + got.남음 ? "warning" : "success", + ); + }); + + const suggestRow = (s: Row): HTMLElement => { + const p = pickOf(s); + const tags = [createBadge(s.꼬리표, TONE[s.꼬리표] ?? "neutral")]; + if (s.후보.some((c: Row) => flat(c.name) === flat(s.name))) + tags.push(createBadge("이름 같음", "accent")); + if (!s.하나) tags.push(createBadge("고름", "warning")); + const target = createSelectField({ + options: s.후보.map((c: Row) => ({ value: c.key, text: `→ ${c.name}` })), + value: p.target, + compact: true, + ariaLabel: "이을 우리 줄", + onChange: (v) => (p.target = v), + }); + const lineMode = createSelectField({ + options: [{ value: "", text: "창 갈래" }, ...MODES], + value: p.mode ?? "", + compact: true, + ariaLabel: "이 줄 수량 갈래", + onChange: (v) => (p.mode = (v || null) as Mode | null), + }); + const items: HTMLElement[] = [...tags, target.root, lineMode.root]; + if (s.kind !== "item") { + const count = createInputField({ + type: "number", + compact: true, + min: 0, + value: p.count, + placeholder: "개수(B08)", + onInput: (v) => (p.count = v), + }); + items.push(count.root); + } + items.push( + createButton({ + label: "잇기", + variant: "ghost", + onClick: () => run(() => linkPairs(cur, done, [pairOf(s)], countsOf([s]))), + }), + ); + if (s.꼬리표 === "지난번 이음") + items.push( + createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(s.key) }), + ); + return createListRow({ + label: label(s), + sub: s.까닭 || (s.kind === "item" ? "" : "묶음 → 구조물 줄 통째"), + trailing: createToolbar({ items, size: 24 }), + }); + }; + const freeRow = (r: Row): HTMLElement => + createListRow({ + label: label(r), + sub: r.kind === "item" ? "" : "묶음", + trailing: createButton({ + label: "우리 공종으로 잇기", + variant: "ghost", + size: 24, + onClick: () => + pickOurs(seen.자리, r, (target) => + run(() => linkPairs(cur, done, [{ line: r.key, target }], {})), + ), + }), + }); + const linkedRow = (r: Row): HTMLElement => + createListRow({ + label: `${label(r.from)} → ${r.name}`, + sub: HEAD[String(r.key).split(":")[0]] ?? "", + trailing: createToolbar({ + size: 24, + items: [ + createButton({ + label: "잇기 풀기", + variant: "ghost", + onClick: () => run(() => undoLink(cur, done, r.key)), + }), + createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(r.key) }), + ], + }), + }); + + const draw = (): void => { + const lines: Row[] = seen.줄; + const has = new Set(lines.map((s) => s.key)); + const free = (seen.남은 as Row[]).filter((r) => !has.has(r.key)); + const items = (seen.남은 as Row[]).filter((r) => r.kind === "item").length; + const recall = lines.filter((s) => s.꼬리표 === "지난번 이음").length; + all.disabled = !lines.length; + body.replaceChildren( + createText( + `안 이은 불러온 줄 — 항목 ${items} · 묶음 ${seen.남은.length - items} · 제안 ${lines.length}` + + `(지난번 이음 ${recall} · 짝 표 ${lines.length - recall}) · 이은 줄 ${seen.이은.length}`, + "muted", + 14, + ), + createToolbar({ items: [modeSel.root] }), + ...(lines.length ? [createText("제안", "muted", 14), ...lines.map(suggestRow)] : []), + ...(free.length ? [createText("제안 없음", "muted", 14), ...free.map(freeRow)] : []), + ...(seen.이은.length + ? [createText("이은 줄", "muted", 14), ...seen.이은.map(linkedRow)] + : []), + ); + }; + openModal({ + title: mode ? "다음: 우리 공종으로 잇기" : "우리 공종으로 잇기", + closeLabel: "닫기", + width: "960px", + mount: (box, close, actions) => { + all.dataset.b09LinkAll = ""; + all.addEventListener("click", () => { + const lines: Row[] = seen.줄; + run(async () => { + const used = new Set(); // 같은 우리 줄 둘째부터 = 남김(고름) + const pairs = lines.map(pairOf).filter((p) => !used.has(p.target) && used.add(p.target)); + const next = await linkPairs(cur, done, pairs, countsOf(lines)); + close(); // 짧은 길 — 남은 줄은 알림 · 우클릭으로 + return next; + }); + }); + actions.append(all); + draw(); + box.append(body); + }, + }); +} + +async function undoLink(user: Row, done: LinkDone, target: string): Promise { + const got = await post("undo", { target, user }); + done(got.payload, got.user); + showToast("잇기 풂 — 불러온 줄로 · [저장]해야 남음", "success"); + return got.user; +} + +/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 · 잊기 */ +export function linkMenu(ctx: TabContext, key: string): MapContextMenuItem[] { + const done: LinkDone = (payload, user) => { + Object.assign(ctx.payload, payload); + ctx.setUser(user); + }; + const entry = ((ctx.user.bill?.rows as Row[]) || []).find((e) => e.key === key); + const forget = (): void => + void post("forget", { key, user: ctx.user }).then( + (got) => showToast(got.남음 ? "회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", "info"), + fail("못 잊음"), + ); + if (entry?.was) + return [ + ["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))], + ["이 짝 잊기", forget], + ]; + if (!key.startsWith("hand:x")) return []; + return [["우리 공종으로 잇기", () => void openBillLink(ctx.user, done)]]; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 1f102dad6..d802bdf02 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -26,6 +26,7 @@ import { 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"; +import { linkMenu } from "./B09_Estimation_UI_Link"; /** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */ export type BillTable = TabTable & { @@ -269,6 +270,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯) parts[1].push(...picks, ...more); } + parts[1].push(...linkMenu(ctx, key)); // 707-4 내역 줄 ↔ 우리 공종 잇기 · 풀기 · 잊기 // 옮김 · 레벨 — 같은 부모 형제와 자손째 if (!fixed(key)) { add(2, "위로 ↑", (w, e) => { From 0959f287177eaa96cd370850a3eb0b28ccf60d42 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:29:04 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C):=20=EC=9D=B4=20=EC=A7=9D=20=EC=9E=8A?= =?UTF-8?q?=EA=B8=B0=20=3D=20=EC=9E=8A=EC=9D=8C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EC=96=B5=EC=97=90=20=EB=82=A8=EA=B9=80=20?= =?UTF-8?q?=E2=80=94=20=EB=8A=90=EC=8A=A8=ED=95=9C=20=EA=B8=B0=EC=96=B5(?= =?UTF-8?q?=ED=92=88=EB=AA=85=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=EA=B0=99?= =?UTF-8?q?=EC=9D=8C)=EC=9C=BC=EB=A1=9C=EB=8F=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=20=EC=95=88=20=EB=90=A8=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=9C=BC=EB=A9=B4=20=ED=92=80=EB=A6=BC=20?= =?UTF-8?q?(PLAN=20707-4=20=EB=8D=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- B09_Estimation/B09_Estimation_Bill_Link.py | 11 +++++--- B09_Estimation/B09_Estimation_Router_Link.py | 7 ++--- resources/tester/test_707_4_bill_link.py | 27 +++++++++++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Link.py b/B09_Estimation/B09_Estimation_Bill_Link.py index 0732de6f8..456cc062c 100644 --- a/B09_Estimation/B09_Estimation_Bill_Link.py +++ b/B09_Estimation/B09_Estimation_Bill_Link.py @@ -33,6 +33,7 @@ from M02_MasterTemplete.M02_Hopyo_Match import line_key MEMORY = "내역맞춤" TABLE, RECALL = "짝 표", "지난번 이음" +FORGOT = "잊음" # 기억 칸 {잊음: True} = 이 짝 잊기 표시 — 느슨한 기억에서도 안 불러옴 · 다시 이으면 덮임 HEADS = ("earth", "st", "haul") _TAIL = re.compile(r"^(.+?)\((.*)\)$") ROCK = ("절토(연암)", "암발파") @@ -97,11 +98,15 @@ def _loose(key: str) -> str: def recall(memory: dict[str, dict[str, Any]], row: dict[str, Any]) -> dict[str, Any] | None: - """기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것.""" + """기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것(잊은 칸 = 없음).""" key = key_of(row) if isinstance(memory.get(key), dict): - return memory[key] - loose = [v for k, v in memory.items() if isinstance(v, dict) and _loose(k) == _loose(key)] + return None if memory[key].get(FORGOT) else memory[key] + loose = [ + v + for k, v in memory.items() + if isinstance(v, dict) and not v.get(FORGOT) and _loose(k) == _loose(key) + ] return loose[0] if len({v.get("확정") for v in loose}) == 1 else None diff --git a/B09_Estimation/B09_Estimation_Router_Link.py b/B09_Estimation/B09_Estimation_Router_Link.py index d7f27a368..3f8f05ebf 100644 --- a/B09_Estimation/B09_Estimation_Router_Link.py +++ b/B09_Estimation/B09_Estimation_Router_Link.py @@ -10,7 +10,8 @@ 이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층) POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기(맞춤 기억 그대로) POST …/link/forget {key, user?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 → - 맞춤 열쇠 · 쓸 수 있는 층에서 지움 · 남음 = 못 지운 위 층 기억) + 맞춤 열쇠 · 쓸 수 있는 층에 잊음 표시 `{잊음: True}` — 느슨한 기억도 안 불러옴 · + 개인 층 표시가 회사 기억을 덮음 · 남음 = 그래도 살아 있는 기억) 맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김). 끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`. """ @@ -175,6 +176,6 @@ async def forget( memo = memo or next((r["memo"] for r in board["이은"] if r["key"] == body.key), None) if not memo: raise HTTPException(status_code=400, detail="불러온 줄 · 이은 줄이 아님") - layer = await asyncio.to_thread(keep, session, {memo: None}) - left = memo in await asyncio.to_thread(recalled, session) + layer = await asyncio.to_thread(keep, session, {memo: {linker.FORGOT: True}}) + left = bool((await asyncio.to_thread(recalled, session)).get(memo, {}).get("확정")) return {"잊음": memo, "기억층": layer, "남음": left} diff --git a/resources/tester/test_707_4_bill_link.py b/resources/tester/test_707_4_bill_link.py index 13b342a39..4be04925b 100644 --- a/resources/tester/test_707_4_bill_link.py +++ b/resources/tester/test_707_4_bill_link.py @@ -274,9 +274,28 @@ def test_줄마다_갈래_판(geo): def test_이_짝_잊기(tmp_path, monkeypatch): monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) user = {"company_id": 7, "user_id": 3, "role": "USER"} + gone = {lk.FORGOT: True} route.keep(user, {"a||": {"확정": "earth:4"}, "b||": {"확정": "earth:5"}}) hopyo_store.remember(tmp_path / "company", {"c||": {"확정": "x"}}, lk.MEMORY) - assert route.keep(user, {"a||": None}) == "personal" - assert set(route.recalled(user)) == {"b||", "c||"} - route.keep(user, {"c||": None}) - assert "c||" in route.recalled(user) # 일반 = 회사 기억 못 지움(남음) + assert route.keep(user, {"a||": gone}) == "personal" + assert route.recalled(user)["a||"] == gone and route.recalled(user)["b||"]["확정"] == "earth:5" + route.keep(user, {"c||": gone}) # 일반 = 회사 기억을 개인 층 표시로 덮음 + assert route.recalled(user)["c||"] == gone + + +def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch): + monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) + user = {"company_id": 7, "user_id": 3, "role": "USER"} + row = {"name": "토사절취", "spec": "A", "unit": "m3"} + other = {"name": "토사절취", "spec": "B", "unit": "m3"} + live = {"확정": "earth:4", "이름": "절토(토사)"} + route.keep(user, {lk.key_of(other): live}) + assert ( + lk.recall(route.recalled(user), row) == live + ) # 규격 달라도 품명 · 단위 같음 = 느슨한 기억 + route.keep(user, {lk.key_of(row): {lk.FORGOT: True}}) + memory = route.recalled(user) + assert lk.recall(memory, row) is None # 잊음 = 느슨한 기억도 끊김 + assert lk.recall(memory, other) == live # 다른 줄은 그대로 + route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림 + assert lk.recall(route.recalled(user), row) == live From f37a521136f5781e44614183f03a312c856233d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:29:24 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(B09=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0):=20.STC(STmate=20=EC=9B=90=EB=B3=B8)=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BD=EA=B8=B0=20=E2=80=94=20=EB=82=B4=EC=97=AD?= =?UTF-8?q?=20BOQ11=20=C2=B7=20=EB=8B=A8=EA=B0=80=20COSTN=20=C2=B7=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EA=B8=B8=20=C2=B7=20=EC=B0=BD=20?= =?UTF-8?q?*.STC=20(PLAN=20707-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B09_Estimation_Stc — DBF 필드 이름으로 읽음 · 묶음 T · 소계 E · 제외 C · 호표 번 = 같은 글자 코드 정렬 차례 - 다공구(BOQ12) · 변경 차수 = 당초만 읽고 알림 · BDQTY · COSTN.SMT · 요율 = UNKNOWN(같은 공사 엑셀로) - Router_Import 이름 끝 갈림(.estx · .stc) · 미리 보기 알림 · UI_Import 파일 고르기 *.stc · 창 알림 줄 - 시험 test_707_6_stc 16 — 실무 STC 일곱 직접비 = PRJLST · 기번3 · 영월 · 봉화 엑셀과 줄마다 같음 Co-Authored-By: Claude Opus 5.5 --- .../B09_Estimation_Router_Import.py | 33 ++- B09_Estimation/B09_Estimation_Stc.py | 274 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Import.ts | 13 +- resources/tester/test_707_6_stc.py | 174 +++++++++++ 4 files changed, 477 insertions(+), 17 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Stc.py create mode 100644 resources/tester/test_707_6_stc.py diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index e2868fde1..fafbea2d6 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -18,9 +18,10 @@ {file_key, name, sheets, counts, linked, missing, patch{labor_cover, machine_cover, materials}} file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 · - .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) = 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · - 요율 = 400 「같은 공사 엑셀에서」 · - 미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) + .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) · .STC(STmate 원본 · 707-6 `B09_Estimation_Stc`) = + 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · 요율 = 400 「같은 공사 엑셀에서」 · + 미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) · + `알림` = 안 읽은 몫(다공구 · 변경 차수) 화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈. `B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한). """ @@ -44,6 +45,7 @@ from B09_Estimation import B09_Estimation_Estx as estx from B09_Estimation import B09_Estimation_Import_Prices as import_prices from B09_Estimation import B09_Estimation_Import_Rates as import_rates from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Stc as stc from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill from B09_Estimation.B09_Estimation_Calc_Base import compute_base @@ -86,22 +88,26 @@ def _keep(root: Path, name: str, data: bytes) -> str: return key -def _is_estx(name: str) -> bool: - return name.lower().endswith(".estx") +_RAW = {".estx": estx, ".stc": stc} + + +def _raw(name: str) -> Any: + """원본 파일 읽기 모듈(.estx · .STC) — 엑셀이면 None.""" + return _RAW.get(Path(name).suffix.lower()) def _parse(name: str, data: bytes) -> dict[str, Any]: try: - return (estx.read_bill if _is_estx(name) else read_bill)(name, data) + return (raw.read_bill if (raw := _raw(name)) else read_bill)(name, data) except BillExcelError as error: raise HTTPException(status_code=400, detail=str(error)) from error def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None: - """.estx 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `estx.UNKNOWN`).""" - if not _is_estx(name): + """원본 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `UNKNOWN`).""" + if not (raw := _raw(name)): return None - got = estx.read_hopyo(name, data) + got = raw.read_hopyo(name, data) codes = {(k, x["코드"]) for k, v in got.items() for x in v} hits = [h for line in parsed["줄"] if (h := line.get("호표"))] return {**{k: len(v) for k, v in got.items()}, @@ -189,6 +195,7 @@ async def import_bill( **importer.preview(parsed, rows), "이음": len(found), "호표": hopyo, + "알림": parsed.get("알림") or [], } @@ -256,8 +263,10 @@ async def import_rate_sheet( root = await _project_root(project_id) key, name, data = await _file(file, file_key, root) draft = _draft(user) - if _is_estx(name): - raise HTTPException(400, "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서 불러옴") + if _raw(name): + raise HTTPException( + 400, f"원가계산서 요율은 {Path(name).suffix} 에서 못 읽음 — 같은 공사 엑셀에서 불러옴" + ) mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[ "estimate" ] @@ -286,7 +295,7 @@ async def import_price_sheets( draft = _draft(user) mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root) try: - reader = estx.read_prices if _is_estx(name) else import_prices.read_prices + reader = raw.read_prices if (raw := _raw(name)) else import_prices.read_prices read = await asyncio.to_thread(reader, name, data) except (ExcelError, BillExcelError) as error: raise HTTPException(400, str(error)) from error diff --git a/B09_Estimation/B09_Estimation_Stc.py b/B09_Estimation/B09_Estimation_Stc.py new file mode 100644 index 000000000..7f19a83b4 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Stc.py @@ -0,0 +1,274 @@ +"""B09 STmate 원본(.STC) 바로 읽기(PLAN 707-6) — 메모리에서 풀어 평문 표만 · 저장 안 함. + +.STC = 보통 ZIP · 표마다 dBase/FoxPro DBF(cp949 · 이름 대소문자 섞임) +(지식DB `original/원가계산/STmate/STC_컨테이너_구조.md` · `STC_필드사전_핵심.md`): + 머리 4 레코드 수 · 8 머리 길이 · 10 레코드 길이 · 32 부터 필드 설명자 32 바이트씩(0x0D 끝) · + 필드 = 이름으로만 찾음(위치는 판마다 다름) · 레코드 첫 바이트 `*` = 지운 줄. + 접미 1 = 당초 · 2~4 = 변경 차수 — 당초만 읽음. + +돌려주는 꼴 = 엑셀 · .estx 읽기와 같은 dict: + `read_bill` = `B09_Estimation_Bill_Excel.read_bill`(707-1) — BOQ11 + BDCODE1 T1~T5 = 묶음(클수록 바깥) · E1~E5 = 소계 · 합계(그 깊이 묶음 닫음 · 안 읽음) · + C1~C5 = 제외금액 합계(닫는 묶음 = 제외 · 관급 등) · `:` = 페이지 넘김 · 이름 없음 = 빈 줄 · + 단위 끝 `%` = 율 줄(제외 안 「수수료」 = 조달수수료율 · 그 밖 = 1 식 · 단가 = 금액) · + 호표 = 코드 B 대가 · D 산근 · M 자재 · 번 = COSTN 같은 글자 코드 정렬 차례 + (STmate 엑셀 비고 「대가 5호표」 · 「자재 28」 과 같음 — 봉화 · 기번3 실측) · + `알림` = 다공구(BOQ12 ~) · 변경 차수 줄 수(안 읽음) + `read_prices` = `B09_Estimation_Import_Prices.read_prices`(707-3) — COSTN + L 노임 · M 자재(+ 출처 다섯 칸 AMT_11~51 · 쪽 PG_11~51 · 이름 = Start8x.Ini JMOK1~5) · + X 기계(시간당 노 · 재 · 경 · 단위 「X」 → 시간) + `read_hopyo` = 호표 목록(COSTN B 대가 · D 산근 — 코드 · 번 · 이름 · 단가 셋) +안 읽음(같은 공사 STmate 엑셀 「일위대가표」 로 메움) = `UNKNOWN`. +""" + +from __future__ import annotations + +import io +import struct +import zipfile +from decimal import Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, _plain, _unwrap + +UNKNOWN = ( + "일위대가 구성 수량(BDQTY — 난독)", + "단가산출 전개 글(COSTN.SMT — 메모 자리 규칙 미확정)", + "원가계산서 요율(PRJLST 는 금액만)", +) +#: 가격자료 다섯 칸 이름 — Start8x.Ini JMOK1~5 가 없을 때 +SOURCES = ("조달가격", "물가자료", "물가정보", "거래가격", "견적단가") +HOPYO = {"B": "대가", "D": "산근", "M": "자재"} +PART_KEYS = ("labor", "material", "expense") + + +class StcError(BillExcelError): + """못 읽는 .STC — 길이 400 으로 답함.""" + + +def _zip(data: bytes) -> tuple[zipfile.ZipFile, dict[str, str]]: + if data[:4] != b"PK\x03\x04": + raise StcError(".STC 가 아님 — ZIP 머리 없음") + try: + book = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile as error: + raise StcError("깨진 .STC") from error + if any(i.flag_bits & 1 for i in book.infolist()): + raise StcError("암호 걸린 .STC — 읽을 수 없음") + return book, {n.upper(): n for n in book.namelist()} + + +def table(book: tuple[zipfile.ZipFile, dict[str, str]], name: str) -> list[dict[str, Any]]: + """`{name}.DBF` → 레코드 목록(파일 차례 · 지운 줄 뺌) — C 글 · N 수(Decimal) · L 참 거짓.""" + zf, names = book + if f"{name}.DBF" not in names: + raise StcError(f".STC 안에 {name} 표가 없음") + d = zf.read(names[f"{name}.DBF"]) + count, start, size = struct.unpack_from(" len(d): + raise StcError(f"{name} 표 길이가 머리와 다름") + out = [] + for r in range(count): + base = start + r * size + if d[base : base + 1] == b"*": + continue + row: dict[str, Any] = {} + for field, kind, off, width in cols: + raw = d[base + off : base + off + width] + if kind == "C": + row[field] = raw.decode("cp949", "replace").strip() + elif kind in "NF": + try: + row[field] = Decimal(raw.decode("ascii").strip() or 0) + except (InvalidOperation, UnicodeDecodeError): + row[field] = Decimal(0) + elif kind == "L": + row[field] = raw in (b"T", b"t", b"Y", b"y") + out.append(row) + return out + + +def _parts(row: dict[str, Any], money: str) -> dict[str, Decimal]: + """money = 'AMT1'(단가) · '_AMT1'(금액) → {labor, material, expense}.""" + return {k: row.get(f"{p}{money}") or Decimal(0) for p, k in zip("LMS", PART_KEYS)} + + +def _numbers(costn: list[dict[str, Any]]) -> dict[str, int]: + """대가 · 산근 · 자재 코드 → 번(같은 글자 코드 정렬 차례).""" + out = {} + for head in HOPYO: + for n, code in enumerate(sorted({r["CODE"] for r in costn if r["CODE"][:1] == head}), 1): + out[code] = n + return out + + +def _changes(rows: list[dict[str, Any]]) -> int: + """변경 차수(2~4) 금액이 찬 줄 수 — 수량 칸만 베낀 당초 판은 0.""" + return sum(any(r.get(f"T_AMT{k}") for k in "234") for r in rows) + + +def read_bill(name: str, data: bytes) -> dict[str, Any]: + """.STC → 엑셀 `read_bill` 과 같은 꼴(`행` = BOQ11 차례 1~).""" + book = _zip(data) + rows = table(book, "BOQ11") + numbers = _numbers(table(book, "COSTN")) + lines: list[dict[str, Any]] = [] + stack: list[tuple[int, dict[str, Any]]] = [] # (깊이, 묶음 줄) + skipped = 0 + for at, row in enumerate(rows, 1): + code, item_name = row.get("BDCODE1") or "", row.get("NAME1") or "" + kind = code[:1] if len(code) == 2 and code[1:].isdigit() else "" + if kind in ("E", "C"): # 소계 · 합계 — 그 깊이 묶음 닫음 · C = 제외 + skipped += 1 + hit = next( + (n for n in range(len(stack) - 1, -1, -1) if stack[n][0] == int(code[1])), None + ) + if hit is not None: + stack[hit][1]["제외"] = stack[hit][1]["제외"] or kind == "C" + del stack[hit:] + continue + if not item_name or code == ":": + continue + group = kind == "T" + if group: + while stack and stack[-1][0] <= int(code[1]): + stack.pop() + up = stack[-1][1] if stack else None + hopyo = None + if not group and code[:1] in HOPYO: + hopyo = {"종류": HOPYO[code[0]], "코드": code} + if code in numbers: + hopyo["번"] = numbers[code] + line = { + "행": at, + "묶음": group, + "위": up["행"] if up else None, + "공종": (row.get("ITNUM") or "") if group else "", + "이름": item_name, + "규격": row.get("SIZE1") or "", + "단위": row.get("UNIT1") or "", + "수량": None if group else row.get("QTY1") or Decimal(0), + "단가": _parts(row, "AMT1"), + "금액": _parts(row, "_AMT1"), + "비고": "", + "호표": hopyo, + "제외": False, + } + if group: + stack.append((int(code[1]), line)) + lines.append(line) + by_row = {x["행"]: x for x in lines} + out, fee, pct_lines = [], None, 0 + for line in lines: # 부모가 앞 — 제외 물려받음 · 맨 위 「관급」 + up = by_row.get(line["위"]) + line["제외"] = line["제외"] or (up["제외"] if up else "관급" in line["이름"]) + if not line["묶음"] and line["단위"].endswith("%"): + if line["제외"] and "수수료" in line["이름"]: + fee = fee if fee is not None else line["수량"] + continue + pct_lines += 1 + qty = _plain(line["수량"]) + line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"}) + line["규격"] = " ".join(t for t in (line["규격"], f"({qty} %)") if t) + out.append(line) + _unwrap(out) + total = {"직접": Decimal(0), "제외": Decimal(0)} + for line in out: + if not line["묶음"]: + total["제외" if line["제외"] else "직접"] += sum(line["금액"].values()) + notes = [ + f"다공구 — {t[:-4]} {len(table(book, t[:-4]))} 줄 있음(당초 첫 공구 BOQ11 만 읽음)" + for t in sorted(book[1]) + if t.startswith("BOQ1") and t.endswith(".DBF") and t != "BOQ11.DBF" + ] + if changed := _changes(rows): + notes.append(f"변경 차수 — {changed} 줄 있음(당초만 읽음)") + return _plain( + { + "꼴": "stc", + "시트": "BOQ11", + "줄": out, + "합계": total, + "조달수수료율": fee, + "합계줄": skipped, + "율줄": pct_lines, + "알림": notes, + } + ) + + +def _item(row: dict[str, Any]) -> dict[str, Any]: + return {"name": row["NAME"], "spec": row.get("SIZE") or "", "unit": row.get("UNIT") or ""} + + +def _titles(book: tuple[zipfile.ZipFile, dict[str, str]]) -> tuple[str, ...]: + zf, names = book + if "START8X.INI" not in names: + return SOURCES + text = zf.read(names["START8X.INI"]).decode("cp949", "replace") + got = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) + return tuple(got.get(f"JMOK{n}", "").strip() or SOURCES[n - 1] for n in range(1, 6)) + + +def read_prices(name: str, data: bytes) -> dict[str, Any]: + """.STC → 707-3 `read_prices` 꼴 {labor, material, machine, *_sheet} · + 자재 줄 + `sources` [{출처, 단가, 쪽}](값 있는 칸만).""" + book = _zip(data) + titles = _titles(book) + costn = [ + r for r in table(book, "COSTN") if r.get("NAME") and not (r.get("UNIT") or "").endswith("%") + ] + labor = [{**_item(r), "price": r["TAMT1"]} for r in costn if r["CODE"][:1] == "L"] + material = [ + { + **_item(r), + "price": r["TAMT1"], + "sources": [ + {"출처": titles[n - 1], "단가": r.get(f"AMT_{n}1"), "쪽": r.get(f"PG_{n}1") or ""} + for n in range(1, 6) + if r.get(f"AMT_{n}1") + ], + } + for r in costn + if r["CODE"][:1] == "M" + ] + machine = [ + {**_item(r), "unit": "시간", **_parts(r, "AMT1")} for r in costn if r["CODE"][:1] == "X" + ] + return _plain( + { + "labor": labor, + "labor_sheet": "COSTN L", + "material": material, + "material_sheet": "COSTN M", + "machine": machine, + "machine_sheet": "COSTN X", + } + ) + + +def read_hopyo(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]: + """.STC → {대가: COSTN B, 산근: COSTN D} 목록 + [{코드, 번, 이름, 규격, 단위, 단가 {labor, material, expense}, 합계}] — 구성 줄은 `UNKNOWN`.""" + costn = table(_zip(data), "COSTN") + numbers = _numbers(costn) + out: dict[str, list[dict[str, Any]]] = {"대가": [], "산근": []} + for r in sorted(costn, key=lambda r: r["CODE"]): + if r["CODE"][:1] in "BD" and r.get("NAME"): + out[HOPYO[r["CODE"][0]]].append( + { + "코드": r["CODE"], + "번": numbers[r["CODE"]], + "이름": r["NAME"], + "규격": r.get("SIZE") or "", + "단위": r.get("UNIT") or "", + "단가": _parts(r, "AMT1"), + "합계": r.get("TAMT1") or Decimal(0), + } + ) + return _plain(out) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 24b46fcaa..fcdd3445b 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -17,6 +17,7 @@ * * EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) · * 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음) + * STmate 원본(.STC · PLAN 707-6) = .estx 와 같은 자리 · 안 읽은 몫(다공구 · 변경 차수) = 창 알림 줄 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -81,9 +82,10 @@ export function createBillImport( ): HTMLButtonElement { const open = (seen: Row, mode: { value: string }): void => { const total = seen.합계 || {}; + const raw = ({ estx: ".estx", stc: ".STC" } as Record)[seen.꼴]; const lines = [ `${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`, - `${seen.꼴 === "estx" ? ".estx" : "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + + `${raw ?? "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + (seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""), seen.이음 ? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음` @@ -91,9 +93,10 @@ export function createBillImport( ...(seen.호표 ? [ `호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`, - "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)", + `원가계산서 요율은 ${raw} 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)`, ] : []), + ...((seen.알림 as string[] | undefined) ?? []), ]; const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { radio: "b09-import-mode", @@ -192,7 +195,7 @@ export function createBillImport( label: "엑셀 내역 불러오기", variant: "ghost", onClick: () => - pickFiles({ accept: ".xls,.xlsx,.estx" }, ([file]) => { + pickFiles({ accept: ".xls,.xlsx,.estx,.stc" }, ([file]) => { const form = new FormData(); form.append("file", file); form.append("user", JSON.stringify(user())); @@ -357,7 +360,7 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri function pick(kind: Kind, ctx: TabContext): void { pickFiles( - { accept: ".xlsx,.xls,.estx" }, + { accept: ".xlsx,.xls,.estx,.stc" }, ([file]) => void postSheet(kind, ctx.user, file).then((got) => openResult(kind, ctx, got), fail), ); @@ -376,7 +379,7 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] { ? void postSheet(kind, ctx.user, null).then( (got) => openResult(kind, ctx, got), (error: unknown) => { - fail(error); // 지난 파일이 .estx(요율) · 없음 = 까닭 알리고 다시 고름 + fail(error); // 지난 파일이 .estx · .STC(요율) · 없음 = 까닭 알리고 다시 고름 pick(kind, ctx); }, ) diff --git a/resources/tester/test_707_6_stc.py b/resources/tester/test_707_6_stc.py new file mode 100644 index 000000000..a27ed41d7 --- /dev/null +++ b/resources/tester/test_707_6_stc.py @@ -0,0 +1,174 @@ +"""707-6 STmate 원본(.STC) 바로 읽기 — `B09_Estimation_Stc` (읽기만 · 저장 안 함). + +- 실무 .STC 일곱(지식DB 실무문서) = 항목 수 · 직접비 · 제외 · 노임 · 자재 · 기계 · 호표 목록 줄 수 · + 직접비 = 파일 안 공사 금액(PRJLST TCOST1) +- 기번3 · 영월 · 봉화 = 같은 공사 STmate 엑셀(707-1 · 707-3)과 줄마다 같음 + (이름 · 규격 · 수량 · 단가 · 금액 · 호표 코드 · 번 · 제외 · 묶음 나무 · 단가표) +- 다공구(BOQ12) · 변경 차수 = 당초만 읽고 알림 · 707-1 짜기 그릇에 그대로 부음 +- 깨진 파일 · 표 없음 = 막힘 · 불러오기 길 = 이름 끝 .STC 갈림 +""" + +import io +import sys +import time +import zipfile +from pathlib import Path + +import pytest + +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_Bill_Import as imp # noqa: E402 +from B09_Estimation import B09_Estimation_Import_Prices as ip # noqa: E402 +from B09_Estimation import B09_Estimation_Stc as st # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Excel import read_bill as read_excel # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +K = ROOT / "resources" / "knowledge" / "original" / "실무문서" +G3 = "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3)" +YW = "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월" +BH = "04.산불 봉화/01. 내역서/{}2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km" +SG = "25년 산불진화임도(기번8)(울진소광)/" +NEW = "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)" +FILES = { + "기번3": K / f"{G3}.STC", + "영월": K / f"{YW}.STC", + "봉화": K / f"{BH.format('13-')}.STC", + "계류보전": K / "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", # noqa: E501 + "소광": K / f"{SG}1. 설계원본/03-내역/22-2025년 산불진화임도사업(울진 금강송 소광).STC", + "소광다공구": K / f"{SG}{NEW}.STC", + "소광변경": K / f"{SG}변경 1차분-{NEW}.STC", +} # fmt: skip +EXCEL = {"기번3": K / f"{G3}.xlsx", "영월": K / f"{YW}.xlsx", "봉화": K / f"{BH.format('')}.xlsx"} +#: (항목 수, 직접비, 제외, 노임, 자재, 기계, 대가, 산근) +WANT = { + "기번3": (136, 710359136, 69474220, 12, 39, 25, 34, 46), + "영월": (108, 767148700, 75263810, 16, 55, 29, 23, 69), + "봉화": (77, 781283415, 142783280, 19, 67, 25, 45, 65), + "계류보전": (98, 93027484, 0, 7, 25, 18, 13, 35), + "소광": (46, 197135038, 53316160, 14, 35, 24, 25, 40), + "소광다공구": (46, 197135038, 53316160, 14, 41, 24, 37, 62), + "소광변경": (70, 173311386, 53316160, 14, 40, 31, 39, 54), +} +NOTES = { + "계류보전": ["변경 차수 — 96 줄 있음(당초만 읽음)"], + "소광다공구": ["다공구 — BOQ12 89 줄 있음(당초 첫 공구 BOQ11 만 읽음)"], + "소광변경": ["변경 차수 — 93 줄 있음(당초만 읽음)"], +} +EMPTY = {"materials": [], "bill": {"items": []}, "hopyo": {}} + + +@pytest.fixture(scope="module") +def data(): + return {k: p.read_bytes() for k, p in FILES.items()} + + +@pytest.mark.parametrize("name", list(FILES)) +def test_실무_stc_줄_수_합계_공사금액(data, name): + t = time.perf_counter() + bill = st.read_bill(FILES[name].name, data[name]) + prices = st.read_prices(FILES[name].name, data[name]) + hopyo = st.read_hopyo(FILES[name].name, data[name]) + print(f"{name} 읽기 {time.perf_counter() - t:.3f} s") + got = ( + sum(1 for x in bill["줄"] if not x["묶음"]), + bill["합계"]["직접"], + bill["합계"]["제외"], + *(len(prices[k]) for k in ("labor", "material", "machine")), + len(hopyo["대가"]), + len(hopyo["산근"]), + ) + assert got == WANT[name] + assert bill["알림"] == NOTES.get(name, []) + # 직접비 = 파일 안 공사 금액(첫 공구) + prj = st.table(st._zip(data[name]), "PRJLST") + assert bill["합계"]["직접"] == prj[0]["TCOST1"] + assert all(r["price"] > 0 for r in prices["labor"]) + assert all(h["합계"] == sum(h["단가"].values()) for h in hopyo["대가"] + hopyo["산근"]) + + +@pytest.mark.parametrize("name", list(EXCEL)) +def test_엑셀과_줄마다_같음(data, name): + mine = st.read_bill("a.STC", data[name]) + excel = read_excel(EXCEL[name].name, EXCEL[name].read_bytes()) + + def key(line, numbered): + h = line["호표"] or {} + number = h.get("번") if numbered.get(line["행"]) else None + return (line["이름"], line["규격"], line["단위"], line["수량"], line["단가"], + line["금액"], h.get("코드"), number, line["제외"]) # fmt: skip + + # 번 = 엑셀 비고에 있을 때만 견줌(영월 · 봉화 자재 = 비고에 번 없음) + has = [bool((x["호표"] or {}).get("번")) for x in excel["줄"]] + assert len(mine["줄"]) == len(has) + numbered = [{x["행"]: n for x, n in zip(got["줄"], has)} for got in (mine, excel)] + items = [[key(x, n) for x in got["줄"]] for got, n in zip((mine, excel), numbered)] + assert items[0] == items[1] + + def tree(got): + names = {x["행"]: x["이름"] for x in got["줄"]} + return [(x["이름"], names.get(x["위"])) for x in got["줄"]] + + assert tree(mine) == tree(excel) + assert mine["합계"] == excel["합계"] + assert mine["조달수수료율"] == excel["조달수수료율"] == 0.54 + + +@pytest.mark.parametrize("name", ["기번3", "봉화"]) +def test_단가표_엑셀과_같음(data, name): + mine = st.read_prices("a.STC", data[name]) + excel = ip.read_prices(EXCEL[name].name, EXCEL[name].read_bytes()) + for kind in ("labor", "material", "machine"): + keys = ("name", "spec", "unit", "price", "labor", "material", "expense") + rows = [sorted(tuple(r.get(k) for k in keys) for r in got[kind]) for got in (mine, excel)] + assert rows[0] == rows[1], kind + + +def test_자재_출처_칸(data): + stuff = st.read_prices("a.STC", data["봉화"])["material"] + pipe = next(x for x in stuff if x["name"] == "PVC파이프") + assert pipe["price"] == 3475 + assert {"출처": "거래가격", "단가": 3475, "쪽": "793"} in pipe["sources"] # 엑셀 자재단가대비표 + assert [s["출처"] for s in pipe["sources"]] == ["물가자료", "물가정보", "거래가격"] + + +def test_707_1_그릇에_부음(data): + got = st.read_bill("a.STC", data["봉화"]) + rows = compute_estimation(EMPTY, {}, None, {})["bill"]["rows"] + made, report = imp.apply(got, {}, rows, "abcd1234") + made = store.clean_user(made) + bill = compute_estimation(EMPTY, made, None, {})["bill"] + assert report["실음"] == len(got["줄"]) + assert bill["totals"]["direct"]["sum"] == 781283415 + assert bill["totals"]["direct"]["blank"] == 0 + assert imp.differences(got, report, bill["rows"]) == [] + assert made["estimate"]["procurement_fee_pct"] == 0.54 + + +def test_깨진_파일_표_없음_막힘(): + with pytest.raises(st.StcError): + st.read_bill("a.STC", b"not a zip") + with pytest.raises(st.StcError): + st.read_bill("a.STC", b"PK\x03\x04 broken") + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("COSTN.DBF", b"") + with pytest.raises(st.StcError, match="BOQ11"): + st.read_bill("a.STC", buf.getvalue()) + + +def test_불러오기_길_stc_갈림(data): + """이름 끝 .STC = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보).""" + from B09_Estimation import B09_Estimation_Router_Import as route + + name = "봉화.stc" + parsed = route._parse(name, data["봉화"]) + assert parsed["꼴"] == "stc" and parsed["합계"]["직접"] == 781283415 + assert route._hopyo(name, data["봉화"], parsed) == {"대가": 45, "산근": 65, "가리킴": 61} + assert route._hopyo("a.xlsx", data["봉화"], parsed) is None + with pytest.raises(route.HTTPException) as got: + route._parse("a.STC", b"not a zip") + assert got.value.status_code == 400 From b3f3aed0a5dc7af2fbd8e9a082f315638570091c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:39:41 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix(M02=20=ED=86=B5=ED=95=A9=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=C2=B7=20704-4=20=C3=97=20706-1):=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=EC=97=90=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=C2=B7=20=ED=9A=8C=EC=82=AC=EB=A1=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EB=95=8C=20=EA=B7=9C=EA=B2=A9=20=EC=97=B4=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=EC=9D=B4=20=EB=B2=84=EB=A0=A4=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=ED=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흠: 개인 층 하위 호표(사전조합)를 담은 구조물 조합을 회사로 올리면 참조가 회사 새 키로 바뀌는데 규격 열 수량 열쇠(`참조|personal|…`)는 그대로 → 그 줄 열 값이 버려져 기본 수량으로 셈 (실측: 거창 제5 · 6호표 묶음 2.5x2.0 열 1,270,715.6 → 회사 사본 1,259,136.2 · 차 11,579.4) - 고침: 참조 따라 복사(carry)할 때 규격 수량 열쇠도 새 참조로 바꿈 · 따라 복사되는 하위 문서도 같게 - 고친 파일: M02_MasterTemplete/M02_Labor_Ref.py · resources/tester/test_m02_labor_ref.py - 확인: 시험 test_m02_labor_ref · test_706_1 27 · test_704_4_spec 10 통과 · 브레인검증회사 다시 요청 · 승인 → 회사 사본 2.5 열 1,270,715.6 = 원본 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Ref.py | 16 +++++++++++++--- resources/tester/test_m02_labor_ref.py | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 69cb5600e..34a8f3005 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -350,12 +350,22 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]: got = read_ref(ref) if got is None: raise _bad(f"없는 참조 「{ref['키']}」") - body = origin_mark({**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)}, ref) - made = create(kind_of(ref["키"]), body) + body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)} + made = create(kind_of(ref["키"]), origin_mark(_spec_moved(body, done), ref)) done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]} return done[ref_id(ref)] - return {**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)} + return _spec_moved({**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}, done) + + +def _spec_moved(doc: dict[str, Any], done: dict[str, Ref]) -> dict[str, Any]: + """규격 열 수량(704-4 · 줄 id = `ref_id`) 열쇠도 새 참조로 — 안 바꾸면 그 줄 열 값이 버려짐.""" + spec = doc.get("규격") + qty = spec.get("수량") if isinstance(spec, dict) else None + if not isinstance(qty, dict) or not done: + return doc + moved = {old: ref_id(new) for old, new in done.items()} + return {**doc, "규격": {**spec, "수량": {moved.get(k, k): v for k, v in qty.items()}}} def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]: diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 412ea1a10..aa12c2a0e 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -326,6 +326,27 @@ def test_복사는_못_읽는_참조를_같이_복사_본뜬(world): assert e.value.status == 400 +def test_복사_새_키_규격_열_수량도_따라감(world): + """통합 점검 — 회사에 올리기 승인 · 회사로 복사: 개인 참조가 회사 새 키로 바뀌면 규격 열 수량 열쇠도.""" + inner = _combo(world["personal"], [{"로직": TEN}], "안") + old = f"참조|personal|7/42|{inner}" + spec = { + "이름": "H", + "열": ["1.5", "2.0"], + "기본": "1.5", + "수량": {old: {"1.5": 1, "2.0": 1.3}, "고정|돌|가|㎡": {"1.5": 2, "2.0": 2.6}}, + } + doc = {"담은로직": [{"참조": {**MINE, "키": inner}}], "규격": spec} + with store.at(world["company"]): + got = refs.carry(doc, ("personal", "7/42")) + new = f"참조|company|7|{got['담은로직'][0]['참조']['키']}" + assert got["규격"]["수량"] == { + new: {"1.5": 1, "2.0": 1.3}, + "고정|돌|가|㎡": {"1.5": 2, "2.0": 2.6}, + } + assert doc["규격"]["수량"][old] == {"1.5": 1, "2.0": 1.3} # 원본 그대로 + + def test_길_새로_복사_권한(world): from fastapi import FastAPI from fastapi.testclient import TestClient From 74f3fb80092333aa3f8fc3709bccfbcfc64e60e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:46:00 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C):=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EB=81=9D=20=3D=20=EC=A7=80=EB=82=9C=EB=B2=88=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C=20=EB=B0=94=EB=A1=9C=20=EC=9E=87=EA=B3=A0=20=EB=82=A8?= =?UTF-8?q?=EC=9D=80=20=EC=A0=9C=EC=95=88=EB=A7=8C=20=EC=B0=BD=EC=97=90(?= =?UTF-8?q?=EB=82=A8=EC=9D=80=200=20=3D=20=EC=B0=BD=20=EC=97=86=EC=9D=8C)?= =?UTF-8?q?=20=C2=B7=20=EC=9E=8A=EC=9D=80=20=EC=A7=9D=20=EA=B3=81=20?= =?UTF-8?q?=EB=8A=90=EC=8A=A8=ED=95=9C=20=EA=B8=B0=EC=96=B5=20=EC=95=88=20?= =?UTF-8?q?=EC=A2=81=ED=9E=98=20(PLAN=20707-4=20=EB=8D=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- B09_Estimation/B09_Estimation_Bill_Link.py | 11 ++++----- B09_Estimation/B09_Estimation_UI_Import.ts | 2 +- B09_Estimation/B09_Estimation_UI_Link.ts | 27 ++++++++++++++++++++-- resources/tester/test_707_4_bill_link.py | 2 ++ 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Link.py b/B09_Estimation/B09_Estimation_Bill_Link.py index 456cc062c..97920a1d7 100644 --- a/B09_Estimation/B09_Estimation_Bill_Link.py +++ b/B09_Estimation/B09_Estimation_Bill_Link.py @@ -98,16 +98,13 @@ def _loose(key: str) -> str: def recall(memory: dict[str, dict[str, Any]], row: dict[str, Any]) -> dict[str, Any] | None: - """기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것(잊은 칸 = 없음).""" + """기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것 · + 잊은 칸은 없음 취급 + 느슨한 기억에서는 다른 짝으로 셈(잊은 짝 곁에서 하나로 좁히지 않음).""" key = key_of(row) if isinstance(memory.get(key), dict): return None if memory[key].get(FORGOT) else memory[key] - loose = [ - v - for k, v in memory.items() - if isinstance(v, dict) and not v.get(FORGOT) and _loose(k) == _loose(key) - ] - return loose[0] if len({v.get("확정") for v in loose}) == 1 else None + loose = [v for k, v in memory.items() if isinstance(v, dict) and _loose(k) == _loose(key)] + return loose[0] if len({v.get("확정") for v in loose}) == 1 and loose[0].get("확정") else None def _targets(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 889de4691..9d88bcd11 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -161,7 +161,7 @@ export function createBillImport( // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); done(got.payload, made); - void openBillLink(user(), done, mode.value); // 707-4 다음: 우리 공종으로 잇기 + void openBillLink(user(), done, mode.value, true); // 707-4 다음: 지난번 이음 바로 · 남은 것만 창 const n = got.알림 || {}; const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; diff --git a/B09_Estimation/B09_Estimation_UI_Link.ts b/B09_Estimation/B09_Estimation_UI_Link.ts index bd87dca85..59e495b2c 100644 --- a/B09_Estimation/B09_Estimation_UI_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Link.ts @@ -110,16 +110,39 @@ function pickOurs(spots: Row[], line: Row, take: (key: string) => void): void { }); } -/** 잇기 창 — user = 지금 캐시 · mode = 불러올 때 고른 갈래(없으면 마지막) */ -export async function openBillLink(user: Row, done: LinkDone, mode?: string): Promise { +/** 잇기 창 — user = 지금 캐시 · mode = 불러올 때 고른 갈래(없으면 마지막) · + * fresh = 불러오기 끝에서 부름 — 지난번 이음은 먼저 바로 잇고 남은 것만 창에(남은 0 = 창 없음) */ +export async function openBillLink( + user: Row, + done: LinkDone, + mode?: string, + fresh = false, +): Promise { if (mode === "take" || mode === "price") lastMode = mode; let cur = user; let seen: Row; try { seen = await post("suggest", { user: cur }); + if (fresh && seen.저절로) { + const got = await post("apply", { + pairs: [], + auto: true, + mode: lastMode, + counts: {}, + user: cur, + }); + done(got.payload, got.user); + cur = got.user; + showToast( + `지난번 이음 ${got.알림.이음 + got.알림.묶음} 줄 바로 이음 — [저장]해야 남음`, + "success", + ); + seen = await post("suggest", { user: cur }); + } } catch (error) { return fail("제안을 못 받음")(error); } + if (fresh && !seen.남은.length) return; // 남은 줄 없음 — 창 안 뜸 if (!seen.남은.length && !seen.이은.length) return; // 불러온 줄 없음 let busy = false; const run = (job: () => Promise): void => { diff --git a/resources/tester/test_707_4_bill_link.py b/resources/tester/test_707_4_bill_link.py index 4be04925b..f6ce3ddf5 100644 --- a/resources/tester/test_707_4_bill_link.py +++ b/resources/tester/test_707_4_bill_link.py @@ -297,5 +297,7 @@ def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch): memory = route.recalled(user) assert lk.recall(memory, row) is None # 잊음 = 느슨한 기억도 끊김 assert lk.recall(memory, other) == live # 다른 줄은 그대로 + third = {"name": "토사절취", "spec": "C", "unit": "m3"} + assert lk.recall(memory, third) is None # 잊은 짝 곁에서는 느슨하게 안 좁힘 route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림 assert lk.recall(route.recalled(user), row) == live From 36200c2756da7125c67579393bb027d5692c6cc6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:09:10 +0900 Subject: [PATCH 11/11] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EA=B8=B8=20=EC=A0=90=EA=B2=80(sub6)=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=E2=80=94=20=EB=8C=80=ED=91=9C=20=EC=9D=BC?= =?UTF-8?q?=20=EC=B5=9C=EC=A2=85=20=EB=88=84=EB=A6=84=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C=EB=A7=A4=20=ED=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_내역서/_점검_통합길.md | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/raw/요구_내역서/_점검_통합길.md diff --git a/docs/raw/요구_내역서/_점검_통합길.md b/docs/raw/요구_내역서/_점검_통합길.md new file mode 100644 index 000000000..85f38f40e --- /dev/null +++ b/docs/raw/요구_내역서/_점검_통합길.md @@ -0,0 +1,50 @@ +# 통합 길 점검 — 701~707 합친 판 (sub6 · 2026-10-31) + +판 = sub_laptop_6 (31cb4ba3a 포함 · 받기 뒤) · 서버 8008 · 5181 · 검증 프로젝트 B 검증_sub6 · 층 쓰기 = 브레인검증회사(3) 일반 · 관리자 「sub6임시」. +화면 = 헤드리스(Edge) 한 창을 띄워 두고 차례로 조작 · 사진 `tmp/통합_화면/`. +누름 = 마우스 누름 + 글 입력 칸 하나 = 1 · 「전」 = `_분석_내역3-C` · `_분석_내역5-C` 표. + +## 1. 대표 일 누름 표 + +| 일 | 전 | 지금 누름 · 화면 이동 | 막힘 | 수치 | +| --- | --- | --- | --- | --- | +| T1 있는 공사 열기 → 첫 화면 | 3 · 첫 화면 빈 원가계산서 · 빈 줄 수 셋 | 3(프로젝트 · 카드 · 원가계산) · 이동 1 | 없음 | 첫 화면 = 설계내역서 · 「금액 빈 줄 23」 하나 | +| T2 일위대가 품 고쳐 이 공사만 | 7 막힘 | 2(내역 줄 · 품 칸) + [저장] 1 · 이동 0 | 없음 | 레미콘 품 0.57 → 0.6 · 줄 38,465 → 40,490 즉시 · [저장] 뒤 서버 161,960 = 화면 | +| T3 규격만 다른 일위대가 새로 | 12~13 · 이동 2 | 6(우클릭 · 복사해 새 · 규격 · [만들기] · [바꿈] · 품 칸) · 이동 0 | 흠 A(고침) | 대가 복사본에 덮개 0.6 같이 감 · 합판 5 | +| T4 지난 공사에서 가져오기 | 막힘 | 3(내역 줄 · 「지난 공사」 · 두 번 누름) | 없음 | 1.9 s 안 연결 · 흠 ③ | +| T5 노임 새 판 · 고친 값 지킴 | 공사마다 2 + 구조물마다 2~3 | 3(내역 점검 · [업데이트] · [적용]) | 흠 A(고침) | 미리 보기 「값 바뀜 2 · 고친 칸 3 지킴」 → 덮개 그대로 · 40,490 그대로 | +| T6 줄 추가 → 단가 → 금액 → 총액 | 10 막힘 | 11(우클릭 · 항목 추가 · 이름 · 수량 · 단위 · 단가연결 · 찾기 · 줄 · [담기] · [연결] · [저장]) | 흠 ① ② | 연결 뒤 1.4 s 5,651 × 10 = 56,510 · 원가계산서 직접노무비 48,690 · 서버 = 화면 | +| K1 엑셀 호표 하나(붙여넣기) | 13~19 | 3(격자 · Ctrl+V · [닫기]) + 처음 한 번 열 짝 [확인] 1 | 없음 | 대흥 제17호표 「엑셀 합계 = 우리 합계 1/1」 · 줄만 붙임 = 고정 줄 2 · 89,156 · [저장] 뒤 남음 | +| K2 시트 통째 | 약 74 | 6(펼침 · 올리기 3 · [모두 고정으로 들여오기] · [들여오기]) | 없음 | 거창 호표 14 · 줄 68 · 빠짐 0 · 14/14 · 61.6 s | +| K3 새 구조물 조합 | 약 50 | 5(호표 [수정] · [구조물 조합으로] · 이름 · [만들기]) + [새로고침] 1 | 흠 ⑥ | 제5 · 6호표 = 규격 열 둘 저절로 · 1,057,254(엑셀 1,057,245 · 하위 호표 끝수) | +| K4 규격만 다른 조합 | 22~28 + 도면 | 0(K3 묶음이 열 둘을 바로 만듦) · 손으로 열 더하기 = 7(704-4 값) | 없음 | 2.0 열 1,146,411.6 · 2.5 열 1,270,715.6(붙인 고정 줄 포함) | +| K5 회사에 올리기 요청 · 승인 | 일반 막힘 · 관리자 4 | 요청 3(우클릭 · 요청 · [요청]) · 승인 3 + 회사 단추 1~2 | 흠 B(고침) · 흠 ⑦ | 고친 뒤 회사 사본 2.5 열 1,270,715.6 = 원본 | +| K6 설계(B06)에서 고르고 금액 | 약 11~12 | B06 구조물 배치 · 측점 · 미지정 · 열 · [적용] · [저장] = 6 → B09 1 = 7 | 없음(금액 빈칸 = 고른 마스터 항목의 빈 수량 6 줄 · 자료 몫) | B08 집계표 열 · B09 2.2 「관 공」 대가1 연결 · B06 저장 127 s | +| 덧 경쟁사 결과물 통째 | 707 각 창 값 | 내역 3(단추 · 파일 · [불러오기]) + [저장] · 요율 2(단추 · [적용] — 같은 파일 기억) · 단가표 2 | .estx · 공종 잇기 화면 미합류 | 거창 직접비 384,916,227 + 이 공사 줄 218,470 = 385,134,697 = 서버 · 단가표 뒤 385,129,593 = 서버 · 덮개 0.6 그대로 | + +## 2. 이음매 흠 — 고친 것 + +| # | 만나는 기능 | 흠 | 고침 · 파일 | 확인 | +| --- | --- | --- | --- | --- | +| A | 703-2 복사 × 703-3 [업데이트] × 204-2 서고 정리 | 사전조합 연결 줄을 복사해 [바꿈] → 복사본 원본 열쇠(`C:참조…`)를 「쓰는 사본」으로 안 셈 → [업데이트] 뒤 노임 줄이 책에서 빠져 금액 40,490 → 0(빈 줄 3) · [저장] 정리가 원본 사본 폴더를 지울 수 있음 | 복사본의 복사본은 맨 처음 원본까지 · `C:참조` = 그 참조 사본 폴더도 지킴 · `B09_Estimation_Bill_Library.py` · `B09_Estimation_Router.py` · 시험 `test_703_b09_daega_copy.py` | 시험 29 통과 · [업데이트] 뒤 40,490 그대로 · [저장] 뒤 서버 161,960 = 화면 · 사본 폴더 남음 · ebd33de9c | +| B | 704-4 규격 열 × 706-1 승인(회사로 복사) | 개인 하위 호표 참조가 회사 새 키로 바뀌는데 규격 열 수량 열쇠는 옛 키 → 그 줄 열 값 버려짐(2.5 열 1,270,715.6 → 1,259,136.2 · 차 11,579.4) | 참조 따라 복사(`carry`) 때 규격 수량 열쇠도 새 참조로 · `M02_Labor_Ref.py` · 시험 `test_m02_labor_ref.py` | 시험 27 + 704_4 10 통과 · 다시 승인 → 1,270,715.6 = 원본 · b3f3aed0a | + +## 3. 이음매 흠 — 목록(안 고침) + +| # | 자리 | 흠 | 크기 | +| --- | --- | --- | --- | +| ① | B09 단가연결 찾기 | 내역서 자기 줄 이름 「측구파기」로 찾으면 0 건(로직 이름 「측구터파기」) · 목록 금액 칸 「-」(704-1 기준 목록 금액이 이 창엔 안 옴) | 작음 · 낱말 짝 표 | +| ② | 704-2 계산서 × B09 산근 | [담기] 전 계산서 5,654 · 연결 뒤 5,651 — 계산서는 줄 반올림, B09 는 줄마다 버림 | 작음 · 끝수 규칙 하나로 | +| ③ | 703-1 가져오기 × 701-3 빈 줄 | 줄 0 개 일위대가를 이으면 금액 0 으로 「빈 줄」에서 빠짐(22 → 21) — 빈 일위대가가 확정을 통과할 수 있음 | 판단 필요(빈 = 0 인지 모름인지) · 계산 짝 | +| ④ | B09 단가연결 | ㎥ 줄에 「개소」 일위대가를 이어도 단위 다름 알림 없음 | 작음 | +| ⑤ | 번호 노출(703-4 밖 자리) | 복사해 새 일위대가 창 규격 칸에 「SC-000001」 · 재료 창고 규격 「SC-0…」 · B09 줄 비고 「personal\|1/3\|SP3-000015」 · M02 격자 머리 「SU-000011」 · 비고 「SC-000002」 | 작음 · 낱말 | +| ⑥ | 704-4 [구조물 조합으로] | 만든 항목이 M02 표(행렬)에 [새로고침] 전엔 안 보임 | 작음(+1 누름) | +| ⑦ | 706-1 관리자 | 「올리기 요청 n」 띠가 회사 층 단추를 눌러야 보임(관리자 기본 = 개인) | 작음(+1~2 누름) | +| ⑧ | 걸린 시간(기록만) | B09 [저장] 16.6 · 32.6 · 53.5 s → 내역 116 줄 뒤 206.8 · 257.3 s · B06 저장 127 s · B08 저장 135 s · K2 들여오기 61.6 s · 엑셀 내역 불러오기 64.8 s | 속도는 따로 | +| ⑨ | 미합류 | 707-5 .estx 고리(sub7) · 707-4 공종 잇기 화면(sub5) — 이 판에선 내역 불러오기 뒤 잇기 창 없음 | 그 창 몫 | +| ⑩ | B06 [저장](70x 밖 · 참고) | 관 지점 옵션 옛 칸(집수정인데 inlet_revet_* 돌쌓기 10 m)이 지워지고 동물이동형 기본 true 가 들어감 | 참고 | + +## 4. 되돌림 + +- 검증_sub6 — B09(사용자 값 · 단가 책 · 서고 사본) · B06 저장이 바꾼 파일(관 지점 · 종단 · 횡단 351 m · 새 파일 둘) · 구조물 사본 = 시작 때 판으로 되돌림 → 서버 「빈 줄 23 · 연결 0 · 줄 37」 = 시작 때. +- 브레인검증회사 — 개인 · 회사 구조물 조합 「sub6임시 BOX(암거)」 화면 지우기 · 사전조합 개인 15 · 회사 10 · 실무자료 둘 지움 · 올리기 요청 장부(내 줄 둘뿐) 지움 → `storage/3` 안 「sub6임시」 0 건.