diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 9968fd86..da8c108f 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -308,7 +308,8 @@ def save(batch: list[dict]) -> list[dict]: raise StoreError(422, {"errors": new}) out = [] for name in dict.fromkeys(names): - text = dump(after[name]) + old_text = (FOLDER / name).read_bytes().decode("utf-8") + text = keep_shape(old_text, before[name], after[name]) or dump(after[name]) tmp = FOLDER / f".{name}.tmp" tmp.write_text(text, encoding="utf-8", newline="\n") os.replace(tmp, FOLDER / name) @@ -323,20 +324,72 @@ def _atom(v) -> str: return json.dumps(v, ensure_ascii=False) -def _flat(v) -> str: +def _flat(v, tight: bool = False) -> str: if isinstance(v, dict): - return ( - "{ " + ", ".join(f"{_atom(k)}: {_flat(x)}" for k, x in v.items()) + " }" if v else "{}" - ) + if not v: + return "{}" + body = ", ".join(f"{_atom(k)}: {_flat(x, tight)}" for k, x in v.items()) + return "{" + body + "}" if tight else "{ " + body + " }" if isinstance(v, list): - return "[" + ", ".join(_flat(x) for x in v) + "]" + return "[" + ", ".join(_flat(x, tight) for x in v) + "]" return _atom(v) -def dump(v, indent: int = 0, width: int = 160) -> str: - """UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로.""" +def _skip(text: str, pos: int) -> int: + while text[pos] in " \t\r\n": + pos += 1 + return pos + + +def keep_shape(old_text: str, before: dict, after: dict) -> str | None: + """고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈). + 머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump).""" + key = items_key(after) + head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731 + old_items, new_items = before.get(key) or [], after.get(key) or [] + if head(before) != head(after) or not old_items or not new_items: + return None + for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀 + spans, pos, dec = [], m.end(), json.JSONDecoder() + try: + while old_text[pos := _skip(old_text, pos)] != "]": + _, end = dec.raw_decode(old_text, pos) + spans.append((pos, end)) + pos = _skip(old_text, end) + pos += old_text[pos] == "," + except (IndexError, ValueError): + return None + if len(spans) == len(old_items): + break + else: + return None + nl = "\r\n" if "\r\n" in old_text else "\n" + lead = old_text[m.end() : spans[0][0]] + indent = len(lead) - len(lead.rstrip(" ")) + olds = {str(r.get("열쇠")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)} + shape, parts = old_text[spans[0][0] : spans[0][1]], [] + for row in new_items: + was, text = olds.get(str(row.get("열쇠")), (None, None)) + shape = text or shape + if was == row: + parts.append(text) + elif "\n" not in shape: + parts.append(_flat(row, tight=shape.startswith('{"'))) + 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] :] + same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after + return out if same else None + + +def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str: + """UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침).""" flat = _flat(v) - if indent and len(flat) + indent <= width or not isinstance(v, (dict, list)) or not v: + if ( + not isinstance(v, (dict, list)) + or not v + or (not expand and indent and len(flat) + indent <= width) + ): return flat + ("" if indent else "\n") pad = " " * (indent + 2) if isinstance(v, dict): diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 4f559904..58087282 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; export interface LogicInput { 이름: string; 단위?: string; - 고르기?: string[]; + 고르기?: (string | number)[]; 범위?: [number, number]; } export interface HoLine { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 56bcb9b8..23138cdd 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -34,7 +34,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { let control: HTMLInputElement | HTMLSelectElement; if (spec.고르기?.length) { control = el("select", { className: "m01-logic__input" }); - for (const option of ["", ...spec.고르기]) { + for (const option of ["", ...spec.고르기.map(String)]) { control.append(el("option", { text: option, attrs: { value: option } })); } } else { @@ -59,7 +59,10 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { for (const spec of ctx.row.입력 ?? []) { const raw = (ctx.values[spec.이름] ?? "").trim(); if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤 - inputs[spec.이름] = spec.고르기?.length || Number.isNaN(Number(raw)) ? raw : Number(raw); + // 고르기는 원래 값(수면 수) 그대로 — 글 "35" 로 보내면 「고르기 밖」 + const option = spec.고르기?.find((o) => String(o) === raw); + inputs[spec.이름] = + option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); } const draft = ctx.dirty() || ctx.savedKey === null; try { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts index 38fd879f..d3df84d7 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts @@ -228,7 +228,9 @@ function inputsSection( .split(",") .map((x) => x.trim()) .filter(Boolean); - if (items.length) spec.고르기 = items; + // 모두 수면 수로 — 글 "35" 로 적으면 표의 35 와 안 맞음 + const numbers = items.every((x) => !Number.isNaN(Number(x))); + if (items.length) spec.고르기 = numbers ? items.map(Number) : items; else delete spec.고르기; }), ), diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index d87ab5f9..85967f56 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -382,6 +382,14 @@ def _inputs(row: dict, given: dict) -> dict: if name not in given: raise FormulaError(f"「{row['열쇠']}」 입력 「{name}」 없음") value = given[name] + if isinstance(value, str): # 화면이 글로 보낸 수 — 고르기 원래 값 · 아니면 수로 + if "고르기" in spec: + value = next((o for o in spec["고르기"] if str(o) == value.strip()), value) + else: + try: + value = Decimal(value.strip()) + except ArithmeticError: + pass if isinstance(value, (int, float)): value = Decimal(str(value)) if "고르기" in spec and value not in spec["고르기"]: diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index dc7621d6..be6b494e 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -23,6 +23,7 @@ LOGIC_BOOK, LOGIC_KEY = ( "건설품셈", "3-3-1 암발파(미진동굴착 TYPE-Ⅰ)", ) # 화약취급공 1016 · 보통인부 1002 +LOGIC_INPUTS = {"지역": "전국평균", "보정작업": "해당 없음"} @pytest.fixture @@ -44,7 +45,8 @@ def _get(client: TestClient, url: str, **params) -> dict: def _calc(client: TestClient, **inputs) -> dict: res = client.post( - "/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": inputs} + "/api/m01/calc", + json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": {**LOGIC_INPUTS, **inputs}}, ) assert res.status_code == 200, res.text return res.json() @@ -175,12 +177,19 @@ def test_단가_자동_요소_찾기_저장전_시험계산(client: TestClient) before = _calc(client)["sums"]["계"] half = {**one["logic"], "호표": one["logic"]["호표"][:1]} # 보통인부 줄 뺌 res = client.post( - "/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": {}, "row": half} + "/api/m01/calc", + json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": LOGIC_INPUTS, "row": half}, ) lines = res.json()["lines"] assert [x["이름"] for x in lines] == ["화약취급공"] and res.json()["sums"]["계"] < before new = {**half, "열쇠": "새 로직"} - body = {"book": LOGIC_BOOK, "key": "새 로직", "inputs": {}, "row": new, "file": one["file"]} + body = { + "book": LOGIC_BOOK, + "key": "새 로직", + "inputs": LOGIC_INPUTS, + "row": new, + "file": one["file"], + } assert client.post("/api/m01/calc", json=body).json()["ok"] is True assert (store.FOLDER / one["file"]).read_bytes() == raw # 시험 계산은 안 씀 @@ -193,6 +202,41 @@ def test_쓰기_모양은_읽은_값과_같음() -> None: assert "\r" not in text and text.endswith("}\n") +def test_시험계산_글로_온_수도_받음(client: TestClient) -> None: + def calc(**inputs): + body = {"book": "산림품셈", "key": "13-4-1 메쌓기", "inputs": inputs} + return client.post("/api/m01/calc", json=body).json() + + base = {"돌": "견치돌", "쌓기": "골쌓기", "높이": 1, "초과증가율": 80} + assert calc(뒷길이=35, **base)["sums"]["계"] == pytest.approx(203281.2) + as_text = calc(**{**base, "뒷길이": "35", "높이": "1"}) + assert as_text["ok"] is True and as_text["sums"]["계"] == pytest.approx(203281.2) + assert "고르기 밖" in calc(뒷길이="36", **base)["reason"] + + +def test_저장은_고친_줄만_바꿈(client: TestClient) -> None: + old = (store.FOLDER / LABOR).read_text(encoding="utf-8").split("\n") + row, version = _labor_row(client, "1016") + change = {"op": "edit", "key": "1016", "row": {**row, "값": row["값"] + 1}} + body = {"files": [{"file": LABOR, "version": version, "changes": [change]}]} + assert client.post("/api/m01/save", json=body).status_code == 200 + new = (store.FOLDER / LABOR).read_text(encoding="utf-8").split("\n") + assert len(new) == len(old) and sum(a != b for a, b in zip(old, new)) == 1 + one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY) + path = store.FOLDER / one["file"] + old = path.read_text(encoding="utf-8") + edited = {**one["logic"], "비고": "고침"} + change = {"op": "edit", "key": LOGIC_KEY, "row": edited} + body = {"files": [{"file": one["file"], "version": one["version"], "changes": [change]}]} + assert client.post("/api/m01/save", json=body).status_code == 200 + new = path.read_text(encoding="utf-8") + at = old.index(f'"열쇠": "{LOGIC_KEY}"') + head = old[: old.rfind("\n", 0, at)] # 고친 요소 앞은 글자 그대로 + assert new.startswith(head) and "\r" not in new + tail = old[old.index('"열쇠"', at + 1) :] # 다음 요소부터도 그대로 + assert new.endswith(tail) + + def test_main_은_시스템관리자만() -> None: main = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8") assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main