fix(M01): 시험 계산 수 고르기 글로 보냄 · 저장이 파일 글 모양을 지킴

- 화면 [계산] — 고르기 값은 원래 값(수면 수) 그대로 보냄 · 고르기 칸 고칠 때 모두 수면 수로 저장
- 엔진 입력 — 글로 온 수를 고르기 원래 값·Decimal 로 받음
- 저장 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소 한 줄 · 들여쓰기 · 줄바꿈) · 고친 요소만 새로 씀
- 시험 — 13-4-1 글 입력 계산 · 저장 차이가 고친 줄만 · 3-3-1 시험에 지역·보정작업 입력

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-19 19:41:59 +09:00
co-authored by Claude Opus 5
parent 280d51879f
commit 43d72588f6
6 changed files with 126 additions and 16 deletions
+62 -9
View File
@@ -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):
@@ -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 {
@@ -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 {
@@ -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.고르기;
}),
),
@@ -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["고르기"]:
+47 -3
View File
@@ -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