From d1e2132109fadcb959f53f13ee8f79ac8672fad4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 02:16:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(M01):=20=EC=8B=9C=EC=A4=91=EB=AC=BC?= =?UTF-8?q?=EA=B0=80=20=EC=A4=84=20=EC=A0=80=EC=9E=A5=20=EB=95=8C=20?= =?UTF-8?q?=EC=95=88=EC=AA=BD=20=EB=AC=B6=EC=9D=8C=20=EB=9D=84=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EC=95=88=20=EB=B0=94=EB=80=8C=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EA=B1=B0=EB=A6=84(sub)=EA=B3=BC=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=9D=B4=EB=A6=84(refs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장기: 바깥 묶음과 안쪽 묶음의 띄어쓰기를 따로 옛 글에서 읽어 그대로 씀 - /rows: sub(조사·세부분류) 거름 · 참조 칸 키의 이름 refs Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- M01_MasterData/M01_MasterData_Router.py | 6 ++- M01_MasterData/M01_MasterData_Store.py | 53 ++++++++++++++++++++++--- resources/tester/test_m01_api.py | 28 +++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index f9ff0e84..2e94c4b5 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -69,8 +69,10 @@ def get_files(group: str) -> dict: @router.get("/rows") -def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False) -> dict: - return _call(store.rows, file, page, size, q, unlinked) +def get_rows( + file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False, sub: str = "" +) -> dict: + return _call(store.rows, file, page, size, q, unlinked, sub) @router.get("/tables") diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index ee5e1a5b..79aa3ed5 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -98,19 +98,57 @@ def files_of(group: str) -> list[dict]: return sorted(out, key=lambda f: (f["order"], f["file"])) -def rows(file: str, page: int, size: int, q: str, unlinked: bool = False) -> dict: +_REF_KEY = re.compile(r"^[A-Z]{2}\d{6}$") +_REF_FILE = { + "LB": "인력.json", + "MT": "재료_시중물가.json", + "MN": "재료_나라장터자재.json", + "MP": "재료_품셈재료.json", + "EQ": "기계.json", +} + + +def _ref_keys(row: dict) -> list[str]: + """줄이 가리키는 키 — 준용 · 연결 · 조달 출처(「나라장터:<키>」).""" + put = ( + row.get("준용"), + row.get("연결"), + (row.get("값") or {}).get("조달", {}).get("출처") + if isinstance(row.get("값"), dict) + else None, + ) + out = [v.removeprefix("나라장터:") for v in put if isinstance(v, str)] + return [k for k in out if _REF_KEY.match(k)] + + +def refs_of(rows_: list[dict]) -> dict[str, str]: + """참조 칸에 키 옆에 보일 이름 — {키: 「이름 규격」}.""" + want = {k for r in rows_ for k in _ref_keys(r)} + out: dict[str, str] = {} + for file in {_REF_FILE[k[:2]] for k in want if k[:2] in _REF_FILE}: + for r in read(file)[0].get("줄") or []: + if r.get("키") in want: + out[r["키"]] = f"{r.get('이름') or ''} {r.get('규격') or ''}".strip() + return out + + +def rows(file: str, page: int, size: int, q: str, unlinked: bool = False, sub: str = "") -> dict: data, version = read(file) hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)] + if sub: # 인력 조사 · 기계 세부분류 + hits = [r for r in hits if sub in (r.get("조사"), r.get("세부분류"))] if unlinked: # 품셈재료 — 아직 못 이은 줄 hits = [r for r in hits if not r.get("연결")] page, size = max(page, 1), min(max(size, 1), 500) + shown = hits[(page - 1) * size : page * size] return { "file": file, "version": version, "total": len(hits), "page": page, "size": size, - "rows": hits[(page - 1) * size : page * size], + "rows": shown, + "refs": refs_of(shown), } @@ -393,14 +431,16 @@ def _atom(v) -> str: return json.dumps(v, ensure_ascii=False) -def _flat(v, tight: bool = False) -> str: +def _flat(v, tight: bool = False, inner: bool | None = None) -> str: + """한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음).""" + inner = tight if inner is None else inner if isinstance(v, dict): if not v: return "{}" - body = ", ".join(f"{_atom(k)}: {_flat(x, tight)}" for k, x in v.items()) + body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items()) return "{" + body + "}" if tight else "{ " + body + " }" if isinstance(v, list): - return "[" + ", ".join(_flat(x, tight) for x in v) + "]" + return "[" + ", ".join(_flat(x, inner) for x in v) + "]" return _atom(v) @@ -443,7 +483,8 @@ def keep_shape(old_text: str, before: dict, after: dict) -> str | None: if was == row: parts.append(text) elif "\n" not in shape: - parts.append(_flat(row, tight=shape.startswith('{"'))) + tight = shape.startswith('{"') + parts.append(_flat(row, tight, tight or ': {"' in shape)) else: parts.append(dump(row, indent, expand=True).replace("\n", nl)) out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :] diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 21e13fe2..797f9a8c 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -304,3 +304,31 @@ def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClien one = _get(client, "/api/m01/logic", key=key) power = one["prices"][key_of("MP:전력")] assert power["값"] > 0 and power["출처"].startswith(key_of("MT:M0005250064") + ".") + + +def test_하위_거름과_참조_이름(client: TestClient) -> None: + every = _get(client, "/api/m01/rows", file=LABOR, size=500) + survey = _get(client, "/api/m01/rows", file=LABOR, size=500, sub="측량") + assert 0 < survey["total"] < every["total"] and {r["조사"] for r in survey["rows"]} == {"측량"} + machine = _get(client, "/api/m01/rows", file="기계.json", size=500, sub="산림품셈") + assert 0 < machine["total"] < 100 and {r["세부분류"] for r in machine["rows"]} == {"산림품셈"} + linked = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500) + keyed = [r for r in linked["rows"] if isinstance(r["연결"], str)] + assert keyed and all(linked["refs"][r["연결"]] for r in keyed) + + +def test_시중물가_저장은_띄어쓰기를_안_바꿈(client: TestClient) -> None: + market = "재료_시중물가.json" + old = (store.FOLDER / market).read_text(encoding="utf-8").split("\n") + rows = _get(client, "/api/m01/rows", file=market, size=1) + row = rows["rows"][0] + row["값"]["시중"]["가격정보"] = 1 + change = {"op": "edit", "key": row["키"], "row": row} + body = {"files": [{"file": market, "version": rows["version"], "changes": [change]}]} + assert client.post("/api/m01/save", json=body).status_code == 200 + new = (store.FOLDER / market).read_text(encoding="utf-8").split("\n") + diff = [(a, b) for a, b in zip(old, new) if a != b] + assert len(new) == len(old) and len(diff) == 1 + assert diff[0][1].replace('"가격정보": 1,', '"가격정보": X,').count("{ ") == diff[0][0].count( + "{ " + )