feat(z01): 마스터 화면에 공종 축 산림·건설 상자 — base-prices 목록·한 표가 공종 축 둘을 포함 · 거르기 인자 · WorkItems 읽기 모듈 신설

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 19:00:53 +09:00
co-authored by Claude Fable 5
parent 459033786b
commit e6bc8f8deb
8 changed files with 434 additions and 24 deletions
@@ -51,6 +51,8 @@ export interface RowsQuery {
q: string;
/** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */
sort?: SortOrder | null;
/** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */
filters?: Record<string, string>;
}
/**
@@ -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<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
}
+58 -11
View File
@@ -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 {}),
}
@@ -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(
+10 -4
View File
@@ -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
+27 -3
View File
@@ -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, string>): string {
return picked[filter.key] ?? filter.default;
}
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
export interface TableMeta {
filters?: TableFilter[];
/** 한 표가 모은 원본마다 한 줄 */
source?: TableSource[];
/**
+37 -2
View File
@@ -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<string, string>,
};
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;
+3 -3
View File
@@ -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);
+294
View File
@@ -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),
},
}