diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 775553d86..127960c1b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -13,8 +13,11 @@ · 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`) · 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급) → 재료비목록표 단가 = 적용 단가 - 재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0) - (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) + 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) + (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 = + 입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 · + 없는 키 빈칸) · + `material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열) 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 @@ -178,14 +181,18 @@ def compare_sheet( return rows -def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]: - """재료비목록표 — 단가 = 대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0).""" - keep = ("no", "key", "name", "spec", "unit", "supply", "origin", "qty") +def material_sheet( + compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 · + 총액 = ROUND(수량 × 단가, 0).""" + keep = ("no", "key", "name", "spec", "unit", "supply", "origin") rows = [] for row in compare: - price, q = row["applied"], row["qty"] + price = row["applied"] + q = row["qty"] if qty_of is None else num(qty_of.get(row["key"])) amount = None if price is None or q is None else round_half(q * price, 0) - rows.append({**{k: row[k] for k in keep}, "price": price, "amount": amount}) + rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount}) return rows @@ -328,7 +335,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any] "expense": expense, "basis": basis, "compare": compare, - "material": material_sheet(compare), + "material": material_sheet(compare, inputs.get("material_qty")), "machine_blocks": blocks, "machine": machine, "gaps": gaps, diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index c5223530c..7c133ea91 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -9,7 +9,8 @@ * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) * 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 · * 관급 · 별도 표시 → 재료비목록표 - * 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 + * 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력 + * material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열) * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 * ========================================================================== */ @@ -157,13 +158,14 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { }); } -function materialSheet(compare: Row[]): Row[] { - const keep = ["no", "key", "name", "spec", "unit", "supply", "origin", "qty"]; +function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] { + const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; return compare.map((row) => { const price: Num = row.applied; - const q: Num = row.qty; + const q: Num = qtyOf == null ? row.qty : num(qtyOf[row.key]); return { ...Object.fromEntries(keep.map((k) => [k, row[k]])), + qty: q, price, amount: price === null || q === null ? null : roundHalf(q * price, 0), }; @@ -301,7 +303,7 @@ export function computeBase(inputs: Row, user: Row): Row { expense, basis, compare, - material: materialSheet(compare), + material: materialSheet(compare, inputs.material_qty), machine_blocks: blocks, machine, gaps, diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index ded22e253..ff2427dd3 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -3,7 +3,9 @@ 연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용). 사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며 실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째 -(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음). +(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) · +사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(183-1 · 로직을 안 돌아 `get` 에 안 걸림). +고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`). 구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. """ @@ -57,6 +59,10 @@ def used_keys(folder: Path) -> list[str]: labor_doc.run_line( snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") ) + for mat in [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]: + code = str(mat.get("코드") or "") if isinstance(mat, dict) else "" + if code[:2] in RESOURCE_TABLES and code not in seen: + seen.append(code) return seen diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index ffa23ccb6..b4a7d7185 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -37,7 +37,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router -from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines +from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -81,6 +81,19 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> return sheets +def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> tuple[dict, dict]: + """183-1 두 번 셈 — 1차 셈으로 쓰는 자재만 남기고(`used_materials`) 재료비목록표 수량 = + 1차 역전개 총수량(`material_qty` = 재료비수량금액집계표 수량) → 2차 셈 · 집계표. + `inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음).""" + first = compute_sheets(root, inputs, user) + summary = compute_summary(first, hopyo_lines(first)) + keep = used_materials(first, inputs, user) + inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep] + inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]} + sheets = compute_sheets(root, inputs, user) + return sheets, compute_summary(sheets, hopyo_lines(sheets)) + + async def _demand(root: Path) -> list[str]: """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2).""" keys = await asyncio.to_thread(linked_resource_keys, root) @@ -103,7 +116,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) user = await asyncio.to_thread(store.read_user, root) - sheets = await asyncio.to_thread(compute_sheets, root, inputs, user) + sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user) return { "inputs": inputs, "user": user, @@ -113,7 +126,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "rate_table": rate_table(), "notes": notes, # 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜 - "summary": compute_summary(sheets, hopyo_lines(sheets)), + "summary": summary, } diff --git a/B09_Estimation/B09_Estimation_Summary.py b/B09_Estimation/B09_Estimation_Summary.py index 5569f8f31..9a660b718 100644 --- a/B09_Estimation/B09_Estimation_Summary.py +++ b/B09_Estimation/B09_Estimation_Summary.py @@ -155,6 +155,25 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]: return out +def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[str, Any]) -> set: + """재료비목록표에 남길 자재 키(183-1) — 대가 · 산근 · 중기 블록 재료 줄(수량 빈칸 · 0 도 = + 집계표 역전개와 같은 줄) · B08 총괄 줄(수량 있는 입력) · 관급 · 별도 표시 줄. + 그 밖(고르기 후보 줄) = 뺌.""" + blocks = [*(sheets.get("sg") or []), *(sheets.get("daega_blocks") or [])] + blocks += sheets.get("machine_blocks") or [] + keep = { + str((line.get("ref") or {}).get("key")) + for block in blocks + for line in block.get("lines") or [] + if line.get("kind") == "material" and (line.get("ref") or {}).get("key") + } + marks = user.get("materials") or {} + for m in inputs.get("materials") or []: + if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"): + keep.add(m["key"]) + return keep + + def hopyo_lines(sheets: dict[str, Any]) -> dict[str, Any]: """호표 구성(1 단위당 자원 · 겹 호표) — 산근(145-1) + 대가(145-2 · 들어오면).""" return {**block_lines(sheets.get("sg")), **block_lines(sheets.get("daega_blocks"))} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 8a81b93ae..67219f9e2 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -106,3 +106,9 @@ .b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child { --pick-r: inset -2px 0 0 var(--color-danger); } + +/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */ +.b09-estimation .ui-data-table__table tbody td.is-picked { + background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas)); + font-weight: var(--font-weight-bold); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 39109d305..7147fe004 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -49,7 +49,7 @@ import { buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; -import { attachRowMenu, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { buildSumExpense, buildSumLabor, @@ -206,6 +206,7 @@ export function renderB09Estimation(root: HTMLElement): void { rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, + cellClass: spec.cellClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); @@ -215,6 +216,17 @@ export function renderB09Estimation(root: HTMLElement): void { // 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임 const menu = (spec as BillTable).menu; if (menu) attachRowMenu(made.root, menu); + // 183-3 칸 고르기 메뉴 — 누른 칸 아래에 B09 공용 메뉴 + if (spec.choices) + made.root.addEventListener("click", (event) => { + const cell = (event.target as Element).closest("tr[data-i] td"); + const column = cell ? spec.columns[cell.cellIndex] : undefined; + const items = + cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key); + if (!cell || !items?.length) return; + const box = cell.getBoundingClientRect(); + openMenuAt(box.left, box.bottom, items); + }); // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title if (spec.tip) made.root.addEventListener("mouseover", (event) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1b07aa109..8f68f8ead 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -7,6 +7,7 @@ * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 * ========================================================================== */ +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; @@ -107,6 +108,10 @@ export interface TabTable { rowClass?: (index: number) => string | null | undefined; /** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */ tip?: (index: number, key: string) => string | null | undefined; + /** 칸 꼴 class(183-3 공용 표 `cellClass` · 예 고른 출처 칸 강조) */ + cellClass?: (index: number, key: string) => string | null | undefined; + /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ + choices?: (index: number, key: string) => MapContextMenuItem[] | null; } export type TabBuild = (ctx: TabContext) => TabTable | string; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 04670f20d..76d071d45 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -2,14 +2,18 @@ * B09_Estimation_UI_Tabs_Base.ts * B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 · * 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`). - * 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3) + * 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 · + * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * - * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 - * · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) - * · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) + * 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 → + * `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) · + * 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력 + * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) + * · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; @@ -107,10 +111,18 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; -/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */ -function nextPick(r: Row): string | null { +/** 적용 단가 고르기 항목(183-3) — 값 있는 출처(견적 포함 · 0 뺌) 「출처 단가 · 최저」 + 「최저(자동)」 · + * `pick(s)` = 그 출처 · null = 자동 */ +function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] { const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0); - return have[have.indexOf(r.pick) + 1] ?? null; + const label = (s: string): string => + `${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` + + (s === r.lowest_from ? " · 최저" : ""); + return [ + ...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]), + "-", + [`${r.pick || r.applied_from === "사용자" ? "" : "✓ "}최저(자동)`, () => pick(null)], + ]; } export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { @@ -148,35 +160,42 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { text("name", "품명", 180), text("spec", "규격", 140), text("unit", "단위", 56), - ...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]), + // 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값) + ...SOURCES.flatMap((s) => [ + money(`p:${s}`, "단가", [s]), + s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]), + ]), money("lowest", "최저"), money("applied", "적용 단가", ["적용"]), text("applied_from", "출처", 80, ["적용"]), text("supply", "관급 · 별도", 80), - text("files", "견적서", 72), ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", - editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + editable: (_i, key) => key === "p:견적", onEdit: (i, key, value) => { const row = out[i]; - if (!row) return; - const cell = mine(String(row.key)); - if (key === "g:견적") { - if (value.trim()) cell.quote_page = value.trim(); - else delete cell.quote_page; - return put(String(row.key), cell); - } const v = parseNumber(value); - if (v === undefined) return; - const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null; - if (!field) return; - if (v === null) delete cell[field]; - else cell[field] = v; - if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂 + if (!row || key !== "p:견적" || v === undefined) return; + const cell = mine(String(row.key)); + if (v === null) delete cell.quote; + else cell.quote = v; put(String(row.key), cell); }, + // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴 + cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null), + choices: (i, key) => { + const r = rows[i]; + if (key !== "applied" || !r) return null; + return appliedChoices(r, (s) => { + const cell = mine(r.key); + delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 + if (s) cell.pick = s; + else delete cell.pick; + put(r.key, cell); + }); + }, onCell: (i, key) => { const row = out[i]; const r = rows[i]; @@ -192,12 +211,6 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { put(r.key, now); }); } - if (key === "applied_from") { - const pick = nextPick(r); - if (pick) cell.pick = pick; - else delete cell.pick; - return put(String(row.key), cell); - } if (key !== "supply") return; const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null; if (next) cell.supply = next; @@ -207,10 +220,33 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { }; }; +/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」 + * (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */ +function usesOf(sheets: Row): Map { + const out = new Map(); + const add = (key: unknown, label: string): void => { + if (!key) return; + const list = out.get(String(key)) ?? []; + if (!list.includes(label)) out.set(String(key), [...list, label]); + }; + const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void => + (blocks || []).forEach((b, n) => + (b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))), + ); + scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`); + scan(sheets.sg, (b) => `산근 ${b.no}호표`); + scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`); + for (const r of sheets.bill?.rows || []) + if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대"); + return out; +} + export const buildMaterial: TabBuild = ({ sheets }): TabTable => { + const uses = usesOf(sheets); const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({ ...r, supply: r.supply ?? "", + uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""), })); return { columns: [ @@ -222,14 +258,14 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { money("price", "단가"), money("amount", "총액"), text("supply", "관급 · 별도", 80), - text("origin", "비고", 90), + text("uses", "비고(쓰는 곳)", 240), ], rows, total: rows.length ? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) } : null, frozen: 2, - hint: `수량 = B08 총괄자재수량집계표 합계 열(할증 포함 · 사본 자원 줄은 빈칸) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · ${LINK_HINT}`, + hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`, link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 5f69f8ab6..5d9d186e1 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -348,17 +348,27 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); } +/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */ +function sharedMenu(): MapContextMenu { + shared ??= createMapContextMenu("b09-estimation"); + if (!shared.element.isConnected) { + shared.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) + document.body.append(shared.element); + } + return shared; +} + +/** 화면 자리(x · y)에 B09 메뉴를 엶(183-3 칸 고르기) */ +export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): void { + if (items.length) sharedMenu().open(x, y, items); +} + /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ export function attachRowMenu( root: HTMLElement, menu: (index: number) => MapContextMenuItem[], ): void { - shared ??= createMapContextMenu("b09-estimation"); - const ctxMenu = shared; - if (!ctxMenu.element.isConnected) { - ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) - document.body.append(ctxMenu.element); - } + const ctxMenu = sharedMenu(); // 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶 root.addEventListener( "click", diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index c8a37513c..2969bb99c 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -14,7 +14,7 @@ SPOTS = { "B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'], - "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"], + "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"], "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'], "ui_template/ui_template_locale_b2.ts": [ "수량 원천: 수기 입력(직접비)", diff --git a/resources/tester/test_183_2_b09_compare_head.py b/resources/tester/test_183_2_b09_compare_head.py new file mode 100644 index 000000000..3040fe43a --- /dev/null +++ b/resources/tester/test_183_2_b09_compare_head.py @@ -0,0 +1,29 @@ +"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래. + +소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SRC = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8") + + +def _compare() -> str: + start = SRC.index("export const buildCompare") + return SRC[start : SRC.index("export const buildMaterial")] + + +def test_견적은_단가_견적서_그_밖은_단가_출처(): + body = _compare() + assert ( + 's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body + ) + assert '"면"' not in body # 면 머리 없음 + assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움 + + +def test_견적_면_수기_칸_없앰(): + body = _compare() + assert 'key === "g:견적"' not in body and "quote_page" not in body + assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py new file mode 100644 index 000000000..dcc5b1c91 --- /dev/null +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -0,0 +1,113 @@ +"""183-3 자재단가대비표 적용 단가 — 칸 누름 고르기 메뉴 · 고른 출처 칸 강조 · 기본 최저. + +- 공용 표 `cellClass` 옵션(칸 class · 안 주면 같은 글) — Node 로 `renderDataTableHtml` +- 고르기 항목 `appliedChoices`(값 있는 출처 · 견적 · 최저(자동) · ✓ 표시) — Node 로 함수만 떼어 돌림 +- 배선: 수기 적용 칸 · 출처 칸 돌림 없앰 · Shell 이 cellClass · choices 를 넘김 · B09 메뉴 한 벌 +셈 · 저장(pick › applied › 최저)은 그대로(test_144 · test_166_4). +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" + + +def _src(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script: str): + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = (code) => ts.transpileModule(code, {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +""" + + +def test_공용_표_cellClass_칸_class_안_주면_같은_글(): + table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) + got = _node( + PRELUDE + + f""" +const box = {{ exports: {{}} }}; +new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))( + box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); +const m = box.exports; +const o = {{ columns: [{{ key: "a", label: "A" }}, {{ key: "b", label: "B" }}], + rows: [{{ a: 1, b: 2 }}, {{ a: 3, b: 4 }}] }}; +const marked = m.renderDataTableHtml({{ ...o, + cellClass: (i, k) => (i === 0 && k === "b" ? "is-picked" : null) }}); +console.log(JSON.stringify({{ + plain: m.renderDataTableHtml(o), + none: m.renderDataTableHtml({{ ...o, cellClass: () => null }}), + count: marked.split("is-picked").length - 1, + row0: marked.split("")[1].split("")[0], +}})); +""" + ) + assert got["plain"] == got["none"] + assert got["count"] == 1 and '2' in got["row0"] + + +def test_고르기_항목_값_있는_출처_견적_최저_자동(): + base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts") + start = base.index("function appliedChoices") + body = base[start : base.index("\n}\n", start) + 3] + sources = _src("B09_Estimation/B09_Estimation_Calc_Base.ts") + at = sources.index("export const SOURCES") + line = sources[at : sources.index(";", at) + 1] + "\n" + got = _node( + PRELUDE + + f""" +const appliedChoices = new Function(js({json.dumps(line.replace("export ", "") + body)}) + "; return appliedChoices;")(); +const r = {{ prices: {{ 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: 1500 }}, + lowest_from: "유통물가", applied_from: "유통물가", pick: null }}; +const seen = []; +const items = appliedChoices(r, (s) => seen.push(s)); +items.forEach((x) => Array.isArray(x) && x[1]()); +const pick = {{ ...r, applied_from: "견적", pick: "견적" }}; +console.log(JSON.stringify({{ + labels: items.map((x) => (Array.isArray(x) ? x[0] : x)), + seen, + picked: appliedChoices(pick, () => 0).map((x) => (Array.isArray(x) ? x[0] : x)), +}})); +""" + ) + assert got["labels"] == [ + "물가정보 1,200", + "✓ 유통물가 1,000 · 최저", + "견적 1,500", + "-", + "✓ 최저(자동)", + ] + assert got["seen"] == ["물가정보", "유통물가", "견적", None] + assert got["picked"][2] == "✓ 견적 1,500" and got["picked"][-1] == "최저(자동)" + + +def test_배선_수기_적용_칸_출처_돌림_없앰(): + base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts") + compare = base[base.index("export const buildCompare") : base.index("function usesOf")] + assert 'editable: (_i, key) => key === "p:견적",' in compare + assert "nextPick" not in base and 'key === "applied_from"' not in compare + assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare + assert 'if (key !== "applied" || !r) return null;' in compare + assert "delete cell.applied" in compare + shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") + assert ( + "cellClass: spec.cellClass," in shell and "openMenuAt(box.left, box.bottom, items)" in shell + ) + edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") + assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit + css = _src("B09_Estimation/B09_Estimation_UI_Page.css") + assert "td.is-picked" in css and "var(--color-accent)" in css diff --git a/resources/tester/test_183_b09_material_list.py b/resources/tester/test_183_b09_material_list.py new file mode 100644 index 000000000..e6905631c --- /dev/null +++ b/resources/tester/test_183_b09_material_list.py @@ -0,0 +1,88 @@ +"""183-1 재료비목록표 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재만 · 수량 · 단가 한 줄 · 쓰는 곳. + +- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도 든다(`Linked.used_keys`) +- 거름: 대가 · 산근 · 중기 블록 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(`Summary.used_materials`) +- 수량 = 입력 `material_qty`(1차 역전개 총수량) — `Calc_Base` 짝 거울 · 두 번 셈(`Router.compute_twice`) +- 화면 비고 = 쓰는 곳(`UI_Tabs_Base.usesOf`) +""" + +import json +from pathlib import Path + +from B09_Estimation import B09_Estimation_Router as router_mod +from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Linked import used_keys +from B09_Estimation.B09_Estimation_Summary import used_materials +from resources.tester.test_144_b09_base_calc import _run_ts + +ROOT = Path(__file__).resolve().parents[2] + +MATERIALS = [ + {"key": "MT1", "name": "쓰는 자재", "prices": {"물가자료": 1000}}, + {"key": "MT2", "name": "고르기 후보", "prices": {"물가자료": 500}}, + {"key": "MT3", "name": "관급 표시", "prices": {"물가자료": 300}}, + {"key": "MO1", "name": "경유", "prices": {"유가": 1800}}, + {"key": "b08:pipe_800", "name": "파형강관", "prices": {}, "qty": 8, "supply": "관급"}, +] +USER = {"materials": {"MT3": {"supply": "관급"}}} +SG = [{"key": "SG1", "no": 1, "lines": [{"kind": "material", "ref": {"key": "MT1"}, "qty": 2}]}] +MACHINE = [{"key": "EQ1", "lines": [{"kind": "material", "ref": {"key": "MO1"}, "qty": None}]}] + + +def test_사본_일위대가_자재_사전조합_자재_코드도_수요(tmp_path): + labor = { + "줄": {"담은로직": [], "자재": [{"코드": "MT000053"}, {"코드": "PM000001"}]}, + "참조사본": {"참조|system||SC-000001": {"담은로직": [], "자재": [{"코드": "MT000008"}]}}, + } + (tmp_path / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8") + assert used_keys(tmp_path) == ["MT000053", "MT000008"] # 개인 자재(PM) = 원가 표 밖 + + +def test_쓰는_자재만_남김(): + sheets = {"sg": SG, "daega_blocks": [], "machine_blocks": MACHINE} + inputs = {"materials": MATERIALS} + assert used_materials(sheets, inputs, USER) == {"MT1", "MO1", "MT3", "b08:pipe_800"} + + +def test_수량_원천_material_qty_거울(): + inputs = {"labor": [], "machines": [], "materials": MATERIALS} + old = {r["key"]: r["qty"] for r in compute_base(inputs, USER)["material"]} + assert old["b08:pipe_800"] == 8 and old["MT1"] is None # 없으면 입력 줄 수량(지금 길) + got = compute_base({**inputs, "material_qty": {"MT1": 6, "b08:pipe_800": 8}}, USER) + rows = {r["key"]: r for r in got["material"]} + assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6, 6000) + assert rows["MT2"]["qty"] is None and rows["MT2"]["amount"] is None # 없는 키 = 빈칸 + for case in (inputs, {**inputs, "material_qty": {"MT1": 6}}): + assert _run_ts(case, USER) == compute_base(case, USER) + + +def test_두_번_셈_후보_줄_빠지고_수량_역전개(monkeypatch): + calls = [] + + def fake(root, inputs, user): + calls.append([m["key"] for m in inputs["materials"]]) + base = compute_base(inputs, user) + bill = {"rows": [{"kind": "item", "qty": 3, "ref": {"tab": "sg", "key": "SG1"}}]} + return {**base, "sg": SG, "daega_blocks": [], "machine_blocks": MACHINE, "bill": bill} + + monkeypatch.setattr(router_mod, "compute_sheets", fake) + inputs = {"labor": [], "machines": [], "materials": list(MATERIALS)} + sheets, summary = router_mod.compute_twice(Path("."), inputs, USER) + assert calls[0] == [m["key"] for m in MATERIALS] + assert calls[1] == ["MT1", "MT3", "MO1", "b08:pipe_800"] # 후보 MT2 뺌 + assert inputs["material_qty"] == {"MT1": 6.0} # 설계내역서 3 × 호표 2 + rows = {r["key"]: r for r in sheets["material"]} + assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6.0, 6000) + assert [r["qty"] for r in summary["sum_material"]] == [rows["MT1"]["qty"]] # 집계표와 같은 값 + + +def test_화면_비고_쓰는_곳(): + src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8") + for label in ( + "`대가 ${b.no}호표`", + "`산근 ${b.no}호표`", + "`중기 ${n + 1}호표`", + '"내역 자재대"', + ): + assert label in src + assert 'text("uses", "비고(쓰는 곳)"' in src and 'text("origin", "비고"' not in src diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index f16bc7ecf..13e7a3b54 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -60,6 +60,8 @@ export interface DataTableOptions { onEdit?: (index: number, key: string, text: string) => void; /** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */ rowClass?: (index: number) => string | null | undefined; + /** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */ + cellClass?: (index: number, key: string) => string | null | undefined; } export interface DataTable { @@ -139,6 +141,7 @@ function rowHtml( editable?: (index: number, key: string) => boolean, focus = false, extra: string | null | undefined = null, + cellClass?: (key: string) => string | null | undefined, ): string { let tr = ""; for (let j = 0; j < cols.length; j++) { @@ -152,7 +155,12 @@ function rowHtml( } const [txt, empty] = formatCell(r[key], fmts[j], blank); const frz = left[j] !== null; - const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"] + const cls = [ + frz && "is-frozen", + frz && left[j + 1] == null && "is-edge", + empty && "is-empty", + cellClass?.(key), + ] .filter(Boolean) .join(" "); const attrs = @@ -218,6 +226,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin o.editable, o.focus === i, o.rowClass?.(i), + o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, ); if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : "";