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_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 174c5315..9a3d7342 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import re from datetime import date from decimal import Decimal from functools import lru_cache @@ -20,9 +21,10 @@ from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Tables as tables +from Z01_MasterData import Z01_MasterData_WorkItems as work_items -#: 기초단가 다섯 + 기초데이터 아홉(품셈 근거 8 · 돌쌓기 갈래 1) — 이름은 자료 이름 그대로 -KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC) +#: 기초단가 다섯 + 기초데이터 열 + 공종 축 둘(산림·건설) — 이름은 자료 이름 그대로 +KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC, *work_items.KINDS) SOURCES_DIR = ( tables.RESOURCES / "data_master_sources" ) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지 @@ -37,8 +39,11 @@ _RATE_EDITABLE = ( "manager_thresholds.default_estimated_amount_krw", "manager_thresholds.civil_main_work_estimated_amount_krw", ) +#: ⚠ **지금 상태를 먼저** — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」). +#: 뒤 문장은 프로젝트 복사(요구 14)가 붙으면 참이 되는 약속이라 지우지 않고 「그 길이 붙으면」으로 둠. _SCOPE_NOTICE = ( - "여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜" + "⚠ **아직 어디에도 안 쓰임** — 고친 값은 마스터에만 쌓임 · 프로젝트로 옮기는 길(프로젝트 복사)이 아직 없음." + " 그 길이 붙으면: 여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜" "(프로젝트는 만들 때 뜬 사본을 봄)" ) _RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)" @@ -276,6 +281,8 @@ def base_rows(kind: str) -> list[dict[str, Any]]: if kind == "machine": doc, name = _doc("mach_base") return machine.base_rows(name, edition_of(doc)) + if kind in work_items.KINDS: + return work_items.base_rows(kind) spec_of = base_tables.SPEC.get(kind) if spec_of: doc, name = _doc(spec_of["file"]) @@ -323,15 +330,21 @@ class SortError(ValueError): """모르는 열로 세우라 함 — 조용히 원본 차례로 돌아가지 않음(브레인 ④).""" +#: 품셈 번호 꼴(「9-3-2」·「12-17」) — 글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기 · 브레인). +_NUMBER_PATH = re.compile(r"^\d+(?:-\d+)+$") + + def _order_key(value: Any) -> tuple: - """섞인 칸도 세움 — 수 먼저, 그 뒤 글자.""" + """섞인 칸도 세움 — 수 먼저, 품셈 번호 꼴은 **마디마다 수로**, 그 뒤 글자.""" if isinstance(value, bool): - return (1, str(value)) + return (2, str(value)) if isinstance(value, (int, float)): return (0, float(value)) if isinstance(value, (dict, list)): - return (1, json.dumps(value, ensure_ascii=False, default=str)) - return (1, str(value)) + return (2, json.dumps(value, ensure_ascii=False, default=str)) + if isinstance(value, str) and _NUMBER_PATH.match(value): + return (1, tuple(int(part) for part in value.split("-"))) + return (2, str(value)) def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[str, Any]]: @@ -343,7 +356,10 @@ def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[st #: 돌쌓기 표준경사는 B06 횡단설계가 같은 값으로 기울기를 그림 — 아홉 중 **그림에도 닿는** 하나(브레인). -_SLOPE_NOTICE = "고치면 횡단 도면 모양도 함께 바뀜 — B06 횡단설계가 같은 값으로 벽 기울기를 그림" +_SLOPE_NOTICE = ( + "⚠ **B06 횡단설계가 아직 여기서 고친 값을 안 읽음**(원본 값으로 벽 기울기를 그림) — 지금은 고쳐도 횡단 도면이 안 바뀜." + " 읽게 되면: 고치면 횡단 도면 모양도 함께 바뀜" +) #: 같은 「1:0.3」 이 두 자리에 있음(코덱스 검증 2) — 값은 같으나 **축과 근거가 다름**. #: masonry_slope = 품셈 13-4-4 [주]⑪ 표준경사(직고 · 메/찰 · 성토/절토 축 · 원문표) #: masonry_class.face_slope = 교본 7-3 돌흙막이(구조물 **형식**별 기본값 · 판정이 안 되는 자리의 종전값) @@ -360,6 +376,8 @@ _EXTRA_NOTICE = { def notice(kind: str) -> list[str]: """표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③).""" + if kind in work_items.KINDS: + return work_items.notice(kind) return [_SCOPE_NOTICE] + ([_EXTRA_NOTICE[kind]] if kind in _EXTRA_NOTICE else []) @@ -367,6 +385,8 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]: """표 수준 — editable · locked · formula.""" if kind == "machine": return machine.spec(columns) + if kind in work_items.KINDS: + return work_items.spec(columns) if kind in base_tables.SPEC: spec_of = base_tables.SPEC[kind] editable = [c for c in spec_of["editable"] if c in columns] @@ -408,6 +428,8 @@ BASE_PRICE_GROUP, PUMSEM_GROUP = "base_price", "pumsem_basis" def kind_group(kind: str) -> str: + if kind in work_items.KINDS: + return work_items.GROUP return PUMSEM_GROUP if kind in base_tables.SPEC else BASE_PRICE_GROUP @@ -418,14 +440,21 @@ def kind_label(kind: str) -> str: return named.get("name_ko") or kind +def group_label(group: str) -> str: + """상자 이름 — 이름표 `base_price_groups[갈래].name_ko` 에서만(없으면 영문 그대로 · 화면은 `group_label` 을 읽음).""" + named = (tables.load_labels().get("base_price_groups") or {}).get(group) or {} + return named.get("name_ko") or group + + def kinds() -> list[dict[str, Any]]: """화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸). - 갈래 · kind · 한글 이름(이름표) · 줄 수. + 갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수. """ return [ { "group": kind_group(kind), + "group_label": group_label(kind_group(kind)), "kind": kind, "label": kind_label(kind), "rows": len(rows(kind)), @@ -439,6 +468,8 @@ def row_label(kind: str, row: dict[str, Any] | None) -> str: return "" if kind == "labor": return str(row.get("occupation_name") or "") + if kind in work_items.KINDS: + return str(row.get("name") or "") if kind == "machine": return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip() if kind == "material": @@ -493,6 +524,18 @@ def public(row: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in row.items() if k not in ("@source", "@axis")} +#: 찾기만 같게 보는 글자 — 원문이 물결표·가운뎃점을 여러 벌로 씀(∼ 26 · ~ 8 · ․ · ㆍ) · 자간 공백(「굴 삭 기」). +#: ⚠ **찾기에만** 씀 — 열쇠·칸 값은 원문 그대로(손질하면 갈래 열쇠가 갈림 · 브레인 2026-09-17). +_SEARCH_SAME = str.maketrans( + {"∼": "~", "~": "~", "〜": "~", "․": "·", "ㆍ": "·", "・": "·", "‧": "·"} +) + + +def fold(text: str) -> str: + """찾기 비교용 — 소문자 · 물결표·가운뎃점 한 벌 · 공백 없앰.""" + return "".join(str(text or "").lower().translate(_SEARCH_SAME).split()) + + def table( kind: str, page: int = 1, @@ -500,19 +543,22 @@ def table( q: str = "", sort: str = "", desc: bool = False, + filters: dict[str, str] | None = None, ) -> dict[str, Any]: all_rows = rows(kind) columns = _columns(kind, all_rows) - needle = q.strip().lower() + needle = fold(q) hits = ( [ r for r in all_rows - if needle in json.dumps(public(r), ensure_ascii=False, default=str).lower() + if needle in fold(json.dumps(public(r), ensure_ascii=False, default=str)) ] if needle else all_rows ) + if kind in work_items.KINDS: # 서버가 준 거르기(부문·줄 구실) — 고른 값만 옴 + hits = work_items.apply_filters(kind, hits, filters or {}) if sort: # ⚠ 거른 뒤에 세움 — 거꾸로 하면 차례가 깨짐(브레인 ③) if sort not in columns: raise SortError(f"없는 열로 세울 수 없음: {sort}") @@ -528,6 +574,7 @@ def table( **spec(kind, columns), "notice": notice(kind), "source": sources(kind), + **(work_items.extra(kind) if kind in work_items.KINDS else {}), } diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py index 05640fa0..a89bdf7f 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py @@ -77,7 +77,8 @@ def _formwork_reuse(doc: dict[str, Any], name: str) -> list[_ROW]: ) ) ratios = doc["reuse_ratio_pct"] - for material in ("plywood", "timber"): + # ⚠ 원문은 **합판 한 줄에 두 열**(재료별% · 노무비%)이고 각재 줄엔 비율이 없다(2026-09-18). + for material in ("plywood_material_pct", "plywood_labor_pct"): for count, percent in (ratios.get(material) or {}).items(): rows.append( _row( diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index 7179fef1..294d1337 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -2,8 +2,8 @@ GET /api/master-data/tree 갈래 → 파일 → 표 GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) - GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수) - GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 한 표(기초단가 다섯 · 품셈 기준 열) + GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함) + GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표(기초단가 다섯 · 품셈 기준 열 · 공종 축 둘) PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기 GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. @@ -13,13 +13,14 @@ from __future__ import annotations from typing import Any, Literal -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel from common_util.common_util_auth import verify_session from Z01_MasterData import Z01_MasterData_BasePrices as base_prices from Z01_MasterData import Z01_MasterData_Tables as tables +from Z01_MasterData import Z01_MasterData_WorkItems as work_items router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"]) @@ -58,14 +59,19 @@ def get_base_price_kinds() -> dict: @router.get("/base-prices/{kind}") def get_base_prices( kind: str, + request: Request, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "", sort: str = "", desc: int = 0, ) -> dict: + # 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실) + picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS} try: - return base_prices.table(_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc)) + return base_prices.table( + _kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked + ) except base_prices.SortError as error: raise HTTPException(status_code=400, detail=str(error)) from error diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index c207aa2d..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 }); } @@ -88,18 +90,22 @@ export function outdatedBanner(sources?: TableSource[]): string[] { /** 서버가 주는 표 한 벌 — 갈래·kind·한글 이름·줄 수. 화면은 제 목록을 안 가짐(늘면 저절로 섬). */ export interface ServedKind { group: string; + /** 갈래 한글 이름(이름표 `base_price_groups`) — 안 오면 group 키가 그대로 떠 빠진 것이 드러남 */ + group_label?: string; kind: string; label: string; rows: number; } +type KindBox = { group: string; label: string; items: ServedKind[] }; + /** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */ -export function groupKinds(kinds: ServedKind[]): { group: string; items: ServedKind[] }[] { - const boxes: { group: string; items: ServedKind[] }[] = []; +export function groupKinds(kinds: ServedKind[]): KindBox[] { + const boxes: KindBox[] = []; for (const kind of kinds) { const box = boxes.find((each) => each.group === kind.group); if (box) box.items.push(kind); - else boxes.push({ group: kind.group, items: [kind] }); + else boxes.push({ group: kind.group, label: kind.group_label ?? kind.group, items: [kind] }); } return boxes; } @@ -130,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..7309885c 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,30 @@ 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, + ), + ); + // 숨긴 열이 없는 표(17 중 15)에서는 눌러도 아무 일이 없어 체크를 감춤. + hiddenLabel.hidden = !meta.columns.some((column) => column.hidden); const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); @@ -429,6 +462,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_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index d245d1ee..0e958c6d 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -56,7 +56,7 @@ export function buildSidePanel( button.addEventListener("click", () => { activate(button); view.show({ - title: `${box.group} › ${served.label}`, + title: `${box.label} › ${served.label}`, key: `base-prices/${served.kind}`, load: (query) => fetchBasePrices(served.kind, query), save: (rowId, values) => saveBasePrice(served.kind, rowId, values), @@ -64,7 +64,7 @@ export function buildSidePanel( }); grid.append(button); } - return { group: box.group, grid }; + return { label: box.label, grid }; }); const overridesButton = el("button", { className: "z01-master__base z01-master__base--wide", @@ -131,7 +131,7 @@ export function buildSidePanel( } panel.append( - ...boxes.map((box) => section(box.group, box.grid)), + ...boxes.map((box) => section(box.label, box.grid)), section(L("Z01_MasterData_Tree"), tree), ); attachCollapsible(panel); diff --git a/Z01_MasterData/Z01_MasterData_WorkItems.py b/Z01_MasterData/Z01_MasterData_WorkItems.py new file mode 100644 index 00000000..23c8d85b --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_WorkItems.py @@ -0,0 +1,294 @@ +"""Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4). + +줄 = **품셈 표가 붙은 마디**(`axis_policy.json` `flat_list_filter: pum_tables > 0` — 잎으로 거르면 부모에 붙은 표가 안 뜸) · + 갈래(`variant_keys`)마다 한 줄로 폄 — 줄 수 = 편 수(`stats.units`) · 공종 수 = `stats.work_items`. +이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침. +계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight` + — 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐. +`@id` = 불변 열쇠(FW-·CW-) — 없는 줄은 목차 코드로 서고 알림에 드러냄. +⚠ 읽기만 — 고칠 칸 없음(원문 값 풀기 8-7 뒤). 원본 `work_item_code`(FP-)는 B08·B09 가 씀 — 안 건드림. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from Z01_MasterData import Z01_MasterData_Tables as tables + +GROUP = "work_item" +#: kind → 원본 품셈 dataset_id — 공종 축 파일 `dataset_version.dataset_id` 로 가림(파일 이름에 기대지 않음) +SOURCE_OF = { + "work_item_forest": "pum_forest", + "work_item_const": "pum_const", +} # 이름표 `work_item_axis` 와 같은 이름 +FOLDER = tables.RESOURCES / "data_work_item_master" +#: 불변 열쇠 칸 — ⚠ 데스크탑 서브 자료가 오면 칸 이름을 맞출 것(여기 한 곳) +KEY_FIELD = "work_item_key" +PATH_JOIN = " › " +_LOCKED = "공종 축 뼈대 — 품셈 원문으로만 바뀜(값 고치기는 원문 값 풀기 뒤)" + + +@lru_cache(maxsize=8) +def _read(path: str, _mtime_ns: int) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def doc(kind: str) -> tuple[dict[str, Any], str] | None: + """그 품셈의 공종 축 끝 판(문서 · 파일 이름) — 없으면 None(건설은 아직 없음).""" + found = [] + for path in sorted(FOLDER.glob("*work_item_master_*.json")): # 건설은 `const_` 앞꼬리 + d = _read(str(path), path.stat().st_mtime_ns) + if (d.get("dataset_version") or {}).get("dataset_id") == SOURCE_OF[kind]: + found.append((d, path.name)) + return max(found, key=lambda each: each[0].get("effective_date") or "", default=None) + + +#: 자료가 있는 품셈만 kind 로 섬 — 빈 상자는 이름만 뜨고 안 열림 · 건설은 자료가 오면 서버 재시작 뒤 저절로 +KINDS = tuple(kind for kind in SOURCE_OF if doc(kind)) + + +def _key(item: dict[str, Any]) -> str: + return item.get(KEY_FIELD) or item["work_item_code"] + + +def _tree(d: dict[str, Any]) -> tuple[list[dict], dict[str, dict], dict[str, list[dict]]]: + items = d.get("work_items") or [] + children: dict[str, list[dict]] = {} + for item in items: + if item.get("parent_code"): + children.setdefault(item["parent_code"], []).append(item) + return items, {i["work_item_code"]: i for i in items}, children + + +def _path(item: dict[str, Any] | None, by: dict[str, dict]) -> str: + names = [] + while item: + names.append(item["name"]) + item = by.get(item.get("parent_code")) + return PATH_JOIN.join(reversed(names)) + + +def _shown_path(item: dict[str, Any], by: dict[str, dict]) -> str: + """목록에 보일 경로 — 조각마다 자간 공백 다듬기(「토목부문 › 측 량」→「측량」). 열쇠와 무관.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import tidy_spacing + + return PATH_JOIN.join(tidy_spacing(part) for part in _path(item, by).split(PATH_JOIN)) + + +def base_rows(kind: str) -> list[dict[str, Any]]: + found = doc(kind) + if found is None: + return [] + d, name = found + items, by, children = _tree(d) + # 계산 규칙 이름 — 이름표 `value_overrides["work_item_master::<방식>"]`(「아래를 단계로 다 더함」) · 없으면 영문 + named = tables.load_labels().get("value_overrides") or {} + linked, roles = linked_keys(), _role_names() + rows = [] + for item in items: + if not item.get("tables"): + continue + parent = by.get(item.get("parent_code")) or {} + weight = next( + (s["weight"] for s in parent.get("steps") or [] if s["code"] == item["work_item_code"]), + None, + ) + key = _key(item) + # 갈래마다 한 줄 — 일위대가가 갈래마다 달라 집는 단위가 갈래임(교목굴취 나무높이 9 · 2026-09-17 브레인 ③). + # 열쇠 = `공종 열쇠#갈래 번호`(`FW-00105#01`) — **갈래 글자를 열쇠에 담지 않는다.** + # 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다(2026-09-17 브레인 · 장부가 번호를 준다). + for variant in item.get("variants") or [None]: + rows.append( + { + "@id": f"{key}#{variant['variant_key']}" if variant else key, + "@key": key, # 공종 열쇠 — 거르기·줄 수는 공종 단위로 + "@source": name, + "work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만 + "number": item["number"], + "name": _shown_path(item, by), # 자간 공백만 다듬음 — 원문 이름은 마스터에 + "variant": variant["name"] if variant else None, # 원문 그대로 + "variant_clean": variant["name_clean"] if variant else None, # 다듬은 것 + "pum_tables": [t["pum_table_id"] for t in item["tables"]], + "rule": (named.get(f"work_item_master::{parent.get('parent_mode')}") or {}).get( + "name_ko" + ) + or parent.get("parent_mode"), + "step_weight": weight, + # 「임도가 쓰는 것」 — 연결고리 표가 확정으로 이은 공종(판정은 그 표 · 감추지 않고 표시만) + "link": LINKED if key in linked else "", + # 줄 구실(총칙·공종) — 이름은 axis_policy.json · 표시 칸(거르기 상자로 고름) + "axis_role": roles.get(str(item.get("axis_role") or ""), ""), + "effective_date": d.get("pum_edition") or d.get("effective_date"), + } + ) + return rows + + +LINK_FOLDER = tables.RESOURCES / "data_work_item_link" +LINKED = "연결됨" + + +def linked_keys() -> frozenset[str]: + """연결고리 표(`work_item_link_*.json` 끝 판 · 코덱스)가 **확정**으로 이은 열쇠(산림·건설). + + ⚠ 목록을 코드에 안 둠 — 판정은 그 표 · 미판정은 안 이음(표의 `unconfirmed_action: do_not_link`). + """ + found = sorted(LINK_FOLDER.glob("work_item_link_*.json")) if LINK_FOLDER.is_dir() else [] + if not found: + return frozenset() + links = _read(str(found[-1]), found[-1].stat().st_mtime_ns).get("links") or [] + return frozenset( + key + for link in links + if link.get("status") == "확정" + for key in (link.get("forest_key"), link.get("const_key")) + if key + ) + + +def rules(kind: str) -> list[dict[str, Any]]: + """부모 마디 계산 규칙 — choose_one(아래 중 하나) · sum_steps(아래를 무게대로 더해야 한 단위).""" + found = doc(kind) + if found is None: + return [] + items, by, children = _tree(found[0]) + out = [] + for item in items: + mode = item.get("parent_mode") + if not mode: + continue + rule = { + "@id": _key(item), + "work_item_code": item["work_item_code"], + "name": _shown_path(item, by), + "mode": mode, + "children": [_key(c) for c in children.get(item["work_item_code"], [])], + } + if mode == "sum_steps": + rule["steps"] = [ + {"@id": _key(by[s["code"]]), "weight": s["weight"]} for s in item["steps"] + ] + rule["basis"] = item.get("steps_basis") + out.append(rule) + return out + + +def spec(columns: list[str]) -> dict[str, Any]: + return {"editable": [], "locked": dict.fromkeys(columns, _LOCKED), "formula": {}} + + +def notice(kind: str) -> list[str]: + lines = ["공종 축은 읽기만 — 고칠 칸 없음(품셈 원문 값 풀기 뒤에 열림)"] + found = doc(kind) + if found and any(KEY_FIELD not in i for i in found[0].get("work_items") or []): + lines.append( + "불변 열쇠(FW-·CW-) 아직 없음 — 목차 코드(FP-)를 열쇠로 씀 · 품셈 개정으로 번호가 밀리면 딴 줄을 가리킬 수 있음" + ) + return lines + + +#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실 · 연결고리 표). 어느 줄을 거를지는 서버 한 곳 · +#: 화면은 고른 값만 보냄 · 연결은 기본 「전부」(감추지 않음 — 「어디에 쓸지 모르니 건설 전체」 · 브레인) · +#: 줄 구실만 기본 「공종」(총칙 기본 감춤 · 서브 2ee6a7ed · 브레인). +FILTER_KEYS = ("link", "division", "axis_role") +ALL = "" +#: 값을 안 보냈을 때 거는 값 — **잣대가 정한다**(`axis_policy.json` 의 `list_default`). +#: ⚠ 코드에 박지 않는다 — 총칙 범위는 기관이 정하는 것이라 관리자가 고칠 수 있어야 한다. +#: 잣대를 못 읽을 때만 쓰는 대비값(총칙 줄은 기본 감춤 · 「전부」를 고르면 보임 · 2026-09-17 브레인). +FALLBACK_DEFAULTS = {"axis_role": "work_item"} + + +def _facets(kind: str) -> dict[str, dict[str, str]]: + found = doc(kind) + items = found[0].get("work_items") or [] if found else [] + linked = linked_keys() + return { + _key(i): { + "link": LINKED if _key(i) in linked else ALL, + "division": str(i.get("division") or ""), + "axis_role": str(i.get("axis_role") or ""), + } + for i in items + } + + +def _policy() -> dict[str, Any]: + """줄 구실 잣대 — `axis_policy.json`(데스크탑 서브) 한 곳에서.""" + path = FOLDER / "axis_policy.json" + return _read(str(path), path.stat().st_mtime_ns) if path.is_file() else {} + + +def _role_names() -> dict[str, str]: + """거르기 상자에 뜰 이름. 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름으로 적는다.""" + roles = _policy().get("roles") or {} + return { + key: str(role.get("list_label") or role.get("name_ko") or key) + for key, role in roles.items() + } + + +def _defaults() -> dict[str, str]: + """안 고른 가름에 걸 값 — 잣대의 `list_default`. 못 읽으면 대비값.""" + wanted = _policy().get("list_default") or {} + key = str(wanted.get("filter") or "") + return {key: str(wanted.get("value") or ALL)} if key else dict(FALLBACK_DEFAULTS) + + +def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """거르기 상자 — 값이 둘 이상 갈리는 가름만 · 기본은 **잣대**(`list_default`)가 정한다 — 그 값이 표에 있을 때만.""" + facets = _facets(kind) + labels = { + "link": ("연결", {LINKED: "연결된 것만"}), + "division": ("부문", {}), + "axis_role": ("줄 구실", _role_names()), + } + out = [] + for key in FILTER_KEYS: + counts: dict[str, int] = {} + for row in rows: + value = facets.get(row["@key"], {}).get(key, ALL) + counts[value] = counts.get(value, 0) + 1 + values = [v for v in counts if v != ALL] + if len(counts) < 2: # 다 같은 값이면 거를 것이 없음(연결은 「연결됨」 · 빈칸 둘로 갈림) + continue + label, names = labels[key] + default = _defaults().get(key, ALL) + out.append( + { + "key": key, + "label": label, + "options": [{"value": ALL, "label": "전부", "rows": len(rows)}] + + [{"value": v, "label": names.get(v, v), "rows": counts[v]} for v in values], + "default": default if default in values else ALL, + } + ) + return out + + +def apply_filters( + kind: str, rows: list[dict[str, Any]], picked: dict[str, str] +) -> list[dict[str, Any]]: + facets = _facets(kind) + # 안 보낸 가름은 상자 기본값으로 — 상자에 보이는 값과 걸린 값이 같게(찾기 결과가 아니라 표 전체로 정함) + defaults = {f["key"]: f["default"] for f in filters(kind, base_rows(kind))} + for key in FILTER_KEYS: + value = picked.get(key, defaults.get(key, ALL)) + if value != ALL: + rows = [r for r in rows if facets.get(r["@key"], {}).get(key) == value] + return rows + + +def extra(kind: str) -> dict[str, Any]: + """표 수준 덧붙임 — 계산 규칙 · 줄 수 · 갈래 편 수(쪽 나누기와 무관하게 전체).""" + rows = base_rows(kind) + return { + "filters": filters(kind, rows), + "rules": rules(kind), + "stats": { + "work_items": len({r["@key"] for r in rows}), + "units": len(rows), + }, + }