feat(792-1): 단가산출 식 칸 TS 캐시 모델 · 지킴 시험 · 같은 내용 지문에 식 칸

- 새 logic/M02_Sangeun_Formula_Model.ts — setFormula · addVar · editVar · dropVar(서버 clean 이 받는 꼴)
- Const_Model restoreM01 · coverKinds 에 식덮개(서버 restore · kinds 거울)
- M02_Library_Same SK 지문에 상수 · 표값 · 식 덮개 · 변수줄(식만 다른 SK 를 같은 것 409 로 막던 흠 · 빈 칸 = 옛 지문 그대로)
- Formula 검사 길 0 나눔 알림이 못 읽는 식에서 멈추던 흠
- 새 test_792_1_sangeun_formula.py 13 — 할증율 변수 줄 51,501 · 합판거푸집 공구손료 식 51,802 · 수량식 · 이름 꼴 왕복 · 검사 까닭 · 상한 · 순환 · 옛 SK · 저장 400 · B09 굳힘 · TS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:29:40 +09:00
co-authored by Claude Opus 5.5
parent 61ebb81971
commit bb39bacd26
5 changed files with 453 additions and 4 deletions
+2
View File
@@ -131,6 +131,8 @@ class Same:
"덮개": norm(_bare(doc.get("덮개"))),
"더한줄": _sorted([self._line(x, trail, True) for x in doc.get("더한줄") or []]),
"뺀줄": sorted(map(str, doc.get("뺀줄") or [])),
# 값 바꾸는 새 칸(790-2 · 792-1) — 없으면 빠짐 = 옛 지문 그대로
**{k: norm(doc.get(k)) for k in ("상수덮개", "표값덮개", "식덮개", "변수줄")},
}
return {k: v for k, v in body.items() if v not in EMPTY}
+5 -1
View File
@@ -603,8 +603,12 @@ def _risks(text: str, base: dict, doc: dict) -> list[str]:
"""0 나눔 위험 — 나누는 쪽 이름이 입력값 0 · 고르기에 0."""
mf, out = _eng(), []
given = doc.get("입력값") or {}
try:
tree = mf.parse(text)
except (mf.FormulaError, RecursionError):
return out
picks = {str(x.get("이름")): x.get("고르기") or [] for x in base.get("입력") or []}
for node in mf.walk(mf.parse(text)):
for node in mf.walk(tree):
if node[0] == "bin" and node[1] == "/":
for n in (x[1] for x in mf.walk(node[3]) if x[0] == "name"):
zero = str(given.get(n)) in ("0", "0.0") or any(
@@ -31,7 +31,7 @@ export interface ConstDoc {
[k: string]: unknown;
}
export type CoverKind = "품" | "단가" | "상수" | "표값" | "뺌";
export type CoverKind = "품" | "단가" | "상수" | "표값" | "식" | "뺌";
const ADD = /^add:#(\d+)(\/.*)?$/;
const PARTS = ["labor", "material", "expense"];
@@ -120,6 +120,7 @@ export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
let out: ConstDoc = { ...doc };
delete out.상수덮개;
delete out.표값덮개;
delete out.식덮개; // 식 칸(792-1)
delete out.까닭;
const why = { ...(doc.까닭 ?? {}) };
if (key == null) {
@@ -149,8 +150,9 @@ export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
} else {
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
for (const name of ["상수덮개", "표값덮개"] as const) {
const got = Object.fromEntries(Object.entries(doc[name] ?? {}).filter(([s]) => s !== key));
for (const name of ["상수덮개", "표값덮개", "식덮개"] as const) {
const all = (doc[name] ?? {}) as Record<string, unknown>;
const got = Object.fromEntries(Object.entries(all).filter(([s]) => s !== key));
out = put(out, name, got);
}
}
@@ -187,6 +189,7 @@ export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] {
if (PARTS.some((k) => k in cover)) out.push("단가");
if (doc.상수덮개?.[spot]) out.push("상수");
if (doc.표값덮개?.[spot]) out.push("표값");
if ((doc.식덮개 as Record<string, string> | undefined)?.[spot]) out.push("식");
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
return out;
}
@@ -0,0 +1,116 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts
* 단가산출(SK) 사용자 식 칸 캐시 고치기(PLAN 792-1 · 요구서_652 · _분석_650-H3 E4) —
* 서버 `M02_MasterTemplete/M02_Sangeun_Formula.py`(꼴 검사 · 번역 · 셈)와 같은 꼴 · 서버 짝 함수 없음
* · 시험 `resources/tester/test_792_1_sangeun_formula.py`(여기 결과를 서버 `clean` 이 받음).
* 변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄 · 자리 = 그 뒤 중간 `mid:이름`("" = 맨 위)
* 식덮개 {자리: 식} 중간 `mid:이름` · 호표 · 덧줄 cid
* 더한줄[k].수량식 율 줄 · 로직 줄 빼고
* 식 글 = 검사 길(`formula-check`) 답 `식`(엔진) 을 넣음 · 이름 꼴 글을 넣어도 서버가 번역.
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { ConstDoc } from "./M02_Sangeun_Const_Model";
export interface VarLine {
이름: string;
식: string;
표시?: number;
자리: string;
}
export interface FormulaDoc extends ConstDoc {
변수줄?: VarLine[];
식덮개?: Record<string, string>;
}
const ADD_ONE = /^add:#(\d+)$/;
/** 빈 묶음 · 목록 칸은 지움(서버처럼 없으면 안 둠). */
function putField<T extends FormulaDoc>(doc: T, name: string, value: unknown): T {
const out: FormulaDoc = { ...doc };
const empty =
value == null ||
(Array.isArray(value)
? value.length === 0
: typeof value === "object" && Object.keys(value as object).length === 0);
if (empty) delete out[name];
else out[name] = value;
return out as T;
}
const isVar = (doc: FormulaDoc, spot: string): boolean =>
spot.startsWith("mid:") && (doc.변수줄 ?? []).some((v) => v.이름 === spot.slice(4));
/** 자리 식 — 변수 줄 = 그 식 · `add:#k` = 수량식 · 그 밖 = 식덮개 · null · "" = 지움(M01 식 · 수). */
export function setFormula<T extends FormulaDoc>(doc: T, spot: string, text: string | null): T {
const t = (text ?? "").trim();
if (isVar(doc, spot)) return editVar(doc, spot.slice(4), { 식: t });
const hit = ADD_ONE.exec(spot);
if (hit) {
const k = Number(hit[1]);
const adds = (doc.더한줄 ?? []).map((x, i) => {
if (i !== k) return x;
const one: Record<string, unknown> = { ...x };
if (t) one.수량식 = t;
else delete one.수량식;
return one;
});
return { ...doc, 더한줄: adds };
}
const covers = { ...(doc.식덮개 ?? {}) };
if (t) covers[spot] = t;
else delete covers[spot];
return putField(doc, "식덮개", covers);
}
/** 변수 줄 넣기 — `at` 차례(없으면 끝) · 같은 이름이 있으면 그대로. */
export function addVar<T extends FormulaDoc>(doc: T, line: VarLine, at?: number): T {
const lines = [...(doc.변수줄 ?? [])];
if (lines.some((v) => v.이름 === line.이름)) return doc;
lines.splice(at ?? lines.length, 0, { ...line });
return putField(doc, "변수줄", lines);
}
/** 변수 줄 고침 — 이름 바꾸면 까닭 · 글줄 · 다른 변수 자리 열쇠 `mid:이름` 도 따라감(식 속 이름은
* 그대로 — 검사 길이 짚음). */
export function editVar<T extends FormulaDoc>(doc: T, name: string, patch: Partial<VarLine>): T {
const to = patch.이름 ?? name;
const lines = (doc.변수줄 ?? []).map((v) => {
if (v.이름 === name) {
const one: VarLine = { ...v, ...patch };
if (patch.표시 == null && "표시" in patch) delete one.표시;
return one;
}
return v.자리 === `mid:${name}` ? { ...v, 자리: `mid:${to}` } : v;
});
const out = putField(doc, "변수줄", lines);
return to === name ? out : moveKey(out, `mid:${name}`, `mid:${to}`);
}
/** 변수 줄 빼기 — 그 뒤에 붙은 변수는 그 자리를 이어받음 · 까닭 지움 · 글줄은 맨 위로. */
export function dropVar<T extends FormulaDoc>(doc: T, name: string): T {
const gone = (doc.변수줄 ?? []).find((v) => v.이름 === name);
if (!gone) return doc;
const lines = (doc.변수줄 ?? [])
.filter((v) => v.이름 !== name)
.map((v) => (v.자리 === `mid:${name}` ? { ...v, 자리: gone.자리 } : v));
return moveKey(putField(doc, "변수줄", lines), `mid:${name}`, null);
}
/** 까닭 · 글줄 열쇠 옮김(null = 까닭 지움 · 글줄 맨 위). */
function moveKey<T extends FormulaDoc>(doc: T, from: string, to: string | null): T {
let out: T = doc;
if (doc.까닭 && from in doc.까닭) {
const why = { ...doc.까닭 };
const text = why[from];
delete why[from];
if (to) why[to] = text;
out = putField(out, "까닭", why);
}
if (doc.글줄) {
out = { ...out, 글줄: doc.글줄.map((t) => (t.자리 === from ? { ...t, 자리: to ?? "" } : t)) };
}
return out;
}