From 209654f31104382f68d79062a3369f5077f40079 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 24 Sep 2026 14:35:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(M01):=20=EA=B3=84=EC=82=B0=20=EB=A9=88?= =?UTF-8?q?=EC=B6=A4=EC=97=90=EB=8F=84=20=EC=88=98=EB=9F=89=20=EC=B9=B8=20?= =?UTF-8?q?=EB=82=A0=EC=8B=9D=20=C2=B7=20=EA=B9=8C=EB=8B=AD=20=EB=82=A0?= =?UTF-8?q?=EA=B8=80=20=EC=95=88=20=EC=B0=8D=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=A1=B0=EA=B0=81=20=C2=B7=20?= =?UTF-8?q?=EB=B9=88=EC=B9=B8=20=C2=B7=20=EA=B9=8C=EB=8B=AD=20=EA=B1=B8?= =?UTF-8?q?=EB=9F=AC=EB=83=84=20(PLAN=206=EC=9E=A5=20=EC=85=8B=EC=A7=B8=20?= =?UTF-8?q?=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 4 ++++ .../M01_MasterData_UI_LogicLab_Detail.ts | 14 ++++++++++---- .../M01_MasterData_UI_LogicLab_Formula.ts | 9 ++++++--- M01_MasterData/M01_MasterData_UI_Logic_Calc.ts | 7 +++++-- M01_MasterData/M01_MasterData_UI_Logic_Text.ts | 10 ++++++++++ 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index d7ad8825..3658548f 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -59,6 +59,8 @@ interface Opened extends Draft { reasons: string[]; lines: CalcLine[] | null; text: TextAnswer | null; + /** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */ + lastText?: TextAnswer; ownHost: HTMLElement; values: Record; } @@ -170,6 +172,7 @@ export async function mountM01LogicLab( values: current.values, onText: (text) => { current.text = text; + if (text.ok) current.lastText = text; drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리 }, onLines: (lines) => { @@ -202,6 +205,7 @@ export async function mountM01LogicLab( prices: current.prices, lines: current.lines, text: current.text, + lastText: current.lastText, values: current.values, ownHost: current.ownHost, onChange: touch, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index cbdc2b1b..7a030dd2 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -20,7 +20,7 @@ import type { import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; -import { tx } from "./M01_MasterData_UI_Logic_Text"; +import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; import { condText, @@ -64,6 +64,8 @@ export interface DetailContext { lines: CalcLine[] | null; /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ text: TextAnswer | null; + /** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */ + lastText?: TextAnswer; values: Record; /** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */ ownHost: HTMLElement; @@ -166,7 +168,7 @@ function infoBox(ctx: DetailContext): HTMLElement { className: "m01-logic__reasons", children: [ el("strong", { text: tx("Head_Blocked") }), - ...ctx.reasons.map((r) => el("div", { text: r })), + ...ctx.reasons.map((r) => el("div", { text: safeReason(r) })), ], }), ] @@ -287,14 +289,18 @@ function entries(ctx: DetailContext): Entry[] { * 이름을 차례로 따라가며 줄 차례에 맞춤 · 금액 0 로직 부름 줄처럼 묶음이 달라도 찾음 · * 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */ function attachPieces(all: Entry[], ctx: DetailContext): void { - if (!ctx.text?.ok) return; + const source = ctx.text?.ok ? ctx.text : ctx.lastText; + if (!source?.ok) { + if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸 + return; + } const ho = ctx.row.호표 ?? []; const names = [ ...ho.map((item) => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소)), ...(ctx.row.덧줄 ?? []).map((extra) => extra.이름), ]; const found = new Map(); - for (const g of ctx.text.groups) { + for (const g of source.groups) { let from = 0; for (const line of g.줄) { const at = names.findIndex((n, i) => i >= from && n === line.이름); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index d90f66be..9adccc09 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -8,7 +8,7 @@ import { el } from "@ui/ui_template_elements"; import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; -import { tx } from "./M01_MasterData_UI_Logic_Text"; +import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Formula.css"; @@ -31,7 +31,10 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { host.replaceChildren( el("div", { className: "m01-logic__reasons", - children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: text.reason })], + children: [ + el("strong", { text: tx("Calc_Stopped") }), + el("div", { text: safeReason(text.reason) }), + ], }), ); return; @@ -62,7 +65,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { ? [ el("div", { text: `${line.이름} : ${line.글}` }), ...(line.까닭 - ? [el("div", { className: "m01-logic__muted", text: line.까닭 })] + ? [el("div", { className: "m01-logic__muted", text: safeReason(line.까닭) })] : []), ] : [el("div", { className: "m01-logic__muted", text: tl("Formula_Slot") })], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index b09bb7e2..6e8b6eac 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -17,7 +17,7 @@ import { } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; -import { tx } from "./M01_MasterData_UI_Logic_Text"; +import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; /** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */ const KEY_RE = /^[A-Z]{2}\d{6}$/; @@ -201,7 +201,10 @@ function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLEle ...note, el("div", { className: "m01-logic__reasons", - children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: answer.reason })], + children: [ + el("strong", { text: tx("Calc_Stopped") }), + el("div", { text: safeReason(answer.reason) }), + ], }), ]; } diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 8ef71ee7..e0cf849f 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -138,6 +138,10 @@ const TEXT = { "「{name}」 규격이 여럿이라 정하지 못함 — 위 「쓰이는 재료·기계」에서 하나를 골라 주세요", "「{name}」 has several specs — pick one in the materials list above", ], + Stop_Generic: [ + "이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인", + "Cannot be calculated with these inputs — check the values and the table", + ], Stop_NoPrice: [ "「{name}」 단가가 자료에 비어 있어 셈하지 못함 — 자료를 채워야 함", "「{name}」 has no price in the data yet", @@ -158,6 +162,12 @@ export function plainReason(reason: string): string { return reason; } +/** 화면에 찍는 까닭의 마지막 방어 — 중괄호 · 따옴표 · Decimal( 이 섞인 날글은 사람 말 한 줄로 */ +export function safeReason(reason: string): string { + const plain = plainReason(reason); + return /[{}"']|Decimal\(/.test(plain) ? tx("Stop_Generic") : plain; +} + /** 흐름 그림 머리 「이렇게 씀」 — 줄 목록 */ /** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */ From 3ef2ba566f395f006234549f2f1d68a2a874d05c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 24 Sep 2026 15:07:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(M01):=20=ED=91=9C=20=EC=B0=BE=EA=B8=B0?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=EB=8A=94=20=EA=B7=B8=20=EC=B0=BE=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=93=B0=EB=8A=94=20=EC=A4=84=EB=A7=8C=20=EB=B9=84?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EA=B9=8C=EB=8B=AD=20=EA=B8=80=20=EB=82=A0?= =?UTF-8?q?=EA=B8=80=20=EC=97=86=EC=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoRow · Blank — 표에 맞는 줄이 없으면 중간값을 거쳐 쓰는 줄까지 단가·금액 null + 까닭 · 소계·계는 빈 줄 빼고 더함 - 까닭 「표 QC001869(1-2-3)에 시공구분 기계시공 · … 인 줄이 없음」 · 재료 고르기 후보 없음도 같은 규칙 - 시험 한 개 더 (test_m01_auto.py) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- .../master_data/scripts/master_formula.py | 90 ++++++++++++++----- .../master_data/scripts/master_material.py | 17 +++- resources/tester/test_m01_auto.py | 13 +++ 3 files changed, 94 insertions(+), 26 deletions(-) diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 14f6e88c..f33c88eb 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -36,6 +36,22 @@ class EmptyInput(FormulaError): """값을 안 넣은(null) 입력을 씀 — 그 줄만 비우고 로직은 계속(`run`).""" +class NoRow(EmptyInput): + """표에서 조건에 맞는 줄이 하나가 아님 — 그 찾기를 쓰는 줄만 비움(값이 비어 있는 입력과 같은 길).""" + + +class Blank: + """못 구한 중간값 — 이 값을 쓰는 줄만 까닭과 함께 비움.""" + + def __init__(self, why: str): + self.why = why + + +def _plain(v) -> str: + """사람이 읽는 값 글 — 파이썬 날글(Decimal('1000') · 따옴표) 없이.""" + return format(v.normalize(), "f") if isinstance(v, Decimal) else str(v) + + # ── 읽기 ────────────────────────────────────────────────────────────── class _Parser: def __init__(self, text: str): @@ -299,7 +315,9 @@ def fill(template: str, env: dict) -> str: if m.group(1) not in env: raise FormulaError(f"원문번호의 {{{m.group(1)}}} 값 없음") value = env[m.group(1)] - return format(value.normalize(), "f") if isinstance(value, Decimal) else str(value) + if isinstance(value, Blank): + raise EmptyInput(value.why) + return _plain(value) return _HOLE.sub(one, template) @@ -353,7 +371,13 @@ def find(table: dict, conds: dict, want: str | None = None) -> dict: if len(hits) > 1 and want: # 여러 줄이면 찾는 값 칸이 있는 줄만 hits = [row for row in hits if want in row] if len(hits) != 1: - raise FormulaError(f"「{_name(table)}」 {conds} 맞는 줄 {len(hits)}개") + where = f"표 {table.get('키')}({table.get('원문번호')})" + what = " · ".join(f"{c} {_plain(v)}" for c, v in conds.items()) + raise NoRow( + f"{where}에 {what} 인 줄이 없음" + if not hits + else f"{where}에 {what} 인 줄이 {len(hits)}개" + ) return hits[0] @@ -366,6 +390,8 @@ def evaluate(node, env: dict, master: Master, depth: int = 0): raise FormulaError(f"모르는 이름 「{node[1]}」") if env[node[1]] is None: raise EmptyInput(f"입력 「{node[1]}」 비어 있음") + if isinstance(env[node[1]], Blank): + raise EmptyInput(env[node[1]].why) return env[node[1]] if kind == "ref": value = element(master, fill(node[1], env), env)[0] @@ -489,15 +515,36 @@ def _inputs(row: dict, given: dict) -> dict: return env +def _shown(env: dict) -> dict: + """돌려줄 중간값 — 못 구한 값은 null.""" + return {k: None if isinstance(v, Blank) else v for k, v in env.items()} + + +def _blank_line(item: dict, why: str) -> dict: + """비어 있는 입력·못 구한 값을 쓰는 호표 줄 — 단가·금액만 비우고 까닭.""" + return { + "이름": item.get("이름", ""), + "단위": item.get("단위", ""), + "수량": None, + "단가": None, + "금액": None, + "비목": {item["비목"]: None} if "비목" in item else {}, + "까닭": why, + } + + def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: """로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나.""" row = master.logic(ref) env = _inputs(row, given) for step in row.get("중간", []): - env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth) + try: + env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth) + except EmptyInput as e: # 못 구한 중간값 — 쓰는 줄만 비움 + env[step["이름"]] = Blank(str(e)) if "결과" in row: value = evaluate(parse(row["결과"]["식"]), env, master, depth) - return {"결과": cut(row, value, "결과"), "중간": env} + return {"결과": cut(row, value, "결과"), "중간": _shown(env)} sums = dict.fromkeys(COST_ITEMS, Decimal(0)) lines = [] for item in row.get("호표", []): @@ -505,21 +552,14 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: try: qty = evaluate(parse(item["수량"]), env, master, depth) except EmptyInput as e: # 비어 있는 입력을 쓰는 줄만 비움 - qty, price, why = None, None, str(e) - lines.append( - { - "이름": item.get("이름", ""), - "단위": item.get("단위", ""), - "수량": None, - "단가": None, - "금액": None, - "비목": {item["비목"]: None} if "비목" in item else {}, - "까닭": why, - } - ) + lines.append(_blank_line(item, str(e))) continue if item["종류"] == "로직": - bundle = evaluate(parse(item["요소"]), env, master, depth) + try: + bundle = evaluate(parse(item["요소"]), env, master, depth) + except EmptyInput as e: + lines.append(_blank_line(item, str(e))) + continue price = bundle["계"] split = ( {k: qty * bundle[k] for k in COST_ITEMS} @@ -529,11 +569,15 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: else: # 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂 # 조건 값의 `{입력}`(관경 등)도 채움 — 규격이 입력 따라 바뀌는 관 줄 - ref = ( - {k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()} - if isinstance(item["요소"], dict) - else fill(item["요소"], env) - ) + try: + ref = ( + {k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()} + if isinstance(item["요소"], dict) + else fill(item["요소"], env) + ) + except EmptyInput as e: + lines.append(_blank_line(item, str(e))) + continue # 후보 0 · 값 빈 품목은 그 줄만 단가·금액을 비우고 까닭을 닮 — 로직은 계속 · 합에서 뺌 # (없는 키는 틀린 로직 — 지금처럼 멈춤) if isinstance(ref, str) and qty != 0: @@ -589,7 +633,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: } ) sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 - return {"줄": lines, **sums, "계": sum(sums.values()), "중간": env} + return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} # ── 검사 ────────────────────────────────────────────────────────────── diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index 2230825e..4a13ecb6 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -16,7 +16,15 @@ LINKED = "MP" # 품셈재료 테이블ID MARKET = "MT" # 자재품목 테이블ID PRICED = "MO" # 유가전력 테이블ID — 값 한 칸 SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열 -PICK_KEYS = ("구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별") # 재료 고르기 조건 칸 (`_틀.md` 7장) +PICK_KEYS = ( + "구분", + "상세구분", + "이름", + "규격", + "대표", + "지역", + "계약종별", +) # 재료 고르기 조건 칸 (`_틀.md` 7장) REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀 TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음 _UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"} @@ -98,7 +106,9 @@ def _region(master: mf.Master, label_ref, cond: dict, env: dict, unit) -> list[s if not hits: raise mf.FormulaError(f"「{master.label(label_ref)}」 지역 값 없음 — {env.get(REGION)}") if len({s for s, _ in hits}) > 1: - raise mf.FormulaError(f"「{master.label(label_ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)") + raise mf.FormulaError( + f"「{master.label(label_ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)" + ) return [key for _, key in hits] @@ -155,7 +165,8 @@ def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> s keys = candidates(master, cond, env, unit) keys = [k for k in keys if _offers(master, k)] or keys if not keys: - raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}") + what = " · ".join(f"{k} {mf._plain(v)}" for k, v in cond.items()) + raise mf.FormulaError(f"재료 고르기 후보 없음 — {what}") return keys[0] diff --git a/resources/tester/test_m01_auto.py b/resources/tester/test_m01_auto.py index 3201ff34..2e49670f 100644 --- a/resources/tester/test_m01_auto.py +++ b/resources/tester/test_m01_auto.py @@ -124,3 +124,16 @@ def test_빠진_원_입력도_null_처럼_줄만_비움(client) -> None: assert got["ok"] is True, (path, got) lines = client.post("/api/m01/calc", json={"key": "GF000056", "inputs": given}).json()["lines"] assert {o["이름"] for o in lines if o.get("까닭")} == {"체인오일", "체인톱 손료"} + + +def test_표_찾기_실패는_그_줄만_비우고_까닭은_날글_없이(client) -> None: + given = {**_auto(client, "GC001000"), "시공구분": "기계시공"} # 표 1-2-3 에 그 조합 줄이 없음 + got = client.post("/api/m01/calc", json={"key": "GC001000", "inputs": given}).json() + assert got["ok"] is True, got + blank = [one for one in got["lines"] if one.get("까닭")] + assert blank and all(one["금액"] is None for one in blank) + why = blank[0]["까닭"] + assert "표 QC001869(1-2-3)에 시공구분 기계시공" in why and "인 줄이 없음" in why + assert not any(ch in why for ch in "{}'") and "Decimal" not in why + rest = sum(Decimal(str(one["금액"])) for one in got["lines"] if one["금액"] is not None) + assert Decimal(str(got["sums"]["계"])) == rest