feat(z01): 공종 축 거르기를 서버가 냄 — 서브 화면 상자(b890fba0) 짝 · 건설 부문 다섯 · 줄 구실(이름은 axis_policy.json) · 값이 둘 이상 갈릴 때만 · 기본 「전부」 · 고른 key=value 만 받음 · 「임도가 쓰는 것」은 잣대가 없어 안 냄(브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -511,6 +511,7 @@ 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)
|
||||
@@ -524,6 +525,8 @@ def table(
|
||||
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}")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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}?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
|
||||
|
||||
|
||||
@@ -144,10 +144,68 @@ def notice(kind: str) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실). 어느 줄을 거를지는 서버 한 곳 · 화면은 고른 값만 보냄.
|
||||
#: ⚠ 「임도가 쓰는 것」 같은 쓰임 가름은 잣대가 아직 없어 안 냄(지어내지 않음).
|
||||
FILTER_KEYS = ("division", "axis_role")
|
||||
ALL = ""
|
||||
|
||||
|
||||
def _facets(kind: str) -> dict[str, dict[str, str]]:
|
||||
found = doc(kind)
|
||||
items = found[0].get("work_items") or [] if found else []
|
||||
return {_key(i): {key: str(i.get(key) or "") for key in FILTER_KEYS} for i in items}
|
||||
|
||||
|
||||
def _role_names() -> dict[str, str]:
|
||||
"""줄 구실 이름 — `axis_policy.json`(데스크탑 서브 잣대) 한 곳에서."""
|
||||
path = FOLDER / "axis_policy.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
roles = _read(str(path), path.stat().st_mtime_ns).get("roles") or {}
|
||||
return {key: str(role.get("name_ko") or key) for key, role in roles.items()}
|
||||
|
||||
|
||||
def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""거르기 상자 — 값이 둘 이상 갈리는 가름만 · 기본은 「전부」(안 거름)."""
|
||||
facets = _facets(kind)
|
||||
labels = {"division": ("부문", {}), "axis_role": ("줄 구실", _role_names())}
|
||||
out = []
|
||||
for key in FILTER_KEYS:
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
value = facets.get(row["@id"], {}).get(key, ALL)
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
values = [v for v in counts if v != ALL]
|
||||
if len(values) < 2:
|
||||
continue
|
||||
label, names = labels[key]
|
||||
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": ALL,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def apply_filters(
|
||||
kind: str, rows: list[dict[str, Any]], picked: dict[str, str]
|
||||
) -> list[dict[str, Any]]:
|
||||
facets = _facets(kind)
|
||||
for key, value in picked.items():
|
||||
if key in FILTER_KEYS and value != ALL:
|
||||
rows = [r for r in rows if facets.get(r["@id"], {}).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(rows),
|
||||
|
||||
@@ -113,3 +113,39 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
|
||||
json={"values": {"name": "바꿈"}},
|
||||
)
|
||||
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
|
||||
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
|
||||
건설 부문 다섯 · 줄 구실(이름은 axis_policy.json). ⚠ 「임도가 쓰는 것」은 잣대가 없어 안 냄."""
|
||||
const = _get(client, "work_item_const", size=1)
|
||||
by_key = {f["key"]: f for f in const["filters"]}
|
||||
assert set(by_key) == {"division", "axis_role"}
|
||||
division = by_key["division"]
|
||||
assert division["default"] == ""
|
||||
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
|
||||
assert [o["value"] for o in division["options"][1:]] == [
|
||||
"공통부문",
|
||||
"토목부문",
|
||||
"건축부문",
|
||||
"기계설비부문",
|
||||
"유지관리부문",
|
||||
]
|
||||
assert sum(o["rows"] for o in division["options"][1:]) == const["total"]
|
||||
assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[
|
||||
"general_provision"
|
||||
] == "총칙"
|
||||
|
||||
civil = _get(client, "work_item_const", size=500, division="토목부문")
|
||||
assert civil["total"] == next(
|
||||
o["rows"] for o in division["options"] if o["value"] == "토목부문"
|
||||
)
|
||||
assert all(r["name"].startswith("토목부문 › ") for r in civil["rows"])
|
||||
rules = _get(
|
||||
client, "work_item_const", size=500, division="공통부문", axis_role="general_provision"
|
||||
)
|
||||
assert rules["total"] > 0
|
||||
assert all(r["name"].startswith("공통부문 › 적용기준") for r in rules["rows"])
|
||||
|
||||
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == ["axis_role"]
|
||||
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름
|
||||
|
||||
Reference in New Issue
Block a user