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:
@@ -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["고르기"]:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user