From 6c3831ce43e4ec24a7f5eb951f0bb05d11bcdb52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:51:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=ED=91=9C=20=EB=84=98=EA=B9=80=20=E2=80=94?= =?UTF-8?q?=20B09=2018=EC=9E=A5=20=3D=20=EA=B8=B0=EB=B2=883=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EC=8B=9C=ED=8A=B8=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=C2=B7=20=ED=99=94=EB=A9=B4=20=ED=91=9C=20=EC=A7=9C=EA=B8=B0(Bu?= =?UTF-8?q?ilders)=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=97=B0=EA=B2=B0=20=EC=B9=B8=20=C2=B7=20=EC=86=90=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EA=B0=95=EC=A1=B0=20=C2=B7=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EA=B3=A0=EC=B9=A8=20=EB=BA=8C=20(302-4)?= 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_01XWh5wNNycMkbwMpACz1rte --- B09_Estimation/draft/B09_Draft_Print_Cost.ts | 96 ++++++++++ .../tester/print_cost/print_cost_entry.ts | 44 +++++ resources/tester/test_302_4_print_cost.py | 178 ++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Cost.ts create mode 100644 resources/tester/print_cost/print_cost_entry.ts create mode 100644 resources/tester/test_302_4_print_cost.py diff --git a/B09_Estimation/draft/B09_Draft_Print_Cost.ts b/B09_Estimation/draft/B09_Draft_Print_Cost.ts new file mode 100644 index 000000000..a05fc2abd --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Cost.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * B09_Draft_Print_Cost.ts + * 인쇄 미리보기(PLAN 302-4 · 초안) 「원가계산」 영역 — B09 18장을 실무 내역서 차례로 모음. + * 차례 · 이름 · 방향 = 기번3 내역서 xlsx 시트(목차 · 안내 빼고 · page_setup.orientation) + * 값 = GET …/estimation + 화면 캐시(`b09-estimation`) → `computeSheets` = B09 화면과 같은 값 + * 표 = 화면 표 짜기(`…_UI_Builders`) 그대로 · 링크 · 강조 · 고칠 칸 표시 · 단가연결 칸만 뺌 + * 공기 초안 탭(공기집계 · 공기산출) = 내역서 차례 밖 → 안 넣음 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../../A00_Common/b_page_state"; +import type { Row } from "../B09_Estimation_Calc_Base"; +import { tableOptionsOf, tabTableOf } from "../B09_Estimation_UI_Builders"; +import { computeSheets } from "../B09_Estimation_UI_Shell"; +import type { TabContext, TabId } from "../B09_Estimation_UI_Tabs"; +import type { CollectSheets, PrintSheet } from "./B09_Draft_Print_Types"; + +type Orientation = NonNullable; + +/** 기번3 내역서 시트 차례 — [탭, 시트 이름, 인쇄 방향] */ +export const COST_SHEETS: Array<[TabId, string, Orientation]> = [ + ["cost", "공사원가계산서", "landscape"], + ["total", "총괄설계내역서", "landscape"], + ["bill", "설계내역서", "landscape"], + ["dg_list", "일위대가목록표", "landscape"], + ["dg", "일위대가표", "landscape"], + ["sg_list", "단가산출근거목록표", "landscape"], + ["sg", "단가산출근거", "landscape"], + ["basis", "환율및기초자료", "landscape"], + ["machine", "중기목록표", "landscape"], + ["machine_blocks", "중기사용료", "landscape"], + ["material", "재료비목록표", "landscape"], + ["labor", "노무비목록표", "landscape"], + ["expense", "경비목록표", "landscape"], + ["compare", "자재단가대비표", "landscape"], + ["sum_material", "재료비수량금액집계표", "landscape"], + ["sum_labor", "노무비수량금액집계표", "landscape"], + ["sum_expense", "경비수량금액집계표", "landscape"], + ["sum_machine", "중기시간금액집계표", "landscape"], +]; + +/** 화면 조작 전용 칸(설계내역서 「단가연결 ▾」) */ +const UI_COLUMNS = new Set(["connect"]); +/** 인쇄에 남길 줄 꼴(제목 줄 굵게) — 손 줄 · 고른 줄 표시는 뺌 */ +const KEEP_ROW = new Set(["is-title"]); + +/** 화면 표 옵션 → 인쇄 표(값 · 머리 · 레벨 · 합계 · 병합 그대로 · 누름 · 고침 · 강조 뺌) */ +export function printTable(options: DataTableOptions, caption: string): DataTableOptions { + const { columns, rows, total, frozen, empty, level, mergeSame, rowClass } = options; + return { + columns: columns.filter((c) => !UI_COLUMNS.has(c.key)), + rows, + total, + frozen, + empty, + caption, + mergeSame, + level: level && { ...level, tree: false }, + rowClass: + rowClass && + ((i) => + rowClass(i) + ?.split(" ") + .filter((c) => KEEP_ROW.has(c)) + .join(" ") || null), + }; +} + +/** 받은 자료 + 사용자 값 → 18장(차례 그대로 · 표가 없는 탭 = 빈 표) */ +export function costSheetsOf(payload: Row, user: Row): PrintSheet[] { + const ctx: TabContext = { + sheets: computeSheets(payload, user), + payload, + user, + setUser: () => {}, + }; + return COST_SHEETS.map(([id, name, orientation]) => { + const spec = tabTableOf(id, ctx); + const table = + !spec || typeof spec === "string" + ? { columns: [], rows: [], caption: name } + : printTable(tableOptionsOf(spec, id), name); + return { id, name, orientation, table }; + }); +} + +export const collectCostSheets: CollectSheets = async (projectId) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`원가 자료를 불러오지 못했습니다 — HTTP ${response.status}`); + const payload = (await response.json()) as Row; + const user = readState("b09-estimation", projectId) ?? payload.user ?? {}; + return costSheetsOf(payload, user); +}; diff --git a/resources/tester/print_cost/print_cost_entry.ts b/resources/tester/print_cost/print_cost_entry.ts new file mode 100644 index 000000000..67bb250a7 --- /dev/null +++ b/resources/tester/print_cost/print_cost_entry.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * print_cost_entry.ts (PLAN 302-4 시험 진입) + * 원가계산 인쇄 쪽(`B09_Draft_Print_Cost.costSheetsOf`)을 화면 표 짜기(`tabTableOf`)와 대조. + * vite `--ssr` 로 번들해 node 로 실행 — 인자 = payload JSON 경로 · 출력 = 쪽마다 대조 JSON. + * ========================================================================== */ + +import { readFileSync } from "node:fs"; +import { costSheetsOf } from "../../../B09_Estimation/draft/B09_Draft_Print_Cost"; +import { tabTableOf } from "../../../B09_Estimation/B09_Estimation_UI_Builders"; +import { computeSheets } from "../../../B09_Estimation/B09_Estimation_UI_Shell"; +import type { TabId } from "../../../B09_Estimation/B09_Estimation_UI_Tabs"; + +const payload = JSON.parse(readFileSync(process.argv[2], "utf-8")); +const user = payload.user ?? {}; +const ctx = { sheets: computeSheets(payload, user), payload, user, setUser: () => {} }; + +const out = costSheetsOf(payload, user).map((sheet) => { + const spec = tabTableOf(sheet.id as TabId, ctx); + const screen = !spec || typeof spec === "string" ? null : spec; + const t = sheet.table; + const n = t.rows.length; + return { + id: sheet.id, + name: sheet.name, + orientation: sheet.orientation, + rows: n, + screenRows: screen ? screen.rows.length : 0, + sameRows: JSON.stringify(t.rows) === JSON.stringify(screen?.rows ?? []), + total: t.total ?? null, + screenTotal: screen?.total ?? null, + columns: t.columns.map((c) => c.key), + screenColumns: screen ? screen.columns.map((c) => c.key) : [], + groups: t.columns.filter((c) => c.group?.length).length, + level: t.level ? { key: t.level.key, tree: t.level.tree ?? false } : null, + levels: t.level ? Array.from({ length: n }, (_, i) => t.level?.of(i) ?? null) : [], + rowClass: t.rowClass ? Array.from({ length: n }, (_, i) => t.rowClass?.(i) ?? null) : [], + mergeSame: t.mergeSame ?? [], + caption: t.caption, + interactive: ["editable", "onEdit", "onCellClick", "onRowClick", "cellClass", "focus"].filter( + (k) => k in t, + ), + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_302_4_print_cost.py b/resources/tester/test_302_4_print_cost.py new file mode 100644 index 000000000..658c7c010 --- /dev/null +++ b/resources/tester/test_302_4_print_cost.py @@ -0,0 +1,178 @@ +"""PLAN 302-4 인쇄 미리보기 원가계산 — B09 18장 = 기번3 내역서 시트 차례 · 이름 · 방향 · +쪽 표 = 화면 표 짜기와 같은 줄 · 합계(링크 · 강조 · 고칠 칸 · 단가연결 칸만 뺌). + +차례 = 실무 xlsx(openpyxl · 목차 · 안내 · 숨김 시트 빼고) · 견본 = 임시 폴더 프로젝트(145-2 한 바퀴 꼴) +→ 서버 셈 payload → `print_cost_entry.ts` 를 vite `--ssr` 로 번들해 node 로 대조. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +import warnings +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +for path in (ROOT, ROOT / "resources" / "tester"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import test_145_2_b09_labor_calc as t145 # noqa: E402 + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table # noqa: E402 +from B09_Estimation.B09_Estimation_Router import compute_twice # noqa: E402 + +COST = ROOT / "B09_Estimation" / "draft" / "B09_Draft_Print_Cost.ts" +TABS = ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts" +ENTRY = ROOT / "resources" / "tester" / "print_cost" / "print_cost_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" +XLSX = ( + ROOT + / "resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)" + / "1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx" +) +#: 내역서 앞 시트(차례 밖) +FRONT = ("〓 목 차 〓", "※※안내※※") + +ENTRY_RE = re.compile(r'\["(\w+)", "([^"]+)", "(landscape|portrait)"\]') + + +def _cost_sheets() -> list[tuple[str, str, str]]: + src = COST.read_text("utf-8") + body = src[src.index("COST_SHEETS") : src.index("];", src.index("COST_SHEETS"))] + return ENTRY_RE.findall(body) + + +def _practical() -> list[tuple[str, str]]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # 인쇄 영역 정의 이름 경고 + wb = openpyxl.load_workbook(XLSX) + return [ + (ws.title, ws.page_setup.orientation or "landscape") + for ws in wb.worksheets + if ws.sheet_state == "visible" and ws.title not in FRONT + ] + + +def test_차례_이름_방향_기번3_내역서(): + ours = _cost_sheets() + assert len(ours) == 18 + assert [(name, o) for _id, name, o in ours] == _practical() + assert all(len(name) <= 31 for _id, name, _o in ours) # 엑셀 시트 이름 한도 + # 탭 이름 = 화면 탭 이름(B09 TABS 앞 18장 · 같은 차례) + tabs = re.findall(r'^ \["(\w+)", "([^"]+)"\],$', TABS.read_text("utf-8"), re.MULTILINE) + assert [(i, n) for i, n, _o in ours] == tabs[:18] + assert not {"schedule_sum", "schedule_calc"} & {i for i, _n, _o in ours} # 공기 초안 뺌 + + +def _payload(root: Path) -> dict: + """145-2 한 바퀴 견본 — 관 둘(대가 · 산근 · 중기 · 사전조합) · 설계내역 한 줄 → 라우터 꼴 payload.""" + t145._snap(root, "pipe@100.0", "SS000901", t145.LINES, t145._values(t145.STONE)) + promoted = [ + {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, + {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, + ] + t145._snap(root, "pipe@400.0", "SS000902", promoted, t145._values(t145.STONE), t145.SC) + inputs = prices.build_inputs(None, linked_resource_keys(root)) + inputs["daega"] = daega_inputs(root) + inputs["hopyo"] = {} + inputs["bill"] = { + "items": [ + {"key": "st:revet", "section": "2.1", "name": "돌기슭막이", "spec": "", "unit": "m", + "qty": 10, "ref": {"tab": "dg_list", "key": "revet"}, "note": "", + "owners": {"pipe@100.0": 4, "pipe@400.0": 6}}, + ] + } # fmt: skip + sheets, summary = compute_twice(root, inputs, {}) + return { + "inputs": inputs, + "user": {}, + "book": None, + "stale": None, + "sheets": sheets, + "rate_candidates": rate_candidates(), + "rate_table": rate_table(), + "notes": [], + "summary": summary, + } + + +@pytest.fixture(scope="module") +def pages(tmp_path_factory) -> list[dict]: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + work = tmp_path_factory.mktemp("p302_4") + root = t145._project(work / "proj", [(100, "SS000901"), (400, "SS000902")]) + data = work / "payload.json" + data.write_text(json.dumps(_payload(root), ensure_ascii=False, default=str), "utf-8") + with tempfile.TemporaryDirectory(prefix="print_cost_test_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "print_cost_entry.js"), str(data)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), f"시험 진입이 아무 결과도 안 냄:\n{run.stderr}" + return json.loads(run.stdout) + + +def test_쪽_차례_이름_방향(pages): + assert [(p["id"], p["name"], p["orientation"]) for p in pages] == _cost_sheets() + assert all(p["caption"] == p["name"] for p in pages) + + +def test_쪽_표_화면과_같은_줄_합계(pages): + for p in pages: + assert p["sameRows"] and p["rows"] == p["screenRows"], p["id"] + assert p["total"] == p["screenTotal"], p["id"] + assert p["columns"] == [k for k in p["screenColumns"] if k != "connect"], p["id"] + assert p["interactive"] == [], p["id"] # 누름 · 고침 · 강조 뺌 + by = {p["id"]: p for p in pages} + # 견본이 표를 채움 — 내역 · 대가 · 산근 · 중기 · 집계 + for tab in ("bill", "dg_list", "dg", "sg_list", "sg", "machine", "sum_labor", "cost", "total"): + assert by[tab]["rows"] > 0, tab + + +def test_머리_여러_줄_레벨_병합_꼴(pages): + by = {p["id"]: p for p in pages} + bill = by["bill"] + assert bill["groups"] > 0 # 머리 두 줄(재료비 · 노무비 · 경비 묶음) + assert "connect" not in bill["columns"] # 단가연결 메뉴 칸 뺌 + assert bill["level"] == {"key": "name", "tree": False} # 들여쓰기만 · 나무 아이콘 뺌 + assert max(v for v in bill["levels"] if v) >= 2 + assert {c for c in bill["rowClass"] if c} <= {"is-title"} # 손 줄 · 고른 줄 표시 뺌 + assert "is-title" in bill["rowClass"] + assert by["total"]["mergeSame"] == ["part"] # 총괄 공종 칸 세로 병합