From 8fed757fc1dc93fe1e3c632c29434bff621ed9cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 07:57:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(M02):=20=ED=91=9C=EC=A4=80=20=ED=91=9C?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=9C=20=EA=B0=92=20=3D=20=5Fstandard/=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=90=20=EB=B0=94=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=AA=A9=EB=A1=9D=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=90=EC=B2=B4=20=ED=8C=8C=EC=9D=BC=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=EC=97=90=20=EC=95=88=20=EA=B8=B0=EB=8C=90(=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EA=B2=B0=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M02_MasterTemplete/DataFlow/M02_DF_Router.py | 15 +++++++++------ M02_MasterTemplete/DataFlow/M02_DF_Values.py | 8 +++----- resources/tester/test_m01_api.py | 3 +++ resources/tester/test_m02_df_router.py | 18 ++++++++++++------ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Router.py b/M02_MasterTemplete/DataFlow/M02_DF_Router.py index 3892588d9..3e48df45c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Router.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Router.py @@ -311,20 +311,23 @@ def get_materials(q: str = "", sub: str = "", detail: str = "", page: int = 1, s @router.get("/standards") def get_standards() -> dict: - return _call(dv.standards, STANDARD, DATA) + return _call(dv.standards, STANDARD) def _standards_put(body: dict) -> dict: + """고친 표준 표 = 본 프로그램 값 — git 이 따라가는 `_standard/` 파일에 바로(시스템 관리자).""" dv.standards_check(body) for part, name in dv.STANDARDS.items(): - _write(DATA / name, body[part]) - return dv.standards(STANDARD, DATA) + tmp = STANDARD / f".{name}.tmp" + text = json.dumps(body[part], ensure_ascii=False, indent=2) + "\n" + tmp.write_text(text, encoding="utf-8", newline="\n") + os.replace(tmp, STANDARD / name) + return dv.standards(STANDARD) def _standards_reset() -> dict: - for name in dv.STANDARDS.values(): - (DATA / name).unlink(missing_ok=True) - return dv.standards(STANDARD, DATA) + """고친 값을 따로 두지 않음(바로 저장) — 되돌릴 처음 값 없음 · 지금 값 그대로.""" + return dv.standards(STANDARD) @router.put("/standards") diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Values.py b/M02_MasterTemplete/DataFlow/M02_DF_Values.py index 2ddd9cb3e..4befa7693 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Values.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Values.py @@ -550,13 +550,11 @@ def group_find(group: str, slot: dict, value: Any) -> dict: STANDARDS = {"unit": "단위표준.json", "money": "금액단위표준.json"} -def standards(first: Any, mine: Any) -> dict: - """{unit, money, saved} — 고친 것(`_data`)이 있으면 그것 · 없으면 처음 값(`_standard`).""" +def standards(folder: Any, _old: Any = None) -> dict: # _old = 옛 고친 값 폴더(안 씀 · 옛 부름 꼴) + """{unit, money, saved} — 표준 표 파일(`_standard/` · 고치면 바로 그 파일) · saved 늘 False.""" out: dict[str, Any] = {"saved": False} for part, name in STANDARDS.items(): - path = mine / name if (mine / name).is_file() else first / name - out["saved"] = out["saved"] or path.parent == mine - out[part] = json.loads(path.read_text(encoding="utf-8")) + out[part] = json.loads((folder / name).read_text(encoding="utf-8")) return out diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 4bcf6e1b6..094462d2d 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -152,6 +152,9 @@ def test_로직_목록_하나_시험계산(client: TestClient) -> None: assert all((x["구분"], x["상세구분"]) == (LOGIC_SUB, detail) for x in listed) assert not any(x["blocked"] for x in listed) files = _get(client, "/api/m01/groups/로직/files")["files"] + files = [ + f for f in files if f["file"] != "로직_자체.json" + ] # 자체 파일은 마스터 내용 따라 있다 없다 함 assert files[0]["file"].startswith("로직_산림품셈_") and files[-1]["chapter"].startswith( "유지관리" ) diff --git a/resources/tester/test_m02_df_router.py b/resources/tester/test_m02_df_router.py index fe51c9239..686cfc776 100644 --- a/resources/tester/test_m02_df_router.py +++ b/resources/tester/test_m02_df_router.py @@ -578,7 +578,12 @@ def test_선_규칙_끝수_재료_조건_설계_때_정할_것(client: TestClien assert bad["ports"]["f"]["합"] == "#N/A" and _d(bad["cost"]["재료비"]) == 0 -def test_표준_표_읽기_쓰기_처음값(client: TestClient, folder: Path) -> None: +def test_표준_표_읽기_쓰기_바로_저장(client: TestClient, tmp_path: Path, monkeypatch) -> None: + """고친 표준 표 = `_standard/` 파일에 바로(본 프로그램 값) — 시험은 그 사본 폴더 · 정본 파일은 안 바뀜.""" + real = cf.STANDARD + before = {p.name: p.read_bytes() for p in real.glob("*.json")} + shutil.copytree(real, tmp_path / "_standard") + monkeypatch.setattr(cf, "STANDARD", tmp_path / "_standard") first = client.get("/api/m02/df/standards").json() assert first["saved"] is False and first["unit"]["줄"] and first["money"]["줄"] edited = json.loads(json.dumps(first)) @@ -586,14 +591,15 @@ def test_표준_표_읽기_쓰기_처음값(client: TestClient, folder: Path) -> res = client.put( "/api/m02/df/standards", json={"unit": edited["unit"], "money": edited["money"]} ) - assert res.status_code == 200 and res.json()["saved"] is True + assert res.status_code == 200 assert client.get("/api/m02/df/standards").json()["money"]["줄"][4]["지위"] == 1 + saved = json.loads((cf.STANDARD / "금액단위표준.json").read_text(encoding="utf-8")) + assert saved["줄"][4]["지위"] == 1 # 파일에 바로 assert client.put("/api/m02/df/standards", json={"unit": {}, "money": {}}).status_code == 400 - back = client.delete("/api/m02/df/standards").json() assert ( - back["saved"] is False and back["money"]["줄"][4]["지위"] == first["money"]["줄"][4]["지위"] - ) - assert (cf.STANDARD / "금액단위표준.json").is_file() # 처음 값 파일은 그대로 + client.delete("/api/m02/df/standards").json()["money"]["줄"][4]["지위"] == 1 + ) # 되돌릴 처음 값 없음 + assert {p.name: p.read_bytes() for p in real.glob("*.json")} == before def test_새_길도_시스템_관리자만(folder: Path) -> None: From 54fc4f1b1f25c0896f12580652b9100b53b9d5f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 08:01:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(M01):=20=ED=9D=90=EB=A6=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=98=20=EC=8B=9C=ED=97=98=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=B9=B8=20=3D=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=EC=9D=98=20=EC=84=A4=EA=B3=84=20=EB=95=8C=20=EC=A0=95=ED=95=A0?= =?UTF-8?q?=20=EA=B2=83(=EB=B3=80=EC=88=98=20=C2=B7=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=B2=94=EC=9C=84=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 14 ++++++++++---- .../M01_MasterData_UI_LogicLab_Flow.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 3143cfa60..3f29328c9 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -41,6 +41,7 @@ import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { buildSplit, emptyFlow, + designFields, examplesOf, fetchFlow, fetchFlowFor, @@ -86,6 +87,11 @@ interface Opened extends Draft { /** 줄의 흐름(있으면 흐름도로 셈) */ const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; const OWN_FILE = "로직_자체.json"; +/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) */ +const calcRow = (row: LogicRow | null): LogicRow | null => { + const f = flowOf(row); + return f && row ? { ...row, 입력: designFields(f) } : row; +}; const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); @@ -169,7 +175,7 @@ export async function mountM01LogicLab( else drafts[opened.id] = pick(opened, clone(opened.row)); persist(); if (!flowOf(opened.row)) scheduleFlow(opened); // 왼쪽 편집기로 고침 — 오른쪽 흐름도도 따라감 - const inputs = JSON.stringify(opened.row.입력 ?? []); + const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []); if (inputs !== calcInputs) drawCalc(); if (isOwnLogic(opened.row)) { // 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림 @@ -192,11 +198,11 @@ export async function mountM01LogicLab( return; } const current = opened; - calcInputs = JSON.stringify(current.row?.입력 ?? []); + calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []); buildCalc(calc, { savedKey: current.origKey, file: current.file, - row: current.row as LogicRow, + row: calcRow(current.row) as LogicRow, prices: current.prices, dirty: () => current.id in drafts, values: current.values, @@ -309,7 +315,7 @@ export async function mountM01LogicLab( /** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */ const syncLeft = (o: Opened, ex: Record): void => { - const names = new Set((o.row?.입력 ?? []).map((x) => x.이름)); + const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름)); const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v); if (!changed.length) return; quiet = true; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts index 4642527d9..e673caed8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -195,6 +195,25 @@ export function withExamples(lg: CfLogic, vals: Record): CfLogic return next; } +/** 흐름 로직의 시험 계산 입력 칸 — 「설계 때 정할 것」(변수 · 고르기 · 범위 · 단위) · 이름이 겹치면 하나 */ +export function designFields( + lg: CfLogic, +): { 이름: string; 단위?: string; 고르기?: (string | number)[]; 범위?: [number, number] }[] { + const seen = new Set(); + return varsOf(lg).flatMap((v) => { + if (!v.name || seen.has(v.name)) return []; + seen.add(v.name); + return [ + { + 이름: v.name, + ...(v.unit && v.unit !== "-" ? { 단위: v.unit } : {}), + ...(v.type === "choice" && v.choices?.length ? { 고르기: v.choices } : {}), + ...(v.range ? { 범위: v.range } : {}), + }, + ]; + }); +} + /** 예시값만 다른가 — 그 밖이 같으면 고친 것으로 안 셈 */ export function sameShape(a: CfLogic, b: CfLogic): boolean { const strip = (lg: CfLogic): string => { From d822a08a5e6fcefbea93d4ccb37738f5fd872330 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 08:02:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(m02-df):=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=85=88=20=3D=20=EC=98=9B=20=EC=85=88=20=E2=80=94=20=EB=B9=88?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=20=EC=A4=84=EC=9D=84=20=EB=B6=80=EB=A5=B4?= =?UTF-8?q?=EB=8A=94=20=EB=8D=A7=EC=A4=84=EC=9D=80=20=EB=B9=88=20=EA=B0=92?= =?UTF-8?q?=20=C2=B7=20=EC=A0=84=EC=88=98=201,472=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EB=A7=9E=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 단가(blank) 요소 값 = 「#N/A」(서버 Compute · 화면 값 받기 Entry_Detail · Api CfAnswer.blank) → 그 값을 쓰는 식은 빈 값 · 결과가 오류일 때만 끊긴 곳 「단가 없는 품목을 씀」(Eval NoPrice · Compute 같음) - 변환: 비목 합 · 덧줄의 비목 이름 = 빈 줄 0(SUM(IFERROR…)) · 호표 줄 = IF(수량=0,0,수량×단가) · 줄.이름은 빈 줄이면 빈 값(옛 env 줄) - 오류 값 칸은 NA() 로(Compute _is_err · Eval isErrorValue) - Compute 700줄 넘어 표 찾기 · 값 같음을 M02_DF_Lookup.py 로 나눔(Compute 가 다시 내보냄) - 전수 대조 시험입력 · 기본 입력 1,472/1,472 · 시험 test_m02_df_model 37 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/DataFlow/M02_DF_Api.ts | 2 + M02_MasterTemplete/DataFlow/M02_DF_Compute.py | 116 +++--------------- M02_MasterTemplete/DataFlow/M02_DF_Convert.py | 14 ++- .../DataFlow/M02_DF_Entry_Detail.ts | 1 + M02_MasterTemplete/DataFlow/M02_DF_Eval.ts | 21 +++- M02_MasterTemplete/DataFlow/M02_DF_Lookup.py | 112 +++++++++++++++++ resources/tester/test_m02_df_model.py | 66 ++++++++++ 7 files changed, 231 insertions(+), 101 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Lookup.py diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index 1d341911d..37eb783c7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -47,6 +47,8 @@ export interface CfAnswer { name?: string; unit?: string; reason?: string; + /** 빈 단가 · 맞는 재료 없음 — 옛 셈처럼 그 값만 비우고 셈은 계속(흐름은 「#N/A」) */ + blank?: boolean; /** 재료 조건 찾기가 여럿 맞을 때 후보 다섯 */ candidates?: CfMaterial[]; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index cef97b616..53969e00c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -28,6 +28,15 @@ from M01_MasterData import ( ) # _num — M01 Router 가 이 파일을 부름(서로 부름) from M01_MasterData import M01_MasterData_Store as store from M02_MasterTemplete.DataFlow import M02_DF_Values as dv +from M02_MasterTemplete.DataFlow.M02_DF_Lookup import ( # noqa: F401 — 시험이 cp._lookup 을 부름 + RANGE_RULE, + _cond_of, + _exact, + _is_err, + _lookup, + _num_of, + _same, +) _CELL = re.compile(r"^([A-Z]+)(\d+)$") TABLE_PORT, OUTPUT_PORT = "표", "값" @@ -156,7 +165,7 @@ def _element_value( got = dv.one_value(item, ctx["local"]) if not got["ok"] and got.get("blank"): # 옛 셈처럼 그 값만 비움(알림) — 셈은 계속 ctx["notes"].append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}") - return None + return NA_VALUE # 그 값을 쓰는 식은 빈 값(옛 엔진) — 비목 합은 0 if not got["ok"]: errors.append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}") return None @@ -305,32 +314,6 @@ def design_inputs(lg: dict) -> list[dict]: # ── 선 규칙(`M02_DF_Rule.applyRule` 와 같은 뜻 · 값 기준) ──────────────────── -def _num_of(v: Any) -> Decimal | None: - if isinstance(v, bool) or v is None: - return None - if isinstance(v, (int, float, Decimal)): - return Decimal(str(v)) - return None - - -def _same(a: Any, b: Any) -> bool: - """엑셀 「=」 — 수끼리는 수로 · 글끼리는 대소문자 무시 · 빈 칸은 0 · 빈 글과 같음.""" - na, nb = _num_of(a), _num_of(b) - if a is None or b is None: - other = b if a is None else a - return other is None or other == "" or _num_of(other) == 0 - if na is not None and nb is not None: - return na == nb - if isinstance(a, str) and isinstance(b, str): - return a.lower() == b.lower() - return False - - -def _exact(a: Any, b: Any) -> bool: - """표 찾기 같음 — 글끼리는 대소문자까지(옛 find · Rule lookup FIND) · 그 밖은 엑셀 「=」.""" - return a == b if isinstance(a, str) and isinstance(b, str) else _same(a, b) - - def _compare(a: Any, op: str, b: Any) -> bool: if op in ("=", "≠"): return _same(a, b) == (op == "=") @@ -359,67 +342,6 @@ def _rounded(value: Any, r: dict) -> Any: ) -def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: - """표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나 - · 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄 - (옛 엔진 find 와 같음 · Rule lookup 과 같음).""" - if not isinstance(block, list) or len(block) < 2: - return None, "오류" - head = [str(h if h is not None else "") for h in block[0]] - out = rule.get("col") - two = out not in head and {out + dv.RANGE_LOW, out + dv.RANGE_HIGH} <= set(head) - if out not in head and not two: - return None, "오류" - tests, ups = [], [] - rk = head.index(RANGE_RULE) if RANGE_RULE in head else None - for c in rule.get("conds") or []: - col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {}) - if col in head: - i = head.index(col) - tests.append(lambda row, i=i, want=want: row[i] is None or _exact(row[i], want)) - elif col + dv.RANGE_LOW in head and col + dv.RANGE_HIGH in head: - lo, hi, x = ( - head.index(col + dv.RANGE_LOW), - head.index(col + dv.RANGE_HIGH), - _num_of(want), - ) - ups.append(hi) - - def fits(row, lo=lo, hi=hi, x=x) -> bool: - low, high = _num_of(row[lo]), _num_of(row[hi]) - if low is not None and low == high: # 숫자 하나(아래 = 위) = 같음(브레인 정함) - return x is not None and x == low - word = str(row[rk] or "") if rk is not None else "" # 경계 — 이상 · 미만(Rule 같음) - return ( - x is not None - and ( - row[lo] is None - or (low is not None and (low <= x if word[:2] == "이상" else low < x)) - ) - and ( - row[hi] is None - or (high is not None and (x < high if word[-2:] == "미만" else x <= high)) - ) - ) - - tests.append(fits) - else: - return None, "오류" - at = head.index(out + dv.RANGE_LOW if two else out) # 값 칸이 범위로 갈림 — 아래 = 위면 그 값 - rows = [list(r) + [None] * len(head) for r in block[1:]] - hits = [r for r in rows if r[at] not in (None, "") and all(t(r) for t in tests)] - if not hits: - return None, "표에 없는 조건" - for hi in ups: # 위 끝만 적힌 범위(「20 이하」 「40 이하」 …) — 위 끝이 가장 작은 줄 - tops = [_num_of(r[hi]) for r in hits if _num_of(r[hi]) is not None] - hits = [r for r in hits if r[hi] is None or not tops or _num_of(r[hi]) == min(tops)] - if len(hits) > 1: - return None, "표에 여럿 맞음" - if two and not _same(hits[0][at], hits[0][head.index(out + dv.RANGE_HIGH)]): - return None, "오류" - return hits[0][at], None - - def apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]: """(값, 까닭) — 까닭 = 통과 안 됨 · 표에 없는 조건 · 표에 여럿 맞음 · 오류 · None.""" kind = rule.get("kind") @@ -465,12 +387,6 @@ def _gate_conds(rule: dict) -> tuple[list[dict], str]: return rows, rule.get("join") or "그리고" -def _cond_of(conds: dict, port: dict) -> Any: - """조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름(Rule condOf).""" - key = f"{port.get('box')}|{port.get('name')}" - return conds[key] if key in conds else conds.get(port.get("name")) - - # ── 계산(`M02_DF_Eval.evalLogic` 와 같은 차례) ───────────────────────────── def _depends(lg: dict) -> list[tuple[str, str]]: """상자 사이 기댐 — 선 + 선 규칙 조건 알약 + 재료 조건 찾기 알약(Model `depends`).""" @@ -516,7 +432,7 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: if str(p.get("cell") or "").strip(): port = p.get("port") v = arrived[port] if arrived.get(port) is not None else given.get(port) - _place(cells, p["cell"], {"식": "NA()"} if v == NA_VALUE else v) + _place(cells, p["cell"], {"식": "NA()"} if _is_err(v) else v) # 오류 값 · 빈 단가 for v in func.get("variables") or []: if v.get("cell"): _place(cells, v["cell"], given.get(v.get("name"), v.get("example"))) @@ -524,7 +440,6 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: REST = "앞 조건이 안 맞아 쉬는 갈래" -RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE) NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음) @@ -545,6 +460,15 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N src = ln.get("from") or {} v = ports.get(src.get("box"), {}).get(src.get("name")) rule = ln.get("rule") + if ( + v == NA_VALUE + and (boxes.get(src.get("box"), {}).get("input") or {}).get("mode") == "group" + ): + to = ln["to"] # 빈 단가 — 결과가 오류일 때만 끊긴 곳(Eval NoPrice 와 같음) + text = f"「{name(to.get('box'))}」 — 단가 없는 품목을 씀(「{src.get('name')}」)" + brk = {"kind": "단가 없는 품목", "level": "문제", "box": to.get("box")} + brk |= {"port": to.get("name"), "link": ln.get("id"), "text": text} + late.setdefault(to.get("box"), []).append(brk) if not rule: return v if v is None and rule.get("kind") in ("scale", "round"): diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py index 949f0f2d3..e8e4e6199 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py @@ -284,6 +284,12 @@ class _Flow: return take({"box": self.logic(node), "name": "결과"}, f"{node[1]} 결과") if kind == "@port": return take(node[1], node[1]["name"]) + if kind == "@sum0": # 비목 합 — 빈 줄(빈 단가 · 못 셈 덧줄)은 0(옛 엔진 sums += 0) + cells = [f"IFERROR({take(p, p['name'])},0)" for p in node[1]] + return f"SUM({','.join(cells)})" if cells else "0" + if kind == "@line": # 호표 줄 수량 × 단가 — 수량 0 이면 0(옛 엔진 · 빈 단가여도) + q, p = self.excel(node[1], take, scope), self.excel(node[2], take, scope) + return f"IF({q}=0,0,{q}*{p})" if kind == "@sum": cells = [take(p, p["name"]) for p in node[1]] return f"SUM({','.join(cells)})" if cells else "0" @@ -294,8 +300,8 @@ class _Flow: raise Unsupported(f"모르는 식 마디 「{kind}」") def name(self, name: str, take, scope: dict) -> str: - if name in scope: # 덧줄의 비목 합 · 줄 금액 - return self.excel(("@sum", scope[name]), take, scope) + if name in scope: # 덧줄의 비목 합(빈 줄 = 0) · 줄 금액(빈 줄이면 빈 값 — 옛 env 「줄」) + return self.excel(("@sum0" if name in COSTS else "@sum", scope[name]), take, scope) if name in self.named: return take(self.named[name], name) if name in self.specs: @@ -382,7 +388,7 @@ def _body(f: _Flow, row: dict) -> None: ref = item["요소"] # 그룹 = 키 앞 두 글자(「MO:경유:{지역}」 = 유가전력) · 없으면 줄 종류 group = GROUP_OF.get(ref[:2], str(item.get("종류"))) if isinstance(ref, str) else "재료" price = f.priced(group, ref, unit, f"{name} 단가") - part = f.func(f"{name} 금액", ("bin", "*", qty, price)) + part = f.func(f"{name} 금액", ("@line", qty, price)) parts[item["비목"]].append(part) lines.setdefault(f"줄.{name}", []).append(part) for extra in row.get("덧줄") or []: @@ -392,7 +398,7 @@ def _body(f: _Flow, row: dict) -> None: parts[extra["비목"]].append(part) for c in COSTS: if parts[c]: - _output(f, c, c, f.func(f"{c} 합", ("@sum", parts[c])), rule, "계") + _output(f, c, c, f.func(f"{c} 합", ("@sum0", parts[c])), rule, "계") # ── 자동 배치 ──────────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts index 9c0ae0366..5d4a95474 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts @@ -272,6 +272,7 @@ export async function fetchValues( await logicDone; for (const a of asks) { const got = answers.find((x) => x.id === a.q.id); + if (got && !got.ok && got.blank) (values[a.box] ??= {})[a.port] = "#N/A"; // 빈 단가 — Eval 이 NA() if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index c1cadd47a..378d6b9e7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -46,6 +46,7 @@ export interface CfBreak { | "통과 안 됨" | "표에 없는 조건" | "맞는 재료 없음" + | "단가 없는 품목" | "조건 겹침" | "조건 안 맞음" | "오류"; @@ -112,6 +113,10 @@ const TEXT = { "「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음", "Line 「{from}」 → 「{box}」 doesn't pass ({rule})", ], + NoPrice: [ + "「{box}」 — 단가 없는 품목을 씀(「{pill}」)", + "「{box}」 — uses an item with no price (「{pill}」)", + ], NoRow: [ "「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음", "Line 「{from}」 → 「{box}」 — no matching table row", @@ -234,7 +239,8 @@ function runFunc( const sheet: Sheet = structuredClone(func.sheet); for (const i of func.inputs) { const v = arrive[i.port] ?? given[i.port] ?? null; - if (v === NA_VALUE) sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; + if (isErrorValue(v)) + sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; // 오류 값 · 빈 단가 else put(sheet.칸, i.cell, v); } for (const v of func.variables) @@ -275,6 +281,19 @@ export function evalLogic( /** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */ const through = (l: CfLink): CfValue => { const v = valueOf(l.from.box, l.from.name); + // 빈 단가(#N/A) — 옛 엔진처럼 그 값을 쓰는 식은 빈 값 · 결과가 오류일 때만 끊긴 곳(late) + if (v === NA_VALUE && lg.boxes.find((b) => b.id === l.from.box)?.input?.mode === "group") { + const text = tl("NoPrice", { box: name(l.to.box), pill: l.from.name }); + const brk: CfBreak = { + kind: "단가 없는 품목", + level: "문제", + box: l.to.box, + port: l.to.name, + link: l.id, + text, + }; + late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]); + } if (!l.rule) return v; if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null; const conds = Object.fromEntries( diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py b/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py new file mode 100644 index 000000000..2d54282b3 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py @@ -0,0 +1,112 @@ +"""M02 흐름도 — 선 규칙 「표에서 찾기」 · 값 같음(Compute 에서 나눔 · Rule lookup 과 같은 뜻). + +표 블록 = 머리 줄 + 줄 · 범위 칸 「이름·아래」 「이름·위」 · 끝 칸 「·범위규칙」. +빈 조건 칸 = 무엇이든 · 꺼낼 칸 빈 줄은 뺌 · 글은 대소문자까지 · 여럿이면 위 끝 최소 줄(옛 find). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete.DataFlow import M02_DF_Values as dv + +RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE) + + +def _num_of(v: Any) -> Decimal | None: + if isinstance(v, bool) or v is None: + return None + if isinstance(v, (int, float, Decimal)): + return Decimal(str(v)) + return None + + +def _same(a: Any, b: Any) -> bool: + """엑셀 「=」 — 수끼리는 수로 · 글끼리는 대소문자 무시 · 빈 칸은 0 · 빈 글과 같음.""" + na, nb = _num_of(a), _num_of(b) + if a is None or b is None: + other = b if a is None else a + return other is None or other == "" or _num_of(other) == 0 + if na is not None and nb is not None: + return na == nb + if isinstance(a, str) and isinstance(b, str): + return a.lower() == b.lower() + return False + + +def _is_err(v: Any) -> bool: + return isinstance(v, str) and re.fullmatch(r"#[A-Z/0!?]+", v) is not None + + +def _exact(a: Any, b: Any) -> bool: + """표 찾기 같음 — 글끼리는 대소문자까지(옛 find · Rule lookup FIND) · 그 밖은 엑셀 「=」.""" + return a == b if isinstance(a, str) and isinstance(b, str) else _same(a, b) + + +def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: + """표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나 + · 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄 + (옛 엔진 find 와 같음 · Rule lookup 과 같음).""" + if not isinstance(block, list) or len(block) < 2: + return None, "오류" + head = [str(h if h is not None else "") for h in block[0]] + out = rule.get("col") + two = out not in head and {out + dv.RANGE_LOW, out + dv.RANGE_HIGH} <= set(head) + if out not in head and not two: + return None, "오류" + tests, ups = [], [] + rk = head.index(RANGE_RULE) if RANGE_RULE in head else None + for c in rule.get("conds") or []: + col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {}) + if col in head: + i = head.index(col) + tests.append(lambda row, i=i, want=want: row[i] is None or _exact(row[i], want)) + elif col + dv.RANGE_LOW in head and col + dv.RANGE_HIGH in head: + lo, hi, x = ( + head.index(col + dv.RANGE_LOW), + head.index(col + dv.RANGE_HIGH), + _num_of(want), + ) + ups.append(hi) + + def fits(row, lo=lo, hi=hi, x=x) -> bool: + low, high = _num_of(row[lo]), _num_of(row[hi]) + if low is not None and low == high: # 숫자 하나(아래 = 위) = 같음(브레인 정함) + return x is not None and x == low + word = str(row[rk] or "") if rk is not None else "" # 경계 — 이상 · 미만(Rule 같음) + return ( + x is not None + and ( + row[lo] is None + or (low is not None and (low <= x if word[:2] == "이상" else low < x)) + ) + and ( + row[hi] is None + or (high is not None and (x < high if word[-2:] == "미만" else x <= high)) + ) + ) + + tests.append(fits) + else: + return None, "오류" + at = head.index(out + dv.RANGE_LOW if two else out) # 값 칸이 범위로 갈림 — 아래 = 위면 그 값 + rows = [list(r) + [None] * len(head) for r in block[1:]] + hits = [r for r in rows if r[at] not in (None, "") and all(t(r) for t in tests)] + if not hits: + return None, "표에 없는 조건" + for hi in ups: # 위 끝만 적힌 범위(「20 이하」 「40 이하」 …) — 위 끝이 가장 작은 줄 + tops = [_num_of(r[hi]) for r in hits if _num_of(r[hi]) is not None] + hits = [r for r in hits if r[hi] is None or not tops or _num_of(r[hi]) == min(tops)] + if len(hits) > 1: + return None, "표에 여럿 맞음" + if two and not _same(hits[0][at], hits[0][head.index(out + dv.RANGE_HIGH)]): + return None, "오류" + return hits[0][at], None + + +def _cond_of(conds: dict, port: dict) -> Any: + """조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름(Rule condOf).""" + key = f"{port.get('box')}|{port.get('name')}" + return conds[key] if key in conds else conds.get(port.get("name")) diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index ad50754b2..1e1250dd7 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -968,3 +968,69 @@ def test_표_찾기_글은_대소문자까지_같아야() -> None: ) assert got == [[3, None], [1, None], [0.45, None], [0.96, None], [None, "표에 없는 조건"]] assert [list(cp._lookup(rule, block, {"g": g})) for g in gs] == got + + +def _fn(i: str, ins: list[str], formula: str) -> dict: + cells = {f"B{k + 2}": {"값": 0} for k in range(len(ins))} + out = f"B{len(ins) + 2}" + cells[out] = {"식": formula} + func = {"sheet": {"id": f"s{i}", "이름": i, "칸": cells}, "variables": []} + func["inputs"] = [{"port": p, "cell": f"B{k + 2}"} for k, p in enumerate(ins)] + func["output"] = {"cell": out, "name": i, "unit": "-"} + return { + "id": i, + "kind": "function", + "name": i, + "color": "#000", + "unit": "-", + "x": 0, + "y": 0, + "func": func, + } + + +def test_빈_단가_쓰는_식은_빈_값_비목_합은_0_끊긴_곳(monkeypatch) -> None: + g = {"mode": "group", "group": "재료", "variables": [], "elements": [ + {"ref": "MT1", "name": "p1", "spec": "", "unit": "-"}, {"ref": "MT2", "name": "p2", "spec": "", "unit": "-"}]} # fmt: skip + boxes = [ + {"id": "g", "kind": "input", "name": "재료", "color": "#000", "unit": "-", "x": 0, "y": 0, "input": g}, + _fn("a", ["p1"], "IF(2=0,0,2*B2)"), + _fn("b", ["p2"], "IF(1=0,0,1*B2)"), + _fn("z", ["a", "b"], "(B2+B3)*2/100"), # 줄 이름을 부르는 덧줄 — 빈 줄이 끼면 빈 값 + _fn("s", ["a", "b", "z"], "SUM(IFERROR(B2,0),IFERROR(B3,0),IFERROR(B4,0))"), + {"id": "o", "kind": "output", "name": "재료비", "color": "#000", "unit": "원", "x": 0, "y": 0, + "output": {"cost": "재료비", "unit": "원"}}, + ] # fmt: skip + pairs = [ + ("g", "p1", "a", "p1"), + ("g", "p2", "b", "p2"), + ("a", "a", "z", "a"), + ("b", "b", "z", "b"), + ] + pairs += [ + ("a", "a", "s", "a"), + ("b", "b", "s", "b"), + ("z", "z", "s", "z"), + ("s", "s", "o", "값"), + ] + links = [{"id": f"l{k}", "from": {"box": a, "name": ap}, "to": {"box": b, "name": bp}} + for k, (a, ap, b, bp) in enumerate(pairs)] # fmt: skip + lg = { + "key": None, + "name": "t", + "resultUnit": "", + "boxes": boxes, + "links": links, + "userElements": [], + } + values = {"g": {"p1": "#N/A", "p2": 100}} + got = _run( + f"const ev = E.evalLogic({json.dumps(lg, ensure_ascii=False)}, {json.dumps(values)});\n" + "out([ev.cost.재료비, ev.ports.z.z, ev.breaks.map(b => [b.kind, b.box])]);" + ) + assert got == [100, "#N/A", [["단가 없는 품목", "a"]]] + from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp + + srv = cp.run(lg, values, lambda key: {}, []) + assert float(srv["cost"]["재료비"]) == 100 and srv["ports"]["z"]["z"] == "#N/A" + assert [[b["kind"], b["box"]] for b in srv["breaks"]] == got[2]