diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 26490385e..73f6e7a43 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -3,7 +3,8 @@ * B09 대가 탭 둘(PLAN 145-2) — 일위대가목록표 · 일위대가표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Labor`). * * 목록표 = 호표 한 줄(합계 · 노무비 · 재료비 · 경비) · 줄 누름 = 그 블록으로 - * 일위대가표 = 블록(머리 · [품셈 인용] · 줄 · 합계) · 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록) + * 일위대가표 = 공용 일위대가 격자(704-3 · `ui_template_hopyo_grid` · 머리 · [품셈 인용] · 줄 · 합계 · 줄 꼬리표) · + * 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록) * 줄 꼴(155-2) = 머리 · 합계 굵게(`is-title`) · 인용 · 줄 = 들여쓰기 「ㄴ」(2단 · 겹 대가는 안 펼침) * 연결된 구조물 조합 사본이 없으면 빈 표 「연결 없음」 * 빈 줄(166-6) = 합계 = 아는 줄 합 · 옆 「빈 줄 n」 · 풍선 = 빈 줄 이름 · 내역 단가엔 안 넘김 @@ -14,7 +15,6 @@ import type { Row } from "./B09_Estimation_Calc_Base"; import { blockLevel, money, - qty, text, type Link, type TabBuild, @@ -22,11 +22,10 @@ import { type TabTable, } from "./B09_Estimation_UI_Tabs"; import { raw } from "./B09_Estimation_UI_Tabs_Sangeun"; +import { HOPYO_TAG, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid"; const EMPTY = "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; -/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ -const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1) const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)"; /** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : ""); @@ -78,82 +77,48 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { const blocks: Row[] = sheets.daega_blocks || []; if (!blocks.length) return EMPTY; - const rows: DataRow[] = []; - const links: Array = []; - const titles = new Set(); - for (const b of blocks) { - titles.add(rows.length); - rows.push({ + // 704-3 — 공용 일위대가 격자(M02 카드 · 상세와 같은 열 · 줄 꼴) · 값 = 서버 `Calc_Labor` 그대로 + const grid = hopyoGrid( + blocks.map((b) => ({ key: b.id, - name: `제 ${b.no} 호표 ${b.name}`, no: b.no, - title: b.name, // 171-3 묶음 카드 머리 + name: b.name, spec: b.spec, spec_raw: b.spec_raw, unit: b.unit, - note: b.kind, - }); - links.push(null); - if (b.cite) { - rows.push({ key: `${b.id}|cite`, name: `${CHILD}${b.cite}`, note: "품셈 인용" }); - links.push(null); - } - b.lines.forEach((ln: Row, n: number) => { - rows.push({ - key: `${b.id}|${n}`, - name: `${CHILD}${ln.name || ""}`, + kind: b.kind, + cite: b.cite, + total: { sum: b.sum, labor: b.labor, material: b.material, expense: b.expense }, + blank: b.blank ?? 0, + blank_names: b.blank_names ?? [], + lines: b.lines.map((ln: Row) => ({ + name: ln.name || "", spec: ln.spec || "", spec_raw: ln.spec_raw, qty: ln.qty, unit: ln.unit || "", - sum_price: ln.price, - sum: ln.amount, - labor_price: ln.prices.labor, - labor: ln.amounts.labor, - material_price: ln.prices.material, - material: ln.amounts.material, - expense_price: ln.prices.expense, - expense: ln.amounts.expense, + prices: { sum: ln.price, ...ln.prices }, + amounts: { sum: ln.amount, ...ln.amounts }, note: ln.ref_no || ln.why || "", - }); - const tab = LINE_TAB[ln.kind]; - links.push(tab && ln.key ? { tab, key: String(ln.key) } : null); - }); - titles.add(rows.length); - rows.push({ - key: `${b.id}|합계`, - name: "합 계", - sum: b.sum, - labor: b.labor, - material: b.material, - expense: b.expense, - note: blankNote(b), - blank: b.blank, - blank_names: b.blank_names, - }); - links.push(null); - } - const pair = (key: string, label: string) => [ - money(`${key}_price`, "단가", [label], 1), - money(key, "금액", [label], 1), - ]; + tag: HOPYO_TAG.pum, + })), + })), + ); + const { rows, titles } = grid; + const links: Array = rows.map((_, i) => { + const at = grid.at.get(i); + const ln: Row | undefined = at && blocks[at[0]].lines[at[1]]; + const tab = ln && LINE_TAB[ln.kind]; + return tab && ln.key ? { tab, key: String(ln.key) } : null; + }); return { - columns: [ - text("name", "명칭", 240), - text("spec", "규격", 160), - qty("qty", "수량"), - text("unit", "단위", 56), - ...pair("sum", "합계"), - ...pair("labor", "노무비"), - ...pair("material", "재료비"), - ...pair("expense", "경비"), - text("note", "비고", 120), - ], + columns: grid.columns, rows, frozen: 1, hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`, link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), + cellClass: (i: number, key: string) => hopyoCellClass(rows, i, key), level: blockLevel("name", (i) => titles.has(i)), tip: (i: number, key: string) => key === "spec" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 5b71c1bfe..dfdcf42fc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -13,6 +13,7 @@ * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) + * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 * ========================================================================== */ import { @@ -64,6 +65,7 @@ import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; +import { laborGrid } from "./M02_MasterTemplete_Labor_Grid"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; @@ -135,6 +137,7 @@ export function mountLaborDetail( let pills = new Map(); const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved; + const gridHost = el("div", { attrs: { "data-labor-grid": "" } }); // 704-3 엑셀 꼴 격자(맨 위) const logicHost = el("div"); const matHost = el("div"); const dgHost = el("div"); @@ -215,6 +218,7 @@ export function mountLaborDetail( ), ); const draw = (): void => { + gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics)); drawLogic(); matHost.replaceChildren( matTable( @@ -642,8 +646,11 @@ export function mountLaborDetail( }), ]), // 사전조합 = 같은 [로직 추가](51-11) - section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), - section(tc("Labor_Mats"), opt.card ? null : addMat, matHost), + section(tc("Grid_Title"), null, gridHost), + ...[ + section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), + section(tc("Labor_Mats"), opt.card ? null : addMat, matHost), + ].filter(() => edit || !opt.card), // 읽기만 카드 = 격자만 ...(opt.card || lib ? [] : [dgBox]), ], }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts new file mode 100644 index 000000000..c2a4b7b9e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Grid.ts + * 일위대가 엑셀 꼴 격자(704-3) — 문서 · 서버 calc 답 → 공용 격자(`ui_template_hopyo_grid` · B09 일위대가표와 같은 꼴). + * 일위대가 카드 · 상세(`Labor_Detail`)가 위에 붙임. 값은 calc 답 그대로(여기서 셈하지 않음): + * 머리 = 일위대가번 · 단위 · 1 단위 합계(`반영합`) + * 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈 + * 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」 + * 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄 + * ========================================================================== */ + +import { + createHopyoGrid, + HOPYO_TAG, + type HopyoBlock, + type HopyoLine, + type HopyoMoney, +} from "@ui/ui_template_hopyo_grid"; +import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { valueLine } from "./M02_Labor_Values_Model"; + +const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney => + m + ? { + sum: m.계 / times, + labor: m.노무비 / times, + material: m.재료비 / times, + expense: m.경비 / times, + } + : {}; + +/** 문서 + calc 답 → 호표 하나 — calc 없으면(계산 중) 금액 빈 칸 · 빈 줄 0 */ +export function laborBlock( + doc: LaborDoc, + calc: LaborCalc | null, + logics: ReadonlyMap, +): HopyoBlock { + const logicLines = doc.담은로직.map((row): HopyoLine => { + const id = lineId(row) ?? ""; + const line = calc?.줄.find((l) => lineId(l) === id); + const logic = logics.get(row.로직); + const layer = + row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; + const qty = line?.수량 ?? null; + return { + name: row.별칭 || ((row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직), + spec: [valueLine(logic?.입력 ?? [], row.입력값).text, layer].filter(Boolean).join(" · "), + qty, + unit: line?.분모 ?? (row.참조 ? "" : (logic?.결과단위 ?? "")), + prices: money(line?.기준, line?.배 || 1), + amounts: money(line?.반영), + note: line?.까닭 ?? (calc && qty === null ? tc("Grid_NoQty") : (row.참조?.키 ?? row.로직)), + tag: HOPYO_TAG.pum, + }; + }); + const matLines = doc.자재.map((mat): HopyoLine => { + const line = calc?.자재.find((m) => m.코드 === mat.코드); + const qty = line?.수량 ?? null; + const price = line?.기준단가 ?? null; + const cost = line?.반영 ?? null; + return { + name: mat.이름 ?? mat.코드, + spec: mat.규격 ?? "", + qty, + unit: mat.단위 ?? "", + prices: { sum: price, material: price }, + amounts: { sum: cost, material: cost }, + note: + line?.까닭 ?? + (calc && qty === null + ? tc("Grid_NoQty") + : mat.별도계상 !== "직접계산" + ? tc("Grid_Apart") // 짝 `settle` 의 aside 와 같은 규칙 + : mat.코드), + }; + }); + const lines = [...logicLines, ...matLines]; + return { + key: doc.열, + name: doc.일위대가번 || doc.열, + unit: doc.단위 ?? "", + total: money(calc?.반영합), + lines, + ...(calc ? {} : { blank: 0 }), + }; +} + +/** 격자 하나(읽기 전용) */ +export const laborGrid = ( + doc: LaborDoc, + calc: LaborCalc | null, + logics: ReadonlyMap, +): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 35792580c..17a29f798 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -173,6 +173,10 @@ const TEXT = { Lib_Base: ["기준 금액", "Base amount"], Lib_Date: ["고친 날", "Modified"], // 서고 구분 · 연결(51-12) + // 엑셀 꼴 격자(704-3) + Grid_Title: ["일위대가표", "Unit-price sheet"], + Grid_NoQty: ["수량 없음", "No quantity"], + Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 2e74ef513..f431d252e 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -14,6 +14,7 @@ IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) COMMON = "ui_template/ui_template_group_cards.ts" TABS = "B09_Estimation/B09_Estimation_UI_Tabs.ts" GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts" +GRID = "ui_template/ui_template_hopyo_grid.ts" # 704-3 일위대가표 격자(Tabs_Labor 앞) # 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝 STUBS = r""" diff --git a/resources/tester/test_155_b09_tabs.py b/resources/tester/test_155_b09_tabs.py index a59f43219..462abae0a 100644 --- a/resources/tester/test_155_b09_tabs.py +++ b/resources/tester/test_155_b09_tabs.py @@ -35,10 +35,11 @@ BLOCK = { def _node(js_body, tab="UI_Tabs_Labor"): - src = "".join( + grid = IMPORTS.sub("", (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text("utf-8")) + src = grid + "".join( IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8")) for n in ("UI_Tabs", tab) - ) + ) # 704-3 일위대가표 = 공용 격자 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src + js_body], diff --git a/resources/tester/test_157_4_b09_labor_spec.py b/resources/tester/test_157_4_b09_labor_spec.py index 24f7a1f40..3e02ba46d 100644 --- a/resources/tester/test_157_4_b09_labor_spec.py +++ b/resources/tester/test_157_4_b09_labor_spec.py @@ -57,9 +57,14 @@ def test_규격_칸_풍선(): "labor": 1, "material": 0, "expense": 0, "sum": 1}], } # fmt: skip sangeun = (B09 / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8") - src = re.search(r"^export const raw =.*?;$", sangeun, re.MULTILINE | re.DOTALL)[0] + "".join( - IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8")) - for n in ("UI_Tabs", "UI_Tabs_Labor") + grid = IMPORTS.sub("", (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text("utf-8")) + src = ( + grid + + re.search(r"^export const raw =.*?;$", sangeun, re.MULTILINE | re.DOTALL)[0] + + "".join( + IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8")) + for n in ("UI_Tabs", "UI_Tabs_Labor") + ) ) # 산근 탭의 풍선 함수 `raw` 만(같은 이름 상수가 겹쳐 파일째는 못 잇음) body = f""" const sheets = {json.dumps(sheets, ensure_ascii=False)}; diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index 5e6f376f9..413f4cb3c 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -131,7 +131,10 @@ def test_B08_B09_적용(): mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8") assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8") - assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor + grid = (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text( + "utf-8" + ) # 704-3 줄 글 = 공용 격자 + assert 'HOPYO_CHILD = "ㄴ "' in grid and '" 합 계"' not in labor + grid # 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림) shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ".cellIndex" not in shell and shell.count("cellColumn(") >= 2 diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 7d33b35ce..a953cf527 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -15,7 +15,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 +from resources.tester.helper_b09_group_cards import COMMON, GRID, GROUPS, RAW, TABS, run # noqa: E402 B09 = "B09_Estimation/B09_Estimation_UI_" BAR = f"{B09}TabBar.ts" @@ -74,7 +74,7 @@ console.log(JSON.stringify({ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): """쉘이 하는 대로 — 줄 그림 → 묶음 판 그림(`setTools` = 보조 줄 도구) → 단추는 줄에, 판에는 없음.""" out = run( - [COMMON, TABS, f"{B09}Tabs_Labor.ts", GROUPS, BAR], + [COMMON, TABS, GRID, f"{B09}Tabs_Labor.ts", GROUPS, BAR], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" "\nconst bars = createTabBars(() => {});" "\nconst hint = document.createElement('p');" diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py index 53266f234..becce2294 100644 --- a/resources/tester/test_304_b09_view_setting.py +++ b/resources/tester/test_304_b09_view_setting.py @@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 +from resources.tester.helper_b09_group_cards import COMMON, GRID, GROUPS, RAW, TABS, run # noqa: E402 LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts" DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts" @@ -33,7 +33,7 @@ SPEC = ( def test_한_설정_셋_탭_함께(): out = run( - [COMMON, TABS, LABOR, GROUPS], + [COMMON, TABS, GRID, LABOR, GROUPS], SPEC + "\nconst a = kinds();" "\nchooseGroupView('table');" "\nconst b = kinds();" @@ -59,7 +59,7 @@ def test_옛_탭별_값_첫_읽기에_한_값으로(): ] for old, want in cases: out = run( - [COMMON, TABS, LABOR, GROUPS], + [COMMON, TABS, GRID, LABOR, GROUPS], "\nkept.set('b09-group-view', " + json.dumps(json.dumps(old)) + ");" diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py new file mode 100644 index 000000000..bb9b3e521 --- /dev/null +++ b/resources/tester/test_704_3_hopyo_grid.py @@ -0,0 +1,142 @@ +"""704-3 일위대가 엑셀 꼴 격자 — 공용 부품 `ui_template_hopyo_grid` 줄 꼴 · M02 문서 + calc → 격자 줄 값. + +TS 소스를 이어 붙여 Node(타입 지움)로 돌림 — 값은 받은 그대로(단가 = 기준 ÷ 배 · 금액 = 반영 · 합계 = 반영합). +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +GRID = "ui_template/ui_template_hopyo_grid.ts" + + +def _node(files, body, prelude=""): + src = "".join(IMPORTS.sub("", (ROOT / f).read_text("utf-8")) for f in files) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=prelude + src + body, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +LINE = {"name": "돌쌓기", "spec": "뒷길이 35cm", "qty": 2.09, "unit": "㎡", "tag": "품셈", + "prices": {"sum": 52995, "labor": 41250, "expense": 11745}, + "amounts": {"sum": 110759.6, "labor": 86212.5, "expense": 24547.1}} # fmt: skip + + +def test_머리_구성_합계_줄_꼬리표_빈_줄(): + blocks = [ + {"key": "A", "no": 3, "name": "돌기슭막이", "unit": "m", "cite": "산림품셈 13-2", + "total": {"sum": 110759.6, "labor": 86212.5}, "lines": [LINE, {**LINE, "name": "잡재료", + "qty": None, "prices": {}, "amounts": {}}]}, + {"key": "B", "name": "번호 없음", "unit": "식", "total": {}, "lines": [], "blank": 2}, + ] # fmt: skip + out = _node([GRID], f"\nconst g = hopyoGrid({json.dumps(blocks, ensure_ascii=False)});" + "\nconst cls = g.rows.map((_, i) => hopyoCellClass(g.rows, i, 'tag') ?? hopyoCellClass(g.rows, i, 'note'));" + "\nconsole.log(JSON.stringify({ rows: g.rows, titles: [...g.titles], at: [...g.at], cls," + " cols: g.columns.map((c) => c.key) }));") # fmt: skip + rows = out["rows"] + assert [r["name"] for r in rows] == [ + "제 3 호표 돌기슭막이", "ㄴ 산림품셈 13-2", "ㄴ 돌쌓기", "ㄴ 잡재료", "합 계", "번호 없음", "합 계", + ] # fmt: skip + assert rows[0]["sum"] == 110759.6 # 머리 = 1 단위 합계 + assert ( + rows[2]["labor_price"] == 41250 and rows[2]["labor"] == 86212.5 and rows[2]["qty"] == 2.09 + ) + assert rows[2]["material_price"] is None and rows[2]["tag"] == "품셈" + assert rows[4]["note"] == "빈 줄 1" and rows[4]["blank_names"] == [ + "잡재료" + ] # 금액 모르는 줄에서 셈 + assert rows[6]["note"] == "빈 줄 2" # 준 빈 줄 수 그대로 + assert out["titles"] == [0, 4, 5, 6] and out["at"] == [[2, [0, 0]], [3, [0, 1]]] + assert out["cls"][2] == "ui-hopyo-grid__tag is-pum" and out["cls"][4] == "ui-hopyo-grid__blank" + assert out["cols"][-1] == "tag" and out["cols"][:4] == ["name", "spec", "qty", "unit"] + + +STUBS = r""" +const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%" })[k] ?? k; +const valueLine = (inputs, values) => ({ text: Object.values(values ?? {}).join(" · "), empty: 0 }); +""" + +DOC = { + "종류": "일위대가", + "열": "C1", + "일위대가번": "SU-000006", + "단위": "m", + "담은로직": [ + {"로직": "GF1", "별칭": None, "입력값": {"뒷길이": "35cm"}, "수량": None, "켜쌓기율": 90}, + {"로직": "GF2", "별칭": "고임돌", "입력값": {}, "수량": None}, + ], + "자재": [ + {"코드": "MT1", "이름": "큰돌", "규격": "30cm", "단위": "개", "수량": None}, + { + "코드": "MT2", + "이름": "철선", + "규격": "", + "단위": "kg", + "수량": None, + "별도계상": "직접계산", + }, + ], +} +CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09, + "기준": {"노무비": 412500, "재료비": 0, "경비": 117450, "계": 529950}, + "반영": {"노무비": 86212.5, "재료비": 0, "경비": 24547.05, "계": 110759.55}}, + {"로직": "GF2", "분모": "㎥", "배": 1, "수량": None, + "기준": {"노무비": 24816, "재료비": 0, "경비": 0, "계": 24816}, "반영": None}], + "자재": [{"코드": "MT1", "기준단가": 1200, "수량": 5, "반영": 6000}, + {"코드": "MT2", "기준단가": 900, "수량": 2, "반영": 1800}], + "반영합": {"노무비": 86212.5, "재료비": 1800, "경비": 24547.05, "계": 112559.55}} # fmt: skip + + +def test_M02_문서_calc_를_격자_줄로(): + files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip + logics = [ + ["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}], + ["GF2", {"이름": "고임돌 채집"}], + ] + out = _node(files, ( + f"\nconst b = laborBlock({json.dumps(DOC, ensure_ascii=False)}, {json.dumps(CALC, ensure_ascii=False)}," + f" new Map({json.dumps(logics, ensure_ascii=False)}));" + "\nconst wait = laborBlock(" + json.dumps(DOC, ensure_ascii=False) + ", null, new Map());" + "\nconsole.log(JSON.stringify({ b, foot: hopyoGrid([b]).rows.at(-1), wait: hopyoGrid([wait]).rows.at(-1) }));" + ), STUBS) # fmt: skip + b = out["b"] + assert b["name"] == "SU-000006" and b["unit"] == "m" and b["total"]["sum"] == 112559.55 + one, two, mt1, mt2 = b["lines"] + assert ( + one["name"] == "돌쌓기 찰쌓기" + and one["spec"] == "35cm · 켜쌓기 90%" + and one["unit"] == "㎡" + ) + assert one["prices"] == { + "sum": 52995, + "labor": 41250, + "material": 0, + "expense": 11745, + } # 기준 ÷ 배 + assert one["amounts"]["sum"] == 110759.55 and one["tag"] == "품셈" + assert two["name"] == "고임돌" and two["qty"] is None and two["note"] == "수량 없음" + assert mt1["prices"] == {"sum": 1200, "material": 1200} and mt1["note"] == "별도계상 · 합계 밖" + assert ( + mt2["amounts"] == {"sum": 1800, "material": 1800} + and mt2["note"] == "MT2" + and "tag" not in mt2 + ) + assert out["foot"]["note"] == "빈 줄 1" and out["foot"]["blank_names"] == ["고임돌"] + assert out["wait"]["note"] == "" # 계산 중 = 빈 줄 알림 없음 + + +def test_세_곳_같은_부품(): + detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8") + assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail + m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8") + b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8") + assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09 + kit = (ROOT / "ui_template/ui_template_ui_kit_parts.ts").read_text("utf-8") + assert 'key: "hopyo-grid"' in kit # 부품 모음 견본 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index f7449a623..3aca5dae7 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -360,7 +360,9 @@ def test_좌측_판_자리와_권한(): def test_로직_추가_한_곳과_파일_규칙(): detail = _text("M02_MasterTemplete_Labor_Detail.ts") - assert len(detail.splitlines()) == 693 # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2) + assert ( + len(detail.splitlines()) == 700 + ) # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2 · 704-3 격자 +7) assert "withHopyo(library)," in detail pick = _text("M02_MasterTemplete_Labor_Pick.ts") assert "more?: () => Promise;" in pick and "...later" in pick diff --git a/ui_template/ui_template_hopyo_grid.css b/ui_template/ui_template_hopyo_grid.css new file mode 100644 index 000000000..f31819c12 --- /dev/null +++ b/ui_template/ui_template_hopyo_grid.css @@ -0,0 +1,21 @@ +/* 일위대가 엑셀 꼴 격자(PLAN 704-3) — 줄 꼬리표 · 빈 줄 알림만(표 꼴은 공용 데이터표) */ + +.ui-data-table__table tbody td.ui-hopyo-grid__tag { + text-align: center; + font-size: var(--text-xs); + font-weight: var(--font-weight-bold); +} + +.ui-data-table__table tbody td.ui-hopyo-grid__tag.is-pum { + color: var(--color-accent); +} + +.ui-data-table__table tbody td.ui-hopyo-grid__tag.is-fix { + color: var(--color-warning); +} + +/* 합계 줄(is-title) 안에서도 붉게 */ +.ui-data-table__table tbody tr td.ui-hopyo-grid__blank.ui-hopyo-grid__blank { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts new file mode 100644 index 000000000..af7320113 --- /dev/null +++ b/ui_template/ui_template_hopyo_grid.ts @@ -0,0 +1,223 @@ +/* ============================================================================= + * ui_template_hopyo_grid.ts + * 일위대가 엑셀 꼴 격자(PLAN 704-3) — 호표마다 머리 줄 + 구성 줄 + 합계 줄 · 공용 데이터표(`ui_template_data_table`) 위. + * M02 일위대가 카드 · 상세 · B09 일위대가표(호표보기)가 같은 열 · 같은 줄 꼴을 씀. + * + * 머리 줄 = 「제 N 호표 이름」(번호 없으면 이름만) · 규격 · 단위 · 1 단위 합계(합계 금액 칸) · 비고 = 호표 꼴 + * 품셈 인용 줄(있으면) = 「ㄴ 조항」 · 비고 「품셈 인용」 + * 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표) + * 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만 + * 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸) + * 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음 + * + * const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]); + * B09 처럼 탭 표가 따로 그리면 `hopyoGrid(blocks)` 의 열 · 줄 · 제목 줄만 씀 + * ========================================================================== */ + +import { + createDataTable, + type DataColumn, + type DataRow, + type DataTableOptions, +} from "./ui_template_data_table"; +import "./ui_template_hopyo_grid.css"; + +export interface HopyoMoney { + sum?: number | null; + labor?: number | null; + material?: number | null; + expense?: number | null; +} + +export interface HopyoLine { + name: string; + spec?: string | null; + /** 규격 원문(풍선 · 부르는 쪽 몫) */ + spec_raw?: unknown; + /** 품 · 수량(1 단위 당) — null = 수량 없음 */ + qty: number | null; + unit?: string | null; + /** 1 단위(위 단위) 당 단가 */ + prices: HopyoMoney; + /** 단가 × 품/수량 — sum null = 금액 모름(빈 줄) */ + amounts: HopyoMoney; + note?: string | null; + /** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */ + tag?: string | null; +} + +export interface HopyoBlock { + /** 줄 키 앞머리(머리 = key · 줄 = key|n · 합계 = key|합계) */ + key: string; + no?: string | number | null; + name: string; + spec?: string | null; + spec_raw?: unknown; + unit?: string | null; + /** 호표 꼴(머리 줄 비고) */ + kind?: string | null; + /** 품셈 인용(조항) */ + cite?: string | null; + total: HopyoMoney; + lines: HopyoLine[]; + /** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */ + blank?: number; + blank_names?: string[]; +} + +/** 줄 꼬리표 낱말 */ +export const HOPYO_TAG = { pum: "품셈", fix: "고정" } as const; + +/** 하위 줄 앞 — 공용 표가 앞 빈칸을 접지 않음(들여쓰기 = level) */ +export const HOPYO_CHILD = "ㄴ "; + +const HOPYO_COSTS = [ + ["sum", "합계"], + ["labor", "노무비"], + ["material", "재료비"], + ["expense", "경비"], +] as const; + +const hopyoText = (key: string, label: string, width?: number): DataColumn => ({ + key, + label, + width, +}); +const hopyoMoney = (key: string, label: string, group: string): DataColumn => ({ + key, + label, + group: [group], + kind: "number", + decimals: 1, + width: 96, +}); + +/** 열 — 명칭 · 규격 · 수량 · 단위 · 비목 넷(단가 / 금액) · 비고 · 구분 */ +export const HOPYO_COLUMNS: DataColumn[] = [ + hopyoText("name", "명칭", 240), + hopyoText("spec", "규격", 160), + { key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 76 }, + hopyoText("unit", "단위", 56), + ...HOPYO_COSTS.flatMap(([key, label]) => [ + hopyoMoney(`${key}_price`, "단가", label), + hopyoMoney(key, "금액", label), + ]), + hopyoText("note", "비고", 120), + hopyoText("tag", "구분", 56), +]; + +export interface HopyoGrid { + columns: DataColumn[]; + rows: DataRow[]; + /** 머리 · 합계 줄 번호(굵게 · 레벨 1) */ + titles: Set; + /** 줄 번호 → [호표 차례, 구성 줄 차례] · 구성 줄만 */ + at: Map; +} + +const hopyoBlank = (b: HopyoBlock): { n: number; names: string[] } => { + if (b.blank !== undefined) return { n: b.blank, names: b.blank_names ?? [] }; + const names = b.lines.filter((l) => l.amounts.sum == null).map((l) => l.name); + return { n: names.length, names }; +}; + +/** 호표 → 표 줄(값 그대로) */ +export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { + const rows: DataRow[] = []; + const titles = new Set(); + const at = new Map(); + blocks.forEach((b, bi) => { + titles.add(rows.length); + rows.push({ + key: b.key, + name: b.no != null && b.no !== "" ? `제 ${b.no} 호표 ${b.name}` : b.name, + no: b.no, + title: b.name, + spec: b.spec ?? "", + spec_raw: b.spec_raw, + unit: b.unit ?? "", + sum: b.total.sum ?? null, // 1 단위 합계 + note: b.kind ?? "", + }); + if (b.cite) + rows.push({ key: `${b.key}|cite`, name: `${HOPYO_CHILD}${b.cite}`, note: "품셈 인용" }); + b.lines.forEach((ln, n) => { + at.set(rows.length, [bi, n]); + const row: DataRow = { + key: `${b.key}|${n}`, + name: `${HOPYO_CHILD}${ln.name}`, + spec: ln.spec ?? "", + spec_raw: ln.spec_raw, + qty: ln.qty, + unit: ln.unit ?? "", + note: ln.note ?? "", + tag: ln.tag ?? "", + }; + for (const [key] of HOPYO_COSTS) { + row[`${key}_price`] = ln.prices[key] ?? null; + row[key] = ln.amounts[key] ?? null; + } + rows.push(row); + }); + const blank = hopyoBlank(b); + titles.add(rows.length); + rows.push({ + key: `${b.key}|합계`, + name: "합 계", + sum: b.total.sum ?? null, + labor: b.total.labor ?? null, + material: b.total.material ?? null, + expense: b.total.expense ?? null, + note: blank.n ? `빈 줄 ${blank.n}` : "", + blank: blank.n, + blank_names: blank.names, + }); + }); + return { columns: HOPYO_COLUMNS, rows, titles, at }; +} + +/** 칸 꼴 — 꼬리표 · 빈 줄 알림 */ +export function hopyoCellClass(rows: DataRow[], i: number, key: string): string | null { + const r = rows[i]; + if (key === "tag" && r?.tag) + return `ui-hopyo-grid__tag${r.tag === HOPYO_TAG.fix ? " is-fix" : " is-pum"}`; + if (key === "note" && Number(r?.blank) > 0) return "ui-hopyo-grid__blank"; + return null; +} + +/** 공용 표 옵션(격자 꼴 그대로) — `extra` 로 누름 · 풍선 자리를 더함 */ +export function hopyoTableOptions( + blocks: HopyoBlock[], + extra: Partial = {}, +): DataTableOptions { + const g = hopyoGrid(blocks); + return { + columns: g.columns, + rows: g.rows, + frozen: 1, + empty: "", + rowClass: (i) => (g.titles.has(i) ? "is-title" : null), + cellClass: (i, key) => hopyoCellClass(g.rows, i, key), + level: { key: "name", of: (i) => (g.titles.has(i) ? 1 : 2) }, + ...extra, + }; +} + +export interface HopyoGridHandle { + root: HTMLElement; + set: (blocks: HopyoBlock[]) => void; +} + +/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 */ +export function createHopyoGrid( + blocks: HopyoBlock[], + extra: Partial = {}, +): HopyoGridHandle { + const root = document.createElement("div"); + root.className = "ui-hopyo-grid"; + const set = (next: HopyoBlock[]): void => { + root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root); + }; + set(blocks); + return { root, set }; +} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index afb3a99e8..4477ed974 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -40,6 +40,7 @@ import { createCard } from "./ui_template_card"; import { createCollapsibleHead, createPanelCard } from "./ui_template_collapsible"; import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "./ui_template_calc_row"; import { createDataTable } from "./ui_template_data_table"; +import { createHopyoGrid } from "./ui_template_hopyo_grid"; import { createBarChart, createDonut, createLineChart } from "./ui_template_elements_chart"; import { createEmpty } from "./ui_template_empty"; import { createLegend } from "./ui_template_flow_legend"; @@ -337,6 +338,7 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) }, { key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts", demo: (s) => (s === "default" ? kitCalc() : null) }, + { key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) }, { key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts", demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4); const corner = createProgressCircle({ size: 40, corner: true }); corner.set(0.65, "지형 65%"); const box = document.createElement("div"); box.style.cssText = "position:relative;height:72px;border:1px dashed var(--color-border);border-radius:var(--radius-inputs)"; box.append(corner.root);