Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-16 11:21:22 +09:00
3 changed files with 98 additions and 4 deletions
+35 -1
View File
@@ -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),
+11 -3
View File
@@ -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}")
+52
View File
@@ -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"