From b342f8f7846a1b5e1718855bcbd5ea4fa8672026 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:00:21 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(b09):=20=EC=9C=84=20=ED=83=AD=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=20=ED=95=9C=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C=20=EB=8B=A8=EA=B0=80=EC=97=B0=EA=B2=B0=20=EC=B9=B8=20?= =?UTF-8?q?=EB=9E=A8=ED=94=84=20(257-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위 탭 총괄설계내역서 · 설계내역서 = 「설계내역」 한 묶음(총괄 · 설계내역서) - 단가연결 칸 = 램프(초록 연결 · 노랑 없음) · 풍선 = 글 · 머리 「단가↵연결」 · 폭 44 - 시험 test_257_1 · test_181_1 · test_229_1 기대값 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Page.css | 24 +++++++- B09_Estimation/B09_Estimation_UI_Tabs.ts | 3 +- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 20 ++++++- resources/tester/test_181_1_b09_tab_groups.py | 23 ++++---- .../test_229_1_b09_level_buttons_subbar.py | 2 +- resources/tester/test_257_1_b09_tabs_lamp.py | 58 +++++++++++++++++++ 6 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_257_1_b09_tabs_lamp.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 56f923c40..2d29972ac 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -30,9 +30,9 @@ visibility: hidden; } -/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ +/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈(257-1 머리 글 안 줄바꿈 글자도 살림) */ [data-b09-table] thead th { - white-space: normal; + white-space: pre-line; overflow-wrap: anywhere; } @@ -136,6 +136,26 @@ font-weight: var(--font-weight-bold); } +/* 257-1 설계내역서 단가연결 칸 = 램프(B08 구조물집계표 램프 꼴 · 글 숨김 · 풍선 = 글) — 노랑 = 없음 · 초록 = 연결 */ +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp { + font-size: 0; + text-align: center; +} + +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-warning); + vertical-align: middle; +} + +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp.is-linked::before { + background: var(--color-success); +} + /* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 3dea31f92..6c5b8dd3b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -59,8 +59,7 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], - ["총괄설계내역서", ["total"]], - ["설계내역서", ["bill"]], + ["설계내역", ["total", "bill"]], ["일위대가", ["dg_list", "dg"]], ["단가산출", ["sg_list", "sg"]], ["환율및기초자료", ["basis"]], diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index dd0322a7b..3bb3ec7be 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -34,10 +34,12 @@ export const buildBill: TabBuild = (ctx): BillTable => { const bill: Row = sheets.bill || { rows: [], totals: null }; // 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례) const raw: DataRow[] = bill.rows; + // 257-1 연결 칸 = 램프(글은 CSS 가 숨김 · 풍선 = 글) — 연결 = 호표 줄(ref) 또는 손 줄 비고(대가N) + const linkText = (r: Row): string => (!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"); const rows: DataRow[] = raw.map((r: Row) => ({ ...r, name: r.no ? `${r.no} ${r.name}` : r.name, - connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"} ▾` : "", + connect: linkable(r) ? `${linkText(r)} ▾` : "", })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ @@ -57,7 +59,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { ...pair("labor", "노무비"), ...pair("material", "재료비"), ...pair("expense", "경비"), - text("connect", "단가연결", 96), + { ...text("connect", "단가\n연결", 44), title: "단가연결" }, text("note", "비고", 200), ], rows, @@ -88,9 +90,21 @@ export const buildBill: TabBuild = (ctx): BillTable => { if (r.key !== "fee") return editCell(ctx, raw, i, key, v); setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } }); }, - cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null), + cellClass: (i, key) => { + const r = rows[i]; + const lamp = + key === "connect" && r && linkable(raw[i]) + ? `b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}` + : ""; + return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null; + }, tip: (i, key) => { const r = rows[i]; + if (r && key === "connect" && linkable(raw[i])) { + const auto = (r.auto_ref as Row | null)?.key ?? "빈칸"; + const back = edited(r, key) ? ` — 자동 값 ${auto} — 우클릭 [자동값으로 되돌리기]` : ""; + return linkText(raw[i]) + back; + } if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)"; if (!r || !edited(r, key)) return null; const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`]; diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index 12f27f1d5..09b83bd94 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -1,4 +1,4 @@ -"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. +"""181-1 B09 탭 두 층 — 위 탭 8(203-1 연결 점검 뺌 · 257-1 설계내역 한 묶음) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. - 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 - 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) @@ -19,8 +19,7 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" # 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 EXPECTED = [ ["공사원가계산서", ["cost"]], - ["총괄설계내역서", ["total"]], - ["설계내역서", ["bill"]], + ["설계내역", ["total", "bill"]], ["일위대가", ["dg_list", "dg"]], ["단가산출", ["sg_list", "sg"]], ["환율및기초자료", ["basis"]], @@ -42,7 +41,7 @@ def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩(): # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 firsts = list(dict.fromkeys(out["of"])) assert firsts == [g[0] for g in EXPECTED] - assert len(firsts) == 9 + assert len(firsts) == 8 def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): @@ -60,26 +59,26 @@ const view = () => ({ }); bars.draw('dg'); const a = view(); -bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.top.children[2].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 bars.sub.children[0].click(); // 일위대가목록표 bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 const b = view(); bars.draw('sum_material'); bars.draw('dg'); -bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) -bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.top.children[6].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[5].click(); // 중기 = 본 적 없음 → 첫 아래 탭 bars.draw('cost'); const c = view(); -bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭 +bars.top.children[1].click(); // 설계내역 = 첫 아래 탭(총괄) bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 console.log(JSON.stringify({ a, b, c, picks })); """, ) a, b, c = out["a"], out["b"], out["c"] - assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [ - "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출" + assert len(a["top"]) == 8 and [t[0] for t in a["top"]][:5] == [ + "공사원가계산서", "설계내역", "일위대가", "단가산출", "환율및기초자료" ] # fmt: skip - assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5 + assert [t[2] for t in a["top"]] == ["false"] * 2 + ["true"] + ["false"] * 5 assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] assert not a["blank"] and a["role"] == "tablist" @@ -88,7 +87,7 @@ console.log(JSON.stringify({ a, b, c, picks })); ["자재단가대비표", "true", "compare"], ["재료비수량금액집계표", "false", "sum_material"], ] # fmt: skip - assert b["top"][7][2] == "true" and b["top"][3][2] == "false" + assert b["top"][6][2] == "true" and b["top"][2][2] == "false" # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None diff --git a/resources/tester/test_229_1_b09_level_buttons_subbar.py b/resources/tester/test_229_1_b09_level_buttons_subbar.py index 2d40334c7..50a605e4a 100644 --- a/resources/tester/test_229_1_b09_level_buttons_subbar.py +++ b/resources/tester/test_229_1_b09_level_buttons_subbar.py @@ -75,7 +75,7 @@ console.log(JSON.stringify({ assert out["a"]["levels"] == ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"] assert out["a"]["guideIn"] is True # 안내 글 = 단추 줄이 아니라 따로(표 아래로) assert out["guideTag"] == "P" and "ui-general-block__empty" in out["guideCls"] - assert out["subBlank"] # 하위 탭 없는 묶음 = 빈 보조 줄(숨은 단추 + 도구) + assert not out["subBlank"] # 257-1 설계내역 = 아래 탭 둘(총괄 · 설계내역서) · 빈 보조 줄 아님 assert out["disabledBefore"] == [True] * 4 # 고른 줄 없음 = 흐림 assert out["disabledAfter"] == [True, False, False, True] # 줄 누름 = 메뉴 항목대로 assert out["b"] == [] # 다른 탭(일위대가표) = 단추 없음 diff --git a/resources/tester/test_257_1_b09_tabs_lamp.py b/resources/tester/test_257_1_b09_tabs_lamp.py new file mode 100644 index 000000000..4a15af2be --- /dev/null +++ b/resources/tester/test_257_1_b09_tabs_lamp.py @@ -0,0 +1,58 @@ +"""257-1 B09 위 탭 「설계내역」(총괄설계내역서 · 설계내역서 한 묶음) · 설계내역서 단가연결 칸 램프. + +- 위 탭 = `TAB_GROUPS` 「설계내역」[total, bill] · 옛 두 위 탭 없음 +- 단가연결 칸 = 줄 칸 class `b09-bill__lamp`(+ 연결 `is-linked`) · 풍선 = 글(대가N · 연결됨 · 단가연결) +- 머리 = 「단가↵연결」 · 폭 44 · 머리 CSS pre-line +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.test_184_3_b09_bill_menu import ROWS, SRC, _node, _ts # noqa: E402 + +JS = """ +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser() {} }; +const table = buildBill(ctx); +const at = (key) => table.rows.findIndex((r) => r.key === key); +const keys = ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"]; +const col = table.columns.find((c) => c.key === "connect"); +console.log(JSON.stringify({ + col: { label: col.label, width: col.width, title: col.title }, + cls: keys.map((k) => table.cellClass(at(k), "connect")), + tip: keys.map((k) => table.tip(at(k), "connect")), + other: table.cellClass(at("earth:4"), "qty"), + groups: TAB_GROUPS.map(([label, tabs]) => [label, tabs]), +})); +""" + + +def test_램프_칸_class_풍선_머리(): + body = JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body)) + assert out["col"] == {"label": "단가\n연결", "width": 44, "title": "단가연결"} + assert out["cls"] == [ + "b09-bill__lamp is-linked", # 자동 줄 · 호표 연결 + "b09-bill__lamp", # 연결 없음 = 노랑 + "b09-bill__lamp is-linked", # 손 줄 · 비고 대가3 + None, # 자재 줄 = 연결 대상 아님 + None, + None, # 묶음 줄 + ] + assert out["tip"] == ["연결됨", "단가연결", "대가3", None, None, None] + assert out["other"] is None + labels = [g[0] for g in out["groups"]] + assert "설계내역" in labels and "총괄설계내역서" not in labels and "설계내역서" not in labels + assert ["설계내역", ["total", "bill"]] in out["groups"] + + +def test_CSS_램프_초록_노랑_머리_줄바꿈(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert "td.b09-bill__lamp::before" in css and "var(--color-warning)" in css + assert "td.b09-bill__lamp.is-linked::before" in css and "var(--color-success)" in css + head = css.split("[data-b09-table] thead th {", 1)[1].split("}", 1)[0] + assert "white-space: pre-line" in head From 8752bb0d1a516513fed88db2de62d64bc45d82db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:01:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EC=9E=A5=EC=86=8C=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=98=81=EA=B5=AC=20=EC=A0=80=EC=9E=A5=20=E2=80=94=20place/.png=20=C2=B7=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=92=A4=20=EB=B0=94=EB=80=90=20=EC=83=81=EC=9E=90=EB=A7=8C=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=C2=B7=20place=5Fimages=20(256-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- .../B08_Quantity_Milestone_Capture.py | 46 ++++-- B08_Quantity/B08_Quantity_Router_Milestone.py | 115 +++++++++++++- .../B08_Quantity_UI_Milestone_State.ts | 41 +++++ .../test_249_1_b08_milestone_capture_child.py | 2 +- .../test_256_1_b08_milestone_place_image.py | 146 ++++++++++++++++++ 5 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_256_1_b08_milestone_place_image.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index 7a3b50975..0ae895ca1 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v 캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 · 키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가 드라이버 하위 프로세스를 못 만듦(249-1). +장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`. """ from __future__ import annotations @@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 TAIL = " — [그림 변경]으로 넣으세요." +PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌 _lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) @@ -45,17 +47,22 @@ class CaptureError(Exception): """까닭 한 줄(키 · 예외 원문 없음).""" -def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: - """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" +def page_html( + key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None +) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail. + view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함).""" + w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT) + level = view["level"] if view else 7 data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c") sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false" return f""" -
+
""" @@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]: """자식 프로세스 몸통 — 그림 찍어 `/.png` 저장 → {saved, reason}.""" key = os.environ.get(KAKAO_JS, "").strip() try: - png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"]) + png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"]) except CaptureError as error: return {"saved": False, "reason": str(error)} out_dir = Path(job["out"]) @@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]: def make_route_image( - out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] + out_dir: Path, + route_id: str, + lines: list[Any], + marks: list[dict[str, Any]], + view: dict[str, int] | None = None, + what: str = "경로 그림", + tail: str = TAIL, ) -> dict[str, Any]: """그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림.""" key = os.environ.get(KAKAO_JS, "").strip() if not key: return { "saved": False, - "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}", + "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}", } if not any(lines) and not marks: - return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} + return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"} origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") - job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks} + job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view} with _lock: got = spawn({**job, "origin": origin}) if not got.get("saved"): - return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"} + return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"} return {"saved": True, "reason": None} +def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]: + """장소 그림(256-1) — 작은 지도 · 표지 하나 → `/<상자 id>.png`.""" + return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "") + + if __name__ == "__main__": print(json.dumps(capture(json.loads(sys.stdin.buffer.read())))) diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index e68cccdbc..9b883c87c 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -9,6 +9,10 @@ POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 + POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 · + 이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름 + GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/.png` + · `auto/.png` · `.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음) GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램 지금 중점 측점(244-12 · 상자 작은 글씨) @@ -24,6 +28,10 @@ 그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). 그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. +장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만 +다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 · +GET 답 `place_images` {상자 id: "place/.png"} · `place_times` {상자 id: 바뀐 시각 ms} +(캐시 깨기). 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). @@ -32,9 +40,11 @@ from __future__ import annotations import asyncio +import hashlib import json import math import os +import threading import urllib.error import urllib.parse import urllib.request @@ -42,7 +52,7 @@ from pathlib import Path from typing import Annotated, Any, Literal from uuid import UUID -from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, ValidationError, model_validator @@ -51,7 +61,7 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 -from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image +from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from common_util.common_util_initial_snapshot import design_route_csv_path @@ -69,6 +79,8 @@ PATH = "/{project_id}/quantity/milestone" FOLDER = Path("B08_Quantity") / "milestone" DATA_FILE = "milestone.json" AUTO = "auto" # 자동 그림 하위 폴더(244-11) +PLACE = "place" # 장소 그림 하위 폴더(256-1) +PRINTS = ".prints.json" # 그림 지문 {"place/": 해시} — 바뀐 것만 다시 캡처 IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" @@ -212,15 +224,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]: } +def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]: + """좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = "b1".""" + # ponytail: 단일 기준점 — 254-1 bases[] 합친 뒤 bases[].id 로 + boxes = [(r["id"], r.get("name") or "", r.get("lon"), r.get("lat")) for r in data["routes"]] + base = data.get("base") or {} + boxes.append(("b1", base.get("name") or "기준점", base.get("lon"), base.get("lat"))) + return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None] + + def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: site = site_info(root, data["site"]["mid_station_m"]) auto = images(root, AUTO) + place = images(root, PLACE) return { "data": data, "site": site, "table": compute_milestone(data, site["mid_default_m"]), "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 "auto_images": auto, + "place_images": place, + "place_times": { + stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items() + }, } @@ -230,13 +256,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: path.mkdir(parents=True, exist_ok=True) atomic_write_json(path / DATA_FILE, data) keep = {route["id"] for route in data["routes"]} - for sub in ("", AUTO): + for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})): for stem, name in images(root, sub).items(): - if stem not in keep: + if stem not in ids: (path / name).unlink(missing_ok=True) return payload(root, data) +def _print(*parts: Any) -> str: + """지문 — 좌표 · 이름 해시(좌표 원문은 안 남김).""" + return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16] + + +def _read_prints(root: str | Path) -> dict[str, str]: + try: + return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _set_print(root: str | Path, key: str, value: str | None) -> None: + """지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처).""" + with _prints_lock: + prints = _read_prints(root) + if prints.get(key) == value: + return + if value is None: + prints.pop(key, None) + else: + prints[key] = value + atomic_write_json(folder(root) / PRINTS, prints) + + +_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나 + + +def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict: + """장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}.""" + got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat}) + if got["saved"]: + _set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat)) + return got + + +def refresh_images(root: str | Path) -> None: + """[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 · + 실패 = 다음 [저장]에 다시.""" + data = read_data(root) + prints = _read_prints(root) + have = images(root, PLACE) + for place_id, name, lon, lat in places(data): + if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have: + place_image(root, place_id, name, lon, lat) + + def _route_id(route: str) -> str: try: return Route(id=route).id @@ -260,9 +333,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]: @router.put(PATH) -async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: +async def milestone_save( + project_id: UUID, body: Milestone, tasks: BackgroundTasks +) -> dict[str, Any]: root = await _project_root(project_id) - return await asyncio.to_thread(save_data, root, body) + out = await asyncio.to_thread(save_data, root, body) + tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 + return out @router.get(PATH + "/site-address") @@ -375,6 +452,32 @@ async def milestone_image_auto( return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} +FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$" + + +@router.post(PATH + "/place-image") +async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict: + """장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/.png · 좌표는 저장 안 함.""" + place_id = _route_id(id) + root = await _project_root(project_id) + got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat) + if not got["saved"]: + return {"name": None, "time": None, "reason": got["reason"]} + name = f"{PLACE}/{place_id}.png" + time_ms = int((folder(root) / name).stat().st_mtime * 1000) + return {"name": name, "time": time_ms, "reason": None} + + +@router.get(PATH + "/file") +async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)): + """그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음).""" + root = await _project_root(project_id) + path = folder(root) / name + if not path.is_file(): + raise HTTPException(status_code=404, detail="그림이 없습니다.") + return FileResponse(path) + + KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" KAKAO_KEY = "KAKAO_REST_API_KEY" diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 456a44b57..4909cf736 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -13,6 +13,8 @@ * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) + * 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` · + * [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -35,6 +37,8 @@ interface Payload { site: SiteInfo; images: Record; auto_images: Record; + place_images: Record; + place_times: Record; } export const EMPTY: MilestoneData = { @@ -204,6 +208,43 @@ export async function autoRouteImage( changes.dispatchEvent(new Event("change")); } +/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */ +export const placeImageKey = (placeId: string): string | null => { + const name = saved?.place_images?.[placeId]; + return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null; +}; + +/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */ +export async function capturePlace( + projectId: string, + placeId: string, + mark: { name: string; lon: number; lat: number }, +): Promise { + let reason: string | null; + try { + const res = await request<{ name: string | null; time: number | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`, + { method: "POST", body: JSON.stringify(mark) }, + ); + reason = res.reason; + if (res.name && saved) { + saved.place_images = { ...saved.place_images, [placeId]: res.name }; + saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() }; + } + } catch (error) { + reason = `장소 그림 캡처 요청 실패(${String(error)})`; + } + if (reason) showToast(reason, "warning"); + changes.dispatchEvent(new Event("change")); +} + +/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */ +export async function placeImageBlobUrl(projectId: string, placeId: string): Promise { + const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`; + const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`; + return URL.createObjectURL(await (await call(url)).blob()); +} + /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py index 2e1948356..3f303ed2f 100644 --- a/resources/tester/test_249_1_b08_milestone_capture_child.py +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]] MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}] if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로 - cap.page_html = lambda key, lines, marks: HTML + cap.page_html = lambda key, lines, marks, view=None: HTML print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read())))) sys.exit(0) diff --git a/resources/tester/test_256_1_b08_milestone_place_image.py b/resources/tester/test_256_1_b08_milestone_place_image.py new file mode 100644 index 000000000..53be8f62e --- /dev/null +++ b/resources/tester/test_256_1_b08_milestone_place_image.py @@ -0,0 +1,146 @@ +"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7). + +캡처는 가짜(make_place_image 자리)로: +① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함 +② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/.png · + GET 답 place_images · place_times +③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음 +④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음) +""" + +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-place" +DATA = { + "base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68}, + "routes": [ + {"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866}, + {"id": "r2", "name": "좌표 없음"}, + ], +} + + +class Fake: + """가짜 캡처 — 부른 상자 기록 · ok=False 면 실패.""" + + def __init__(self): + self.calls = [] + self.ok = True + + def __call__(self, out_dir, place_id, mark): + self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"])) + if not self.ok: + return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"} + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / f"{place_id}.png").write_bytes(PNG) + return {"saved": True, "reason": None} + + +def _save(root, data): + return ms.save_data(root, ms.Milestone.model_validate(data)) + + +def _fake(monkeypatch): + fake = Fake() + monkeypatch.setattr(ms, "make_place_image", fake) + return fake + + +def test_장소_그림_페이지_작은_지도(): + html = cap.page_html( + "k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW + ) + assert "width:480px;height:320px" in html and "level: 4" in html + assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌 + assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로 + + +def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + got = ms.payload(tmp_path, ms.read_data(tmp_path)) + assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"} + assert set(got["place_times"]) == {"b1", "r1"} + assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values()) + assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG + prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8") + assert "127.9095" not in prints and "35.6866" not in prints # 해시만 + + +def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + fake.calls.clear() + ms.refresh_images(tmp_path) + assert fake.calls == [] # 안 바뀜 = 캡처 0 + moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]} + _save(tmp_path, moved) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["r1"] + fake.calls.clear() + (tmp_path / "B08_Quantity/milestone/place/b1.png").unlink() + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1"] + # 실패 = 지문 안 적음 → 다음에 다시 + fake.calls.clear() + fake.ok = False + _save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}}) + ms.refresh_images(tmp_path) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1", "b1"] + + +def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch): + _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움 + assert got["place_images"] == {} and got["place_times"] == {} + assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir()) + + +def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + base = f"/api/projects/{uuid4()}/quantity/milestone" + # [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄) + assert client.put(base, json=DATA).status_code == 200 + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + assert client.get(base).json()["place_images"]["r1"] == "place/r1.png" + # 등록 · 끌기 뒤 화면이 부름 + fake.calls.clear() + mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5} + res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json() + assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0 + assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)] + fake.ok = False + bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json() + assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"} + assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400 + # 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음 + assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG + assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404 + for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"): + assert client.get(base + "/file", params={"name": name}).status_code == 422, name From eaf93376388669bf4c2c87d137af330d1884ce3d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:02:18 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(b06):=20=EC=83=81=EC=84=B8=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=E2=80=94=20=EA=B3=B5=EC=9A=A9=20=ED=8C=AC=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=EB=A7=88=EB=8B=A4=20moved=20=ED=92=82=20?= =?UTF-8?q?=C2=B7=20=EC=95=94=20=EA=B2=BD=EA=B3=84=EC=84=A0=20=EB=82=98?= =?UTF-8?q?=EB=88=94=20=C2=B7=20=EA=B3=A0=EB=A6=84=20(259-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가운데 단추 팬 뒤 왼쪽 누름 click 삼킴 없앰(거리이정표 다이어그램 같은 줄) - 암 경계선 조각 고름 · 서버 pick_lines rock 짝 · 스키마 · 타입 rock Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/b_svg_zoom_pan.ts | 2 + B06_Section/B06_Section_Api_Types.ts | 2 +- B06_Section/B06_Section_Schema.py | 2 +- .../B06_Section_UI_Cross_Detail_Geom.ts | 11 ++ .../B06_Section_UI_Cross_Detail_Select.ts | 11 +- ...07_DesignDetail_Engine_Cross_Table_Edit.py | 11 +- resources/tester/helper_259_detail_region.cjs | 133 ++++++++++++++++++ resources/tester/test_259_detail_region.py | 115 +++++++++++++++ 8 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_259_detail_region.cjs create mode 100644 resources/tester/test_259_detail_region.py diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index bb293e404..951564e82 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -148,6 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { + // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) + moved = false; // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7c09a92ed..7bda5f9f9 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -638,7 +638,7 @@ export type CrossCellSource = /** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */ export interface CrossPickPiece { - line: "design" | "ground" | "spoil" | "structure"; + line: "design" | "ground" | "spoil" | "structure" | "rock"; start: [number, number]; end: [number, number]; } diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index fae03161c..05740c7ef 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel): class CrossPickPiece(BaseModel): """[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고).""" - line: Literal["design", "ground", "spoil", "structure"] + line: Literal["design", "ground", "spoil", "structure", "rock"] start: list[float] = Field(..., min_length=2, max_length=2) end: list[float] = Field(..., min_length=2, max_length=2) diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index f137535dc..3f15d056d 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -44,6 +44,16 @@ export function groundRuns(samples: ReadonlyArray | undefined): P const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); +/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */ +export function rockRuns( + design: CrossDesign | undefined, + samples: ReadonlyArray | undefined, +): Pt[][] { + const offset = design?.rock_boundary_offset_m; + if (design?.geometry_preset !== "rock" || typeof offset !== "number") return []; + return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset])); +} + /** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ export function pickLines( design: CrossDesign | undefined, @@ -53,6 +63,7 @@ export function pickLines( design: [polyline(design?.design_line)], ground: groundRuns(samples), spoil: [polyline(design?.spoil_fill_line)], + rock: rockRuns(design, samples), }; } diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 2a5aca824..b3d8442c4 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Cross_Detail_Select.ts - * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로 + * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로 * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). @@ -79,6 +79,8 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli else if (el.classList.contains("b06-chart__cross-profile")) others.push({ line: "ground", points }); else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); + else if (el.classList.contains("b06-chart__rock-boundary")) + others.push({ line: "rock", points }); } const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ line: piece.structure ? "structure" : "design", @@ -102,6 +104,7 @@ const LINE_NAME: Record = { structure: "구조물 선", ground: "원지반선", spoil: "사토장선", + rock: "암 경계선", }; /** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ @@ -111,7 +114,11 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie const [piece] = pieces; const from = piece.points[0]; const to = piece.points[piece.points.length - 1]; - if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil") + if ( + design && + piece.points.length === 2 && + (piece.line === "design" || piece.line === "structure") + ) return pieceLabel(design, { from: { offset: from[0], elevation: from[1] }, to: { offset: to[0], elevation: to[1] }, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index d522ab3f9..3cc85c50f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -181,12 +181,21 @@ def ring_area(paths: list[list[Point]]) -> float | None: return area if area > 1e-9 else None +def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]: + """암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙).""" + offset = _num(design.get("rock_boundary_offset_m")) + if design.get("geometry_preset") != "rock" or offset is None: + return [] + return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)] + + def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: - """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선).""" return { "design": [_design_line(design)], "ground": ground_runs(samples), "spoil": [_polyline(design.get("spoil_fill_line"))], + "rock": rock_runs(design, samples), } diff --git a/resources/tester/helper_259_detail_region.cjs b/resources/tester/helper_259_detail_region.cjs new file mode 100644 index 000000000..61139b6dd --- /dev/null +++ b/resources/tester/helper_259_detail_region.cjs @@ -0,0 +1,133 @@ +/* PLAN 259 단위검증 헬퍼 — [상세] 공용 팬 누름 풂 · 암 경계선 나눔 · 닫힌 영역 · 표 칸 → 그래프 고름. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes, rock} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_259_detail_region.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const Z = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +/* ① 공용 팬 — 가운데 단추 팬(click 없음) 뒤 왼쪽 누름 click 이 안 삼켜짐 */ +function panCase(panStart) { + const listeners = {}; + const svg = { + addEventListener: (name, fn) => (listeners[name] ??= []).push(fn), + setAttribute: () => {}, + classList: { add: () => {}, remove: () => {} }, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 300 }), + }; + const layer = { setAttribute: () => {} }; + Z.attachSvgZoomPan({ + svg, + layer, + widthPx: 600, + heightPx: 300, + pad: { left: 10, right: 10, top: 10, bottom: 10 }, + content: { x: -600, y: -300, width: 1800, height: 900 }, + panStart, + }); + const fire = (name, init = {}) => { + let stopped = false; + const event = { + button: 0, + clientX: 0, + clientY: 0, + pointerId: 1, + preventDefault: () => {}, + stopPropagation: () => (stopped = true), + ...init, + }; + (listeners[name] ?? []).forEach((fn) => fn(event)); + return stopped; + }; + const drag = (button) => { + fire("pointerdown", { button, clientX: 100, clientY: 100 }); + fire("pointermove", { button, clientX: 140, clientY: 120 }); + fire("pointerup", { button, clientX: 140, clientY: 120 }); + }; + const click = (x = 50) => { + fire("pointerdown", { clientX: x, clientY: 50 }); + fire("pointerup", { clientX: x, clientY: 50 }); + return fire("click", { clientX: x, clientY: 50 }); + }; + return { drag, click, fire }; +} +{ + const middle = panCase(); + middle.drag(1); // 가운데 단추 팬 — 브라우저는 click 을 안 보냄(auxclick) + out.panMiddle = { firstClickStopped: middle.click(), secondClickStopped: middle.click() }; + // 거리이정표 꼴 — 빈 곳 왼쪽 끌기 팬(끌기로 끝난 click 은 막고 · 다음 누름은 통과) + const left = panCase((event) => event.button === 1 || event.button === 0); + left.drag(0); + // 끌기 끝 click(왼쪽은 브라우저가 보냄) — 막혀야 함 + out.panLeft = { afterDragStopped: left.fire("click"), nextClickStopped: left.click() }; +} + +/* ② 암 경계선 — 그림 요소 → 조각(설계선과 만나는 점에서 나눔) */ +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]); +const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]); +const rockPts = G.rockRuns(design, samples)[0]; +const elements = [ + el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }), + el(["b06-chart__cross-profile"], groundPts), + el(["b06-chart__rock-boundary"], rockPts), +]; +const pieces = S.collectPieces(elements, axes); +out.pieces = pieces; +const rockPieces = pieces.filter((p) => p.line === "rock"); +out.rockLabel = S.selectionLabel(design, [rockPieces[1]]); +out.rockSource = S.selectionSource("length", [rockPieces[1]]); +out.rockLocal = T.localCellValue(out.rockSource, design, samples); +out.rockLength = G.pathLength(rockPieces[1].points); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_259_detail_region.py b/resources/tester/test_259_detail_region.py new file mode 100644 index 000000000..4a0c3b66e --- /dev/null +++ b/resources/tester/test_259_detail_region.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""PLAN 259 — B06 [상세] 영역 · 길이 · 표 강조. + +못박는 것 + - 259-1 공용 팬(`A00_Common/b_svg_zoom_pan.ts`) — 가운데 단추 팬 뒤 왼쪽 누름 click 이 안 삼켜짐 · + 거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음. + - 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 · + 암 프리셋 아니면 다시 고름. + +./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value, pick_lines + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)] +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": -1.0, + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 98.0}, + "right": {"offset_m": -2.0, "elevation_m": 98.0}, + }, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] +AXES = { + "widthPx": 600, + "heightPx": 300, + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 110, + "elevationMid": 100, + "exaggeration": 2, +} +ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_259_detail_region.cjs")], + input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 259-1 공용 팬 ──────────────────────────────────────────────────────── + + +def test_가운데_단추_팬_뒤_왼쪽_누름_통과(out): + assert out["panMiddle"] == {"firstClickStopped": False, "secondClickStopped": False} + + +def test_거리이정표_왼쪽_끌기_팬은_끌기_click_만_막음(out): + assert out["panLeft"] == {"afterDragStopped": True, "nextClickStopped": False} + + +# ── 259-1 암 경계선 나눔 · 고름 ─────────────────────────────────────────── + + +def test_암_경계선이_설계선과_만나는_점에서_나뉨(out): + rock = [p for p in out["pieces"] if p["line"] == "rock"] + assert len(rock) == 3 + ends = sorted(round(p["points"][0][0], 4) for p in rock) + assert ends == pytest.approx([-10.0, -ROCK_X, ROCK_X], abs=1e-3) + # 설계 경사도 암 경계선에서 잘림(경사 끝 · 암 교점 · 원지반 교점) + design_ends = { + round(q[0], 3) + for p in out["pieces"] + if p["line"] == "design" + for q in (p["points"][0], p["points"][-1]) + } + assert round(ROCK_X, 3) in design_ends + + +def test_암_경계선_고름_값_저장_꼴(out): + length = 2 * ROCK_X + assert out["rockLength"] == pytest.approx(length, abs=1e-3) + assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m" + source = out["rockSource"] + assert source["src"] == "length" and source["pieces"][0]["line"] == "rock" + patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}}) + assert patch.model_dump()["detail_table"]["cells"]["fence"]["pieces"][0]["line"] == "rock" + + +def test_암_경계선_서버_거울(out): + number, _text, again = cell_value(out["rockSource"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(out["rockLocal"], abs=1e-6) + assert pick_lines(DESIGN, SAMPLES)["rock"] == [[(x, 99.0) for x in (-10, -5, 0, 5, 10)]] + # 토사 프리셋이면 암 경계선 없음 → 다시 고름 + soil = {**DESIGN, "geometry_preset": "soil"} + assert pick_lines(soil, SAMPLES)["rock"] == [] + assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True) From 01bca00e3cb8a8b62a8d17c3cf43265e4c46efbd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:03:54 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(b08):=20=EC=9A=B4=EB=B0=98=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=81=B4=EC=8B=9C=EA=B0=84=20=EC=A7=9D=20=C2=B7=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=85=EB=A0=A5=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80=20(253-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B08_Quantity_Haul_Cycle.py · .ts 짝 — 건설품셈 8-2-8 덤프트럭 · 거창 25 단가산출 1308~1347행 재현(t1 5.33 · t2 101.23 · Cm 109.78) - 로직 입력 이름 채움 Cm · 사이클시간 · t2 · 운반거리 · 덮은 칸(own) 그대로 - B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움 · 덮개 ref 에 cycle · own 남김 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_Haul_Cycle.py | 102 +++++++++++++++++++ B08_Quantity/B08_Quantity_Haul_Cycle.ts | 93 ++++++++++++++++++ B09_Estimation/B09_Estimation_Sangeun.py | 17 +++- B09_Estimation/B09_Estimation_Store.py | 12 ++- resources/tester/test_253_2_haul_cycle.py | 114 ++++++++++++++++++++++ 5 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Haul_Cycle.py create mode 100644 B08_Quantity/B08_Quantity_Haul_Cycle.ts create mode 100644 resources/tester/test_253_2_haul_cycle.py diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.py b/B08_Quantity/B08_Quantity_Haul_Cycle.py new file mode 100644 index 000000000..02557fc77 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.py @@ -0,0 +1,102 @@ +"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + +⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + 조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본). + +식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림) + q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) + t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6 + L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0. +로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 · + `own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김. +기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름). +""" + +from __future__ import annotations + +import math +from typing import Any + +DEFAULTS: dict[str, float] = { + "L1": 0.0, + "L2": 0.0, + "V1": 30.0, + "V2": 35.0, + "V3": 10.0, + "V4": 15.0, + "T": 15.0, + "rt": 1.7, + "f": 1.0, + "qs": 1.72, + "k": 1.0, + "Es": 0.6, + "Cms": 37.4, + "t3": 0.8, + "t4": 0.42, + "t5": 0.5, + "t6": 1.5, +} +#: 로직 입력 이름 → 셈 결과 칸 +FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"} + + +def r2(value: float) -> float: + """소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 100 + 0.5) / 100 + + +def _div(a: float, b: float) -> float: + return a / b if b else 0.0 + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + +def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]: + """사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}.""" + given = {k: _num(v) for k, v in (inputs or {}).items()} + v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()} + q = r2(_div(v["T"], v["rt"]) * v["f"]) + n = r2(_div(q, v["qs"] * v["k"])) + t1 = given.get("t1") + t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1 + l1, l2 = v["L1"], v["L2"] + t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60) + cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"]) + return { + "q": q, + "n": n, + "t1": t1, + "t2": t2, + "cm": cm, + "L": math.floor((l1 + l2) * 1000 + 0.5) / 1000, + } + + +def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]: + """연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로.""" + cycle = dict((ref or {}).get("cycle") or {}) + own = set((ref or {}).get("own") or []) + for name in ("L1", "L2"): + if name not in own and _num((dist or {}).get(name)) is not None: + cycle[name] = dist[name] + if "t1" not in own: + cycle.pop("t1", None) + return cycle + + +def fill_given( + ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None +) -> dict[str, Any]: + """로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로.""" + own = set(ref.get("own") or []) + got = cycle_time(cycle_inputs(ref, dist)) + given = dict(ref.get("given") or {}) + for name, slot in FILL.items(): + if name in names and name not in own: + given[name] = got[slot] + return given diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.ts b/B08_Quantity/B08_Quantity_Haul_Cycle.ts new file mode 100644 index 000000000..7d64079d4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B08_Quantity_Haul_Cycle.ts + * B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + * + * ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + * 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본). + * + * 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) · + * t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 · + * 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 · + * `own` = 사용자가 덮은 칸(그 값이 이김). + * ========================================================================== */ + +export const CYCLE_DEFAULTS: Record = { + L1: 0, + L2: 0, + V1: 30, + V2: 35, + V3: 10, + V4: 15, + T: 15, + rt: 1.7, + f: 1, + qs: 1.72, + k: 1, + Es: 0.6, + Cms: 37.4, + t3: 0.8, + t4: 0.42, + t5: 0.5, + t6: 1.5, +}; +/** 로직 입력 이름 → 셈 결과 칸 */ +export const CYCLE_FILL: Record = { + Cm: "cm", + 사이클시간: "cm", + t2: "t2", + 운반거리: "L", +}; + +export interface CycleResult { + q: number; + n: number; + t1: number; + t2: number; + cm: number; + L: number; +} +export interface CycleRef { + given?: Record | null; + cycle?: Record | null; + own?: string[] | null; +} +export type Dist = { L1?: number | null; L2?: number | null } | null | undefined; + +/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */ +export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100; +const div = (a: number, b: number): number => (b ? a / b : 0); +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */ +export function cycleTime(inputs: Record | null | undefined): CycleResult { + const v: Record = {}; + for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d; + const q = r2(div(v.T, v.rt) * v.f); + const n = r2(div(q, v.qs * v.k)); + const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es)); + const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60); + const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6); + return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 }; +} + +/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */ +export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record { + const cycle: Record = { ...(ref?.cycle || {}) }; + const own = new Set(ref?.own || []); + for (const name of ["L1", "L2"] as const) + if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name]; + if (!own.has("t1")) delete cycle.t1; + return cycle; +} + +/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */ +export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record { + const own = new Set(ref.own || []); + const got = cycleTime(cycleInputs(ref, dist)); + const given: Record = { ...(ref.given || {}) }; + for (const [name, slot] of Object.entries(CYCLE_FILL)) + if (names.includes(name) && !own.has(name)) given[name] = got[slot]; + return given; +} diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 3b7f74dd8..297f9de63 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -37,6 +37,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 @@ -475,13 +476,27 @@ class Solver: LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses") +def _input_names(code: str) -> list[str]: + try: + return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []] + except mf.FormulaError: + return [] + + def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]: - """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)].""" + """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] · + 운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본).""" out = [] + dist = (user or {}).get("haul_dist") or {} for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함 ref = row.get("ref") or {} if ref.get("tab") != "logic" or not ref.get("code"): continue + if row["key"] in dist: + ref = { + **ref, + "given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]), + } src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")), "rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개 diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 07496606d..1016fcac6 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -126,9 +126,15 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None - return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), - "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, - "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + out = {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), + "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, + "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸 + if isinstance(cycle, dict): + out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None} + own = ref.get("own") if isinstance(ref.get("own"), list) else [] + out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40] + return out return None diff --git a/resources/tester/test_253_2_haul_cycle.py b/resources/tester/test_253_2_haul_cycle.py new file mode 100644 index 000000000..59bac4dcb --- /dev/null +++ b/resources/tester/test_253_2_haul_cycle.py @@ -0,0 +1,114 @@ +"""253-2 운반 사이클시간 짝 — 거창 25 설계서 단가산출 1308~1347행 재현 · Py ↔ TS 거울 · +로직 입력 이름 채움 · B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time, fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import hand_sources # noqa: E402 +from B09_Estimation.B09_Estimation_Store import ref_rows # noqa: E402 + +XLSX = next( + (ROOT / "resources/knowledge/original/실무문서").glob( + "01.산불거창*/01.내역서/00.25년_산불_거창*내역+수량.xlsx" + ), + None, +) +#: 칸 → 입력 이름(단가산출 시트 · 32.혼합골재 운반(덤프15톤)) +CELLS = {"S1318": "L1", "AR1318": "L2", "R1320": "V1", "R1321": "V2", "AQ1320": "V3", + "AQ1321": "V4", "K1323": "T", "AB1323": "rt", "Q1325": "f", "L1327": "qs", + "Z1327": "k", "AL1327": "Es", "BA1327": "Cms", "L1337": "t3", "X1337": "t4", + "AK1337": "t5", "AW1337": "t6"} # fmt: skip + + +@pytest.mark.skipif(XLSX is None, reason="거창 설계서 없음") +def test_거창_혼합골재_운반_재현(): + import openpyxl + + ws = openpyxl.load_workbook(XLSX, read_only=False)["단가산출"] + inputs = {} + for cell, name in CELLS.items(): + text = str(ws[cell].value) # =TEXT(25.1,"#,##0.#######") + inputs[name] = float(re.match(r"=TEXT\(([\d.]+),", text).group(1)) + # 식 줄이 같은 꼴인지(t2 · Cm) + assert ( + "(S1318)/(R1320)+(S1318)/(R1321)+(AR1318)/(AQ1320)+(AR1318)/(AQ1321)" in ws["BX1334"].value + ) + assert "BQ1331+BX1334+L1337+X1337+AK1337+AW1337" in ws["CE1339"].value + assert (inputs["L1"], inputs["L2"]) == (25.1, 0.8) + got = cycle_time(inputs) + assert (got["q"], got["n"], got["t1"]) == (8.82, 5.13, 5.33) + assert (got["t2"], got["cm"]) == (101.23, 109.78) + assert cycle_time({"L1": 25.1, "L2": 0.8}) == got # 기본값 = 거창 값 + + +CASES = [ + {"L1": 25.1, "L2": 0.8}, + {"L1": 3.333, "L2": 1.257, "V1": 20, "V2": 25, "V3": 7, "V4": 8, "t5": 3.77}, + {"L1": 0, "L2": 0, "t1": 2.5, "V3": 0}, + {"L1": 12.5, "L2": 4.05, "T": 24, "rt": 1.8, "f": 0.8, "qs": 1.2, "k": 0.9, "Es": 0.75}, +] + + +def test_거울_ts(): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts" + body = ( + f"\nconst cases = {json.dumps(CASES)};" + "\nconsole.log(JSON.stringify(cases.map((c) => cycleTime(c))));" + "\nconst ref = {given: {qt: 1, Cm: 9}, cycle: {L1: 99, t1: 7}, own: ['L1']};" + "\nconst got = fillGiven(ref, ['Cm', 't2', '운반거리'], {L1: 25.1, L2: 0.8});" + "\nconsole.log(JSON.stringify(got));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + ts, ts_fill = (json.loads(line) for line in result.stdout.strip().splitlines()) + assert ts == [cycle_time(c) for c in CASES] + ref = {"given": {"qt": 1, "Cm": 9}, "cycle": {"L1": 99, "t1": 7}, "own": ["L1"]} + assert ts_fill == fill_given(ref, ["Cm", "t2", "운반거리"], {"L1": 25.1, "L2": 0.8}) + + +def test_입력_이름_채움(): + ref = {"given": {"qt": 15}, "cycle": {}} + got = fill_given(ref, ["qt", "Cm", "지역"], {"L1": 25.1, "L2": 0.8}) + assert got == {"qt": 15, "Cm": 109.78} + assert fill_given(ref, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"qt": 15, "운반거리": 25.9} + assert fill_given(ref, ["사이클시간", "t2"], {"L1": 25.1, "L2": 0.8}) == { + "qt": 15, "사이클시간": 109.78, "t2": 101.23} # fmt: skip + # 덮은 칸 = 그 값 · 덮은 L1 = 거리 대신 사이클 값 + own = {"given": {"Cm": 50}, "own": ["Cm"]} + assert fill_given(own, ["Cm"], {"L1": 25.1, "L2": 0.8}) == {"Cm": 50} + mine = {"given": {}, "cycle": {"L1": 10}, "own": ["L1"]} + assert fill_given(mine, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"운반거리": 10.8} + + +def test_서버_셈_거리로_다시_채움(): + ref = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {"Cm": 1}, "cycle": {}} + user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": {"L1": 25.1, "L2": 0.8}}} + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 109.78 + user["haul_dist"]["haul:r-u1"] = {"L1": 10, "L2": 0} # 거리 바뀌면 따라감 + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 45.69 and "운반거리" not in src["given"] # 그 로직 입력만 + + +def test_덮개_ref_사이클_남김(): + from B09_Estimation.B09_Estimation_Store import _clean_ref + + ref = {"tab": "logic", "code": "GF000134", "given": {"Cm": 3}, "cycle": {"V1": 20, "x": "a"}, + "own": ["Cm", 3]} # fmt: skip + got = _clean_ref(ref) + assert got["cycle"] == {"V1": 20} and got["own"] == ["Cm"] + rows = ref_rows({"bill": {"rows": [{"key": "haul:r-u1", "ref": got}]}}) + assert rows[0]["ref"]["cycle"] == {"V1": 20}