From 21bc4f856ae530ccb9a9a793dea1a950cc227e8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 12:51:05 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(B09):=20=EC=B2=AB=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=3D=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C?= =?UTF-8?q?=20=C2=B7=20=EB=B9=88=20=EC=A4=84=20=EC=88=98=20=ED=95=98?= =?UTF-8?q?=EB=82=98(PLAN=20701-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 탭 = 설계내역서(빈 원가계산서 아님) · 엑셀 탭 차례는 그대로 - 빈 줄 = 설계내역서 금액 모르는 직접공사 항목 줄(blankRows) — 설계내역서 안내 · 데이터 점검 · 집계표 넷이 같은 수 · 같은 낱말 - 데이터 점검 = 「빈 줄 n」(줄마다 하나 · 까닭 모음) + 「그 밖 확인 n곳」(호표 · 재료 · 탭 · 키마다 하나) - test_203_1 낡은 값(첫 탭 · 「연결 없음 n곳」) 갱신 · helper 에 593-1 시각 글 stub(dateTimeText 없어 깨져 있던 것) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Check.ts | 84 +++++++++++++------ B09_Estimation/B09_Estimation_UI_Shell.ts | 11 +-- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 ++ .../B09_Estimation_UI_Tabs_Summary.ts | 17 ++-- resources/tester/helper_b09_group_cards.py | 1 + resources/tester/test_203_1_b09_data_check.py | 55 ++++++++---- 6 files changed, 115 insertions(+), 59 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 285941732..07ce77bb7 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -4,7 +4,8 @@ * * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 - * 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 + * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + + * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * ========================================================================== */ import { attachHoverInfo } from "@ui/ui_template_hover_info"; @@ -12,7 +13,7 @@ import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; import { dateTimeText } from "../common_util/common_util_time"; -import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; +import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; export interface CheckItem { link: Link; @@ -21,19 +22,36 @@ export interface CheckItem { why: string; } -/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */ -export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } { - const gaps: Row[] = sheets.gaps || []; - const items = gaps.map((g) => ({ - link: { tab: g.tab as TabId, key: g.key }, - tab: tabName(g.tab), - name: String(g.name ?? ""), - why: String(g.why ?? ""), - })); +const BLANK_WHY = "금액 모름 — 단가연결 칸 램프 · 이은 호표의 빈 줄을 확인"; + +/** 빈 줄(설계내역서 줄마다 하나 · 까닭 모음) · 그 밖 확인 줄(탭 · 키마다 하나) · 알림 글 */ +export function checkItems( + sheets: Row, + payload: Row, +): { items: CheckItem[]; others: CheckItem[]; notes: string[] } { + const why = new Map(); + const order: string[] = []; + const named = new Map(); + for (const g of (sheets.gaps || []) as Row[]) { + const id = `${g.tab}|${g.key}`; + if (!why.has(id)) (why.set(id, []), order.push(id), named.set(id, g)); + const list = why.get(id)!; + if (g.why && !list.includes(String(g.why))) list.push(String(g.why)); + } + const item = (tab: string, key: string, name: unknown, reasons?: string[]): CheckItem => ({ + link: { tab: tab as TabId, key }, + tab: tabName(tab), + name: String(name ?? ""), + why: reasons?.length ? reasons.join(" · ") : BLANK_WHY, + }); + const items = blankRows(sheets).map((r) => item("bill", r.key, r.name, why.get(`bill|${r.key}`))); + const others = order + .filter((id) => !id.startsWith("bill|")) + .map((id) => item(named.get(id)!.tab, named.get(id)!.key, named.get(id)!.name, why.get(id))); const notes: string[] = [...(payload.notes || [])]; const missing: string[] = payload.inputs?.missing || []; if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); - return { items, notes }; + return { items, others, notes }; } /** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ @@ -54,25 +72,24 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi let payload: Row | null = null; let items: CheckItem[] = []; attachHoverInfo(refresh, () => refreshTip(payload)); - const list = document.createElement("div"); - list.className = "b09-check"; - attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); + const box = document.createElement("div"); + box.className = "b09-check"; + attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); - const draw = (next: Row, sheets: Row): void => { - payload = next; - refresh.classList.toggle("is-pending", Boolean(next.stale)); - const made = checkItems(sheets, next); - items = made.items; - const head = createText(`연결 없음 ${items.length}곳`, "muted"); - head.classList.add("b09-check__head"); + const head = (text: string): HTMLElement => { + const p = createText(text, "muted"); + p.classList.add("b09-check__head"); + return p; + }; + const list = (group: CheckItem[], offset: number): HTMLElement => { const rows = document.createElement("div"); rows.className = "ui-sidebar-list"; rows.append( - ...items.map((item, i) => { + ...group.map((item, n) => { const row = document.createElement("button"); row.type = "button"; row.className = "ui-sidebar-row"; - row.dataset.i = String(i); + row.dataset.i = String(offset + n); const name = document.createElement("span"); name.textContent = item.name; const tab = createBadge(item.tab, "neutral", { size: 18 }); @@ -82,12 +99,27 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi return row; }), ); + return rows; + }; + + const draw = (next: Row, sheets: Row): void => { + payload = next; + refresh.classList.toggle("is-pending", Boolean(next.stale)); + const made = checkItems(sheets, next); + items = [...made.items, ...made.others]; + const blank = [ + head(`빈 줄 ${made.items.length}`), + ...(made.items.length ? [list(made.items, 0)] : []), + ]; + const others = made.others.length + ? [head(`그 밖 확인 ${made.others.length}곳`), list(made.others, made.items.length)] + : []; const notes = made.notes.map((text) => { const p = createText(text, "muted"); p.classList.add("b09-check__note"); return p; }); - list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes); + box.replaceChildren(...blank, ...others, ...notes); }; - return { list, draw }; + return { list: box, draw }; } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 4eeb511d8..032a028ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -38,14 +38,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; -import { - DRAFT, - rowIndex, - TABS, - type Link, - type TabContext, - type TabId, -} from "./B09_Estimation_UI_Tabs"; +import { DRAFT, rowIndex, type Link, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { @@ -126,7 +119,7 @@ export function renderB09Estimation(root: HTMLElement): void { let payload: Row | null = null; let user: Row = {}; let sheets: Row = {}; - let current: TabId = TABS[0][0]; + let current: TabId = "bill"; // 701-2 첫 화면 = 설계내역서(빈 원가계산서 아님) // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` const bars = createTabBars((tab) => show(tab)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1adb10aad..d37e2ff84 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -56,6 +56,12 @@ export const TABS: Array<[TabId, string]> = [ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id; +/** 빈 줄(701-2) = 설계내역서 금액 모르는 직접공사 항목 줄 — 안내 · 데이터 점검 · 집계표 · 원가계산서가 같은 수 */ +export const blankRows = (sheets: Row): Row[] => + ((sheets?.bill?.rows || []) as Row[]).filter( + (r) => r.kind === "item" && r.cost === "direct" && r.sum === null, + ); + /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index f3cef1dd2..6bbc83213 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -10,6 +10,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; import { + blankRows, money, qty, text, @@ -20,17 +21,17 @@ import { const HINT = "줄을 누르면 목록표로 이동"; -/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ -const hint = (payload: Row, extra = ""): string => { - const n = (payload.summary || {}).unlinked || 0; - return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`; +/** 안내 + 「빈 줄 n」(설계내역서와 같은 수 · 701-2 — 금액 모르는 줄은 집계에서 빠짐) */ +const hint = (sheets: Row, extra = ""): string => { + const n = blankRows(sheets).length; + return `${HINT}${extra}${n ? ` · 빈 줄 ${n} — 집계에서 빠짐(좌측 데이터 점검)` : ""}`; }; /** 줄이 없을 때 표 위 맨 앞 안내 */ const EXPENSE_NONE = "경비 자원 없음"; function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild { - return ({ payload }): TabTable => { + return ({ payload, sheets }): TabTable => { const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r })); const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0); return { @@ -47,7 +48,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): Tab rows, total: rows.length ? { name: "계", amount: sum } : null, frozen: 2, - hint: !rows.length && none ? `${none} · ${hint(payload)}` : hint(payload), + hint: !rows.length && none ? `${none} · ${hint(sheets)}` : hint(sheets), link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null), }; }; @@ -57,7 +58,7 @@ export const buildSumMaterial = resourceTab("sum_material", "material", "단가" export const buildSumLabor = resourceTab("sum_labor", "labor", "단가"); export const buildSumExpense = resourceTab("sum_expense", "expense", "단가", EXPENSE_NONE); -export const buildSumMachine: TabBuild = ({ payload }): TabTable => { +export const buildSumMachine: TabBuild = ({ payload, sheets }): TabTable => { const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r })); const add = (key: string) => rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0); @@ -85,7 +86,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => { } : null, frozen: 2, - hint: hint(payload), + hint: hint(sheets), link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), }; }; diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 532b80751..2e74ef513 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -18,6 +18,7 @@ GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts" # 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝 STUBS = r""" const kept = new Map(); +const dateTimeText = (iso) => String(iso).replace("T", " "); // 593-1 PC 시간대 글 — 견본은 시간대 없음 const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null); const writeState = (name, value) => kept.set(name, JSON.stringify(value)); const createButton = (o) => { diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py index 5e63d693e..10103fa8a 100644 --- a/resources/tester/test_203_1_b09_data_check.py +++ b/resources/tester/test_203_1_b09_data_check.py @@ -2,7 +2,9 @@ - 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버) - [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending) -- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서 +- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 +- 701-2 첫 화면 = 설계내역서 · 목록 머리 「빈 줄 n」 = 설계내역서 금액 모르는 줄(줄마다 하나 · 까닭 모음) · + 그 밖(호표 · 재료) = 「그 밖 확인 n곳」(탭 · 키마다 하나) """ import json @@ -31,15 +33,15 @@ def test_묶음_이름_단추_글_설명_없음(): assert "check.draw(payload, sheets);" in SHELL -def test_연결_점검_탭_없음_첫_탭_공사원가계산서(): +def test_연결_점검_탭_없음_첫_화면_설계내역서(): out = run( [TABS], "\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n)," " groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));", ) assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"] - assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" - assert "let current: TabId = TABS[0][0];" in SHELL + assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" # 엑셀 탭 차례 그대로 + assert 'let current: TabId = "bill";' in SHELL assert "buildCheck" not in SHELL and "check:" not in SHELL @@ -50,43 +52,64 @@ def test_좌측_목록_이동_호버_배지(): const moves = []; const refresh = document.createElement("button"); const panel = createCheckPanel(refresh, (link) => moves.push(link)); -const sheets = { gaps: [ - { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, - { tab: "material", key: "MT9", name: "모래", why: "단가 없음" }, -] }; +const sheets = { + bill: { rows: [ + { kind: "title", cost: "direct", key: "sec:1", name: "토공사", sum: null }, + { kind: "item", cost: "direct", key: "earth:3", name: "제근", sum: null }, + { kind: "item", cost: "direct", key: "earth:4", name: "절토", sum: 100 }, + { kind: "item", cost: "direct", key: "st:w", name: "옹벽", sum: null }, + { kind: "item", cost: "owner", key: "mat:1", name: "레미콘", sum: null }, + ] }, + gaps: [ + { tab: "bill", key: "earth:3", name: "제근", why: "수량 연결 없음" }, + { tab: "bill", key: "earth:3", name: "제근", why: "준비공 대가 — 연결 없음" }, + { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, + { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, + { tab: "material", key: "MT9", name: "모래", why: "단가 없음" }, + ], +}; const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2", notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } }; panel.draw(payload, sheets); const view = () => ({ pending: refresh.classList.contains("is-pending"), texts: panel.list.children.map((c) => c.text), - rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]), + rows: [1, 3].flatMap((n) => + (panel.list.children[n]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text])), }); const a = view(); -const rowsBox = panel.list.children[1]; +const blankBox = panel.list.children[1]; +const rowsBox = panel.list.children[3]; rowsBox.children[1].click(); const tipRefresh = hoverCalls.find((h) => h.root === refresh).text(); const rowHover = hoverCalls.find((h) => h.root === panel.list); const tipRow = rowHover.text(rowsBox.children[0]); +const tipBlank = [0, 1].map((n) => rowHover.text(blankBox.children[n])); panel.draw({ book: null, stale: null }, { gaps: [] }); const b = view(); const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" }); -console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty })); +console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector: rowHover.selector, + tipEmpty })); """, ) a, b = out["a"], out["b"] assert a["pending"] and not b["pending"] - assert a["texts"][0] == "연결 없음 2곳" + # 빈 줄 = 직접공사 항목 중 금액 모름(관급 · 제목 · 아는 줄 빼고) · 같은 줄 까닭은 하나로 + assert a["texts"][0] == "빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳" assert a["rows"] == [ - ["0", "ui-sidebar-row", "찰쌓기일위대가표"], - ["1", "ui-sidebar-row", "모래재료비목록표"], + ["0", "ui-sidebar-row", "제근설계내역서"], + ["1", "ui-sidebar-row", "옹벽설계내역서"], + ["2", "ui-sidebar-row", "찰쌓기일위대가표"], + ["3", "ui-sidebar-row", "모래재료비목록표"], ] - assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"] + assert out["tipBlank"][0] == "수량 연결 없음 · 준비공 대가 — 연결 없음" + assert out["tipBlank"][1].startswith("금액 모름") # 까닭 없는 빈 줄(이은 대가에 빈 줄) + assert a["texts"][4:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"] assert out["moves"] == [{"tab": "material", "key": "MT9"}] assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]" assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"] assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"] - assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == [] + assert b["texts"] == ["빈 줄 0"] and b["rows"] == [] assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"] From 13e59807320ea369da37885f9eee55e0c0850f7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:01:54 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(M02=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D):=20=EB=8C=80=ED=91=9C=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=ED=8E=BC=EC=B9=9C=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B8=B8=20=E2=80=94=20=EA=B8=88=EC=95=A1=20=3D=20M01=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=C2=B7=20=ED=8C=8C=EC=83=9D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=20(PLAN=20704-1=20=C2=B7=20704-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `M02_Logic_Standard` — 로직 하나를 대표 규격(고르기 값 전부 · 표 찾기 조건 수 = 표 줄마다 · 지역 = 자동값)으로 펼친 줄 · 품명 · 규격 · 단위 · 금액(엔진 계) · 출처 · 공종 · 실무 낱말 별칭(석축 → 돌쌓기) - 목록 = 서버가 뒤에서 한 번 셈(프로세스 4 · 약 90 s) → storage `_derived/m02_logic_standard/` · 열쇠 = 마스터 내용 지문 · 셈 도중 빈 줄 + 준비 false - 계산서 = `master_text.lines` 줄 · 비목 소계 · 계 · 맨 위 출처 · 노임 판 · 규격 축 아닌 입력 400 - 길 `/api/m02/logic-lib/standard` · `/logic-lib/sheet` — 로그인만(일반 사용자도) - 시험 `test_704_logic_standard.py` — 세 항목 금액 = M01 시험 계산 · 계산서 계 = 엔진 값 · 석축 찾기 · 저장본 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Logic_Standard.py | 350 ++++++++++++++++++ .../M02_MasterTemplete_Router_Combos.py | 26 ++ resources/tester/test_704_logic_standard.py | 128 +++++++ 3 files changed, 504 insertions(+) create mode 100644 M02_MasterTemplete/M02_Logic_Standard.py create mode 100644 resources/tester/test_704_logic_standard.py diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py new file mode 100644 index 000000000..71eaac2cb --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -0,0 +1,350 @@ +"""M02 기준 목록 · 계산서 보기(PLAN 704-1 · 704-2 · 요구서 4장 8) — 로직 고르기 창이 읽음. + +기준 목록 = 단가산출 로직 하나를 대표 규격으로 펼친 줄(품명 · 규격 · 단위 · 금액 · 출처 · 공종). +대표 규격 = 고르기 입력 값 전부 · 표 찾기 조건으로 쓰이는 수 입력은 그 표 줄마다 값 하나(범위 줄은 +위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균). +펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 · +바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌). +금액 = M01 엔진(`master_formula.run`) 「계」 — M01 시험 계산과 같은 셈 · 최신 판 노임(마스터 판). +목록은 서버가 뒤에서 한 번 셈(프로세스 `WORKERS` 개 · 수 분) → 프로세스 메모리 + 파생 저장본 +(storage `_derived/m02_logic_standard/`) · 열쇠 = 마스터 내용 지문 + `SHAPE` · 같으면 안 셈 · +셈 도중 목록 = 빈 줄 `준비: false`(화면은 로직 줄 그대로 · 금액 빈칸). +실무 낱말(`WORDS`) = 별칭 칸에 덧붙여 찾기(공용 `matchLogic`)가 「석축」 → 돌쌓기를 찾음. +계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계 = 엔진 값). +M01 은 읽기만 · 권한은 부르는 쪽 몫(로그인). +""" + +from __future__ import annotations + +import hashlib +import os +import sys +import threading +from concurrent.futures import ProcessPoolExecutor +from decimal import Decimal +from typing import Any + +from common_util.common_util_derived_store import derived_key, read_derived, write_derived +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Template_Layers as layers + +SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 +KIND = "m02_logic_standard" +REGION = "지역" # 지역 = 자동값 한 벌(정함 8) +QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값 +#: 실무 낱말 → 로직 이름에 든 품셈 낱말(띄어쓰기 무시) — 맞으면 별칭으로 붙음 +WORDS: dict[str, tuple[str, ...]] = { + "석축": ("돌쌓기", "돌붙임", "찰쌓기", "메쌓기"), + "기슭막이": ("돌쌓기", "돌붙임"), + "골막이": ("돌쌓기",), + "흄관": ("원심력",), + "배수관": ("흄관", "파형강관", "원심력", "관부설"), + "땅파기": ("터파기", "굴착"), + "흙메우기": ("되메",), + "콘크리트치기": ("타설",), + "레미콘": ("타설",), + "비탈녹화": ("녹화", "파종", "식생"), +} +#: 공종 — 차례 = 거름 차례(임도 · 사방 자주 쓰는 것 먼저) · (이름 낱말, 장 낱말) · 이름이 먼저 +KINDS: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = ( + ("돌공", ("돌쌓기", "돌붙임", "석축", "찰쌓기", "메쌓기", "전석", "사석", "돌망태", "개비온"), + ("돌공사",)), + ("관", ("흄관", "강관", "파형", "관부설", "배수관", "PE관", "관접합"), ("관부설",)), + ("콘크리트", ("콘크리트", "거푸집", "철근", "타설", "레미콘", "모르타르"), ("철근콘크리트",)), + ("토공", ("터파기", "되메", "흙", "성토", "절토", "굴착", "땅깎기", "사토", "다짐", "잔토"), + ("토공",)), + ("운반", ("운반", "집재", "운재"), ("운반", "집재")), + ("식재 · 녹화", ("식재", "녹화", "파종", "식생", "묘목"), ("식재", "생육")), + ("벌채", ("벌채", "벌목"), ("나무베기",)), + ("가설", (), ("가설",)), +) # fmt: skip +OTHER = "기타" + +WORKERS = max(1, min(4, (os.cpu_count() or 2) - 1)) +_LOCK = threading.Lock() +_MEMO: dict[str, Any] = {} # whole(색인) → 열쇠 · 목록 · 셈 중 + + +def _sq(text: Any) -> str: + return "".join(str(text or "").split()).lower() + + +def kind_of(row: dict) -> str: + name, chapter = _sq(row.get("이름")), _sq(row.get("상세구분")) + for kind, words, _ in KINDS: + if any(_sq(w) in name for w in words): + return kind + for kind, _, chapters in KINDS: + if any(_sq(w) in chapter for w in chapters): + return kind + return OTHER + + +def words_of(row: dict) -> list[str]: + name = _sq(row.get("이름")) + return [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)] + + +def _range_label(table: dict, low: Any, high: Any, unit: str) -> str: + f = m01.mtx.fmt + if low is None: + return f"{f(high)}{unit} 이하" + if high is None: + rule = str(table.get("범위규칙") or "초과") + return f"{f(low)}{unit} {'이상' if rule.startswith('이상') else '초과'}" + return f"{f(low)}~{f(high)}{unit}" + + +def _table_reps(whole: Any, ref: str, cond: str, unit: str) -> list[tuple[Any, str]]: + """표 `ref` 조건 칸 `cond` 줄마다 (값, 글) — 범위 줄은 위 끝(없으면 아래 끝 · 드는 값).""" + try: + table = whole.get(ref) + except m01.cm.mf.FormulaError: + return [] + ranged = table.get("조건", {}).get(cond) == "범위" + out: dict[Any, str] = {} + for r in table.get("줄", []): + have = r.get(cond) + if have is None: + continue + if not ranged: + if isinstance(have, Decimal): + out.setdefault(have, f"{m01.mtx.fmt(have)}{unit}") + continue + low, high = have + tries = [v for v in (high, low, (low or 0) + 1) if isinstance(v, Decimal)] + got = next((v for v in tries if m01.cm.mf._match(table, r, cond, v)), None) + if got is not None: + out.setdefault(got, _range_label(table, low, high, unit)) + return sorted(out.items()) + + +def axes(whole: Any, logic: dict) -> tuple[dict, list[tuple[str, list[tuple[Any, str]]]]]: + """(바탕 입력, [(입력 이름, [(값, 글) …]) …]) — 규격 축 = 지역 뺀 고르기 · 표 찾기 수 입력.""" + base = m01.mau.values(whole, logic) + finds, _ = m01.mau._uses(logic) + out = [] + for spec in logic.get("입력") or []: + name = spec["이름"] + if name == REGION: + continue + unit = str(spec.get("단위") or "") + if spec.get("고르기"): + labels = labor_inputs._labels(whole, spec) + reps = [(v, str(labels.get(v, m01.mtx.fmt(v) if isinstance(v, Decimal) else v))) + for v in spec["고르기"]] # fmt: skip + elif name in finds: + seen: dict[Any, str] = {} + for ref, cond in finds[name]: + for v, text in _table_reps(whole, ref, cond, unit): + seen.setdefault(v, text) + reps = sorted(seen.items()) + else: + continue + if not reps: + continue + if base.get(name) not in [v for v, _ in reps]: + base[name] = reps[0][0] + out.append((name, reps)) + return base, out + + +def _spec(given: dict, ax: list) -> str: + parts = [] + for name, reps in ax: + text = dict(reps).get(given.get(name), "") + if text and text not in QUIET: + parts.append(text if text.startswith(name) else f"{name} {text}") + return " · ".join(parts) + + +def _unit(logic: dict) -> str: + unit = str(logic.get("결과단위") or "") + return unit[2:] if unit.startswith("원/") else ("" if unit == "원" else unit) + + +def _money(whole: Any, logic: dict, given: dict) -> Decimal | None: + if not str(logic.get("결과단위") or "").startswith("원"): + return None + try: + got = m01.cm.mf.run(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given)) + except (m01.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return None + return got.get("계", got.get("결과")) + + +def _rows_of(whole: Any, logic: dict, blocked: bool) -> list[dict]: + base, ax = axes(whole, logic) + changes: list[dict] = [{}] + for name, reps in ax: + changes += [{name: v} for v, _ in reps if v != base.get(name)] + head = { + "키": logic.get("키"), + "이름": logic.get("이름") or "", + "단위": _unit(logic), + "출처": logic.get("출처") or "", + "공종": kind_of(logic), + "별칭": ", ".join(x for x in [logic.get("별칭") or "", *words_of(logic)] if x), + "구분": logic.get("구분") or "", + "상세구분": logic.get("상세구분") or "", + "막힘": blocked, + } + out = [] + for change in changes: + given = {**base, **change} + money = _money(whole, logic, given) + if change and money == 0: # 표에 없는 규격 조합(견치돌 · 뒷길이 25 따위) — 0 원 줄은 뺌 + continue + out.append( + { + **head, + "규격": _spec(given, ax), + "금액": None if money is None else round(float(money), 2), + "바꿈": labor_inputs._num(change), + } + ) + return out + + +def edition(files: dict) -> str: + """노임 판 글 — 「2026 상반기 노임」(인력 머리 첫 판) · 없으면 빈 글.""" + for head in ((files.get("인력.json") or {}).get("구분") or {}).values(): + day = str(head.get("판") or "") + if len(day) >= 7: + return f"{day[:4]} {'상반기' if int(day[5:7]) < 7 else '하반기'} 노임" + return "" + + +def _stamp() -> str: + """마스터 파일 내용 지문 — 워크트리마다 시각이 달라도 같은 내용이면 같은 열쇠.""" + sha = hashlib.sha1() + for path in sorted(m01.FOLDER.glob("*.json")): + if not path.name.startswith("_"): + sha.update(path.name.encode()) + sha.update(hashlib.sha1(path.read_bytes()).digest()) + return sha.hexdigest() + + +def _chunk(keys: list[str], blocked: set[str]) -> list[dict]: + """일꾼 프로세스 몫 — 로직 키들의 줄(마스터는 그 프로세스가 읽음).""" + whole = m01.loaded()[1] + out = [] + for key in keys: + out += _rows_of(whole, whole.logic(key), key in blocked) + return out + + +def build(files: dict, workers: int = 1) -> dict: + """기준 목록 통째 — 장 차례 · `workers` > 1 이면 로직을 나눠 프로세스 여럿이 셈(차례 그대로).""" + why_of = m01.blocks() + blocked = {k for k, why in why_of.items() if why} + keys = [ + str(logic.get("키")) + for _, data in sorted(m01._logic_files(files), key=lambda f: f[1].get("차례") or 0) + for logic in data.get("줄") or [] + ] + size = max(1, len(keys) // (workers * 8)) + parts = [keys[i : i + size] for i in range(0, len(keys), size)] + if workers > 1: + with ProcessPoolExecutor(workers) as pool: + got = list(pool.map(_chunk, parts, [blocked] * len(parts))) + else: + got = [_chunk(part, blocked) for part in parts] + kinds = [k for k, _, _ in KINDS] + [OTHER] + return {"판": edition(files), "공종": kinds, "줄": [row for part in got for row in part]} + + +def _make(files: dict, whole: Any, key: str) -> None: + try: + value = build(files, WORKERS) + write_derived(layers.storage_root(), KIND, key, value) + with _LOCK: + if _MEMO.get("whole") is whole: + _MEMO.update(value=value) + except Exception as e: # noqa: BLE001 — 다음 요청이 다시 셈 + print(f"[M02 기준 목록] 셈 실패: {e}", file=sys.stderr) + finally: + with _LOCK: + if _MEMO.get("whole") is whole: + _MEMO["job"] = None + + +def listing() -> dict: + """기준 목록 `{판, 공종, 줄, 준비}` — 메모리 → 파생 저장본 → 뒤에서 셈(그동안 빈 줄).""" + files, whole = m01.loaded() + with _LOCK: + if _MEMO.get("whole") is not whole: + _MEMO.clear() + _MEMO.update(whole=whole, key=derived_key(KIND, SHAPE, _stamp()), value=None, job=None) + if _MEMO["value"] is None and _MEMO["job"] is None: + got = read_derived(layers.storage_root(), KIND, _MEMO["key"]) + if got: + _MEMO["value"] = got["value"] + else: + _MEMO["job"] = threading.Thread( + target=_make, args=(files, whole, _MEMO["key"]), daemon=True + ) + _MEMO["job"].start() + if _MEMO["value"] is not None: + return {**_MEMO["value"], "준비": True} + kinds = [k for k, _, _ in KINDS] + [OTHER] + return {"판": edition(files), "공종": kinds, "줄": [], "준비": False} + + +def warm() -> None: + """서버가 뜰 때 뒤에서 — 저장본이 있으면 읽기만 · 없으면 셈 시작.""" + threading.Thread(target=lambda: _quiet(listing), daemon=True).start() + + +def _quiet(fn) -> None: + try: + fn() + except Exception as e: # noqa: BLE001 — 데움 실패는 첫 요청이 다시 셈 + print(f"[M02 기준 목록] 데움 실패: {e}", file=sys.stderr) + + +def _given(whole: Any, logic: dict, change: dict) -> tuple[dict, list]: + base, ax = axes(whole, logic) + known = {name: dict(reps) for name, reps in ax} + for name, value in (change or {}).items(): + if name not in known: + raise m01.StoreError(400, f"규격 축이 아닌 입력 「{name}」") + want = Decimal(str(value)) if isinstance(value, (int, float)) else value + if want not in known[name]: + raise m01.StoreError(400, f"「{name}」 대표 값이 아님: {value}") + base[name] = want + return base, ax + + +def sheet(key: str, change: dict) -> dict: + """계산서 한 장 — 머리(품명 · 규격 · 단위 · 출처 · 판) + 비목 묶음 줄 · 소계 · 계(엔진 값).""" + files, whole = m01.loaded() + try: + logic = whole.logic(key) + except m01.cm.mf.FormulaError as e: + raise m01.StoreError(404, str(e)) from e + given, ax = _given(whole, logic, change) + got = m01.mtx.lines(files, key, m01.cm.mf.fill_empty(logic, given)) + groups = [ + { + "비목": g["비목"], + "소계": g.get("소계"), + "끝수": g.get("끝수") or "", + "줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]], + } + for g in got.get("groups") or [] + ] + return labor_inputs._num( + { + "키": key, + "이름": logic.get("이름") or "", + "규격": _spec(given, ax), + "단위": _unit(logic), + "출처": labor_inputs.sources(logic) or "", + "판": edition(files), + "입력": given, + "묶음": groups, + "계": got.get("계"), + } + ) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index d31f40c65..011163f40 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -9,12 +9,17 @@ 39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). 85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째 (`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만. +704 — 기준 목록(`/logic-lib/standard`) · 계산서(`/logic-lib/sheet`) 로그인만(`M02_Logic_Standard`). """ from __future__ import annotations +import sys +from typing import Any + from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response +from pydantic import BaseModel from common_util.common_util_auth import verify_session from common_util.common_util_http_cache import cached_file_response @@ -22,6 +27,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Make as make from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_Logic_Standard as standard from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -36,6 +42,9 @@ router = APIRouter( prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)] ) +if "pytest" not in sys.modules: # 기준 목록을 뒤에서 한 번 셈(저장본이 있으면 읽기만) + standard.warm() + def _where(key: str) -> tuple[str, str | None]: got = combos.where(key) @@ -107,6 +116,23 @@ def get_logic_inputs(keys: str = "") -> dict: return _read(labor_inputs.logic_inputs, keys.split(",")) +@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)]) +def get_standard() -> dict: + """기준 목록 `{판, 공종, 줄}` — 대표 규격 펼침 · 금액 = M01 엔진(704-1).""" + return _read(standard.listing) + + +class SheetBody(BaseModel): + key: str + 바꿈: dict[str, Any] = {} + + +@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)]) +def post_sheet(body: SheetBody) -> dict: + """계산서 한 장 — 노임 × 품 = 금액 줄 · 비목 소계 · 계(704-2 · 저장 안 함).""" + return _read(standard.sheet, body.key, body.바꿈) + + #: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음) SOURCE_PDF = { "건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf", diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py new file mode 100644 index 000000000..2cd394958 --- /dev/null +++ b/resources/tester/test_704_logic_standard.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""PLAN 704-1 · 704-2 — M02 기준 목록(대표 규격 펼침 · 금액 = M01 엔진) · 계산서 보기. + +① 세 항목(돌쌓기 메쌓기 · 파형강관 · 구조물터파기) 줄 금액 = M01 시험 계산(`store.calc`) +② 대표 규격 — 관경 고르기 11 · 뒷길이 표 줄 3 · 지역 = 자동값 한 벌 +③ 실무 낱말 — 「석축」 찾으면 돌쌓기 · 찰쌓기(서버 찾기 맞춤 `hit` = 화면 `matchLogic` 거울 · 208-1) +④ 계산서 — 계 = 엔진 값 · 비목 소계 합 = 계 · 규격 축 아닌 입력은 400 +⑤ 목록 저장본 — 임시 storage `_derived/` · 같은 지문이면 다시 안 셈 +⑥ 화면 — 고르기 창이 기준 목록 표 · 계산서 [담기] = 지금 담기 동작(`done(logic)`) +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData.M01_MasterData_Store_Find import hit +from M02_MasterTemplete import M02_Logic_Standard as st + +ROOT = Path(__file__).resolve().parents[2] +THREE = ("GF000220", "GC000404", "GF000101") # 돌쌓기 메쌓기(장비) · 파형강관 부설 · 구조물터파기 + + +def _rows(key: str) -> list[dict]: + whole = m01.loaded()[1] + return st._rows_of(whole, whole.logic(key), False) + + +def _given(key: str, change: dict) -> dict: + whole = m01.loaded()[1] + return st._given(whole, whole.logic(key), change)[0] + + +@pytest.mark.parametrize("key", THREE) +def test_줄_금액은_M01_시험_계산(key: str) -> None: + rows = _rows(key) + assert rows + for row in rows: + got = m01.calc(key, _given(key, row["바꿈"])) + assert got["ok"], got + assert row["금액"] == pytest.approx(float(got["sums"]["계"]), abs=0.01), row["규격"] + + +def test_대표_규격_펼침() -> None: + pipe = _rows("GC000404") + assert len({r["규격"] for r in pipe}) == len(pipe) # 줄마다 규격이 다름 + assert sum(1 for r in pipe if not r["바꿈"] or "관경" in r["바꿈"]) == 11 + assert pipe[0]["규격"].startswith("관경 250") and pipe[0]["단위"] == "본" + stone = _rows("GF000220") + backs = [r for r in stone if not r["바꿈"] or "뒷길이" in r["바꿈"]] + assert [r["규격"].split(" · ")[0] for r in backs] == [ + "뒷길이 35㎝ 이하", + "뒷길이 55㎝ 이하", + "뒷길이 75㎝ 이하", + ] + assert all("지역" not in r["바꿈"] for r in stone) # 지역 = 자동값 한 벌 + hand = _rows("GF000219") # 메쌓기(인력) — 견치돌 · 뒷길이 25 같은 표 밖 조합은 0 원 → 뺌 + assert all(r["금액"] != 0 for r in hand[1:]) + assert stone[0]["공종"] == "돌공" and stone[0]["단위"] == "㎡" + assert _rows("GF000101")[0]["공종"] == "토공" + + +def test_석축_찾으면_쌓기() -> None: + hits = [ + r["이름"] + for _, data in m01._logic_files(m01.loaded()[0]) + for logic in data.get("줄") or [] + for r in [{**logic, "별칭": ", ".join(st.words_of(logic))}] + if hit(r, "석축") + ] + assert "돌쌓기 메쌓기(장비)" in hits + assert any("찰쌓기" in name for name in hits) + assert "석축" in _rows("GF000220")[0]["별칭"] + + +@pytest.mark.parametrize("key", THREE) +def test_계산서_계는_엔진_값(key: str) -> None: + row = _rows(key)[-1] + got = st.sheet(key, row["바꿈"]) + assert got["계"] == pytest.approx(row["금액"], abs=0.01) + assert sum(g["소계"] for g in got["묶음"]) == pytest.approx(got["계"], abs=0.01) + assert got["판"].endswith("노임") and got["출처"] + assert all(line["글"] for g in got["묶음"] for line in g["줄"]) + + +def test_계산서_규격_축_아닌_입력은_거절() -> None: + with pytest.raises(m01.StoreError) as e: + st.sheet("GF000220", {"지역": "서울"}) + assert e.value.status == 400 + with pytest.raises(m01.StoreError): + st.sheet("GF000220", {"뒷길이": 40}) # 대표 값 아님 + + +def test_목록_저장본_같은_지문이면_안_셈(tmp_path, monkeypatch) -> None: + calls = [] + + def fake(files, workers=1): + calls.append(workers) + return {"판": "2026 상반기 노임", "공종": ["돌공"], "줄": [{"키": "GF000220"}]} + + monkeypatch.setattr(st.layers, "storage_root", lambda: tmp_path) + monkeypatch.setattr(st, "build", fake) + st._MEMO.clear() + first = st.listing() + assert first["준비"] is False and first["줄"] == [] # 셈 도중 = 빈 줄 + st._MEMO["job"].join() + assert st.listing()["줄"] == [{"키": "GF000220"}] + st._MEMO.clear() # 서버 다시 켬 — 저장본에서 읽음 + assert st.listing()["준비"] is True and len(calls) == 1 + assert list((tmp_path / "_derived" / st.KIND).glob("*.json")) + st._MEMO.clear() + + +def test_화면_기준_목록_계산서_담기() -> None: + pick = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts").read_text("utf-8") + assert 'from "@ui/ui_template_data_table"' in pick and "fetchStandard()" in pick + assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick + assert "(!work.select.value || x.공종 === work.select.value)" in pick + sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8") + assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet + for name in ("Labor_Pick", "Labor_Sheet"): + text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8") + assert len(text.splitlines()) <= 700 + router = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py").read_text("utf-8") + assert '@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)])' in router + assert '@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)])' in router From 508e1ec34d5182763cd5c226c9de9a796d8e0f02 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:02:07 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(M02=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D):=20=EB=A1=9C=EC=A7=81=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=20=EC=B0=BD=20=3D=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=3D=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20=E2=86=92=20?= =?UTF-8?q?[=EB=8B=B4=EA=B8=B0]=20(PLAN=20704-1=20=C2=B7=20704-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고르기 창 단가산출 줄 → 공용 표(품명 · 규격 · 단위 · 금액 · 출처) · 공종 거름 · 찾기 = 공용 matchLogic(규격 · 실무 낱말 별칭까지) · GF 번호 숨김 · 창 폭 960 - 기준 목록이 오기 전 · 서버 셈 도중 = 로직 줄(금액 빈칸) · 서고 · 덧줄 목록은 그대로 - 계산서 창 `M02_MasterTemplete_Labor_Sheet` — 맨 위 출처 · 노임 판 · 「이름 | 노임 × 품 | 금액」 줄 · 비목 소계 · 계(계산 줄 부품) · 곁에 원문 띠 · [담기] = 지금 담기 동작 그대로 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor.css | 27 ++++++ .../M02_MasterTemplete_Labor_Api.ts | 45 +++++++++ .../M02_MasterTemplete_Labor_Pick.ts | 95 +++++++++++++++---- .../M02_MasterTemplete_Labor_Sheet.ts | 76 +++++++++++++++ .../M02_MasterTemplete_Labor_Text.ts | 11 +++ 5 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 1c32aadc0..dd3399808 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -481,3 +481,30 @@ font-weight: 600; font-variant-numeric: tabular-nums; } + +/* 기준 목록(704-1) — 찾기 · 거름 위 · 기준 목록 표 아래(표 높이 = 창 몸에 맞춤) */ +.m02-std-pick { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.m02-std-pick .ui-find__list { + max-height: 10rem; +} + +/* 계산서 보기(704-2) — 계산서 | 원문 띠 */ +.m02-std-sheet { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: var(--spacing-16); + min-height: 0; +} + +.m02-std-sheet__body { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 10e55f950..82d64ec84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -115,6 +115,51 @@ export const fetchLibLogics = (): Promise => export const fetchLibLogicSubs = (): Promise => request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs); +/* --- 기준 목록 · 계산서 보기(704 · 서버 M01 엔진이 셈 · `M02_Logic_Standard`) --- */ +/** 기준 목록 한 줄 — 로직 하나를 대표 규격 하나로 펼친 줄 · `바꿈` = 바탕에서 바꾼 입력 */ +export interface StandardRow { + 키: string; + 이름: string; + 규격: string; + 단위: string; + 금액: number | null; + 출처: string; + 공종: string; + 별칭: string; + 구분: string; + 상세구분: string; + 막힘: boolean; + 바꿈: Record; +} +export interface StandardList { + 판: string; + 공종: string[]; + 줄: StandardRow[]; +} +export interface StandardSheet { + 이름: string; + 규격: string; + 단위: string; + 출처: string; + 판: string; + 묶음: { + 비목: string; + 소계: number | null; + 끝수: string; + 줄: { 이름: string; 글: string; 금액: number | null }[]; + }[]; + 계: number | null; +} +let standard: Promise | null = null; // 페이지에 한 벌(서버 저장본 · 판 바뀌면 새로 고침) +export const fetchStandard = (): Promise => + (standard ??= request("/logic-lib/standard").catch((error) => { + standard = null; + throw error; + })); + +export const fetchStandardSheet = (row: StandardRow): Promise => + request("/logic-lib/sheet", "POST", { key: row.키, 바꿈: row.바꿈 }); + /* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */ /** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index d03b5c56a..a92505ab3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -6,9 +6,13 @@ * 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) + * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more` * (324-4 실무 호표 · `withHopyo`) + * 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 · + * 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 · + * 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { createDataTable } from "@ui/ui_template_data_table"; import { createFindList } from "@ui/ui_template_find_list"; import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { t as L } from "@ui/ui_template_locale"; @@ -19,8 +23,11 @@ import { fetchLibLogics as fetchLogics, fetchLibLogicSubs as fetchLogicSubs, fetchLibrary, + fetchStandard, type LibraryRow, + type StandardRow, } from "./M02_MasterTemplete_Labor_Api"; +import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { ownOwner } from "./M02_MasterTemplete_Layer"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -29,6 +36,7 @@ import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; @@ -90,6 +98,9 @@ export function openLogicPicker( const subs: SubBrief[] = []; let classes: ReturnType = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만 let all: LogicSummary[] = []; + let std: StandardRow[] | null = null; // 기준 목록(서버 셈) — 오기 전 = 로직 줄 그대로 + let listed: StandardRow[] = []; + const work = createSelectField({ options: opts([]), compact: true, label: tc("Std_Kind") }); const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") }); const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); detail.select.disabled = true; @@ -115,19 +126,56 @@ export function openLogicPicker( /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ const finder = createFindList({ placeholder: tc("Pick_Search"), - filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root], + filters: [...(kindSel ? [kindSel.root] : []), work.root, sub.root, detail.root], extra: [layerBar], }); + const table = createDataTable({ + caption: tc("Pick_Title"), + columns: [ + { key: "품명", label: tc("Std_Name"), width: 220 }, + { key: "규격", label: tc("Std_Spec"), width: 220 }, + { key: "단위", label: tc("Std_Unit"), width: 56 }, + { key: "금액", label: tc("Std_Money"), kind: "number", width: 96 }, + { key: "출처", label: tc("Std_Source"), width: 150 }, + ], + rows: [], + onRowClick: (i) => { + const row = listed[i]; + const logic = all.find((x) => x.키 === row?.키); + if (row && logic) + openStandardSheet(row, () => { + close(); + done(logic); + }); + }, + }); + /** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ + const plain = (x: LogicSummary): StandardRow => ({ + 키: x.키, + 이름: x.이름, + 규격: "", + 단위: x.결과단위.replace(/^원\/?/, ""), + 금액: null, + 출처: x.출처 ?? "", + 공종: "", + 별칭: x.별칭 ?? "", + 구분: x.구분, + 상세구분: x.상세구분, + 막힘: x.blocked, + 바꿈: {}, + }); if (pre?.query) finder.search.value = pre.query; const find = (): void => { const q = finder.search.value.trim().toLowerCase(); - const found = all.filter( + const rows = std ?? all.map(plain); + const found = rows.filter( (x) => kindOk("logic") && + (!work.select.value || x.공종 === work.select.value) && (!sub.select.value || x.구분 === sub.select.value) && (!detail.select.value || x.상세구분 === detail.select.value) && - matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1) + matchLogic(finder.search.value, { ...x, 이름: `${x.이름} ${x.규격}` }).ok, // 공용 · 208-1 ); const libFound = libRows.filter( (x) => @@ -142,27 +190,25 @@ export function openLogicPicker( !sub.select.value && (!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)), ); - const total = all.length + libRows.length + (lib?.extra?.length ?? 0) + later.length; + const total = rows.length + libRows.length + (lib?.extra?.length ?? 0) + later.length; const shown = found.length + libFound.length + extra.length; finder.hint.textContent = loading ? tc("Loading") : shown ? `${shown} / ${total}` : tc("Pick_None"); - finder.setRows([ - ...found.slice(0, 100).map((x) => ({ - key: x.키, - tag: tc("Kind_Logic"), - name: x.이름, - sub: [`${x.키} · ${x.구분} ${x.상세구분}`.trim(), aliasNote(matchLogic(q, x).alias)] - .filter(Boolean) - .join(" · "), - unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, - onPick: () => { - close(); - done(x); - }, + listed = found; + table.root.hidden = !!lib?.libOnly; + table.setRows( + found.map((x) => ({ + 품명: [x.이름, aliasNote(matchLogic(q, x).alias)].filter(Boolean).join(" · "), + 규격: x.규격, + 단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`, + 금액: x.금액, + 출처: x.출처, })), + ); + finder.setRows([ ...libFound.map((x) => { const at = scope as Scope; return { @@ -246,6 +292,7 @@ export function openLogicPicker( find(); }); detail.select.addEventListener("change", find); + work.select.addEventListener("change", find); kindSel?.select.addEventListener("change", find); finder.search.addEventListener("input", find); @@ -272,8 +319,15 @@ export function openLogicPicker( title: lib?.title ?? tc("Pick_Title"), closeLabel: tc("Pick_Close"), dialogClass: "m01-logic__pick m01-combo", + width: "960px", // 기준 목록 표 다섯 칸(704-1) backdropClass: "m01-logic__backdrop", - mount: (body) => body.append(finder.root), + mount: (body) => + body.append( + el("div", { + className: "m02-std-pick", + children: [finder.root, createText(tc("Std_Hint")), table.root], + }), + ), })); finder.search.focus(); @@ -288,6 +342,11 @@ export function openLogicPicker( all = l; drawSubs(); find(); + return fetchStandard().then((got) => { + std = got.줄.length ? got.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로 + work.setOptions(opts(got.공종), ""); + find(); + }); }) .catch((error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts new file mode 100644 index 000000000..1c22a7caa --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Sheet.ts + * 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다 + * 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 · + * [담기] = 부르는 쪽 담기 동작 그대로(`put`). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +const won = (v: number | null, digits = 0): string => + v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + +const out = (v: number | null, key = false, digits = 0): HTMLElement => + el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); + +export function openStandardSheet(row: StandardRow, put: () => void): void { + const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } }); + sheet.append(createText(tc("Loading"))); + const source = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + let close = (): void => {}; + const add = createButton({ + label: tc("Std_Put"), + variant: "filled", + onClick: () => { + close(); + put(); + }, + }); + ({ close } = openModal({ + title: `${tc("Std_Sheet")} — ${row.이름}`, + closeLabel: tc("Pick_Close"), + width: "1100px", + wide: true, + actions: [add], + mount: (body) => + body.append(el("div", { className: "m02-std-sheet", children: [sheet, source.root] })), + })); + void fetchStandardSheet(row).then( + (got) => { + const head = [got.이름, got.규격, got.단위].filter(Boolean).join(" · "); + const lines: HTMLElement[] = [ + createText(head, "muted", 14), + createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"), + ]; + for (const g of got.묶음) { + for (const line of g.줄) { + const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); + lines.push( + createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)), + ); + } + lines.push( + createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), + ); + } + lines.push( + createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)), + ); + sheet.replaceChildren(...lines); + source.show(got.출처); + }, + (error) => { + sheet.replaceChildren(createText(tc("Load_Failed"), "error")); + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + }, + ); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index e5b3b3ce6..b379755f3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -57,6 +57,17 @@ const TEXT = { Pick_None: ["찾은 로직 없음", "No logics found"], Pick_Close: ["닫기", "Close"], Pick_Blocked: ["막힘", "Blocked"], + // 기준 목록 · 계산서 보기(704) + Std_Name: ["품명", "Item"], + Std_Spec: ["규격", "Spec"], + Std_Unit: ["단위", "Unit"], + Std_Money: ["금액", "Amount"], + Std_Source: ["출처", "Source"], + Std_Kind: ["공종", "Work type"], + Std_Sheet: ["계산서", "Cost sheet"], + Std_Put: ["담기", "Add"], + Std_Sum: ["계", "Total"], + Std_Hint: ["줄을 누르면 계산서 · [담기]", "Click a row for its sheet · [Add]"], Prev_Labor: ["노무비", "Labor"], Prev_Material: ["재료비", "Material"], Prev_Expense: ["경비", "Expense"], From b7bc6087a760361c3f192ff83656e4713927ae79 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:03:36 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=ED=8E=BC=EC=B9=A8):=20=ED=8E=BC=EC=B9=9C?= =?UTF-8?q?=20=ED=91=9C=20=EC=A4=84=20=EB=B2=88=ED=98=B8=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9D=20=E2=80=94=20=EB=91=90=20=EB=B2=88=20=EB=88=8C?= =?UTF-8?q?=EB=9F=AC=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20[=ED=92=88=EC=85=88=20=EA=B0=92=EC=9C=BC=EB=A1=9C]?= =?UTF-8?q?=20=C2=B7=20=ED=92=8D=EC=84=A0=EC=9D=B4=20=EC=A4=84=EC=9D=84=20?= =?UTF-8?q?=EC=B0=BE=EC=9D=8C=20(PLAN=20702-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 표는 칸 누름(onCellClick)이 있어야 줄에 data-i 를 붙임 — 빈 칸 누름을 줌 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 4bf765137..9867464c2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -129,6 +129,7 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM frozen: 1, empty: "", editOnDouble: true, + onCellClick: () => undefined, // 줄 번호 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리 editable: (i, key) => { const at = lines[i]; if (!at?.cover || !(key in FIELD)) return false; From df6bc10237282fc135d8156876263579f3c77375 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:09:29 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=3D=20=EC=95=84=EB=8A=94=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=EA=B9=8C=EC=A7=80=20+=20=E3=80=8C=EB=B9=88=20?= =?UTF-8?q?=EC=A4=84=20n=20=C2=B7=20=EB=AF=B8=EC=99=84=EC=84=B1=E3=80=8D?= =?UTF-8?q?=20=EB=9D=A0=20=C2=B7=20=EC=9D=B8=EC=87=84=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=EC=9D=80=20=EB=B9=88=20=EC=A4=84=200=20=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C(PLAN=20701-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설계내역서 sums = 빈 줄이 있어도 아는 비목 합 + blank(파이썬 · TS 짝 · 관급은 그대로) - 총괄 · 원가계산서 표 위 공용 띠(경고) — 탭 표 banner 칸 - [인쇄 미리보기] · [>] = 빈 줄 있으면 알림 뒤 머묾(DRAFT.blocked) · 서버 확정(complete) = 정본으로 다시 셈 · 빈 줄 있으면 409 - 설계내역서 안내 글 줄 고침 · 낡은 시험(sums None · 패널 줄 · [>] 흉내) 갱신 · 시험 test_701_3 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Bill.py | 13 +-- B09_Estimation/B09_Estimation_Calc_Bill.ts | 13 +-- B09_Estimation/B09_Estimation_Router.py | 8 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 17 +++- B09_Estimation/B09_Estimation_UI_Tabs.ts | 10 ++ B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 2 +- .../B09_Estimation_UI_Tabs_Total.ts | 3 + B09_Estimation/draft/B09_Draft_Print_UI.ts | 11 ++- .../tester/helper_298_2_b08_b09_buttons.cjs | 5 + resources/tester/test_145_3_b09_bill.py | 11 +-- .../tester/test_202_1_table_hint_bottom.py | 2 +- .../tester/test_298_2_b08_b09_buttons.py | 1 + .../tester/test_701_3_b09_cost_unfinished.py | 96 +++++++++++++++++++ 13 files changed, 163 insertions(+), 29 deletions(-) create mode 100644 resources/tester/test_701_3_b09_cost_unfinished.py diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 6c7379b47..50a783f96 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -48,7 +48,7 @@ 묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림). 원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 · -supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) · +supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 직접공사 = 아는 금액까지 + `blank`(701-3) · 관급 = 빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료). """ @@ -502,14 +502,15 @@ def compute_bill( "direct": {**direct, "blank": blank}, "owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)}, } - # 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None - known = blank == 0 + # 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 · + # 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 None(그대로) owner_known = all(row["sum"] is not None for row in owner) sums_out = { - "material": direct["material"] if known else None, - "labor": direct["labor"] if known else None, - "expense": direct["expense"] if known else None, + "material": direct["material"], + "labor": direct["labor"], + "expense": direct["expense"], "supply_net": net if owner_known else None, + "blank": blank, } return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps} diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 50ad3e3fd..3edec67e9 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -9,7 +9,7 @@ * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 * 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0) - * 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null) + * 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null) * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo) * 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} · * 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리 @@ -472,14 +472,15 @@ export function computeBill( direct: { ...direct, blank }, owner: { net_material: net, fee, total: net + (fee || 0) }, }; - // 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null - const known = blank === 0; + // 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 · + // 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 null(그대로) const ownerKnown = owner.every((row) => row.sum !== null); const sumsOut = { - material: known ? direct.material : null, - labor: known ? direct.labor : null, - expense: known ? direct.expense : null, + material: direct.material, + labor: direct.labor, + expense: direct.expense, supply_net: ownerKnown ? net : null, + blank, }; return { rows, totals, sums: sumsOut, gaps }; } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 841f6caa3..77f62f2be 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -230,8 +230,12 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> @router.post("/{project_id}/estimation/complete") async def estimation_complete(project_id: UUID) -> dict[str, Any]: - """B09 [>] — 단계 6 완료(요구 300 B1).""" - await _project_root(project_id) # 없는 프로젝트는 404 + """B09 [>] — 단계 6 완료(요구 300 B1) · 빈 줄 있으면 409(701-3 확정 = 빈 줄 0 · 서버 정본으로 셈).""" + root = await _project_root(project_id) # 없는 프로젝트는 404 + sheets = (await payload(project_id, root))["sheets"] + blank = (((sheets.get("bill") or {}).get("totals") or {}).get("direct") or {}).get("blank") + if blank: + raise HTTPException(409, f"빈 줄 {blank} · 미완성 — 확정은 빈 줄 0 일 때만") await mark_stage(str(project_id), 6, "next") return {"status": "success", "project_id": str(project_id)} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 032a028ae..e7f90edc7 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -15,6 +15,7 @@ import "./B09_Estimation_UI_Page.css"; import { API_BASE_URL } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createText } from "@ui/ui_template_text"; +import { createBanner } from "@ui/ui_template_banner"; import { createButton, hideLoadingOverlay, @@ -38,7 +39,14 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; -import { DRAFT, rowIndex, type Link, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; +import { + DRAFT, + rowIndex, + unfinished, + type Link, + type TabContext, + type TabId, +} from "./B09_Estimation_UI_Tabs"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { @@ -257,7 +265,9 @@ export function renderB09Estimation(root: HTMLElement): void { // 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임 if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; const foot = hint ?? level?.guide; - panel.replaceChildren(table.root, ...(foot ? [foot] : [])); + // 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위 + const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : []; + panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : [])); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; @@ -302,6 +312,7 @@ export function renderB09Estimation(root: HTMLElement): void { ? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }) : Promise.resolve(true); DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3) + DRAFT.blocked = () => (payload ? unfinished(sheets) : null); // 701-3 인쇄 · 확정 = 빈 줄 0 const refresh = createButton({ label: "업데이트", variant: "ghost", @@ -361,6 +372,8 @@ export function renderB09Estimation(root: HTMLElement): void { left.addEventListener("ui-step-nav", (event) => { if ((event as CustomEvent).detail !== 1 || !goNext || !projectId) return; event.stopPropagation(); + const why = DRAFT.blocked?.(); + if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만 void moveStage(projectId, goNext, [ { name: "원가 저장", run: saveChanged }, { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index d37e2ff84..7a116957a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -62,6 +62,12 @@ export const blankRows = (sheets: Row): Row[] => (r) => r.kind === "item" && r.cost === "direct" && r.sum === null, ); +/** 「빈 줄 n · 미완성」 띠 글(701-3) — 빈 줄 0 = null */ +export const unfinished = (sheets: Row): string | null => { + const n = blankRows(sheets).length; + return n ? `빈 줄 ${n} · 미완성 — 금액은 아는 줄까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : null; +}; + /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], @@ -101,6 +107,8 @@ export interface TabTable { frozen?: number; /** 표 위 안내 글(근거 · 끝수) */ hint?: string; + /** 표 위 띠 알림(공용 띠 · 경고) — 예 원가계산서 「빈 줄 n · 미완성」(701-3) */ + banner?: string; /** 칸 누름 → 갈 줄(호표 칸) */ link?: (index: number, key: string) => Link | null; editable?: (index: number, key: string) => boolean; @@ -140,6 +148,8 @@ export const DRAFT: { next?: () => void; /** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */ save?: () => Promise; + /** 인쇄 · 확정 막는 까닭(셸 · 701-3 빈 줄 있으면 글) — 없으면 null */ + blocked?: () => string | null; } = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 7ae93caf2..1c0065b39 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -68,7 +68,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { hint: "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" + (direct.blank - ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` + ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : ""), editOnDouble: true, editable: (i, key) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index e41a21e1b..1fb329ba6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -25,6 +25,7 @@ import { type TabBuild, type TabContext, type TabTable, + unfinished, } from "./B09_Estimation_UI_Tabs"; type Num = number | null; @@ -251,6 +252,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["part"], + banner: unfinished(ctx.sheets) ?? undefined, hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || @@ -336,6 +338,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["group", "part"], + banner: unfinished(ctx.sheets) ?? undefined, hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), diff --git a/B09_Estimation/draft/B09_Draft_Print_UI.ts b/B09_Estimation/draft/B09_Draft_Print_UI.ts index 10508c02b..db8ee9f9c 100644 --- a/B09_Estimation/draft/B09_Draft_Print_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Print_UI.ts @@ -2,13 +2,13 @@ * B09_Draft_Print_UI.ts * 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → 저장(`DRAFT.save`) 뒤 #/b09-print(`…_Print_Route`) - * · 단계 그대로 · 저장 실패면 머묾 · + * · 단계 그대로 · 저장 실패면 머묾 · 빈 줄 있으면 막음(`DRAFT.blocked` · 701-3) · * [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2) * draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐 * ========================================================================== */ import type { RoutePath } from "@config/config_frontend"; -import { createButton } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { navigateTo } from "../../A00_Common/router"; import { DRAFT } from "../B09_Estimation_UI_Tabs"; import { ROUTE } from "./B09_Draft_Print_Route"; @@ -19,10 +19,13 @@ DRAFT.actions.push(() => { const button = createButton({ label: "인쇄 미리보기", variant: "ghost", - onClick: () => + onClick: () => { + const why = DRAFT.blocked?.(); // 701-3 인쇄 = 빈 줄 0 일 때만 + if (why) return void showToast(why, "warning"); void (DRAFT.save?.() ?? Promise.resolve(true)).then( (ok) => ok && navigateTo(ROUTE.path as RoutePath), - ), + ); + }, }); button.dataset.b09Print = ""; return button; diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs index 63e5bee72..35a9518d4 100644 --- a/resources/tester/helper_298_2_b08_b09_buttons.cjs +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -235,6 +235,8 @@ async function b09(withNext) { return { ok: true, status: 200, json: async () => any() }; }; const tabs = any(); + let unfinished = null; // 701-3 빈 줄 띠 글 — 있으면 [>] 막힘 + tabs.unfinished = () => unfinished; tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] }; if (withNext) tabs.DRAFT.next = () => calls.push("next"); const nextBtn = Object.assign(new Node("button"), { disabled: true }); @@ -290,6 +292,9 @@ async function b09(withNext) { out.completeFail = await nav(1); failOn = null; out.prev = await nav(-1); + unfinished = "빈 줄 2 · 미완성"; + out.blocked = await nav(1); + unfinished = null; // [초기화] const reset = dock.children[0]; const press = async () => { diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index 1113830dd..f18b74948 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -125,15 +125,12 @@ def test_제목_합_빈_줄과_원가계산서_이음(): assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0 assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수 assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음 - # 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None - assert sheets["bill"]["sums"] == { - "material": None, - "labor": None, - "expense": None, - "supply_net": 69474220, - } + # 145-4 이음 = {material, labor, expense, supply_net, blank} · 701-3 빈 줄 있어도 아는 금액까지 + known = {"material": 2771766, "labor": 7565300, "expense": 2627525, "supply_net": 69474220} + assert sheets["bill"]["sums"] == {**known, "blank": 2} # 절토(연암) · 막자갈(단가 없음) full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]} sums = compute_estimation(full, USER)["bill"]["sums"] + assert sums == {**known, "blank": 0} assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14 assert sums["material"] == 54390 + 194376 + 2043000 + 480000 assert sums["expense"] == 2627525 diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 8b75244ec..7d33b35ce 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -106,7 +106,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell + "panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위 ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래) assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) diff --git a/resources/tester/test_298_2_b08_b09_buttons.py b/resources/tester/test_298_2_b08_b09_buttons.py index 64f2735fc..6d498e0e5 100644 --- a/resources/tester/test_298_2_b08_b09_buttons.py +++ b/resources/tester/test_298_2_b08_b09_buttons.py @@ -88,6 +88,7 @@ def test_B09_다음_저장_완료_인쇄(r): "toast:error", ] assert b["prev"] == [False, []] # [<] = 공용 틀 몫 + assert b["blocked"] == [True, ["toast:warning"]] # 701-3 빈 줄 있으면 저장 · 완료 없이 머묾 def test_B09_초기화(r): diff --git a/resources/tester/test_701_3_b09_cost_unfinished.py b/resources/tester/test_701_3_b09_cost_unfinished.py new file mode 100644 index 000000000..fdc18a385 --- /dev/null +++ b/resources/tester/test_701_3_b09_cost_unfinished.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""701-3 — 원가계산서 = 아는 금액까지 + 「빈 줄 n · 미완성」 띠 · 인쇄 · 확정은 빈 줄 0 일 때만. + +- 설계내역서 `sums` = 빈 줄이 있어도 아는 비목 합 + `blank`(파이썬 · TS 짝 같은 값) +- 총괄 · 원가계산서 = 그 합으로 셈(빈칸 아님) +- [>] 확정 = 서버가 정본으로 다시 셈 · 빈 줄 있으면 409 · 단계 완료 안 함 +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(Path(__file__).parent)) + +from test_145_3_b09_bill import INPUTS, USER, _run_ts # noqa: E402 + +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402 + + +def test_빈_줄_있어도_아는_금액_합_거울(): + py = compute_estimation(INPUTS, USER) + sums = py["bill"]["sums"] + assert sums["blank"] == 2 and sums["labor"] == 7565300 and sums["material"] == 2771766 + assert _run_ts(INPUTS, USER)["bill"]["sums"] == sums + + +def test_원가계산서_아는_금액으로_셈(): + sums = compute_estimation(INPUTS, USER)["bill"]["sums"] + estimate = {**USER["estimate"], "estimated_price": 5e8, "duration_days": 120} # 요율 구간 + total = compute_total(sums, estimate, rate_table()) + assert total["sums"]["direct"] == 7565300 + 2771766 + 2627525 + assert total["cost"]["grand"] is not None and total["cost"]["grand"] > total["sums"]["direct"] + + +def _complete(monkeypatch, blank: int) -> list[str]: + marks: list[str] = [] + + async def root(_pid): + return Path("x") + + async def payload(_pid, _root): + return {"sheets": {"bill": {"totals": {"direct": {"blank": blank}}}}} + + async def mark(pid, stage, how): + marks.append(f"{stage}:{how}") + + monkeypatch.setattr(router, "_project_root", root) + monkeypatch.setattr(router, "payload", payload) + monkeypatch.setattr(router, "mark_stage", mark) + asyncio.run(router.estimation_complete("P1")) + return marks + + +def test_확정_빈_줄_있으면_409_단계_그대로(monkeypatch): + with pytest.raises(HTTPException) as caught: + _complete(monkeypatch, 3) + assert caught.value.status_code == 409 and "빈 줄 3 · 미완성" in caught.value.detail + assert _complete(monkeypatch, 0) == ["6:next"] + + +def test_띠_글_빈_줄_0_이면_없음(): + source = (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text(encoding="utf-8") + tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8") + start = tabs.index("export const blankRows") + end = tabs.index("\n};\n", tabs.index("export const unfinished")) + 4 + rows = [ + {"kind": "item", "cost": "direct", "sum": None}, + {"kind": "item", "cost": "direct", "sum": 5}, + ] + script = ( + "type Row = Record;\n" + + tabs[start:end] + + f"\nconsole.log(JSON.stringify([unfinished({{ bill: {{ rows: {json.dumps(rows)} }} }})," + " unfinished({ bill: { rows: [] } })]));" + ) + assert "export type Row" in source # 셈 짝 Row 꼴 그대로(흉내 타입) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + banner, none = json.loads(result.stdout) + assert banner.startswith("빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner + assert none is None From 9246250629df2f20008b27f16658b628eaf430c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:22:51 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=88=98=EB=9F=89=20=EC=95=88=20=EB=84=A3=EC=9D=80?= =?UTF-8?q?=20=EC=A4=84=20=EA=B2=BD=EA=B3=A0=20=C2=B7=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=A6=84=20=E3=80=8C=E2=80=A6?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC=E3=80=8D(PLAN=20701-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 카드 · 상세 합계 줄 — 수량산출 빈 줄 수 「수량 안 넣은 줄 n — 합계에서 빠짐」(좁은 카드에서 제 줄 · 줄바꿈) - 복사 생성(나무 copyIn) = 대상 복사이름 → 새 이름 「원래 이름 복사」 · 또 하면 「… 복사(1)」 · 그냥 붙여넣기는 그대로 - 시험 test_m02_tree_paste(임시 storage) 한 건 더함 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Item_Paste.py | 6 +++-- .../M02_MasterTemplete_Api_Fetch.ts | 2 ++ .../M02_MasterTemplete_Labor.css | 7 ++++++ .../M02_MasterTemplete_Labor_Rows.ts | 23 ++++++++++++++++- .../M02_MasterTemplete_Labor_Text.ts | 4 +++ .../M02_MasterTemplete_TreePanel.ts | 7 +++++- resources/tester/test_m02_tree_paste.py | 25 +++++++++++++++++++ 7 files changed, 70 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_Item_Paste.py b/M02_MasterTemplete/M02_Item_Paste.py index e906dffb6..081eb808f 100644 --- a/M02_MasterTemplete/M02_Item_Paste.py +++ b/M02_MasterTemplete/M02_Item_Paste.py @@ -1,7 +1,8 @@ """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기]. 붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) · -같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」). +같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」) · +복사 생성(대상 `복사이름`) = 새 이름 「원래 이름 복사」(701-4 · 층이 달라도 같은 이름 둘 안 생김). 파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫. """ @@ -73,7 +74,8 @@ def paste( if found is None: raise store.StoreError(404, f"없는 항목 「{key}」") used = [*taken, *(p for _, p in plan)] - path = tree.unique_path([*dest, found[-1]], lambda p: p in used) + name = f"{found[-1]} 복사" if one.get("복사이름") else found[-1] + path = tree.unique_path([*dest, name], lambda p: p in used) renamed += [path[-1]] if path[-1] != found[-1] else [] plan.append((key, path)) continue diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 7c6f6e109..6da5621f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -467,6 +467,8 @@ export const editTree = ( export interface TreeRef { 경로: string[]; 키?: string; + /** 복사 생성(701-4) — 새 이름 = 「원래 이름 복사」(같은 이름 둘 안 생김) */ + 복사이름?: boolean; } /** 복사해 둔 것 — 나무 종류(`kind` · 구조물 · 일위대가) · 복사한 층 · 고른 것들 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 1c32aadc0..083b9ee65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -85,6 +85,13 @@ border-top: 2px solid var(--color-border); } +/* 701-4 수량 안 넣은 줄 경고 — 합계 아래 제 줄 · 좁은 카드에서도 줄바꿈 */ +.m02-labrow__name > .m02-labrow__noqty { + display: block; + max-width: 9rem; + white-space: normal; +} + .m02-labrow__move { white-space: nowrap; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index d7638b889..a48088c51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -493,6 +493,13 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement }); const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => part?.[c] ?? null; + // 701-4 수량산출 빈 줄 = 반영 금액 없음 → 합계에서 빠짐 — 조용히 빠지지 않게 합계 줄에 경고 + const noQty = data.calc + ? data.rows.filter((row) => { + const line = lineOf(lineId(row) ?? ""); + return !!line && (line.수량 === null || line.수량 === undefined); + }).length + : 0; const foot = data.rows.length ? [ el("tr", { @@ -500,7 +507,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__kind", text: "" }), - el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { + className: "m02-labrow__name", + children: [ + el("span", { text: tc("Prev_Sum") }), + ...(noQty + ? [ + el("small", { + className: "ui-text ui-text--error m02-labrow__noqty", + text: tc("Labor_NoQty").replace("{n}", String(noQty)), + attrs: { "data-labor-noqty": String(noQty) }, + }), + ] + : []), + ], + }), el("td", { className: "m02-labrow__alias", text: "" }), el("td", { className: "m02-labrow__qty", text: "" }), el("td", { className: "m02-labrow__unit", text: "" }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index e5b3b3ce6..807f67756 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -96,6 +96,10 @@ const TEXT = { Lib_Personal: ["개인", "Personal"], Labor_AlreadyRef: ["이미 담은 일위대가 조합 — {value}", "Already added — {value}"], Labor_Empty: ["빈 값 {n}", "{n} empty"], + Labor_NoQty: [ + "수량 안 넣은 줄 {n} — 합계에서 빠짐", + "{n} rows without quantity — left out of the sum", + ], Labor_Lost: [ "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", "Inputs no longer in the logic (dropped on save) — {value}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 259dc409c..384e588c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -673,7 +673,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const run = api.paste; if (!run || !opt.canEdit || !(await ready())) return; await apply(() => - run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest), + run( + opt.layer, + owner, + { kind: clipKind, from, refs: [{ 경로: [], 키: key, 복사이름: true }] }, // 701-4 「… 복사」 + dest, + ), ); }; diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 757936cb8..3c19c8dde 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from pathlib import Path from typing import Any from resources.tester import test_m02_tree as base @@ -158,3 +159,27 @@ def test_항목_추가_창_카드_격자_표_접기(): assert "max-height: calc(100dvh - var(--app-header-h));\n overflow-y: auto;" in css main = (m02 / "M02_MasterTemplete_UI_Main.ts").read_text("utf-8") assert "host.hidden = !host.hidden;" in main and 'title.classList.toggle("is-folded"' in main + + +def test_복사_생성_이름_원래_이름_복사_같은_이름_둘_없음(world): + """701-4 — 복사 생성(대상 `복사이름`) = 「원래 이름 복사」 · 또 하면 「… 복사(1)」 · 그냥 붙여넣기는 그대로.""" + company = _company(world) + wall = company.item(["옹벽"])["key"] + client = _as(world) + body = { + "from_layer": "company", + "대상": [{"경로": [], "키": wall, "복사이름": True}], + "경로": [], + } + first = client.post(PASTE.format("personal"), json=body).json() + second = client.post(PASTE.format("personal"), json=body).json() + plain = client.post(PASTE.format("personal"), json={**body, "대상": [{"경로": [], "키": wall}]}) + names = [r["경로"][-1] for r in plain.json()["items"]] + assert sorted(names) == ["옹벽", "옹벽 복사", "옹벽 복사(1)"] + assert first["알림"]["바뀐이름"] == ["옹벽 복사"] and second["알림"]["바뀐이름"] == [ + "옹벽 복사(1)" + ] + assert len(set(names)) == len(names) # 같은 이름 둘 없음 + root = Path(__file__).resolve().parents[2] + ui = (root / "M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts").read_text("utf-8") + assert "refs: [{ 경로: [], 키: key, 복사이름: true }]" in ui # 복사 생성 길만 From 0377334f7112837ac54829ec4a1e509494c5e377 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:29:00 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=20=EA=B2=A9=EC=9E=90):=20=EC=97=91=EC=85=80=20=EA=BC=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EA=B2=A9=EC=9E=90=20=EB=B6=80=ED=92=88=20?= =?UTF-8?q?=E2=80=94=20M02=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=C2=B7=20=EC=83=81=EC=84=B8=20=C2=B7=20B09?= =?UTF-8?q?=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=ED=91=9C=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=B6=80=ED=92=88=20(PLAN=20704-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 공용 부품 ui_template_hopyo_grid(.ts · .css) — 호표 머리 줄(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 합계 · 노무비 · 재료비 · 경비 단가 / 금액 · 비고 · 구분 꼬리표) · 합계 줄 빈 줄 붉게 · 부품 모음 견본 칸 - M02 Labor_Grid — 문서 + 서버 calc 답 그대로(단가 = 기준 ÷ 배 · 금액 = 반영 · 합계 = 반영합 · 수량 없음 줄 = 빈 줄) · Labor_Detail 맨 위 격자(카드 · 상세 같이) · 읽기만 카드 = 격자만 - B09 일위대가표(호표보기 · 박스보기 바탕 줄) = 같은 부품 · 줄 값 · 이동 · 줄 꼴 그대로 + 구분 칸 · 머리 1 단위 합계 - 시험: test_704_3 새로 · B09 표 시험 다섯(155 · 157-4 · 201-1 · 202-1 · 304)은 격자 파일을 이어 붙이게 고침(줄 글이 공용 부품으로 옮김) · hopyo_screen 상세 줄 수 693 → 700 Co-Authored-By: Claude Opus 5.5 --- .../B09_Estimation_UI_Tabs_Labor.ts | 93 +++----- .../M02_MasterTemplete_Labor_Detail.ts | 11 +- .../M02_MasterTemplete_Labor_Grid.ts | 95 ++++++++ .../M02_MasterTemplete_Labor_Text.ts | 4 + resources/tester/helper_b09_group_cards.py | 1 + resources/tester/test_155_b09_tabs.py | 5 +- resources/tester/test_157_4_b09_labor_spec.py | 11 +- .../tester/test_201_1_table_level_merge.py | 5 +- .../tester/test_202_1_table_hint_bottom.py | 4 +- resources/tester/test_304_b09_view_setting.py | 6 +- resources/tester/test_704_3_hopyo_grid.py | 142 +++++++++++ resources/tester/test_m02_hopyo_screen.py | 4 +- ui_template/ui_template_hopyo_grid.css | 21 ++ ui_template/ui_template_hopyo_grid.ts | 223 ++++++++++++++++++ ui_template/ui_template_ui_kit_parts.ts | 2 + 15 files changed, 549 insertions(+), 78 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts create mode 100644 resources/tester/test_704_3_hopyo_grid.py create mode 100644 ui_template/ui_template_hopyo_grid.css create mode 100644 ui_template/ui_template_hopyo_grid.ts 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 b379755f3..29c75cfb3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -169,6 +169,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 8b75244ec..4f08d48e0 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); From 1599014504867f301524e65f831e2298ea976c98 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:33:49 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix(B09):=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=EC=82=B0=EA=B7=BC=20=3D=20=EC=82=B0?= =?UTF-8?q?=EA=B7=BC=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EB=93=A6=20=C2=B7=20?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EB=8B=A8=EA=B0=80=20=EC=B1=84=EC=9B=80(PL?= =?UTF-8?q?AN=20702-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단가연결로 들여온 서고 사본(구조물조합 · 사전조합)의 단가산출 줄이 산근 목록에 안 들어 대가 블록 단가가 빈칸 — 산근 모음이 구조물 사본만 훑던 것 · 차례 = 대가 입력과 같게(구조물 → 서고 사본 → 수기 줄). 시험 test_702_3 서고 사본 산근 2 줄 · 대가 합 채움. Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Sangeun.py | 8 +++- .../tester/test_702_3_b09_estimation_live.py | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_702_3_b09_estimation_live.py diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 297f9de63..ac8be89fd 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -570,11 +570,17 @@ def collect( def build( project_root: str | Path, sheets: dict[str, Any], user: dict[str, Any] | None = None ) -> dict[str, Any]: - """연결된 사본 전부(구조물 id 차례)의 산근 + 설계내역서 손 줄 로직(166-2 · 뒤 번호).""" + """연결된 사본 전부(구조물 id 차례 → 설계내역서 서고 사본 · 702-3)의 산근 + + 설계내역서 손 줄 로직(166-2 · 뒤 번호) — 차례 = `Labor_Source.daega_inputs`.""" + from B09_Estimation.B09_Estimation_Bill_Library import copies + base = structures_dir(project_root) entries = [ (owner, base / linked["폴더"]) for owner, linked in sorted(linked_snapshots(project_root).items()) if "일위대가" in linked.get("부분", []) ] + entries += [ + (f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root) + ] return collect(entries, sheets, hand_sources(user)) diff --git a/resources/tester/test_702_3_b09_estimation_live.py b/resources/tester/test_702_3_b09_estimation_live.py new file mode 100644 index 000000000..26e235fcc --- /dev/null +++ b/resources/tester/test_702_3_b09_estimation_live.py @@ -0,0 +1,45 @@ +"""702-3 B09 단가연결 즉시 금액 · [저장] 단추 · 서고 사본 산근. + +- 서고 사본(단가연결로 들여온 사전조합)의 단가산출 줄 = 산근 목록에 듦 · 대가 블록 단가 채움 + (옛 흠 — 산근이 구조물 사본만 훑어 서고 사본 산근 0 · 대가 빈칸) +- 셈 미리보기 길(`POST …/estimation/calc`) = 화면 캐시로 서버 셈 · 저장 안 함 · 로직 연결 줄 금액 +- 화면 — 연결 즉시 뒤에서 셈(차례 번호 · 옛 요청 끊기) · [저장] 단추 · 옮길 때 물음 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +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 B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402 +from resources.tester.test_204_2_b09_bill_library import ( # noqa: E402, F401 + daega_inputs, + library, + linked_resource_keys, + world, +) + +B09 = ROOT / "B09_Estimation" + + +def test_서고_사본_산근_목록에_듦_대가_단가_채움(world): + root = world["root"] + folder = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] + daega = daega_inputs(root) + block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder]) + wanted = [ln["key"] for ln in block["lines"] if ln["kind"] == "sangeun"] + base = compute_base(prices.build_inputs(None, linked_resource_keys(root)), {}) + sg = sangeun.build(root, base) + assert [r["key"] for r in sg["sg_list"]] == wanted # 둘 다 산근 · 블록 줄 키 그대로 + assert all(r["sum"] for r in sg["sg_list"]) + assert sg["sg_list"][0]["uses"] == ["서고 서고 조합"] + by_key = {r["key"]: r for r in sg["sg_list"]} + labor = compute_labor(daega, {**base, "gaps": []}, by_key) + row = next(r for r in labor["daega"] if r["key"] == block["id"]) + assert row["sum"] and not row.get("blank") From 1c1dc9c523b757bc6fdb10d2012dbfe66634c692 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:35:44 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=ED=8E=BC=EC=B9=A8):=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=B3=B5=EC=82=AC=ED=95=B4=20=EC=83=88=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=82=B4=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=EC=A7=91=EC=97=90=20=EC=A0=80=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EC=96=B4=EB=94=94=20=EC=93=B0=EC=98=80=EB=82=98=20(PLAN=20703-?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복사 = 덮개 K:… 의 @copy {from · name · spec} + 원본 덮개째(B09 사용자 값 · 브레인 정함) — 셈 짝 Calc_Labor py·ts 가 원본 블록을 본뜬 새 호표(번호 끝 · 기본값 = 원본) · 이 줄 단가연결을 새 호표로 바꿀지 물음 · 복사본 지우기(잇는 줄 있으면 막음) - 원본 없음 = 짝 잃음 알림 · 저장 다듬기 @copy 글만 · 서고 사본 원본 = prune 에서 지킴 - 내 자료집 = POST …/estimation/daega/personal → 개인 층 사전조합(B09_Estimation_Daega_Personal) — 넣을 값 · 고른 자재 · 줄 수량 덮개(손) 담음 · 품 · 단가 덮개 = 안 담은 수 알림 · 같은 이름 409 → 덮어쓰기 · 새 이름 - 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로 · 펼침 - 새 파일 B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts · 시험 test_703_b09_daega_copy(거울 · 임시 storage 길) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Library.py | 10 +- B09_Estimation/B09_Estimation_Calc_Labor.py | 21 +- B09_Estimation/B09_Estimation_Calc_Labor.ts | 22 +- .../B09_Estimation_Daega_Personal.py | 165 ++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 37 ++++ B09_Estimation/B09_Estimation_Store.py | 8 +- .../B09_Estimation_UI_Tabs_Bill_Fold.ts | 42 +++- .../B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts | 208 ++++++++++++++++++ resources/tester/test_703_b09_daega_copy.py | 194 ++++++++++++++++ 9 files changed, 693 insertions(+), 14 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Daega_Personal.py create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts create mode 100644 resources/tester/test_703_b09_daega_copy.py diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index e900d37a8..538f200ff 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -341,12 +341,18 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: def used_folders(user: dict[str, Any]) -> set[str]: - """설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더.""" + """설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더 + + 일위대가 복사본(703-2 `@copy`)의 원본 사본.""" + copies = [ + str(lines["@copy"].get("from") or "") + for lines in (user.get("daega_cover") or {}).values() + if isinstance(lines, dict) and isinstance(lines.get("@copy"), dict) + ] return { str(ref["lib"]) for row in ref_rows(user) if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib") - } + } | {src[2:] for src in copies if src.startswith("L:")} def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]: diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 3a7a18abc..3ebcdc119 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -19,6 +19,8 @@ 고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} — 있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) · 짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만. +복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 + (id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}. """ from __future__ import annotations @@ -30,6 +32,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, PARTS = ("labor", "material", "expense") COVER_KEYS = ("qty", *PARTS) TAB = "dg" +COPY = "@copy" WHY = { "labor": "노임 단가 없음", "material": "자재 적용 단가 없음", @@ -142,6 +145,21 @@ def _block( } +def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]: + """복사본(703-2) — 덮개 차례대로 `@copy` 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨).""" + out = list(blocks) + for key, lines in cover.items(): + meta = (lines or {}).get(COPY) + if not isinstance(meta, dict): + continue + src = next((b for b in out if b.get("cover") == meta.get("from")), None) + if src is not None: + spec = meta["spec"] if "spec" in meta else src["spec"] + name = meta.get("name") or src["name"] + out.append({**src, "id": f"DG:{key}", "cover": key, "name": name, "spec": spec}) + return out + + def compute_labor( daega: dict[str, Any] | None, sheets: dict[str, Any], @@ -161,6 +179,7 @@ def compute_labor( "sangeun": sangeun or {}, } cover = cover or {} + blocks = with_copies(blocks, cover) by_id = {b["id"]: b for b in blocks} no = {b["id"]: n for n, b in enumerate(blocks, 1)} done: dict[str, dict[str, Any]] = {} @@ -211,7 +230,7 @@ def compute_labor( {"cover": key, "cid": cid} for key, lines in cover.items() for cid in lines - if cid not in cids.get(key, ()) + if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id) ] return { "daega_blocks": out_blocks, diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 609b4be82..50651819c 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -15,6 +15,8 @@ * 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 * 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} — * 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만) + * 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 + * (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy} * ========================================================================== */ import { @@ -31,6 +33,7 @@ import { const PARTS = ["labor", "material", "expense"] as const; const COVER_KEYS = ["qty", ...PARTS] as const; const TAB = "dg"; +export const COPY = "@copy"; const WHY: Record = { labor: "노임 단가 없음", material: "자재 적용 단가 없음", @@ -146,6 +149,20 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: }; } +/** 복사본(703-2) — 덮개 차례대로 @copy 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨) */ +function withCopies(blocks: Row[], covers: Row): Row[] { + const out = [...blocks]; + for (const [key, lines] of Object.entries(covers)) { + const meta: Row | undefined = (lines || {})[COPY]; + if (!meta || typeof meta !== "object") continue; + const src = out.find((b) => b.cover === meta.from); + if (!src) continue; + const spec = "spec" in meta ? meta.spec : src.spec; + out.push({ ...src, id: `DG:${key}`, cover: key, name: meta.name || src.name, spec }); + } + return out; +} + /** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */ export function computeLabor( daega: Row | null | undefined, @@ -153,8 +170,8 @@ export function computeLabor( sangeun: Row = {}, covers: Row = {}, ): Row { - const blocks: Row[] = (daega || {}).blocks || []; covers = covers || {}; + const blocks: Row[] = withCopies((daega || {}).blocks || [], covers); const index = (rows: Row[] | undefined): Row => Object.fromEntries((rows || []).map((r) => [r.key, r])); const books: Row = { @@ -207,7 +224,8 @@ export function computeLabor( const orphans: Row[] = []; for (const [key, lines] of Object.entries(covers)) for (const cid of Object.keys(lines || {})) - if (!cids.get(key)?.has(cid)) orphans.push({ cover: key, cid }); + if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId)) + orphans.push({ cover: key, cid }); return { daega_blocks: outBlocks, daega: listing, diff --git a/B09_Estimation/B09_Estimation_Daega_Personal.py b/B09_Estimation/B09_Estimation_Daega_Personal.py new file mode 100644 index 000000000..b45119661 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Daega_Personal.py @@ -0,0 +1,165 @@ +"""B09 「내 자료집에 저장」(PLAN 703-2) — 펼친 일위대가(대가 블록) → 개인 층 사전조합(SC). + +담는 것 = 담은로직(넣을 값 · 고른 자재 얹음) · 자재 · 줄 수량(= 수량산출 수기 `손`) — + 맨 위 줄 수량 덮개 = 손(원래 손 × 덮개 ÷ 블록 줄 수량 · 대가 줄 ÷ 배를 되돌림) +안 담는 것 = 품 덮개(부른 블록 안 줄 · 품셈 블록 줄 — 로직 통째 배수로 바꾸면 다른 줄까지 바뀜) · + 단가 덮개(사전조합 꼴에 단가 칸 없음) → 수만 알림(품은 PLAN 705 고정 줄이 생기면 담김) +원천 = 블록 cover — 조합 `S:`/`L:` = 사본 폴더(일위대가 · 넣을 값 · 고른 자재 · 셈 수량) · + 사전조합 `C:`/`L:` = 굳힌 참조사본 문서 · 품셈 `P:` = 로직 한 줄 · + 복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것) +같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 · +쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies +from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Item_Snapshot import ( + MANIFEST_NAME, + PICKS_FILE, + VALUES_FILE, + structures_dir, +) +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json + +KIND = "precombo" +PRICE_KEYS = ("labor", "material", "expense") + + +def _combo(folder: Path) -> tuple[list[dict], list[dict]]: + """구조물조합 사본 → (담은로직 · 자재) — 넣을 값 · 고른 자재 얹고 수량 = 셈 수량(손).""" + labor = read_json(folder / LABOR_FILE) or {} + values = read_json(folder / VALUES_FILE) or {} + picks = read_json(folder / PICKS_FILE) or {} + got = item_prices.calc( + labor, read_json(folder / PRICE_FILE) or {}, values, picks, read_json(folder / AREA_FILE) + ) + doc = item_prices.with_values(labor.get("줄") or {}, values, picks) + lines = [ + {**one, "수량": {"손": done.get("수량")}} + for one, done in zip(doc.get("담은로직") or [], got.get("줄") or []) + ] + mats = [ + {**one, "수량": {"손": done.get("수량")}} + for one, done in zip(doc.get("자재") or [], got.get("자재") or []) + ] + return lines, mats + + +def _frozen(root: Path, rid: str) -> dict[str, Any] | None: + """굳힌 사전조합 문서 — 구조물 사본 · 서고 사본의 `참조사본` 에서 찾음.""" + folders = [p for p in sorted(structures_dir(root).glob("*")) if p.is_dir()] + folders += [p for p, _m in library.copies(root)] + for folder in folders: + doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(rid) + if isinstance(doc, dict): + return doc + return None + + +def _precombo(doc: dict[str, Any]) -> tuple[list[dict], list[dict]]: + lines = [ + {**one, "수량": {"손": (one.get("수량") or {}).get("손", 1)}} + for one in doc.get("담은로직") or [] + if isinstance(one, dict) + ] + return lines, [m for m in doc.get("자재") or [] if isinstance(m, dict)] + + +def source(root: Path, block: dict[str, Any], base: str) -> tuple[list[dict], list[dict]]: + """블록 원천(`base` = 복사본이면 원본 cover) → (담은로직 · 자재) · 못 찾으면 LookupError.""" + head, _, rest = base.partition(":") + if block["kind"] == "품셈": + origin = block.get("origin") or {} + one = {"로직": origin.get("로직"), "입력값": origin.get("입력값") or {}} + one.update({k: origin[k] for k in ("켜쌓기율", "고름") if origin.get(k) is not None}) + return [{**one, "수량": {"손": 1}}], [] + if head == "S": + return _combo(structures_dir(root) / rest) + if head == "L": + folder = library.library_dir(root) / rest + manifest = read_json(folder / MANIFEST_NAME) or {} + if manifest.get("종류") != "precombo": + return _combo(folder) + ref = (manifest.get("원본") or {}).get("참조") + doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(refs.ref_id(ref)) + if isinstance(doc, dict): + return _precombo(doc) + if head == "C" and (doc := _frozen(root, rest)) is not None: + return _precombo(doc) + raise LookupError(f"일위대가 원천을 못 찾음 「{base}」") + + +def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = None) -> dict: + """블록 cover → `{문서, 담음, 안담음}` — 문서 = 사전조합 꼴(키 · 층 쓰기 전).""" + raw = daega_inputs(root).get("blocks") or [] + blocks = {b["cover"]: b for b in with_copies(raw, cover)} + block = blocks.get(cover_key) + if block is None: + raise LookupError(f"없는 일위대가 「{cover_key}」") + base = cover_key + while base.startswith("K:"): # 복사본 → 원본 cover + base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "") + lines, mats = source(root, blocks.get(base, block), base) + mine = cover.get(cover_key) or {} + kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)} + kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines) + skip = {"품": 0, "단가": 0} + top = block["lines"] if block["kind"] != "품셈" else [] + for line, one in zip(top, [*lines, *mats]): + cell = mine.get(line.get("cid")) or {} + skip["단가"] += sum(k in cell for k in PRICE_KEYS) + if "qty" not in cell: + continue + hand, each = (one.get("수량") or {}).get("손"), line.get("qty") + if not isinstance(hand, (int, float)) or not each: + skip["품"] += 1 + continue + one["수량"] = {"손": round(hand * cell["qty"] / each, 9)} + kept["수량"] += 1 + # 맨 위 밖 덮개 = 품셈 블록 줄 · 부른 블록 안 줄(품) · 단가 + by_id = {b["id"]: b for b in blocks.values()} + todo = [] if top else [block["id"]] + todo += [ln.get("key") for ln in top if ln.get("kind") == "daega"] + seen: set[str] = set() + while todo: + sub = by_id.get(todo.pop()) + if sub is None or sub["id"] in seen: + continue + seen.add(sub["id"]) + for cell in (cover.get(sub["cover"]) or {}).values(): + skip["품"] += "qty" in cell + skip["단가"] += sum(k in cell for k in PRICE_KEYS) + todo += [ln.get("key") for ln in sub["lines"] if ln.get("kind") == "daega"] + doc = { + "이름": (name or block.get("name") or "").strip(), + "단위": block.get("unit") or "", + "담은로직": lines, + "자재": mats, + } + return {"문서": doc, "담음": kept, "안담음": skip} + + +def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]: + """지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음.""" + same = [ + path.stem + for path in sorted((store.root() / KIND).glob("SC-*.json")) + if (read_json(path) or {}).get("이름") == doc["이름"] + ] + if overwrite: + if overwrite not in same: + raise store.StoreError(409, f"덮어쓸 「{doc['이름']}」 이 없음 — 다시 누름") + old = store.read(KIND, overwrite) + return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite}) + if same: + raise store.StoreError(409, {"같은이름": same[0], "이름": doc["이름"]}) + return refs.create(KIND, doc) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d25845f50..d11c78572 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -24,6 +24,9 @@ 운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`) POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?} 「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음) + POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?} + 「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합 + (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · @@ -53,6 +56,7 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Daega_Personal as personal from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store @@ -71,6 +75,7 @@ from common_util.common_util_project_settings import reset_section from common_util.common_util_workflow_state import mark_stage from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이) from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft @@ -381,6 +386,38 @@ async def library_inputs( return {"lines": lines} +class PersonalBody(BaseModel): + cover: str = Field(min_length=1, max_length=200) + name: str | None = Field(None, max_length=200) + overwrite: str | None = Field(None, max_length=20) + user: dict[str, Any] | None = None + + +@router.post("/{project_id}/estimation/daega/personal") +async def save_personal( + project_id: UUID, body: PersonalBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root = await _project_root(project_id) + raw = ( + body.user if isinstance(body.user, dict) else await asyncio.to_thread(store.read_user, root) + ) + cover = store.clean_user(raw).get("daega_cover") or {} + try: + made = await asyncio.to_thread(personal.build, root, body.cover, cover, body.name) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + if not made["문서"]["이름"]: + raise HTTPException(status_code=400, detail="이름이 빔") + folder = await asyncio.to_thread(_root, session, "personal", None, True) + got = await asyncio.to_thread(_call, folder, personal.write, made["문서"], body.overwrite) + return { + "키": got["이름"], + "이름": made["문서"]["이름"], + "담음": made["담음"], + "안담음": made["안담음"], + } + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 6a8ec90e3..e228e2de4 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -12,6 +12,7 @@ — 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본) "daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}} (702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`) + + 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만) quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) · 읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함 @@ -227,12 +228,17 @@ def clean_bill(raw: Any) -> dict[str, Any]: def clean_cover(raw: Any) -> dict[str, Any]: - """702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림.""" + """702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림 · + 복사본 표지 `@copy`(703-2) = from · name · spec 글만(from 없으면 버림).""" out: dict[str, Any] = {} for block, lines in (raw if isinstance(raw, dict) else {}).items(): kept = {} for cid, cell in (lines if isinstance(lines, dict) else {}).items(): cell = cell if isinstance(cell, dict) else {} + if cid == "@copy": + if _text(cell.get("from")): + kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell} + continue got = {k: _num(cell[k]) for k in COVER_KEYS if _num(cell.get(k)) is not None} if got: kept[str(cid)[:200]] = got diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 9867464c2..c419f16f6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -9,6 +9,7 @@ * [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4) * 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 * 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로) + * 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`) * ========================================================================== */ import { createButton } from "@ui/ui_template_elements"; @@ -16,9 +17,11 @@ import { createCard } from "@ui/ui_template_card"; import { createText } from "@ui/ui_template_text"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table"; +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { Row } from "./B09_Estimation_Calc_Base"; import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs"; import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu"; /** 고칠 칸 → 덮개 칸 */ const FIELD: Record = { @@ -91,7 +94,14 @@ function clearCovers(ctx: TabContext, ats: FoldLine[]): void { const covered = (at: FoldLine | undefined, field: string): boolean => !!at && (at.line.covered || []).includes(field); -function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTMLElement { +function foldCard( + ctx: TabContext, + raw: DataRow[], + row: Row, + block: Row, + close: () => void, + go: (key: string) => void, +): HTMLElement { const blocks = new Map( (ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]), ); @@ -168,9 +178,11 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM ); attachRowMenu(table.root, (i) => { const at = lines[i]; - return at && (at.line.covered || []).length - ? [["품셈 값으로", () => clearCovers(ctx, [at])]] - : []; + const undo: MapContextMenuItem[] = + at && (at.line.covered || []).length + ? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"] + : []; + return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)]; }); const actions = [ ...(edited.length @@ -204,6 +216,13 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM /** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { const host = document.createElement("div"); + /** 어디 쓰였나 → 그 줄로(보이게 · 펼침) */ + const go = (key: string): void => { + const i = raw.findIndex((r) => String(r.key) === key); + table.querySelector(`tr[data-i="${i}"]`)?.scrollIntoView({ block: "center" }); + open = key; + draw(); + }; const draw = (): void => { const row = raw.find((r) => r.key === open) as Row | undefined; const ref: Row | undefined = row?.ref; @@ -214,10 +233,17 @@ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLEleme host.replaceChildren( ...(row && block ? [ - foldCard(ctx, row, block, () => { - open = null; - draw(); - }), + foldCard( + ctx, + raw, + row, + block, + () => { + open = null; + draw(); + }, + go, + ), ] : []), ); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts new file mode 100644 index 000000000..b0ec94253 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -0,0 +1,208 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts + * B09 설계내역서 펼친 일위대가 우클릭(PLAN 703-2) — 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나. + * + * 복사 = 이름 · 규격 묻기 → 덮개 `K:…` 에 `@copy` {from · name · spec} + 원본 덮개째(캐시 · 셈 짝이 새 호표 · + * 번호 끝) → 「이 줄 단가연결을 새 호표로」 물음 · 복사본 지우기 = 그 덮개 통째(쓰는 줄 있으면 안 지움) + * 내 자료집 = POST …/estimation/daega/personal(화면 캐시 덮개째 · 서버가 개인 층 사전조합) · + * 같은 이름 409 → 「덮어쓰기 · 새 이름」 · 담음 · 안 담음(품 · 단가 덮개) 수 알림 + * 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 그 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; +import { askFields } from "@ui/ui_template_item_picker_ask"; +import { createListRow } from "@ui/ui_template_list_row"; +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { askConfirm, askText, openModal } from "@ui/ui_template_modal"; +import type { DataRow } from "@ui/ui_template_data_table"; +import { createText } from "@ui/ui_template_text"; +import { currentProjectId } from "../A00_Common/b_project_switch"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { COPY } from "./B09_Estimation_Calc_Labor"; +import type { TabContext } from "./B09_Estimation_UI_Tabs"; +import { editCell } from "./B09_Estimation_UI_Tabs_Bill_Edit"; + +/** 어디 쓰였나 — 같은 품목으로 찾는 줄 */ +const GOODS = ["labor", "material", "machine"]; +/** 부른 대가 따라가는 깊이 */ +const DEPTH = 6; + +const covers = (ctx: TabContext): Row => JSON.parse(JSON.stringify(ctx.user.daega_cover || {})); +const newKey = (): string => `K:${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`; + +/** 복사해 새 일위대가 — 덮개째 · 번호 끝 · 이 줄 연결 바꿀지 물음 */ +async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row): Promise { + const got = await askFields({ + title: "복사해 새 일위대가", + note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`, + fields: [ + { key: "name", label: "이름", value: `${block.name} 복사`, required: true }, + { key: "spec", label: "규격", value: block.spec || "" }, + ], + okLabel: "만들기", + closeLabel: "닫기", + emptyText: "{label} 이 빔", + }); + if (!got) return; + const key = newKey(); + const all = covers(ctx); + all[key] = { ...(all[block.cover] || {}), [COPY]: { from: block.cover, ...got } }; // 덮개째 + const relink = await askConfirm({ + title: "단가연결", + message: `「${row.name || ""}」 줄의 단가연결을 새 호표 「${got.name}」 로 바꿀지`, + okLabel: "바꿈", + cancelLabel: "그대로", + }); + const next = { ...ctx, user: { ...ctx.user, daega_cover: all } }; + const i = raw.findIndex((r) => r.key === row.key); + if (relink && i >= 0) editCell(next, raw, i, "ref", { tab: "dg", key: `DG:${key}` }); + else ctx.setUser(next.user); + showToast(`새 호표 「${got.name}」 — 일위대가목록표 끝 · [저장] 때 서버가 다시 셈`, "success"); +} + +/** 복사본 지우기 — 그 호표를 잇는 내역 줄이 있으면 막음 */ +function removeCopy(ctx: TabContext, raw: DataRow[], block: Row): void { + const used = raw.filter((r) => (r.ref as Row | undefined)?.key === block.id); + if (used.length) { + showToast(`잇는 내역 줄 ${used.length} — 단가연결을 먼저 바꿈`, "error"); + return; + } + const all = covers(ctx); + delete all[block.cover]; + ctx.setUser({ ...ctx.user, daega_cover: all }); +} + +async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; got: Row }> { + const response = await fetch( + `${API_BASE_URL}/projects/${currentProjectId()}/estimation/daega/personal`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const got = ((await response.json().catch(() => null)) as Row | null) ?? {}; + return { ok: response.ok, status: response.status, got }; +} + +/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */ +async function savePersonal(ctx: TabContext, block: Row): Promise { + let name = await askText({ + title: "내 자료집에 저장", + message: "개인 층 일위대가 사전조합으로 — 넣을 값 · 고른 자재 · 줄 수량 고친 칸까지", + value: block.name || "", + okLabel: "저장", + }); + let overwrite: string | null = null; + while (name !== null && name.trim()) { + const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user }; + const { ok, status, got } = await postPersonal(body); + const same = got.detail?.같은이름; + if (ok) { + const skip: Row = got.안담음 || {}; + const left = (skip.품 || 0) + (skip.단가 || 0); + const tail = left + ? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김` + : ""; + showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000); + return; + } + if (status !== 409 || !same) { + const detail = got.detail; + showToast(`저장 못 함 — ${typeof detail === "string" ? detail : status}`, "error"); + return; + } + const over = await askConfirm({ + title: "같은 이름", + message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`, + okLabel: "덮어쓰기", + cancelLabel: "새 이름", + }); + if (over) overwrite = same; + else { + overwrite = null; + name = await askText({ title: "새 이름", message: "내 자료집 이름", value: `${name} 2` }); + } + } +} + +/** 블록 나무에 그 품목(종류 · 키) · 그 호표(id)가 있나 */ +function holds(blocks: Map, bid: string, hit: (ln: Row) => boolean): boolean { + const seen = new Set(); + const walk = (id: string, depth: number): boolean => { + const b = blocks.get(id); + if (!b || seen.has(id) || depth > DEPTH) return false; + seen.add(id); + return (b.lines || []).some( + (ln: Row) => hit(ln) || (ln.kind === "daega" && walk(String(ln.key), depth + 1)), + ); + }; + return walk(bid, 0); +} + +/** 어디 쓰였나 — 그 품목 · 호표를 쓰는 내역 줄 → 누르면 그 줄로 */ +function whereUsed( + ctx: TabContext, + raw: DataRow[], + block: Row, + line: Row, + go: (key: string) => void, +): void { + const blocks = new Map( + (ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.id), b]), + ); + const goods = GOODS.includes(line.kind); + const id = line.kind === "daega" ? String(line.key) : String(block.id); + const label = goods ? `${line.name || line.key}` : `${blocks.get(id)?.name || ""}`; + const hit = goods + ? (ln: Row) => ln.kind === line.kind && ln.key === line.key + : (ln: Row) => ln.kind === "daega" && ln.key === id; + const used = raw.filter((r) => { + const ref = r.ref as Row | undefined; + if (ref?.tab !== "dg") return false; + return (!goods && String(ref.key) === id) || holds(blocks, String(ref.key), hit); + }); + openModal({ + title: `어디 쓰였나 — 「${label}」`, + closeLabel: "닫기", + width: "40rem", + mount: (body, close) => { + body.append( + createText(`쓰는 내역 줄 ${used.length} · 누르면 그 줄로`, "muted"), + ...used.map((r) => + createListRow({ + label: String(r.name || ""), + sub: [r.spec, r.qty != null ? `${r.qty} ${r.unit || ""}` : ""] + .filter(Boolean) + .join(" · "), + onClick: () => { + close(); + go(String(r.key)); + }, + }), + ), + ); + }, + }); +} + +/** 펼친 줄 우클릭 덧메뉴 — 복사 · 내 자료집 · 어디 쓰였나(+ 복사본 지우기) */ +export function foldMenu( + ctx: TabContext, + raw: DataRow[], + row: Row, + block: Row, + line: Row | undefined, + go: (key: string) => void, +): MapContextMenuItem[] { + return [ + ["복사해 새 일위대가", () => void copyBlock(ctx, raw, row, block)], + ["내 자료집에 저장", () => void savePersonal(ctx, block)], + ["어디 쓰였나", () => whereUsed(ctx, raw, block, line || {}, go)], + ...(String(block.cover).startsWith("K:") + ? [["이 복사본 지우기", () => removeCopy(ctx, raw, block)] as MapContextMenuItem] + : []), + ]; +} diff --git a/resources/tester/test_703_b09_daega_copy.py b/resources/tester/test_703_b09_daega_copy.py new file mode 100644 index 000000000..b1f272386 --- /dev/null +++ b/resources/tester/test_703_b09_daega_copy.py @@ -0,0 +1,194 @@ +"""703-2 B09 펼친 일위대가 우클릭 — 복사해 새 일위대가 · 내 자료집에 저장. + +- 복사본 = 덮개 `K:…` 의 `@copy` {from · name · spec} — 셈 짝(`Calc_Labor` 파이썬 · TS 거울)이 + 원본 블록을 본떠 끝 번호 새 호표 · 덮개 = 제 것 · 원본 없음 = 짝 잃음 · + 저장 다듬기 · 서고 사본 지킴(prune) +- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 손 · 품 · 단가 덮개 = 안 담음 수 · + 같은 이름 409 → 덮어쓰기 · 새 이름 +""" + +import copy +import importlib.util +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Daega_Personal as personal # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from config import config_system # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as m02 # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 + + +def _load(name: str): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +b145 = _load("test_145_2_b09_labor_calc") +b702 = _load("test_702_b09_daega_cover") + +#: DG9 찰붙임 복사 — 석공 품 0.1 → 0.2(복사본만) · 원본 DG9 덮개 0.12 는 원본에만 +COPY = { + "T:DG9": {"labor:L00005#0": {"qty": 0.12}}, + "K:aa": { + "@copy": {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"}, + "labor:L00005#0": {"qty": 0.2}, + }, + "K:bb": {"@copy": {"from": "K:aa", "name": "복사의 복사"}}, + "K:cc": {"@copy": {"from": "T:없음"}, "x#0": {"qty": 1}}, +} + + +def _block(out, bid): + return next(b for b in out["daega_blocks"] if b["id"] == bid) + + +def test_복사본_새_호표_끝_번호_덮개_제것(): + daega = b702._covered_excel() + out = compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY) + src, mine = _block(out, "DG9"), _block(out, "DG:K:aa") + n = len(daega["blocks"]) + assert [mine["no"], _block(out, "DG:K:bb")["no"]] == [n + 1, n + 2] # 번호 새로(끝) + assert (mine["name"], mine["spec"], mine["cover"]) == ("찰붙임 복사", "규격 B", "K:aa") + assert src["lines"][0]["qty"] == 0.12 and mine["lines"][0]["qty"] == 0.2 + assert mine["lines"][0]["base"]["qty"] == 0.1 # 품셈 값 = 원본 기본값 + assert [ln["amount"] for ln in mine["lines"][1:]] == [ln["amount"] for ln in src["lines"][1:]] + twice = _block(out, "DG:K:bb") + assert twice["spec"] == "규격 B" and twice["lines"][0]["qty"] == 0.1 # 덮개 안 물려받음 + assert any(r["key"] == "DG:K:aa" for r in out["daega"]) # 목록표 = 내역 단가연결 자리 + assert out["daega_orphans"] == [ + {"cover": "K:cc", "cid": "@copy"}, + {"cover": "K:cc", "cid": "x#0"}, + ] + + +def test_거울_파이썬_TS_복사본(): + daega = b702._covered_excel() + py = json.loads(json.dumps(compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY))) + ts = b145._ts(daega, b145.SHEETS, b145.SANGEUN, COPY) + assert b145._same(py, ts["out"]) + + +def test_저장_다듬기_복사본_표지(): + raw = {"daega_cover": {**COPY, "K:dd": {"@copy": {"name": "원본 없음"}}}} + got = store.clean_user(raw)["daega_cover"] + assert got["K:aa"]["@copy"] == {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"} + assert got["K:bb"] == {"@copy": {"from": "K:aa", "name": "복사의 복사"}} + assert "K:dd" not in got + + +def test_서고_사본_원본_지킴(): + user = {"daega_cover": {"K:aa": {"@copy": {"from": "L:combo@system@-@SS1"}}}} + assert library.used_folders(user) == {"combo@system@-@SS1"} + + +# ── 내 자료집(개인 층 사전조합 · 임시 storage) ───────────────────────────── + + +@pytest.fixture() +def world(tmp_path, monkeypatch) -> dict[str, Any]: + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + root = b145._project(tmp_path / "proj", [(100, "SS000901")]) + b145._snap(root, "pipe@100.0", "SS000901", b145.LINES, b145._values(b145.STONE)) + folder = layers.personal_dir(7, 42) + folder.mkdir(parents=True) + items_store.ensure(folder) + got = daega_inputs(root) + block = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@100.0"]) + return {"root": root, "folder": folder, "block": block} + + +def test_자료집_문서_덮개_손_안담음_수(world): + block = world["block"] + each = block["lines"][1]["qty"] # GF000119 줄 수량(산근 = 손 그대로 0.5) + cover = { + block["cover"]: { + "sangeun:SG:GF000119#0": {"qty": 0.8, "labor": 1000}, # 줄 수량 = 담음 · 단가 = 안 담음 + } + } + made = personal.build(world["root"], block["cover"], cover, "내 돌붙임") + doc = made["문서"] + assert each == 0.5 and doc["이름"] == "내 돌붙임" + hands = [ln["수량"]["손"] for ln in doc["담은로직"]] + assert hands == [2, 0.8, 1] # 덮개 줄만 바뀜 + assert doc["담은로직"][0]["입력값"] == b145.STONE # 넣을 값 얹음 + assert made["담음"] == {"수량": 1, "넣을값": 1, "고른자재": 0} + assert made["안담음"] == {"품": 0, "단가": 1} + # 복사본 = 원본까지 따라감 · 이름 기본 = 복사본 이름 · 덮개 = 복사본 것 + cover2 = {"K:aa": {"@copy": {"from": block["cover"], "name": "복사"}}} + cover2["K:aa"]["sangeun:SG:GF000219#0"] = {"qty": 3} + made2 = personal.build(world["root"], "K:aa", cover2) + assert made2["문서"]["이름"] == "복사" + assert [ln["수량"]["손"] for ln in made2["문서"]["담은로직"]] == [3, 0.5, 1] + with pytest.raises(LookupError): + personal.build(world["root"], "S:없음", {}) + + +def test_자료집_같은_이름_물음(world): + block = world["block"] + doc = personal.build(world["root"], block["cover"], {}, "내 돌붙임")["문서"] + with m02.at(world["folder"]): + first = personal.write(doc) + with pytest.raises(m02.StoreError) as e: + personal.write(doc) + assert e.value.status == 409 and e.value.detail["같은이름"] == first["이름"] + changed = copy.deepcopy(doc) + changed["담은로직"][1]["수량"] = {"손": 9} + again = personal.write(changed, first["이름"]) # 덮어쓰기 + assert again["이름"] == first["이름"] + other = personal.write({**doc, "이름": "내 돌붙임 2"}) # 새 이름 + assert other["이름"] != first["이름"] + saved = json.loads( + (world["folder"] / "precombo" / f"{first['이름']}.json").read_text(encoding="utf-8") + ) + assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] == {"손": 9} + assert saved["담은로직"][0]["입력값"] == b145.STONE + assert len(list((world["folder"] / "precombo").glob("SC-*.json"))) == 2 + + +def test_자료집_길_같은_이름_409_덮어쓰기(world, monkeypatch): + """서버 길 — 화면 캐시 덮개째 · 개인 층(임시 storage) · 같은 이름 409 {같은이름} → 덮어쓰기.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from B09_Estimation import B09_Estimation_Router as b09 + from common_util.common_util_auth import verify_session + + async def root_of(_pid): + return world["root"] + + monkeypatch.setattr(b09, "_project_root", root_of) + app = FastAPI() + app.include_router(b09.router) + session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app.dependency_overrides[verify_session] = lambda: session + client = TestClient(app) + block = world["block"] + url = "/api/projects/8b98ca75-c572-4002-8468-2adae4323414/estimation/daega/personal" + user = {"daega_cover": {block["cover"]: {"sangeun:SG:GF000219#0": {"qty": 4}}}} + body = {"cover": block["cover"], "name": "길 시험", "user": user} + first = client.post(url, json=body) + assert first.status_code == 200, first.text + assert first.json()["담음"]["수량"] == 1 + again = client.post(url, json=body) + assert again.status_code == 409 and again.json()["detail"]["같은이름"] == first.json()["키"] + over = client.post(url, json={**body, "overwrite": first.json()["키"]}) + assert over.status_code == 200 and over.json()["키"] == first.json()["키"] + saved = world["folder"] / "precombo" / f"{first.json()['키']}.json" + assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] == {"손": 4} + assert client.post(url, json={**body, "cover": "S:없음"}).status_code == 404 From 40b974c0c01885e728764c894fd7e8474d6e1365 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:46:28 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat(B09=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=90=EA=B2=80):=20[=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8]?= =?UTF-8?q?=20=ED=95=9C=20=EA=B3=B3=20=E2=80=94=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B5=B3=ED=9E=98=20+=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EB=B0=94=EB=80=90=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=EC=9E=AC=EC=A0=81=EC=9A=A9=20=C2=B7=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20=EC=B0=BD=20(PLAN=207?= =?UTF-8?q?03-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST …/estimation/prices = 연결된 구조물 사본 중 원본이 바뀐 것만 재적용(B09_Estimation_Refresh · 막힌 사본 = 알림 · 나머지 함) → 수요 다시 → 단가 책 다시 굳힘 · 덮개 그대로 · 답 notes 첫 줄 「단가 줄 N 바뀜 · 사본 K · 고친 칸 M 그대로」 - POST …/estimation/prices/preview = 바뀌는 단가 줄(새 · 값 바뀜) · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판 전 → 후(쓰지 않음) - 단가 책에 노임 판(labor_edition · 구분마다 M01 인력 판) · 책 견줌 book_diff(book_stale 도 같이 씀) - UI_Check = [업데이트] 누름을 먼저 잡아 미리 보기 창 → [적용] 이면 원래 단추 일 · 호버 글 - 시험 test_703_3_b09_refresh(미리 보기 수 = 적용 수 · 판 · 재적용 원본 바뀐 것만 · 덮개 그대로) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Prices.py | 38 ++++++++--- B09_Estimation/B09_Estimation_Refresh.py | 72 ++++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 45 ++++++++++-- B09_Estimation/B09_Estimation_UI_Check.ts | 69 ++++++++++++++++++- resources/tester/test_703_3_b09_refresh.py | 79 ++++++++++++++++++++++ 5 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Refresh.py create mode 100644 resources/tester/test_703_3_b09_refresh.py diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 8a13f11d3..5fcdf94e7 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -198,6 +198,31 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st "rows": rows, "driver_factor": driver_factor(master), "fx": fx_inputs(master), + "labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」 + } + + +def labor_editions(keys: list[str]) -> dict[str, str]: + """노임 판(703-3) — 수요 노무 줄 구분(건설업 · 산림 …)마다 M01 `인력.json` 그 구분 `판`.""" + files, master = m01.loaded() + groups = (files.get("인력.json") or {}).get("구분") or {} + out: dict[str, str] = {} + for key in demand(master, keys)["labor"]: + kind = str((_row(master, key) or {}).get("구분") or "") + if (groups.get(kind) or {}).get("판"): + out[kind] = str(groups[kind]["판"]) + return out + + +def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]: + """두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3).""" + new = json.loads(json.dumps(new, ensure_ascii=False)) + rows = (old or {}).get("rows") or {} + return { + "new": sum(key not in rows for key in new["rows"]), + "changed": sum(key in rows and rows[key] != row for key, row in new["rows"].items()), + "factor": (old or {}).get("driver_factor") != new["driver_factor"], + "fx": (old or {}).get("fx") != new["fx"], } @@ -206,15 +231,12 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: if book is None: return "단가 아직 안 굳힘" # 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296) - fresh = json.loads(json.dumps(refresh_book(keys, book), ensure_ascii=False)) - rows = book.get("rows") or {} - new = sum(key not in rows for key in fresh["rows"]) - changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items()) + diff = book_diff(book, refresh_book(keys, book)) parts = [ - f"새 단가 줄 {new}" if new else "", - f"M01 에서 바뀐 단가 {changed}" if changed else "", - "운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "", - "환율 바뀜" if book.get("fx") != fresh["fx"] else "", + f"새 단가 줄 {diff['new']}" if diff["new"] else "", + f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "", + "운전사 계수 바뀜" if diff["factor"] else "", + "환율 바뀜" if diff["fx"] else "", ] return " · ".join(p for p in parts if p) or None diff --git a/B09_Estimation/B09_Estimation_Refresh.py b/B09_Estimation/B09_Estimation_Refresh.py new file mode 100644 index 000000000..c29a75999 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Refresh.py @@ -0,0 +1,72 @@ +"""B09 [업데이트] 한 곳(PLAN 703-3) — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]을 함께. + +미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본 + (연결된 것 중 원본이 바뀐 것만 · 원본 없음 = 그대로) · 지킬 고친 칸(덮개 칸 수 · 702-4 — + 재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날) +적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) · + 덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation.B09_Estimation_Calc_Labor import COPY +from M02_MasterTemplete import M02_Item_Copy as item_copy +from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots + + +def changed_copies(root: str | Path) -> list[dict[str, Any]]: + """연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`.""" + linked = {one["폴더"] for one in linked_snapshots(root).values()} + return [ + {"폴더": it["폴더"], "이름": it["이름"] or it["폴더"]} + for it in item_copy.list_items(root) + if it["폴더"] in linked and it["마스터바뀜"] + ] + + +def cover_cells(cover: dict[str, Any] | None) -> int: + """고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌.""" + return sum( + len(cell) for lines in (cover or {}).values() for cid, cell in lines.items() if cid != COPY + ) + + +def edition_text(editions: dict[str, str] | None) -> str: + return " · ".join(f"{kind} {day[:7]}" for kind, day in sorted((editions or {}).items())) + + +def preview( + root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None +) -> dict[str, Any]: + diff = prices.book_diff(book, prices.refresh_book(keys, book)) + before = (book or {}).get("labor_edition") + made = str((book or {}).get("made") or "")[:10] + return { + "단가줄": diff["new"] + diff["changed"], + "새줄": diff["new"], + "바뀐줄": diff["changed"], + "계수": diff["factor"], + "환율": diff["fx"], + "사본": changed_copies(root), + "고친칸": cover_cells(cover), + "노임판": { + "전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"), + "후": edition_text(prices.labor_editions(keys)), + }, + } + + +def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]: + """원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`.""" + done, stuck = [], [] + for one in changed_copies(root): + try: + item_copy.reapply(root, one["폴더"], who) + done.append(one["이름"]) + except (LookupError, ValueError, KeyError, OSError) as error: + stuck.append(f"{one['이름']} — {error}") + return {"다시": done, "막힘": stuck} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d11c78572..09d09379b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -11,8 +11,13 @@ 원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 → 답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음 POST /api/projects/{id}/estimation/prices {user?} - [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · - 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 + [업데이트] — 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) → + 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은 + 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 · + 답 notes 첫 줄 = 바뀐 수 + POST /api/projects/{id}/estimation/prices/preview {user?} + [업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · + 노임 판(쓰지 않음) POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) @@ -26,7 +31,8 @@ 「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음) POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?} 「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합 - (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 + (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · + 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · @@ -58,6 +64,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Daega_Personal as personal from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Refresh as refresh from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices @@ -265,16 +272,44 @@ async def estimation_reset(project_id: UUID) -> dict[str, Any]: @router.post("/{project_id}/estimation/prices") async def refresh_prices( - project_id: UUID, body: dict[str, Any] | None = Body(None) + project_id: UUID, + body: dict[str, Any] | None = Body(None), + session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: + """[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 · + 덮개 그대로.""" root = await _project_root(project_id) raw = (body or {}).get("user") draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함) + again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session)) keys = await _demand(root, draft) old = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.refresh_book, keys, old) await asyncio.to_thread(store.write_book, root, book) - return await payload(project_id, root, draft) + out = await payload(project_id, root, draft) + diff = prices.book_diff(old, book) + cells = refresh.cover_cells((draft or out.get("user") or {}).get("daega_cover")) + out["notes"] = [ + f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 " + f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로", + *(f"재적용 못 함 {why}" for why in again["막힘"]), + *(out.get("notes") or []), + ] + return out + + +@router.post("/{project_id}/estimation/prices/preview") +async def refresh_preview( + project_id: UUID, body: dict[str, Any] | None = Body(None) +) -> dict[str, Any]: + """[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판.""" + root = await _project_root(project_id) + raw = (body or {}).get("user") + draft = store.clean_user(raw) if isinstance(raw, dict) else None + keys = await _demand(root, draft) + book = await asyncio.to_thread(store.read_book, root) + user = draft if draft is not None else await asyncio.to_thread(store.read_user, root) + return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover")) class PreviewBody(BaseModel): diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 07ce77bb7..19d56c11f 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -4,15 +4,23 @@ * * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 + * 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) → + * 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] = + * 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함 * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * ========================================================================== */ +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { openModal } from "@ui/ui_template_modal"; import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; import { dateTimeText } from "../common_util/common_util_time"; +import { readState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; export interface CheckItem { @@ -59,7 +67,65 @@ export function refreshTip(payload: Row | null): string { const made = payload?.book?.made; const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘"; const stale = payload?.stale; - return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; + return `${when} · 누르면 미리 보기 → [적용] = 지금 M01 값으로 새로 굳힘 · 원본 바뀐 구조물 사본 재적용 · 고친 칸 그대로${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; +} + +/** [업데이트] 미리 보기 글 줄(703-3) */ +export function previewLines(got: Row): string[] { + const copies: Row[] = got.사본 || []; + const edition = got.노임판 || {}; + return [ + `바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` + + `${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, + `다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, + `고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`, + `노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`, + ]; +} + +/** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */ +function attachPreview(refresh: HTMLElement, payload: () => Row | null): void { + let pass = false; + refresh.addEventListener( + "click", + (event) => { + if (pass) return void (pass = false); + event.stopImmediatePropagation(); + const projectId = currentProjectId(); + const user = readState("b09-estimation", projectId) ?? payload()?.user ?? {}; + void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user }), + }) + .then(async (response) => { + const got = (await response.json()) as Row; + if (!response.ok) throw new Error(String(got?.detail ?? response.status)); + openModal({ + title: "업데이트 미리 보기", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, head) => { + const apply = createButton({ + label: "적용", + variant: "filled", + onClick: () => { + close(); + pass = true; + refresh.click(); + }, + }); + apply.dataset.b09RefreshApply = ""; + head.append(apply); + body.append(...previewLines(got).map((line) => createText(line))); + }, + }); + }) + .catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error")); + }, + true, + ); } export interface CheckPanel { @@ -72,6 +138,7 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi let payload: Row | null = null; let items: CheckItem[] = []; attachHoverInfo(refresh, () => refreshTip(payload)); + attachPreview(refresh, () => payload); const box = document.createElement("div"); box.className = "b09-check"; attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); diff --git a/resources/tester/test_703_3_b09_refresh.py b/resources/tester/test_703_3_b09_refresh.py new file mode 100644 index 000000000..cd6bb5bc9 --- /dev/null +++ b/resources/tester/test_703_3_b09_refresh.py @@ -0,0 +1,79 @@ +"""703-3 B09 [업데이트] 한 곳 — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 재적용. + +- 미리 보기 수(`Refresh.preview`) = 적용 뒤 책 견줌(`Prices.book_diff`) 수 · 노임 판 전 → 후 · 고친 칸 수 +- 재적용 = 연결된 사본 중 원본이 바뀐 것만 · 하나가 막혀도 나머지 · 덮개(사용자 값) 안 건드림 +""" + +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 B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Refresh as refresh # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 + +KEYS = ["LB000001", "LB000033"] # 작업반장(건설업) · 석공 + + +def test_책_견줌_미리보기_수_적용_수_같음(tmp_path, monkeypatch): + monkeypatch.setattr(refresh, "changed_copies", lambda root: []) + new = prices.refresh_book(KEYS) + assert new["labor_edition"] == prices.labor_editions(KEYS) and new["labor_edition"] + old = json.loads(json.dumps(new)) + old["rows"]["LB000001"] = {**old["rows"]["LB000001"], "price": 1} # 옛 판 값 + del old["rows"]["LB000033"] # 새 줄 + old["labor_edition"] = {"건설업": "2025-09-01"} + cover = { + "S:a": {"x#0": {"qty": 1, "labor": 2}}, + "K:b": {"@copy": {"from": "S:a"}, "y#0": {"qty": 3}}, + } + got = refresh.preview(tmp_path, old, KEYS, cover) + after = prices.refresh_book(KEYS, old) + diff = prices.book_diff(old, after) + assert (got["새줄"], got["바뀐줄"]) == (diff["new"], diff["changed"]) == (1, 1) + assert got["단가줄"] == 2 and got["고친칸"] == 3 and got["사본"] == [] + assert got["노임판"]["전"] == "건설업 2025-09" + assert got["노임판"]["후"].startswith("건설업 2026") + old.pop("labor_edition") + assert refresh.preview(tmp_path, old, KEYS, None)["노임판"]["전"].startswith("굳힌 날 ") + assert prices.book_diff(after, prices.refresh_book(KEYS, after)) == { + "new": 0, + "changed": 0, + "factor": False, + "fx": False, + } + assert prices.book_stale(after, KEYS) is None + + +def test_재적용_원본_바뀐_사본만_막혀도_나머지_덮개_그대로(tmp_path, monkeypatch): + cover = {"S:a": {"x#0": {"qty": 0.3}}} + store.write_user(tmp_path, {"daega_cover": cover}) + monkeypatch.setattr( + refresh, + "linked_snapshots", + lambda root: {"p1": {"폴더": "a"}, "p2": {"폴더": "b"}, "p3": {"폴더": "c"}}, + ) + items = [ + {"폴더": "a", "이름": "가", "마스터바뀜": True}, + {"폴더": "b", "이름": "나", "마스터바뀜": False}, # 원본 그대로 = 안 함 + {"폴더": "c", "이름": "다", "마스터바뀜": True}, + {"폴더": "z", "이름": "끊김", "마스터바뀜": True}, # 연결 안 됨 = 안 함 + ] + monkeypatch.setattr(refresh.item_copy, "list_items", lambda root: items) + calls = [] + + def fake(root, folder, who=None): + calls.append(folder) + if folder == "c": + raise LookupError("원본 층 없음") + + monkeypatch.setattr(refresh.item_copy, "reapply", fake) + assert [c["이름"] for c in refresh.changed_copies(tmp_path)] == ["가", "다"] + got = refresh.reapply_changed(tmp_path) + assert calls == ["a", "c"] + assert got == {"다시": ["가"], "막힘": ["다 — 원본 층 없음"]} + assert store.read_user(tmp_path)["daega_cover"] == cover