From 58c40b7c50e61ffa3595f1bef2750f642cfc6f36 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 11:18:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=20=EC=A4=84=20=EC=84=B8=EC=9A=B0=EA=B8=B0?= =?UTF-8?q?=20=E2=80=94=20sort=C2=B7desc=20=EB=A5=BC=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EC=A0=84=EC=B2=B4=20=EC=A4=84=EB=A1=9C(=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20140=20=EC=AA=BD=EC=9D=B4=EB=9D=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=B4=20=EB=B0=9B=EC=9D=80=2050=20=EC=A4=84?= =?UTF-8?q?=EB=A7=8C=20=EC=84=B8=EC=9A=B0=EB=A9=B4=20=EA=B0=80=EC=A7=9C=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80)=20=C2=B7=20=ED=91=9C=20=EC=88=98=EC=A4=80?= =?UTF-8?q?=20sortable=20=EB=A1=9C=20=EB=88=8C=EB=A6=B4=20=EC=97=B4?= =?UTF-8?q?=EC=9D=84=20=EC=95=8C=EB=A6=BC(=EA=B3=84=EC=82=B0=20=EC=97=B4?= =?UTF-8?q?=EB=8F=84=20=EC=84=B8=EC=9B=80)=20=C2=B7=20=EB=8D=AE=EA=B0=9C?= =?UTF-8?q?=20=EC=96=B9=EC=9D=80=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EB=A5=B8=20=EB=92=A4=EC=97=90=20=EC=84=B8=EC=9B=80=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EC=B9=B8=EC=9D=80=20=EC=96=B4=EB=8A=90=20?= =?UTF-8?q?=EC=AA=BD=EC=9D=B4=EB=93=A0=20=EB=81=9D=20=C2=B7=20=EB=AA=A8?= =?UTF-8?q?=EB=A5=B4=EB=8A=94=20=EC=97=B4=EC=9D=80=20400(=EC=A1=B0?= =?UTF-8?q?=EC=9A=A9=ED=9E=88=20=EC=9B=90=EB=B3=B8=20=EC=B0=A8=EB=A1=80?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=20=EB=8F=8C=EC=95=84=EA=B0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 36 +++++++++++++- Z01_MasterData/Z01_MasterData_Router.py | 14 ++++-- resources/tester/test_z01_base_prices.py | 52 +++++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) 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"