diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index 33db23a5..4d3ef323 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -266,7 +266,6 @@ def finish( from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( assign_keys, decorate, - general_provision_roots, load_registry, toc_edition, ) @@ -274,9 +273,7 @@ def finish( edition = toc_edition(data.get("sources"), data["effective_date"]) registry = load_registry(registry_path, key_prefix) newly_issued, toc_duplicates = assign_keys(nodes, registry, edition=edition) - # 총칙 뿌리는 `axis_policy.json` — 코드에 박으면 관리자가 못 고친다(데스크탑 서브 2026-09-17). - roots = general_provision_roots("forest") if general_roots is None else general_roots - decorate(nodes, edition=edition, general_roots=roots) + decorate(nodes, edition=edition, roots=general_roots) # None = 산림 잣대(`axis_policy.json`) role_counts: dict[str, int] = {} for node in nodes: role_counts[node["axis_role"]] = role_counts.get(node["axis_role"], 0) + 1 diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Const.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Const.py index 642f7f03..c96182de 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Const.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Const.py @@ -12,7 +12,7 @@ 못 씀 → 표가 놓인 원문 줄에서 위로 거슬러 **번호 뒤 글이 목차 이름과 같은** 제목을 찾음. 장 파일 경계가 쪽 기준이라(원문 머리 경고) 앞뒤 장 제목도 받고, 파일 첫머리 표는 앞 장 파일 꼬리까지 봄. ⚠ 못 붙인 표는 조용히 버리지 않고 `orphan_tables` 목록. -⚠ 총칙·묶는 마디 덜어내기 안 함 — 잣대는 데스크탑 서브. 총칙 표시도 비움(`axis_role` 은 공종·묶음·빈 잎). +⚠ 총칙·묶는 마디 덜어내기 안 함(가름만) — 총칙 뿌리는 `axis_policy.json` 건설 축(데스크탑 서브 잣대) · 미확정 둘은 안 넣음. ⚠ 갈래 키 안 붙임 — B09 표 읽기가 산림 표로만 검증됨(빈칸 = 못 가름). """ @@ -24,8 +24,6 @@ import re from pathlib import Path from typing import Any -from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import general_provision_roots - ROOT = Path(__file__).resolve().parent.parent SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_const_2026.json" #: 건설 열쇠 장부 — 산림 장부(`work_item_keys.json`)와 일련번호를 안 섞음 @@ -220,6 +218,20 @@ class SectionFinder: return None +def general_roots() -> tuple[str, ...]: + """총칙 뿌리 — `axis_policy.json` 건설 축의 장 파일 이름을 목차 코드로(「01_공통부문/제1장_적용기준.md」 → `CP-01-01`). + + ⚠ 미확정(`general_provision_pending` · 제8장 건설기계 · 유지관리 제1장)은 안 넣음 — 판정이 오면 자료만 고치면 됨. + """ + from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import general_provision_roots + + roots = [] + for chapter_file in general_provision_roots("const"): + division, chapter = file_place(chapter_file) + roots.append(_code(f"{CODE_PREFIX}-{DIVISIONS.index(division) + 1:02d}", str(chapter))) + return tuple(roots) + + def book_lines(data: dict[str, Any]) -> list[str]: folder = (ROOT / str(data["sources"][0]["path"])).parents[1] return (folder / BOOK_NAME).read_text(encoding="utf-8").splitlines() @@ -286,14 +298,12 @@ def build_const() -> tuple: registry_path=CONST_KEY_REGISTRY, toc_corrections=[], key_prefix=KEY_PREFIX, - # 총칙 잣대 도착(데스크탑 서브 2026-09-17) — `axis_policy.json` 의 건설 뿌리. - # ⚠ 뿌리에 부문 자리가 들어 있다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다. - general_roots=general_provision_roots("const"), + general_roots=general_roots(), policy={ "divisions": list(DIVISIONS), "toc_source": BOOK_NAME + " 머리 「목 차」", "section_from_body": True, "variant_keys_attached": False, - "general_provision_marked": False, + "general_provision_roots": list(general_roots()), }, ) diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Keys.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Keys.py index 74b77447..145273da 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Keys.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Keys.py @@ -48,18 +48,10 @@ def load_policy(axis: str, path: Path = AXIS_POLICY) -> dict[str, Any]: def general_provision_roots(axis: str = "forest", path: Path = AXIS_POLICY) -> tuple[str, ...]: - """총칙 뿌리 — 값표는 붙어 있으나 공종이 아닌 장. ⚠ 지우지 않고 구실로만 가른다. - - ⚠ 뿌리는 **목차 코드**다(산림 `FP-01` · 건설 `CP-01-01`). 건설은 부문마다 장 번호를 - 각자 1부터 세므로 부문 자리까지 있어야 갈린다. - """ + """총칙 뿌리 — 값표는 붙어 있으나 공종이 아닌 장. ⚠ 지우지 않고 구실로만 가른다.""" return tuple(load_policy(axis, path).get("general_provision_roots") or ()) -#: 잣대를 못 읽는 자리(시험 등)에서 쓰는 산림 기본값. **정본은 `axis_policy.json`.** -GENERAL_PROVISION_ROOTS = ("FP-01", "FP-02") - - #: 원문 경로에 박힌 고시 번호 — 「(산림청고시 제2025-82호)」. _EDITION_RE = re.compile(r"([가-힣]*고시\s*제\s*\d{4}-\d+\s*호)") @@ -203,25 +195,21 @@ def path_names(nodes: list[dict[str, Any]]) -> list[str]: def axis_role( - node: dict[str, Any], - *, - has_children: bool, - general_roots: tuple[str, ...] | None = None, + node: dict[str, Any], *, has_children: bool, roots: tuple[str, ...] | None = None ) -> str: """이 줄이 공종인가 문서 구역인가. 차례가 뜻을 가진다(총칙 먼저). `work_item` 품셈 표가 붙은 줄 — 값이 있는 자리 `group` 표는 없고 아래를 묶는 마디 — `parent_mode` 가 계산 규칙을 지닌다(지우면 안 된다) - `general_provision` 총칙(1·2장) — 문서 구역이나 **값표가 붙어 있어 B09 가 읽는다** + `general_provision` 총칙 — 문서 구역이나 **값표가 붙어 있어 B09 가 읽는다** `empty` 표도 아래도 없는 잎 — 원문에 표가 안 붙은 자리 ⚠ **어느 구실이든 지우지 않는다.** 가름은 화면·조합이 무엇을 볼지 고르는 것뿐이다. - `general_roots` 를 안 주면 잣대 자료의 산림 뿌리를 쓴다. """ - if general_roots is None: - general_roots = general_provision_roots() + if roots is None: + roots = general_provision_roots() code = node["work_item_code"] - if any(code == root or code.startswith(root + "-") for root in general_roots): + if any(code == root or code.startswith(root + "-") for root in roots): return "general_provision" if node.get("tables"): return "work_item" @@ -229,17 +217,14 @@ def axis_role( def decorate( - nodes: list[dict[str, Any]], - *, - edition: str, - general_roots: tuple[str, ...] | None = None, + nodes: list[dict[str, Any]], *, edition: str, roots: tuple[str, ...] | None = None ) -> None: """각 줄에 목차 칸·경로 이름·구실을 얹는다. 열쇠는 `assign_keys` 가 이미 박았다. 기존 칸은 손대지 않는다 — 금액을 낳는 값은 그대로다. """ - if general_roots is None: - general_roots = general_provision_roots() + if roots is None: + roots = general_provision_roots() paths = path_names(nodes) has_kid = {n.get("parent_code") for n in nodes} for node, path in zip(nodes, paths): @@ -248,6 +233,4 @@ def decorate( node["toc_number"] = node["number"] node["toc_edition"] = edition node["path_name"] = path - node["axis_role"] = axis_role( - node, has_children=code in has_kid, general_roots=general_roots - ) + node["axis_role"] = axis_role(node, has_children=code in has_kid, roots=roots) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index 2c93abcc..b4da3fc7 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -51,6 +51,8 @@ export interface RowsQuery { q: string; /** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */ sort?: SortOrder | null; + /** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */ + filters?: Record; } /** @@ -87,6 +89,7 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise< params.set("sort", query.sort.key); params.set("desc", query.sort.desc ? "1" : "0"); } + for (const [key, value] of Object.entries(query.filters ?? {})) params.set(key, value); return request(`/master-data/base-prices/${kind}?${params}`); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index e0995b09..8e40b782 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -26,6 +26,8 @@ export function columnTitle(column: MasterColumn): string { export function cellText(value: unknown, unit?: string | null): string { if (value === null || value === undefined) return ""; if (typeof value === "boolean") return value ? "예" : "아니오"; + // 목록 칸(갈래 키 · 딸린 품셈 표) — 괄호·따옴표 없이 쉼표로 · 빈 목록은 빈칸. + if (Array.isArray(value)) return value.map((each) => cellText(each, unit)).join(", "); if (typeof value === "number" && unit) { return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 }); } @@ -134,8 +136,26 @@ export function boldSegments(text: string): { text: string; bold: boolean }[] { .filter((part) => part.text !== ""); } +/** + * 서버가 주는 거르기 한 벌(건설품셈 「임도가 쓰는 것」 따위) — 이름·선택지·줄 수·기본값 모두 서버 것. + * 어느 줄을 거를지도 서버가 판정함 — 화면은 고른 값을 `key=value` 로 보내기만(2026-09-17 브레인). + */ +export interface TableFilter { + key: string; + label: string; + options: { value: string; label: string; rows?: number }[]; + /** 값을 안 보냈을 때 서버가 쓴 선택지 — 화면이 이것을 보여야 보이는 것과 걸린 것이 같음 */ + default: string; +} + +/** 거르기 상자에 보일 값 — 사람이 고른 것 · 안 골랐으면 서버가 이미 건 기본값. */ +export function filterValue(filter: TableFilter, picked: Record): string { + return picked[filter.key] ?? filter.default; +} + /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ export interface TableMeta { + filters?: TableFilter[]; /** 한 표가 모은 원본마다 한 줄 */ source?: TableSource[]; /** diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 203e52ab..1d36d640 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -10,6 +10,7 @@ import { createButton, createInputField, + createSelectField, el, showConfirmDialog, showToast, @@ -21,6 +22,7 @@ import { cellInfo, clampPage, columnTitle, + filterValue, latestOnly, nextSort, noticeLines, @@ -90,7 +92,12 @@ function stateText(info: CellInfo, column: MasterColumn): string { export function buildRowsView(): RowsView { let source: RowsSource | null = null; - const state = { page: 1, q: "", sort: null as SortOrder | null }; + const state = { + page: 1, + q: "", + sort: null as SortOrder | null, + filters: {} as Record, + }; let showHidden = false; let last: MasterRows | null = null; let selected: { index: number; key: string } | null = null; @@ -137,9 +144,10 @@ export function buildRowsView(): RowsView { const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); + const filterBox = el("div", { className: "z01-master__toolbar" }); const toolbar = el("div", { className: "z01-master__toolbar", - children: [search.root, hiddenLabel], + children: [search.root, filterBox, hiddenLabel], }); const pager = el("div", { className: "z01-master__pager", @@ -180,6 +188,7 @@ export function buildRowsView(): RowsView { size: PAGE_SIZE, q: state.q, sort: state.sort, + filters: state.filters, }); } catch (error) { grid.classList.remove("is-loading"); @@ -250,6 +259,28 @@ export function buildRowsView(): RowsView { const outdated = outdatedBanner(meta.source); banner.replaceChildren(...outdated.map((text) => el("p", { text: `⚠ ${text}` }))); banner.hidden = outdated.length === 0; + // 거르기 — 선택지·이름·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 고른 값만 보냄). + filterBox.replaceChildren( + ...(meta.filters ?? []).map( + (filter) => + createSelectField({ + label: filter.label, + options: filter.options.map((option) => ({ + value: option.value, + text: + option.rows == null + ? option.label + : `${option.label} (${option.rows.toLocaleString("ko-KR")})`, + })), + value: filterValue(filter, state.filters), + onChange: (value) => { + state.filters[filter.key] = value; + state.page = 1; + void load(); + }, + }).root, + ), + ); const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); @@ -429,6 +460,8 @@ export function buildRowsView(): RowsView { source = picked; state.page = 1; state.sort = null; + state.filters = {}; + filterBox.replaceChildren(); title.textContent = picked.title; titleKey.textContent = picked.key; notice.hidden = true; diff --git a/Z01_MasterData/Z01_MasterData_WorkItems.py b/Z01_MasterData/Z01_MasterData_WorkItems.py index 5eb029f5..8f05424c 100644 --- a/Z01_MasterData/Z01_MasterData_WorkItems.py +++ b/Z01_MasterData/Z01_MasterData_WorkItems.py @@ -1,6 +1,7 @@ """Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4). -줄 = **잎**(아래 마디 없고 품셈 표가 붙은 마디) — 일위대가가 붙는 단위. 갈래(`variant_keys`)는 칸으로(편 수 = `stats.units`). +줄 = **품셈 표가 붙은 마디**(`axis_policy.json` `flat_list_filter: pum_tables > 0` — 잎으로 거르면 부모에 붙은 표가 안 뜸) · + 갈래(`variant_keys`)는 칸으로(편 수 = `stats.units`). 이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침. 계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight` — 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐. @@ -78,7 +79,7 @@ def base_rows(kind: str) -> list[dict[str, Any]]: items, by, children = _tree(d) rows = [] for item in items: - if item["work_item_code"] in children or not item.get("tables"): + if not item.get("tables"): continue parent = by.get(item.get("parent_code")) or {} weight = next( @@ -144,12 +145,12 @@ def notice(kind: str) -> list[str]: def extra(kind: str) -> dict[str, Any]: - """표 수준 덧붙임 — 계산 규칙 · 잎 수 · 갈래 편 수(쪽 나누기와 무관하게 전체).""" - leaves = base_rows(kind) + """표 수준 덧붙임 — 계산 규칙 · 줄 수 · 갈래 편 수(쪽 나누기와 무관하게 전체).""" + rows = base_rows(kind) return { "rules": rules(kind), "stats": { - "leaves": len(leaves), - "units": sum(max(1, len(r["variant_keys"])) for r in leaves), + "work_items": len(rows), + "units": sum(max(1, len(r["variant_keys"])) for r in rows), }, } diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index 9ec84ab8..561f62cc 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -1366,7 +1366,7 @@ "sources": [ "work_item_master::work_items" ], - "rows_note": "잎 360 줄 · 갈래 펴면 620", + "rows_note": "표가 붙은 줄 365(잎 360 + 표 붙은 부모 5) · 갈래 펴면 627", "columns": [ { "key": "work_item_code", @@ -1429,7 +1429,7 @@ "sources": [ "work_item_master_const::work_items" ], - "rows_note": "잎 995 줄 · 갈래 키 아직 없음(편 수 = 잎 수)", + "rows_note": "표가 붙은 줄 997(잎 995 + 표 붙은 부모 2) · 갈래 키 아직 없음(편 수 = 줄 수)", "columns": [ { "key": "work_item_code", @@ -1826,7 +1826,7 @@ "key": [ "work_item_key" ], - "checked": "잎 360 줄 불변 열쇠(FW-) 겹침 0(전수 · `test_z01_work_items`). 목차 코드(FP-)는 칸으로만 — 개정 때 밀림." + "checked": "표가 붙은 줄 365 불변 열쇠(FW-) 겹침 0(전수 · `test_z01_work_items`). 목차 코드(FP-)는 칸으로만 — 개정 때 밀림." }, { "table": "work_item_const", @@ -1835,7 +1835,7 @@ "key": [ "work_item_key" ], - "checked": "잎 995 줄 불변 열쇠(CW-) 겹침 0(전수 · `test_work_item_master_const`). 목차 코드(CP-)는 칸으로만 — 개정 때 밀림." + "checked": "표가 붙은 줄 997 불변 열쇠(CW-) 겹침 0(전수 · `test_work_item_master_const`). 목차 코드(CP-)는 칸으로만 — 개정 때 밀림." } ], "null_is_real": { @@ -8752,105 +8752,5 @@ "name_ko": "공종 — 품셈 표가 붙은 줄" } }, - "unknown": [], - "work_item_axis": { - "note": "공종 축(불변 열쇠 FW·CW) 화면 이름표. 기초데이터 열넷 셈(`merged_tables`)에 섞이지 않게 여기 따로 둔다. Z01 이 이 구획을 읽어 상자 이름·열 이름을 뜨면 된다.", - "group": "work_item", - "kinds": [ - { - "key": "work_item_forest", - "name_ko": "산림 공종", - "summary": "산림사업 표준품셈 목차가 세운 공종 나무.", - "source": "resources/data_work_item_master/work_item_master_2026-01-01.json", - "key_prefix": "FW" - }, - { - "key": "work_item_const", - "name_ko": "건설 공종", - "summary": "건설공사 표준품셈 목차가 세울 공종 나무(아직 안 뽑음).", - "source": "resources/data_cost_input_value/pum_const_2026.json", - "key_prefix": "CW" - } - ], - "columns": [ - { - "key": "work_item_key", - "name_ko": "공종 열쇠", - "unit": "", - "visible": true - }, - { - "key": "path_name", - "name_ko": "전체 경로 이름", - "unit": "", - "visible": true - }, - { - "key": "name", - "name_ko": "이름", - "unit": "", - "visible": true - }, - { - "key": "toc_code", - "name_ko": "그 판 목차 코드", - "unit": "", - "visible": true - }, - { - "key": "toc_number", - "name_ko": "그 판 목차 번호", - "unit": "", - "visible": true - }, - { - "key": "toc_edition", - "name_ko": "목차 판(고시)", - "unit": "", - "visible": false - }, - { - "key": "level", - "name_ko": "단계", - "unit": "", - "visible": true - }, - { - "key": "axis_role", - "name_ko": "줄 구실", - "unit": "", - "visible": true - }, - { - "key": "pum_tables", - "name_ko": "딸린 품셈 표", - "unit": "", - "visible": true - }, - { - "key": "rule", - "name_ko": "계산 규칙", - "unit": "", - "visible": true - }, - { - "key": "step_weight", - "name_ko": "단계 비중", - "unit": "", - "visible": true - } - ], - "values": { - "axis_role": { - "work_item": "공종 — 품셈 표가 붙은 줄", - "group": "묶는 마디 — 표는 없고 아래를 고르는 자리(계산 규칙을 지닌다)", - "general_provision": "총칙 — 문서 구역이나 값표가 붙어 B09 가 읽는다", - "empty": "빈 잎 — 표도 아래도 없는 자리" - }, - "rule": { - "choose_one": "아래 하나를 고름", - "sum_steps": "아래를 단계로 다 더함" - } - } - } + "unknown": [] } diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 28e2b2cd..abc5c215 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-17T13:27:58+09:00", + "generated_at": "2026-09-17T13:54:34+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,7 +12,7 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "f7658b47d89889207d079f1050cea87f592b97d34995f69272337b064a9d2fb1", + "sha256": "75e452487595c824995695b25f9f3582b1c5ce68fe786353de4f9aef65101804", "size_bytes": 956887 }, { diff --git a/resources/data_work_item_master/_manifest_const.json b/resources/data_work_item_master/_manifest_const.json index 6940c7a5..c2576dd3 100644 --- a/resources/data_work_item_master/_manifest_const.json +++ b/resources/data_work_item_master/_manifest_const.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-17T13:27:59+09:00", + "generated_at": "2026-09-17T13:54:35+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_const", @@ -12,8 +12,8 @@ "files": [ { "file": "const_work_item_master_2026-01-01.json", - "sha256": "cbd4174707c4205f4b0ded703ed15d8edaea0c41dbf6077683897afaa2a90cd9", - "size_bytes": 3752560 + "sha256": "b0ef383aeffa442b9840836c62791a8705ad524f0b9a0a2305ff19f9efb47c38", + "size_bytes": 3752575 }, { "file": "const_form_undetermined_2026-01-01.json", diff --git a/resources/data_work_item_master/axis_policy.json b/resources/data_work_item_master/axis_policy.json index 401d5ff9..a31d3d82 100644 --- a/resources/data_work_item_master/axis_policy.json +++ b/resources/data_work_item_master/axis_policy.json @@ -69,23 +69,23 @@ "code_prefix": "CP", "key_prefix": "CW", "code_shape": "CP-<부문2>-<장2>-<절…>", - "code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 공통 1장은 적용기준, 토목 1장은 도로포장공사다. 부문 자리가 없으면 부문이 다른 공종끼리 덮는다(산림에서 목차 번호 겹침 둘이 실제로 덮고 있었다). ⇒ **총칙 뿌리도 부문 자리까지 적는다.**", + "code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 공통 1장은 적용기준, 토목 1장은 도로포장공사다. 부문 자리가 없으면 부문이 다른 공종끼리 덮는다(산림에서 목차 번호 겹침 둘이 실제로 덮고 있었다).", "general_provision_roots": [ - "CP-01-01" + "01_공통부문/제1장_적용기준.md" ], - "general_provision_basis": "CP-01-01 공통부문 제1장 적용기준 — 줄 37 · 표 46. 산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표다.", + "general_provision_basis": "공통부문 제1장 적용기준(`CP-01-01`) — 줄 37 · 표 46. 산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표다.", "general_provision_pending": [ { - "root": "CP-01-08", - "chapter": "공통부문 제8장 건설기계", + "chapter": "01_공통부문/제8장_건설기계.md", + "code": "CP-01-08", "rows": 75, "tables": 351, "why": "산림 FP-02 「소요재료 및 기계손료」와 짝으로 보이나, 기계 시공능력·손료 파라미터라 공종으로 볼 여지도 있음. ⬜ 안티그래비티 조사 대기.", "already_extracted": "C0426·C0427 은 `resources/data_machine_productivity/` 로 이미 꺼냈음" }, { - "root": "CP-05-01", - "chapter": "유지관리부문 제1장 공통", + "chapter": "05_유지관리부문/제1장_공통.md", + "code": "CP-05-01", "rows": 37, "tables": 40, "why": "이름이 「공 통」 — 기준표 장인지 공종 장인지 원문 확인 필요. ⬜ 안티그래비티 조사 대기." @@ -103,7 +103,8 @@ "general_provision": 0, "empty": 100, "note": "총칙 표시 전 셈(랩탑 메인 첫 산출). 뿌리를 붙이면 `general_provision` 으로 옮겨 간다 — 줄이 줄지는 않는다." - } + }, + "roots_are": "**장 파일 이름**(「01_공통부문/제1장_적용기준.md」). 뽑는 코드가 목차 코드(`CP-01-01`)로 바꿔 준다 — 관리자가 부문 번호를 셀 필요가 없다." } } } diff --git a/resources/data_work_item_master/const_work_item_master_2026-01-01.json b/resources/data_work_item_master/const_work_item_master_2026-01-01.json index 8317a818..570e1457 100644 --- a/resources/data_work_item_master/const_work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/const_work_item_master_2026-01-01.json @@ -4,7 +4,7 @@ "effective_date": "2026-01-01", "pum_edition": "2026-01-01", "toc_edition": "2026-01-01", - "generated_at": "2026-09-17T13:27:59+09:00", + "generated_at": "2026-09-17T13:54:35+09:00", "dataset_version": { "dataset_id": "pum_const", "effective_date": "2026-01-01", @@ -28,7 +28,9 @@ "toc_source": "2026년_건설공사_표준품셈.md 머리 「목 차」", "section_from_body": true, "variant_keys_attached": false, - "general_provision_marked": false + "general_provision_roots": [ + "CP-01-01" + ] }, "stats": { "toc_nodes": 1367, diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index 1eee9f38..5f59c510 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -4,7 +4,7 @@ "effective_date": "2026-01-01", "pum_edition": "2026-01-01", "toc_edition": "산림청고시제2025-82호", - "generated_at": "2026-09-17T13:27:58+09:00", + "generated_at": "2026-09-17T13:54:34+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", diff --git a/resources/tester/test_work_item_key_registry.py b/resources/tester/test_work_item_key_registry.py index 0cbf5181..42c85893 100644 --- a/resources/tester/test_work_item_key_registry.py +++ b/resources/tester/test_work_item_key_registry.py @@ -221,12 +221,13 @@ def test_총칙_범위는_코드가_아니라_자료가_정함() -> None: const = load_policy("const") assert const["key_prefix"] == "CW" assert const["settled"] is False - # ⚠ 건설 뿌리는 **부문 자리까지** — 부문마다 장 번호가 1부터 다시 시작한다. - assert const["general_provision_roots"] == ["CP-01-01"] - assert [p["root"] for p in const["general_provision_pending"]] == ["CP-01-08", "CP-05-01"] + # 건설 뿌리는 **장 파일 이름**으로 적는다 — 뽑는 코드가 목차 코드로 바꿔 준다(랩탑 메인 `file_place`). + # ⚠ 바뀐 코드에는 부문 자리가 들어간다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다. + assert const["general_provision_roots"] == ["01_공통부문/제1장_적용기준.md"] + assert [p["code"] for p in const["general_provision_pending"]] == ["CP-01-08", "CP-05-01"] # 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거 assert ( - axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, general_roots=("FP-09",)) + axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",)) == "general_provision" ) diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index be23448e..3b903240 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,7 +25,7 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - boldSegments, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, + boldSegments, filterValue, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; // kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인) @@ -41,6 +41,15 @@ const served = [ ]; const grouped = groupKinds(served).map((g) => [g.label, g.items.map((i) => i.kind)]); +// 거르기 — 안 골랐으면 서버가 이미 건 기본값을 보임(보이는 것 = 걸린 것) · 고르면 고른 값 +const roadFilter = { + key: "use", + label: "보기", + options: [{ value: "all", label: "전부", rows: 995 }, { value: "road", label: "임도가 쓰는 것", rows: 120 }], + default: "road", +}; +const filtered = [filterValue(roadFilter, {}), filterValue(roadFilter, { use: "all" })]; + // 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터 const sorts = [ nextSort(null, "price_krw"), @@ -170,6 +179,9 @@ writeFileSync(process.argv[2], JSON.stringify({ cellText("보통인부", null), // 소수 끝 떠돌이 자리(부동소수) — 기계 손료계수 0.0001811 이 0.00018109999999999998 로 보이던 것 cellText(0.00018109999999999998, null), + // 공종 축 목록 칸 — 괄호·따옴표 글자로 뜨던 것(2026-09-17 브레인 ④) + cellText(["연암", "보통암", "경암"], null), + cellText([], null), ], pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)], row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns), @@ -207,6 +219,7 @@ writeFileSync(process.argv[2], JSON.stringify({ bolds, sorts, grouped, + filtered, })); """ @@ -254,6 +267,8 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: "예", "보통인부", "0.0001811", + "연암, 보통암, 경암", + "", ] assert got["pages"] == [1, 140, 1] # columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림 @@ -332,3 +347,5 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: ["공종 축", ["forest"]], ["later_group", ["brand_new"]], ] + # 거르기 상자 — 첫 선택지가 아니라 서버 기본값(어긋나면 「전부」로 보이며 걸러진 목록이 뜸) + assert got["filtered"] == ["road", "all"] diff --git a/resources/tester/test_z01_work_items.py b/resources/tester/test_z01_work_items.py index b7a1698e..46fd6179 100644 --- a/resources/tester/test_z01_work_items.py +++ b/resources/tester/test_z01_work_items.py @@ -38,15 +38,15 @@ def _get(client: TestClient, kind: str, **params) -> dict: def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None: listed = client.get("/api/master-data/base-prices").json()["kinds"] forest = next(i for i in listed if i["kind"] == "work_item_forest") - assert forest["group"] == "work_item" and forest["rows"] == 360 + assert forest["group"] == "work_item" and forest["rows"] == 365 table = _get(client, "work_item_forest", size=500) - assert table["total"] == 360 and table["stats"] == {"leaves": 360, "units": 620} + assert table["total"] == 365 and table["stats"] == {"work_items": 365, "units": 627} assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"]) rows = table["rows"] - assert len({r["@id"] for r in rows}) == 360 + assert len({r["@id"] for r in rows}) == 365 assert ( - len({r["name"] for r in rows}) == 360 + len({r["name"] for r in rows}) == 365 ) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1) by_code = {r["work_item_code"]: r for r in rows} assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계" @@ -57,6 +57,7 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"] assert by_code["FP-09-05-02"]["rule"] == "sum_steps" assert by_code["FP-09-05-02"]["step_weight"] == "0.9" + # 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거. assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515) assert not any("불변 열쇠" in line for line in table["notice"]) @@ -67,7 +68,7 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non def test_건설_공종_축도_같은_길(client: TestClient) -> None: """건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one.""" table = _get(client, "work_item_const", size=500) - assert table["total"] == table["stats"]["leaves"] > 900 + assert table["total"] == table["stats"]["work_items"] > 900 rows = table["rows"] assert all(r["@id"].startswith("CW-") for r in rows) assert all(r["name"].split(" › ")[0].endswith("부문") for r in rows) @@ -103,7 +104,7 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림( assert [r["@id"] for r in table["rows"]] == ["CW-00002"] assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음 assert table["rows"][0]["name"] == "토공 › 인력" - assert table["stats"] == {"leaves": 1, "units": 2} + assert table["stats"] == {"work_items": 1, "units": 2} assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}] assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음