diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 0b5c2d8e..c5a74fbd 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -278,6 +278,29 @@ def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]: return list(keys) +class SortError(ValueError): + """모르는 열로 세우라 함 — 조용히 원본 차례로 돌아가지 않음(브레인 ④).""" + + +def _order_key(value: Any) -> tuple: + """섞인 칸도 세움 — 수 먼저, 그 뒤 글자.""" + if isinstance(value, bool): + return (1, 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)) + + +def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[str, Any]]: + """⚠ 빈 칸은 오름·내림 **어느 쪽이든 끝** — 미공표 노임 14 · 손료계수 없는 기계 226 이 + 첫 쪽을 통째로 채우면 표를 못 씀(브레인 ②).""" + filled = [r for r in rows if r.get(column) not in (None, "")] + empty = [r for r in rows if r.get(column) in (None, "")] + return sorted(filled, key=lambda r: _order_key(r[column]), reverse=desc) + empty + + def notice(kind: str) -> list[str]: """표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③).""" return [_SCOPE_NOTICE] + ([_RATE_NOTICE] if kind == "rate" else []) @@ -349,7 +372,12 @@ def public(row: dict[str, Any]) -> dict[str, Any]: def table( - kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" + kind: str, + page: int = 1, + size: int = tables.DEFAULT_PAGE_SIZE, + q: str = "", + sort: str = "", + desc: bool = False, ) -> dict[str, Any]: all_rows = rows(kind) columns = _columns(kind, all_rows) @@ -363,10 +391,16 @@ def table( if needle else all_rows ) + if sort: # ⚠ 거른 뒤에 세움 — 거꾸로 하면 차례가 깨짐(브레인 ③) + if sort not in columns: + raise SortError(f"없는 열로 세울 수 없음: {sort}") + hits = _sorted(hits, sort, desc) size = max(1, min(size, tables.MAX_PAGE_SIZE)) start = (max(page, 1) - 1) * size return { "columns": [column_meta(kind, c) for c in columns], + # 못 고치는 칸(계산값)도 **세우기는 됨** — 화면은 이 목록으로 눌릴 제목을 가림 + "sortable": list(columns), "rows": [public(r) for r in hits[start : start + size]], "total": len(hits), **spec(kind, columns), diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index 31b9e53c..c9296fe9 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -2,7 +2,7 @@ GET /api/master-data/tree 갈래 → 파일 → 표 GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) - GET /api/master-data/base-prices/{kind}?page=&size=&q= 기초단가 한 표(labor|machine|material|oil|rate) + GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 기초단가 한 표(labor|machine|material|oil|rate) PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기 GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. @@ -50,9 +50,17 @@ def _kind(kind: str) -> str: @router.get("/base-prices/{kind}") def get_base_prices( - kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" + kind: str, + page: int = 1, + size: int = tables.DEFAULT_PAGE_SIZE, + q: str = "", + sort: str = "", + desc: int = 0, ) -> dict: - return base_prices.table(_kind(kind), page=page, size=size, q=q) + try: + return base_prices.table(_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc)) + except base_prices.SortError as error: + raise HTTPException(status_code=400, detail=str(error)) from error @router.put("/base-prices/{kind}/{row_id:path}") diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py index 22c58a84..e8324e3e 100644 --- a/resources/tester/test_z01_base_prices.py +++ b/resources/tester/test_z01_base_prices.py @@ -496,3 +496,55 @@ def test_열_이름은_이름표에서만_옴(client: TestClient) -> None: assert not [c for c in _get(client, kind, size=1)["columns"] if c["label"] == c["key"]], ( kind ) + + +def _sorted_rows(client: TestClient, kind: str, column: str, desc: int = 0, **params) -> list: + table = _get(client, kind, sort=column, desc=desc, size=500, **params) + return [r.get(column) for r in table["rows"]] + + +def test_차례_세우기는_서버가_전체_줄로(client: TestClient) -> None: + """브레인 — 자재 140 쪽이라 화면이 받은 50 줄만 세우면 1 쪽 안에서만 맞는 가짜 차례.""" + table = _get(client, "material", size=1) + assert set(table["sortable"]) == {c["key"] for c in table["columns"]} + first = _get(client, "material", sort="price_krw", size=1) + last = _get(client, "material", sort="price_krw", desc=1, size=1) + assert first["total"] == last["total"] == 6999 + assert first["rows"][0]["price_krw"] < last["rows"][0]["price_krw"] + page2 = _get(client, "material", sort="price_krw", size=50, page=2)["rows"] + assert first["rows"][0]["price_krw"] <= page2[0]["price_krw"] # 쪽을 넘어도 이어짐 + values = _sorted_rows(client, "material", "price_krw") + assert values == sorted(values) + + +def test_거른_뒤에_세움_모르는_열은_거절(client: TestClient) -> None: + hits = _get(client, "material", q="육각볼트", sort="price_krw", desc=1, size=500) + values = [r["price_krw"] for r in hits["rows"]] + assert values == sorted(values, reverse=True) and hits["total"] == len(values) + assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in hits["rows"]) + bad = client.get("/api/master-data/base-prices/material", params={"sort": "없는열"}) + assert bad.status_code == 400 and "없는열" in bad.text # 조용히 원본 차례로 안 돌아감 + + +def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None: + """미공표 노임 14 · 손료계수 없는 기계 226 이 첫 쪽을 채우면 표를 못 씀.""" + for desc in (0, 1): + wages = _sorted_rows(client, "labor", "daily_wage_krw", desc) + filled = [v for v in wages if v is not None] + assert wages[: len(filled)] == sorted(filled, reverse=bool(desc)) + assert wages[len(filled) :] == [None] * (len(wages) - len(filled)) and len(filled) < len( + wages + ) + hourly = _sorted_rows(client, "machine", "hourly_total_krw", 1) + assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐 + + +def test_덮개_얹은_값으로_세움(client: TestClient) -> None: + _put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000}) + top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0] + assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000 + + +def test_sort_가_없으면_원본_차례(client: TestClient) -> None: + assert _get(client, "rate", size=1)["rows"][0]["@id"] == "rate_sanjae" + assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007"