feat(z01): 마스터 화면에 공종 축 산림·건설 상자 — base-prices 목록·한 표가 공종 축 둘을 포함 · 거르기 인자 · WorkItems 읽기 모듈 신설
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[];
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user